Filter concepts by levelShowing all levels.

Django · Section 97

Safe Django Upgrades

Level
advanced
Read
22 min
Concepts
2

Django tells you what it is going to remove, well in advance, and the reason upgrades hurt is that the telling is silent unless you ask. The policy is precise: a feature deprecated in a feature release "will continue to work in all A.x versions but raise warnings", and is removed in the next major series — `B.0`, or `B.1` for a late deprecation, so every deprecation spans at least two feature releases. The warning is named after the release that removes it, `RemovedInDjango60Warning`, so it carries its own deadline. And it is silent by default, which is why a suite can be green for two years and then produce hundreds of failures on an upgrade branch. The fix is to make Django's own deprecation warnings errors in your test settings, so the work arrives one small diff at a time. The LTS relationship shapes the strategy: shims added in `X.0` and `X.1` are dropped in `Y.0`, shims added in `X.2` are dropped in `Y.1`, and the next LTS drops none at all, deliberately, to ease LTS-to-LTS upgrades. Going straight from one LTS to the next therefore absorbs every deprecation from the series in between, in one branch, with no incremental verification — which is why stepping through the feature releases is usually the better trade. The roadmap's sequence puts the steps in the order that keeps risk small: read the release notes first, because backwards-incompatible changes are documented and not warned about; identify the deprecations that apply to you; update dependencies *before* Django; run tests; fix warnings; test migrations; benchmark; deploy gradually. Underneath the sequence sit four compatibility constraints, and the slowest of them sets the date. Python is the first, and its pairing is published years ahead — Django supports a Python version "up to and including the first Django LTS release whose security support ends after security support for that version of Python ends", which is why 4.2 is the last release to support 3.9; Django 5.2 supports 3.10 through 3.14, and only the latest micro release of each is officially supported. Third-party packages are the constraint most likely to block, because anything that touches Django internals has its own matrix and one unmaintained package can hold a project on an old LTS for a year. Migration behaviour is the quiet one: a new Django can generate a migration for a model you never edited, and whether that is free or an outage depends entirely on the size of the table — so generate it, read it, and check the row count before applying anything. Security support is the fourth and reframes the rest, because fixes are backported only to supported versions; leaving support converts "we will upgrade when there is time" into a risk with a date. And keep Python and Django moving in separate deploys: the overlap in supported versions exists precisely so that exactly one thing changes at a time.

What is true here

  1. At least two feature releases of warning — named for the release that removes the feature.
  2. The warnings are silent by default; making Django's own ones errors is the whole discipline.
  3. LTS to LTS absorbs every intervening deprecation at once.
  4. Python, packages, migrations and the security window all have to agree.
  5. Read generated migrations; the same operation is free or an outage depending on the table.

What you will be able to do

  • Turn Django deprecations into failing tests so upgrade work stays continuous
  • Choose between stepping through feature releases and jumping LTS to LTS, with reasons
  • Audit the four compatibility constraints before an upgrade branch exists
  • Recognise and price a migration the new version generated on its own
The roadmap's own sequence, with what each step is protecting against
no compatiblereleasereplace,contribute, or wait

Read the release notes

backwards-incompatible changes are documented, never warned about

Identify deprecations

RemovedInDjangoXXWarning — silent until `python -Wd`

Update dependencies first

an unmaintained package is the usual blocker, and it is not yours to fix

Run tests, warnings visible

and make Django's own deprecations errors from here on

Fix warnings

one small diff now, or a project in two years

Test migrations

a migration you did not write, for a model you did not edit — read it

Benchmark critical paths

a regression the suite passes through in silence

Deploy gradually

canary or rolling — everything the earlier steps could not reproduce

Blocked

a package with no compatible release, or a Python version too old

  • Read the release notes — backwards-incompatible changes are documented, never warned about
    • leads to Identify deprecations
  • Identify deprecations — RemovedInDjangoXXWarning — silent until `python -Wd`
    • leads to Update dependencies first
  • Update dependencies first — an unmaintained package is the usual blocker, and it is not yours to fix
    • leads to Run tests, warnings visible
    • on error, leads to Blocked (no compatible release)
  • Run tests, warnings visible — and make Django's own deprecations errors from here on
    • leads to Fix warnings
  • Fix warnings — one small diff now, or a project in two years
    • leads to Test migrations
  • Test migrations — a migration you did not write, for a model you did not edit — read it
    • leads to Benchmark critical paths
  • Benchmark critical paths — a regression the suite passes through in silence
    • leads to Deploy gradually
  • Deploy gradually — canary or rolling — everything the earlier steps could not reproduce
  • Blocked — a package with no compatible release, or a Python version too old
    • leads to Update dependencies first (replace, contribute, or wait)

