---
title: "Python 3.10 End of Life: Move Your Django Project to 3.12 or Newer"
url: "https://nareshkumar.online/blog/python-3-10-end-of-life"
author: "Naresh Kumar"
published: "2026-10-10"
updated: "2026-10-10"
category: "Engineering"
tags: ["Django", "Performance", "Python"]
---

# Python 3.10 End of Life: Move Your Django Project to 3.12 or Newer

**In short:** Python 3.10 reached end of life on October 1, 2026, with 3.10.22. Move to Python 3.12 or newer while you stay on Django 5.2, replace the removed modules (distutils, imp, pkg_resources, unittest aliases), then upgrade Django. The same Django code took 22 to 34 percent less time on 3.12 in my test.

Python 3.10.22, released on October 1, 2026, was the last release of Python 3.10. The [release schedule](https://peps.python.org/pep-0619/) now marks the branch end of life: no more security fixes, and the bug tracker no longer accepts 3.10 issues. A Django project on 3.10 is also stuck on Django 5.2, because Django 6.0 and 6.1 need Python 3.12. I ran a small Django 5.2 test setup on 3.10, 3.12, 3.13 and 3.14 in Docker to see what breaks, what the upgrade tools fix and how much faster the same code runs.

## What end of life changes

The [Python Insider announcement](https://blog.python.org/2026/10/python-31022-31117/) shipped 3.10.22 together with 3.11.17, 3.12.15, 3.13.16 and 3.14.8, and says the 3.10 series "will receive no further security updates." The platforms you deploy on are moving too:

| Where | What changes for 3.10 |
| --- | --- |
| python.org | No more releases. 3.10.22 (October 1, 2026) is final. |
| Official Docker images | The `python:3.10` tags stay on Docker Hub, but 3.10 was [dropped from the image build](https://github.com/docker-library/python/pull/1133) on October 6, so they are no longer rebuilt and will not pick up Debian security updates either. |
| AWS Lambda | The `python3.10` runtime is [deprecated on October 31, 2026](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html). Creating functions is blocked from July 29, 2027, and updating them from August 31, 2027. |
| Ubuntu 22.04 | Canonical keeps patching its own `python3.10` package (3.10.12 with backported fixes) until standard support ends in [May 2027](https://ubuntu.com/about/release-cycle), and longer with Ubuntu Pro. |

Only the last row buys you time, and only if you run the distribution's own Python on 22.04. A `python:3.10` image, a pyenv build or a self-compiled 3.10 gets nothing new from here on.

## Which Python and Django to move to

Django's [installation FAQ](https://docs.djangoproject.com/en/dev/faq/install/) gives the supported pairs, and the [6.0 release notes](https://docs.djangoproject.com/en/6.1/releases/6.0/) say it directly: "The Django 5.2.x series is the last to support Python 3.10 and 3.11."

| Django | Python | Support ends |
| --- | --- | --- |
| 5.2 LTS | 3.10 to 3.14 (3.14 from 5.2.8) | April 2028 |
| 6.0 | 3.12 to 3.14 | April 2027 |
| 6.1 | 3.12 to 3.14 | December 2027 |

Pick 3.14 if your dependencies publish wheels for it: it is the newest version Django supports, it still gets bug fixes, and the [devguide](https://devguide.python.org/versions/) plans its end of life for October 2030. Python 3.12 is the minimum for Django 6 and gets security fixes until October 2028. Skip 3.11 as a target: it ends in October 2027 and Django 6 does not support it. No Django release lists 3.15 yet; I covered what is new there in [Python 3.15 for Django developers](https://nareshkumar.online/blog/python-3-15-django-developers).

Watch for one pip behavior on 3.10. Asking for Django 6 fails loudly, but a plain `pip install django` succeeds and quietly gives you the last release that still accepts 3.10:

```bash
$ pip install "django==6.1.2"        # on Python 3.10.22, output trimmed
ERROR: Ignored the following versions that require a different python version: 6.0 Requires-Python >=3.12; ... 6.1.2 Requires-Python >=3.12; ...
ERROR: No matching distribution found for django==6.1.2

$ pip install django
$ python -m django --version
5.2.18
```

The rest of a typical API stack is not what holds you back. On PyPI, Django REST Framework 3.18.3, psycopg 3.3.6, pytest 9.1.1 and pytest-django 4.14.0 all accept Python 3.10 and newer. Celery is the one to check: the classifiers of 5.6.3, the latest stable release, stop at Python 3.13, and 5.7 is only a beta so far.

## Upgrade Python first, Django second

Change one thing at a time, in this order:

1. If you are still on Django 4.2, whose support ended on April 7, 2026, move to 5.2 first while still on Python 3.10, one feature release at a time (4.2, 5.0, 5.1, 5.2), as Django's [upgrade guide](https://docs.djangoproject.com/en/6.1/howto/upgrade-version/) recommends.
2. On Django 5.2, switch Python to 3.12 or newer. Django 5.2 runs on both versions, so only the interpreter changes in this step.
3. Deploy it, let it run for a while, then upgrade Django to 6.0 and 6.1, using the latest patch release of each. For 6.1 that is 6.1.2, the [October 6 security release](https://nareshkumar.online/blog/django-6-1-2-security-release).

> **Tip:** Run your tests with warnings switched on at every step: `python -Wa manage.py test`, or `PYTHONWARNINGS=always pytest tests --capture=no` with pytest. Both commands come from Django's upgrade guide. Most of what breaks on 3.12 prints a DeprecationWarning on 3.10 first. If you have no API tests yet, start with [testing DRF APIs with pytest](https://nareshkumar.online/blog/testing-drf-apis-with-pytest).

## What breaks between 3.10 and 3.12

I ran common 3.10-era code, one line at a time, in a fresh virtual environment on Python 3.10.22, 3.12.15 and 3.13.16 (the official Docker images). Everything that only warns on 3.10 fails on 3.12. The 3.13 results were the same as 3.12:

| Code | Python 3.10 | Python 3.12 | Use instead |
| --- | --- | --- | --- |
| `import imp` | DeprecationWarning | ModuleNotFoundError | `importlib` |
| `from distutils.version import LooseVersion` | Works | ModuleNotFoundError | `packaging.version.Version` |
| `import asyncore` | DeprecationWarning | ModuleNotFoundError | `asyncio` |
| `import pkg_resources` | DeprecationWarning | ModuleNotFoundError | `importlib.metadata`, `importlib.resources` |
| `self.assertEquals(1, 1)` | DeprecationWarning | AttributeError | `assertEqual` |
| `configparser.SafeConfigParser()` | DeprecationWarning | AttributeError | `configparser.ConfigParser` |
| `datetime.datetime.utcnow()` | Works | DeprecationWarning | `datetime.datetime.now(datetime.UTC)` |

[What's New in Python 3.12](https://docs.python.org/3/whatsnew/3.12.html) also removes `asynchat` and `smtpd`, the remaining unittest aliases such as `assertRegexpMatches` and `failUnless`, and `ssl.wrap_socket()`. Python 3.11 had already removed the `@asyncio.coroutine` decorator, `inspect.getargspec()` and the `binhex` module. Search your code and your vendored packages for all of them.

The distutils row surprised me. On 3.10, inside a virtual environment, `import distutils` already loads the copy bundled with setuptools (`setuptools/_distutils`), which is why it does not warn. On 3.12 setuptools is no longer preinstalled, so that copy is gone.

### The Docker image change that catches people

In my test the `python:3.10-slim` image shipped setuptools 79.0.1 and wheel next to pip. The `python:3.12-slim` image ships only pip, and virtual environments created by 3.12 get no setuptools either. Code that relied on setuptools being there breaks after a one-line `FROM` change. Installing setuptools again brings `distutils` back, but not `pkg_resources`: [setuptools 82.0.0](https://setuptools.pypa.io/en/latest/history.html) removed it on February 8, 2026. In my test 81.0.0 was the last version where `import pkg_resources` still worked, with a deprecation warning.

The replacements are in the standard library, plus the `packaging` package for version comparisons:

`version_check_old.py`

```python
import datetime
from distutils.version import LooseVersion

import pkg_resources

django_version = pkg_resources.get_distribution("django").version
print(LooseVersion(django_version) >= LooseVersion("5.2"))
print(datetime.datetime.utcnow().tzinfo)
```

`version_check.py`

```python
import datetime
from importlib.metadata import version

from packaging.version import Version

django_version = version("django")
print(Version(django_version) >= Version("5.2"))
print(datetime.datetime.now(datetime.UTC).tzinfo)
```

The old file prints two deprecation warnings on 3.10 and stops with `ModuleNotFoundError: No module named 'distutils'` on 3.12. The new one runs cleanly on 3.12 even with `-W error`. It does not run on 3.10, though: `datetime.UTC` only arrived in 3.11. If one codebase has to run on both versions during the switch, use `datetime.timezone.utc` or Django's `timezone.now()` until 3.10 is gone.

## Let the tools do the rewrites

Three tools handle most of the mechanical changes. Install them under the new Python: pyupgrade 3.22.0 itself needs Python 3.11 or newer. I ran these commands on a test file in a Git repository, using pyupgrade 3.22.0, django-upgrade 1.33.0 and Ruff 0.17.0:

```bash
pip install pyupgrade django-upgrade ruff
git ls-files -z -- '*.py' | xargs -0r pyupgrade --py312-plus
git ls-files -z -- '*.py' | xargs -0r django-upgrade --target-version 5.2
ruff check --select UP,DTZ --target-version py312 .
```

The first two exit with a non-zero status when they rewrote a file, so review the result with `git diff`. On my test file the changes were (diff condensed):

`legacy.py`

```diff
-            models.CheckConstraint(check=Q(total__gte=0), name="total_not_negative"),
+            models.CheckConstraint(condition=Q(total__gte=0), name="total_not_negative"),
@@
-def totals(rows: List[Dict[str, int]]) -> Optional[int]:
+def totals(rows: list[dict[str, int]]) -> int | None:
@@
-        self.assertEquals(totals([{"total": 2}]), 2)
+        self.assertEqual(totals([{"total": 2}]), 2)
```

Ruff then flagged the `Dict` and `List` names still in the `from typing import` line (rule UP035), which pyupgrade leaves in place. The `CheckConstraint` rewrite matters before Django 6: on Django 5.2.18, `check=` prints a `RemovedInDjango60Warning`, and on 6.1.2 it raises `TypeError: CheckConstraint.__init__() got an unexpected keyword argument 'check'`.

> **Warning:** Run pyupgrade and Ruff's UP rules only once 3.10 is out of production. With `--py312-plus`, pyupgrade rewrote `datetime.timezone.utc` to `datetime.UTC` in my test, and Ruff's rule UP017 does the same at any target of `py311` or newer. That code no longer runs on 3.10. Until then, use `--py310-plus` and `--target-version py310`, which left it alone.

One thing none of the three tools flagged: `default=datetime.datetime.utcnow` on a `DateTimeField`. It passes the function instead of calling it, so Ruff's DTZ003 rule never sees a call. Search for `utcnow` by hand and use `django.utils.timezone.now` for model defaults.

## How much faster the same code runs

[What's New in Python 3.11](https://docs.python.org/3/whatsnew/3.11.html) says 3.11 is 10 to 60 percent faster than 3.10, with a 1.25x average speedup on the standard benchmark suite. I wanted a Django number. The script below serializes 2,000 rows with a DRF `ModelSerializer` and renders the same rows in a Django template. It used Django 5.2.18 and DRF 3.18.3 on every Python version, with in-memory SQLite and each container pinned to one core of an Intel Core 5 210H. Each run prints the median of 15 samples; I ran it six times per version, in two rounds in opposite order, and took the median.

| Python | DRF, 2,000 rows to JSON | Template, 2,000 rows |
| --- | --- | --- |
| 3.10.22 | 50.8 ms | 163.1 ms |
| 3.12.15 | 39.5 ms (22% less) | 108.1 ms (34% less) |
| 3.13.16 | 41.6 ms (18% less) | 110.8 ms (32% less) |
| 3.14.8 | 39.3 ms (23% less) | 110.8 ms (32% less) |

Nearly all of the gain is already there on 3.12; 3.13 and 3.14 stayed within a few percent of it on this workload. This is CPU-bound work only. A view that spends most of its time waiting for PostgreSQL will gain less, and [fixing N+1 queries](https://nareshkumar.online/blog/django-n-plus-one-queries) will do more for it. Time your own slowest endpoint before and after the switch.

`bench.py`

```python
"""Same Django + DRF work on each Python version: serialize 2,000 rows and render a template.

Single file, in-memory SQLite. Prints the median of 15 samples per task, in milliseconds.
"""
import json, platform, statistics, time

import django
from django.conf import settings

settings.configure(
    DEBUG=False,
    INSTALLED_APPS=["django.contrib.contenttypes", "rest_framework"],
    DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
    TEMPLATES=[{"BACKEND": "django.template.backends.django.DjangoTemplates"}],
    USE_TZ=True,
)
django.setup()

from django.db import connection, models
from django.template import engines
from django.utils import timezone
from rest_framework import serializers
from rest_framework.renderers import JSONRenderer


class Book(models.Model):
    title = models.CharField(max_length=200)
    isbn = models.CharField(max_length=20)
    price = models.DecimalField(max_digits=8, decimal_places=2)
    pages = models.IntegerField()
    published = models.DateTimeField()
    in_stock = models.BooleanField(default=True)

    class Meta:
        app_label = "bench"


class BookSerializer(serializers.ModelSerializer):
    class Meta:
        model = Book
        fields = ["id", "title", "isbn", "price", "pages", "published", "in_stock"]


with connection.schema_editor() as editor:
    editor.create_model(Book)
now = timezone.now()
Book.objects.bulk_create(
    Book(title=f"Book {i}", isbn=f"978-{i:09d}", price=f"{i % 90 + 9}.99",
         pages=100 + i % 700, published=now, in_stock=i % 3 != 0)
    for i in range(2000)
)

template = engines["django"].from_string(
    "<table>{% for b in books %}<tr><td>{{ b.title|title }}</td><td>{{ b.isbn }}</td>"
    "<td>{{ b.price|floatformat:2 }}</td><td>{{ b.published|date:'Y-m-d' }}</td>"
    "<td>{% if b.in_stock %}yes{% else %}no{% endif %}</td></tr>{% endfor %}</table>"
)


def api():
    JSONRenderer().render(BookSerializer(Book.objects.all(), many=True).data)


def page():
    template.render({"books": Book.objects.all()})


def median_ms(fn, samples=15):
    fn()  # warm up
    times = []
    for _ in range(samples):
        t = time.perf_counter()
        fn()
        times.append((time.perf_counter() - t) * 1000)
    return statistics.median(times)


print(json.dumps({
    "python": platform.python_version(),
    "django": django.get_version(),
    "api_ms": round(median_ms(api), 1),
    "template_ms": round(median_ms(page), 1),
}))
```

To repeat it for one version:

```bash
docker run --rm --cpuset-cpus=3 -v "$PWD":/w:ro python:3.12-slim sh -c '
  python -m venv /tmp/v &&
  /tmp/v/bin/pip install -q "django==5.2.18" "djangorestframework==3.18.3" &&
  /tmp/v/bin/python -I /w/bench.py'
```

## If you cannot move this month

- Pin the `python:3.10` image by digest so builds stay reproducible, and put the move on the roadmap. Nothing new will arrive in that image.
- Add 3.12 or newer to your CI matrix now, next to 3.10, so new code stops depending on modules that are gone.
- On Ubuntu 22.04 with the system Python, use the time until May 2027; on AWS Lambda, move functions before October 31, 2026.

## Frequently asked questions

**When did Python 3.10 reach end of life?**

On October 1, 2026, with the release of 3.10.22, the final security release ([PEP 619](https://peps.python.org/pep-0619/)). python.org publishes no further fixes for 3.10.

**Can I keep using Python 3.10 with Django?**

Django 5.2 LTS still supports Python 3.10 until April 2028, but Python itself no longer gets security fixes. Django 6.0 and 6.1 require Python 3.12 or newer.

**Should I move to Python 3.12, 3.13 or 3.14?**

All three work with Django 5.2.8 and later, 6.0 and 6.1. Python 3.14 has the longest support (until October 2030) if your dependencies have wheels for it; 3.12 is the minimum for Django 6. Django does not support 3.15 yet.

**Why does pip install Django 5.2 instead of Django 6 on my machine?**

Django 6.0 and later declare `Requires-Python >=3.12`. On Python 3.10 or 3.11, pip skips them and installs the newest release that still accepts your interpreter, which is 5.2.18 today.

**Is the official python:3.10 Docker image still updated?**

No. Python 3.10 was dropped from the official image build on October 6, 2026. The existing tags stay available but are no longer rebuilt, so they will not receive operating system security updates either.