The policy and the sequence

What Django promises before it removes anything, and the warnings you have to switch on.

The upgrade sequence, and the warnings that are silent by default

coreadvanced

Django removes a feature only after warning about it for at least two feature releases: a feature deprecated in `A.x` "will continue to work in all A.x versions but raise warnings", and is removed in `B.0` (or `B.1` for a late deprecation). The warning is named after the release that removes it — `RemovedInDjango60Warning` — and it is **silent by default**, so your test suite passes while telling you nothing until you turn warnings on.

Think of it as

The reason Django upgrades go wrong is almost never that the new version broke something without notice. It is that the notice was given, in the form of a warning nobody displayed, across two releases nobody was reading the notes for. So the whole discipline is to make the warning visible early and to treat it as work rather than noise. The policy gives you the schedule. A feature deprecated in a feature release keeps working for the rest of that series while raising a `RemovedInDjangoXXWarning`, and disappears two feature releases later — which means that at any moment your code is carrying a list of things that will stop working on a date you can already read. The LTS relationship is worth internalising if you upgrade LTS to LTS, which most teams do: shims added in `X.0` and `X.1` are dropped in `Y.0`, shims added in `X.2` (the LTS) are dropped in `Y.1`, and `Y.2` — the next LTS — drops no shims at all, precisely "to ease LTS-to-LTS upgrades". The practical consequence is that jumping straight from one LTS to the next means absorbing every deprecation from the intervening series in a single change, so the alternative — stepping through each feature release — trades one large risky upgrade for three small ones, and is usually the better trade when the suite is good. The roadmap's own sequence encodes the order that keeps risk small: read the release notes first, because that is where backwards-incompatible changes are listed and there is no substitute; identify the deprecations affecting you; update dependencies before Django itself, since third-party packages are the most common blocker and a package that does not support the new version turns the upgrade into a fork or a wait; run the tests; fix warnings; test migrations, because `makemigrations` on a new Django can generate a migration you did not intend; benchmark the paths that matter; and deploy gradually rather than everywhere at once. Two habits make the "fix warnings" step tractable. Run the suite with warnings visible and, once clean, make them errors so a newly deprecated call cannot be merged. And do the work continuously: a deprecation fixed the week it appears is a small diff, while the same fix made two years later is a migration project with no tests written for it.

bash
python -Wd -m pytest -q       # deprecation warnings are silent without this

What we're doing: Make deprecation warnings impossible to ignore, so the upgrade work happens continuously instead of in one large jump.

config/settings/test.py + pyproject.tomlpython
# config/settings/test.py
import warnings

# Django's own warnings are silent by default, which is why a suite can
# be green for two years and then fail on the upgrade. Making them
# ERRORS means a newly deprecated call cannot be merged at all.
warnings.filterwarnings(
    "error", category=DeprecationWarning, module=r"^(django|myapp)\."
)

# Third-party packages deprecate on their own schedules, and you cannot
# fix their internals. Visible, not fatal — so they are tracked rather
# than blocking, and reviewed when that package is upgraded.
warnings.filterwarnings("default", category=DeprecationWarning)


# pyproject.toml — the same policy for anyone running pytest directly
# [tool.pytest.ini_options]
# filterwarnings = [
#   "error::DeprecationWarning:django.*",
#   "error::PendingDeprecationWarning:django.*",
#   "default::DeprecationWarning",
# ]


# A one-off audit of what the CURRENT version is already warning about.
# Run it before an upgrade, not during: this is the list of work the
# next release will turn from a warning into a failure.
#
#   python -Wd -m pytest -q 2>&1 | grep RemovedInDjango | sort | uniq -c
#
# The output groups by message, so it reads as:
#   12 RemovedInDjangoXXWarning: <one deprecated call, used in 12 places>
#    3 RemovedInDjangoXXWarning: <another one>
#
# Twelve occurrences of one warning is one fix, not twelve — group before
# estimating, or the work looks larger than it is.
4–9
Errors for Django's own deprecations, scoped by module so the rule applies to code you control. This is what converts "we will deal with it at upgrade time" into a failing test on the day the deprecation lands.
11–14
Third-party warnings are visible but not fatal, because you cannot fix another package's internals and a hard failure there would block unrelated work.
17–23
The same policy expressed for pytest, so it applies whether the suite is run through Django's runner or directly. Keeping the two in step avoids a green local run and a red CI run.
26–30
The audit command, run *before* planning an upgrade. `-Wd` is what makes the warnings appear at all — without it the same run prints nothing and looks clean.
36–37
Grouping matters for estimating. One deprecated setting used in twelve tests is a single change; counting raw occurrences makes a two-hour task look like a sprint.

Why this works: A newly deprecated Django call fails in CI on the day it is introduced, third-party deprecations stay visible without blocking, and the size of the next upgrade is a number you can read at any time.

Upgrading LTS to LTS in one step with warnings never enabled

Wrong

bash
# requirements: Django==4.2 -> Django==5.2, one pull request
pip install "Django==5.2" && pytest
# 340 failures, none of which the suite ever warned about

Better

bash
# 1. On 4.2, with warnings as errors, fix everything RemovedInDjango50 flags
# 2. Upgrade to 5.0. Repeat for RemovedInDjango51.
# 3. Upgrade to 5.1, then 5.2. Each step is small and independently shippable.

What you see: An upgrade branch that lives for months, is rebased weekly, accumulates conflicts with ordinary work, and is eventually abandoned — leaving the project on an LTS that is approaching end of security support.

Why: Django's policy is designed to spread the work: a deprecation is announced two feature releases before it bites, which is ample time if you are reading the warnings. Jumping LTS to LTS skips the announcements entirely, so every removal from the intervening series arrives at once, in a single branch, with no incremental verification — and the failures interact, making each one harder to diagnose than it would have been alone. Stepping through the feature releases means each upgrade is small, individually deployable, and verifiable in production before the next one begins. The remaining case for the big jump is a project with a weak test suite, where every intermediate release is equally unverifiable — and there the honest first step is the tests, not the upgrade.

A deprecation, from the release that announces it to the one that removes it

The window is at least two feature releases wide, and the warning names its own end date. What makes upgrades painful is not the policy — it is that the warning is silent unless you ask for it.

  • A horizontal timeline of four Django releases: 4.2 marked LTS, then 5.0, then 5.1, then 5.2 marked LTS.
  • A wide amber band spans from 4.2 through 5.0, labelled: the feature still works, and calling it raises RemovedInDjango51Warning.
  • At 5.1 a red marker shows the feature removed outright, with the note that code still calling it now raises an error.
  • A caption under the band notes that the warning is silent by default and is enabled with python -Wd.
  • A footer notes the LTS-to-LTS consequence: jumping from 4.2 straight to 5.2 absorbs every deprecation from 5.0 and 5.1 in a single change.

Django's own worked example of the policy

Django's own worked example of the policy
ReleaseWhat it does with a feature deprecated in 4.2
Django 4.2contains a backwards-compatible replica, raising `RemovedInDjango51Warning`
Django 5.0still contains the replica — the warning continues
Django 5.1**removes the feature outright**

Together

python
import warnings
warnings.simplefilter("error", DeprecationWarning)   # in your test settings

The sequence, and what each step exists to catch

The sequence, and what each step exists to catch
StepCatches
read the release notesbackwards-incompatible changes — listed nowhere else
identify deprecationswhich warnings apply to *your* code
update dependencies **first**a package that does not support the new Django yet
run tests with warnings visiblethe calls that will break two releases from now
fix warningsthe work itself — small now, a project later
test migrationsa migration the new version generates that you did not intend
benchmark critical pathsa regression the tests pass through silently
deploy graduallyeverything the previous steps could not reproduce

Together

bash
python -Wd -m pytest -q            # warnings visible
python manage.py makemigrations --check --dry-run   # no surprise migration

Remember: Django removes nothing without at least two feature releases of warning: a feature deprecated in `A.x` works through that series, raises `RemovedInDjangoXXWarning` named for the release that removes it, and disappears in `B.0` or `B.1`. Those warnings are silent by default, so turn them on and make Django's own ones errors — that is what makes the work continuous rather than a project. Follow the sequence: release notes, deprecations, dependencies first, tests, warnings, migrations, benchmarks, gradual deploy. And prefer stepping through feature releases to jumping LTS to LTS, because the next LTS deliberately drops no shims — the cost of skipping is paid all at once in one branch.

See also: python versions packages and migration behaviour · the pipeline and where it runs · rolling blue green and canary · versioning backward compatibility and deprecation

Advertisement

The four things that must agree

Python, third-party packages, generated migrations, and how long fixes keep arriving.

Python versions, package compatibility, migrations and security releases

coreadvanced

Four things have to agree before an upgrade can happen: the **Python version** (each Django release supports a specific range — 5.2 supports 3.10 through 3.14), every **third-party package** you depend on, the **migrations** the new version generates for models you did not change, and your ability to take a **security release** quickly when one lands. The slowest of the four sets the pace.

Think of it as

An upgrade is a compatibility problem before it is a code problem, and the four constraints are independent — so the useful first move is to check all four rather than starting with the one you find first. Python comes with a published rule you can plan against: Django supports "a Python version up to and including the first Django LTS release whose security support ends after security support for that version of Python ends", which is why Django 4.2 is the last release to support Python 3.9. That makes the pairing predictable years ahead, and it means an upgrade sometimes really is two upgrades — the interpreter and the framework — which should be done as separate deployable steps rather than as one branch. Note also that "only the latest micro release (A.B.C) is officially supported", so pinning to an old patch version of Python is outside what Django tests against. Third-party packages are the constraint most likely to actually block you, because you do not control them. Every dependency that touches Django internals — DRF, Celery integrations, django-storages, an admin theme, anything with a `models.py` — has its own support matrix, and one unmaintained package can hold a project on an old LTS for a year. That argues for auditing the dependency list *before* the upgrade branch exists, and for treating "does this still have releases?" as a question you ask at adoption time rather than at upgrade time. Migration behaviour is the quiet one. A new Django version can change a field's deconstruction or a default in a way that makes `makemigrations` generate a migration for a model you never touched. That migration might be harmless, or it might rewrite a large table, and the difference matters enormously in production. So the upgrade procedure includes generating migrations and *reading* them, on a copy of production-sized data if the table is large — never applying them because the pipeline was green. Security releases are the fourth constraint and the one that reframes the others. Django backports security fixes only to supported versions; once your version leaves support, a published vulnerability is your problem to backport. That is what turns "we will upgrade when there is time" into a risk with a date attached, and it is the strongest argument for staying close to a supported release and for keeping the upgrade path rehearsed enough that a patch release can go out in a day.

bash
python manage.py makemigrations --check --dry-run   # after the upgrade, before the deploy

What we're doing: Check all four constraints before creating the upgrade branch, so the blocker is known on day one rather than discovered in week three.

upgrade-audit.sh — run on the CURRENT versionbash
#!/usr/bin/env bash
set -euo pipefail

# ---- 1. Python -------------------------------------------------------
# Django 5.2 supports 3.10 through 3.14. If this box is on 3.9, the
# upgrade is two upgrades, and the interpreter goes first, alone.
python -c 'import sys; print("python", ".".join(map(str, sys.version_info[:3])))'
python -c 'import django; print("django", django.get_version())'

# ---- 2. Third-party packages ----------------------------------------
# The list that matters is the packages that touch Django internals:
# anything with models, middleware, a template backend or a storage
# backend. An unmaintained one here is the whole upgrade's schedule.
pip list --format=freeze | grep -iE 'django|drf|celery|storages|allauth'
pip list --outdated                       # what is already behind
pip check                                 # declared conflicts, today

# ---- 3. Migrations ---------------------------------------------------
# On a scratch virtualenv with the TARGET Django, not this one:
#   pip install "Django==5.2.*"
#   python manage.py makemigrations --check --dry-run
#
# A non-zero exit means the new version wants a migration for models
# nobody edited. Generate and READ it before deciding anything:
#   python manage.py makemigrations --dry-run --verbosity 3
#
# Then check the table size, because the same operation is instant on
# 10k rows and an outage on 40 million:
#   SELECT reltuples::bigint FROM pg_class WHERE relname = 'orders';

# ---- 4. Security support --------------------------------------------
# Target a version that will still be receiving fixes for long enough
# to be worth the migration. Upgrading onto a release that leaves
# support in three months buys three months.
#
# Rehearsal is the real deliverable here: if a security release lands
# tomorrow, the question is whether you can ship a patch version the
# same day. If the answer is no, that is the thing to fix first.
echo "audit complete — the slowest constraint sets the date"
4–8
Both versions printed together, because the pairing is the constraint. A box on Python 3.9 cannot run Django 5.2 at all, and finding that out on day one changes the plan rather than the branch.
10–16
The dependency audit is deliberately about packages that touch Django internals. A pure-Python utility rarely blocks an upgrade; anything with a `models.py` frequently does.
18–24
Running `makemigrations --check` against the *target* Django is what surfaces a migration you did not write. It exits non-zero, which makes it usable as a gate as well as an audit.
27–29
Row count before applying anything. The same generated operation is trivial on a small table and a locking outage on a large one — and the migration itself gives no hint which case you are in.
31–38
The security question reframes the schedule: the deliverable is not just "we are on 5.2", it is "we can ship 5.2.4 the day it is published". That capability is what an upgrade is protecting.

Why this works: The blocking constraint is identified before any code is written, the generated migrations are read rather than trusted, and the target version is chosen for how long it will keep receiving fixes.

Applying a migration the upgrade generated without reading it

Wrong

bash
pip install "Django==5.2.*"
python manage.py makemigrations && python manage.py migrate
# a model nobody touched now has a generated migration, applied blind

Better

bash
python manage.py makemigrations --dry-run --verbosity 3   # read it first
# then decide: is this a no-op state change, or an ALTER on 40M rows?

What you see: A deploy that takes the site down for twenty minutes on a table nobody expected to be touched, because a field's deconstruction changed between versions and the generated migration rewrote the column.

Why: Django generates migrations by comparing your models to the recorded migration state, and both sides of that comparison can shift when the framework changes how a field deconstructs or what a default is. The result is a migration you did not author, for a model you did not edit, whose cost depends entirely on the table it touches. Reading it takes a minute and tells you which case you are in: a state-only change is free, while an `ALTER TABLE` that rewrites rows needs the same expand-and-contract treatment as any other dangerous schema change. The habit that makes this reliable is `--dry-run --verbosity 3` in the audit, plus `--check` in CI so a generated migration cannot arrive unnoticed with a routine dependency bump.

Four things that must all agree — the slowest one sets the date

Python interpreter

A published pairing you can plan years ahead: Django supports a Python version up to the first LTS whose support outlives Python's. Upgrade it as its own deploy.

Django itself

The release notes and the deprecation warnings you have already been fixing. This is the part the upgrade is nominally about, and usually the least of the work.

Third-party packages

Anything touching Django internals has its own matrix. This is the constraint most likely to block, because you do not control it — audit before branching.

Your migrations

A new version can generate a migration for a model you never edited. Generate, read, and check the table size before applying anything.

Security support window

Fixes are backported only to supported versions. Leaving support turns "when there is time" into a risk with a date on it.

  1. Python interpreter — A published pairing you can plan years ahead: Django supports a Python version up to the first LTS whose support outlives Python's. Upgrade it as its own deploy.
  2. Django itself — The release notes and the deprecation warnings you have already been fixing. This is the part the upgrade is nominally about, and usually the least of the work.
  3. Third-party packages — Anything touching Django internals has its own matrix. This is the constraint most likely to block, because you do not control it — audit before branching.
  4. Your migrations — A new version can generate a migration for a model you never edited. Generate, read, and check the table size before applying anything.
  5. Security support window — Fixes are backported only to supported versions. Leaving support turns "when there is time" into a risk with a date on it.

Which Python each recent Django supports

Which Python each recent Django supports
DjangoPython versions
4.2 LTS3.8, 3.9, 3.10, 3.11, 3.12 (3.12 added in 4.2.8)
5.03.10, 3.11, 3.12
5.13.10, 3.11, 3.12, 3.13 (3.13 added in 5.1.3)
5.2 LTS3.10, 3.11, 3.12, 3.13, 3.14 (3.14 added in 5.2.8)

Together

toml
[project]
requires-python = ">=3.12"
dependencies = ["Django>=5.2,<5.3"]   # one Django series, explicitly

The four constraints, and how to check each before you start

The four constraints, and how to check each before you start
ConstraintCheckIf it fails
Python versionthe support table above, against your runtimeupgrade Python first, as its own deploy
third-party packageseach package's own matrix, and its release activitywait, contribute a fix, or replace the package
migrations`makemigrations --check --dry-run` on the new versionread the generated migration before applying it
security supportis your target version still receiving fixes?pick a target that is, not one that is nearly out

Together

bash
pip install "Django==5.2.*" && python -m pip check
python manage.py makemigrations --check --dry-run || \
  python manage.py makemigrations --dry-run --verbosity 3   # read it

Remember: Four things must agree, and the slowest sets the date: the Python version (5.2 supports 3.10–3.14, and only the latest micro release of each is officially supported), every third-party package that touches Django internals, the migrations the new version generates for models you never edited, and how long your target version will keep receiving security fixes. Check all four before the branch exists. Read generated migrations rather than applying them, and check the table size, because the same operation is free on a small table and an outage on a large one. And never move Python and Django in the same change — the overlap in supported versions exists precisely so you do not have to.

See also: the upgrade sequence and deprecation warnings · dangerous schema changes · secrets and dependency scanning · dependencies secrets and deployment checks

Advertisement