Upgrading Django 5.2 to 6.0: Tests First, Then Delete Dependencies

Django upgrades have a reputation for being scary, and that reputation is mostly earned by skipping steps. The framework itself is excellent at deprecating things slowly. What hurts is everything around it: the third-party packages, the Python version, and the fact that nobody knows what the app does anymore.
This post is about upgrading a production Django REST API from 5.2 to 6.0, the third major upgrade of the same service (4.2 in 2024, 5.2 in 2025, 6.0 in 2026). The Django upgrade itself ended up as a single, boring commit. The interesting part is why it could be boring.
What You'll Learn
✅ Why the upgrade started with writing tests, not with pip install
✅ The order that worked: Python, then pydantic, then Django
✅ What the Django REST Framework upgrade quietly removed
✅ A one-test safety net for migration drift
✅ How to prune dead dependencies during an upgrade, safely
✅ A verification checklist you can reuse
1. Tests Before the Upgrade
We had a service with weak test coverage and a history of "it worked on my machine" upgrades. So before touching a version number, we spent a stretch of work on tests, organized in phases that followed risk:
| Phase | Covered |
|---|---|
| 0 | Fix failing tests, add coverage reporting and shared helpers |
| 1 | The alarm path: the core of what the service exists to do |
| 2 | Authentication, permissions, sessions |
| 3 | The admin API |
| 4 | Background jobs and maintenance notifications |
| 5 | Emails and monitoring throttling |
Start with the path that pages people at 3 a.m., not the easiest code to test. Two details mattered:
- Pin the current behavior, odd parts included. When we found bugs along the way, we did not fix them inside the upgrade. We pinned them as expected failures with a reference to the follow-up ticket, so the upgrade stayed a pure upgrade.
- Mock the outside world. The unit tests use
unittest.mock, so the whole suite runs on a fresh virtualenv without external services.
By the time we upgraded Django we had 325 tests and about 90% coverage. That number is the real upgrade insurance.
2. The Order Matters: Python, pydantic, Django
Don't upgrade everything in one pull request. We used three:
1. Python 3.13 → 3.14 and pydantic 1 → 2. This was the forced one. pydantic 1.10 can't run on Python 3.14 (deferred annotations), so the move to pydantic 2 came with the Python bump. One behavior change was visible to API clients: validation errors now use pydantic 2's wording and no longer include input, url and ctx. We added a test for exactly that, because the response body is part of the contract.
2. Redis and other infrastructure. Unrelated to Django, but it removed a vendored copy of an ancient Redis client that only worked through a compatibility shim. Cleaning up infrastructure before the framework upgrade means fewer suspects when something breaks.
3. Django 5.2 → 6.0. With everything else settled, a failure in this pull request is almost certainly Django.
Each step is independently deployable and independently revertable. That's the property to protect.
3. What Django 6.0 Actually Needed
Almost nothing in our own code. The Django stack moved together:
Django 5.2.7 -> 6.0.9
djangorestframework 3.16.1 -> 3.18.3
django-filter 25.2 -> 26.2
django-simple-history 3.10.1 -> 3.13.0
drf-yasg 1.21.11 -> 1.21.18The one real breaking change came from DRF, not Django. DRF 3.17 dropped CoreAPI. Our settings still pointed at it:
REST_FRAMEWORK = {
# ...
"DEFAULT_SCHEMA_CLASS": "rest_framework.schemas.coreapi.AutoSchema", # removed
}We deleted the line. The OpenAPI endpoint is generated by drf-yasg with its own generator, so the output was unchanged (we checked the schema endpoint by hand against a running server).
Read the release notes of the packages around Django. That is where the breaking changes usually live.
The lesson is general: run python manage.py check and the test suite with warnings turned on on the old Django version first. Deprecation warnings are a to-do list the framework hands you for free.
4. A One-Test Safety Net for Migration Drift
During the upgrade we found that a model field had been changed in an earlier feature without a migration. It was a harmless blank=True (no SQL change), but only luck made it harmless.
Django can tell you whether models and migrations disagree:
python manage.py makemigrations --check --dry-runWe turned that into a test, so it fails in CI instead of surprising someone later:
from io import StringIO
from django.core.management import call_command
from django.test import TestCase
class TestMigrations(TestCase):
def test_no_missing_migrations(self):
out = StringIO()
try:
call_command("makemigrations", "--check", "--dry-run", stdout=out, stderr=StringIO())
except SystemExit:
self.fail(f"Model changes without a migration.\n{out.getvalue()}")It's ten lines and it will pay for itself on the first forgotten migration. Add it before a major upgrade, since upgrades can change how fields serialize into migrations.
5. Prune While You're There
Upgrades are the one moment when changing requirements.txt is expected. We took the opportunity to delete everything that was not imported or required anywhere, around 25 packages:
- Both CoreAPI leftovers (
coreschema,itypes) - Dev tooling that had leaked into production requirements:
pylint,astroid,isort,mccabe,pipreqs,nose - An unused second MySQL driver (
PyMySQL),django-mysql(not inINSTALLED_APPS),mock(replaced byunittest.mock) and assorted transitive leftovers
Faker moved to a separate requirements-test.txt, so production images no longer ship test libraries.
How to do it safely:
- For each candidate,
grepthe code and the settings for imports andINSTALLED_APPSentries. - Remove them all, and rebuild a fresh virtualenv from the requirement files only.
- Run
pip check(no broken requirements) and the full suite.
The fresh-venv step is the important one. Your current environment hides missing dependencies, because they're still installed from a previous life.
Fewer dependencies means a smaller surface for the next upgrade, and fewer security advisories to triage.
6. The Verification Checklist
Tests aren't the only evidence. Before merging, we ran:
✅ Full test suite on a fresh venv built from the requirement files (325 pass, 2 known pinned failures)
✅ Coverage still at about 90%
✅ manage.py check clean
✅ pip check clean
✅ makemigrations --check clean
✅ A real runserver on Django 6 against local MySQL and Redis, with manual calls to: the schema endpoint, API-key auth, OTP login, admin endpoints, a datatable, token refresh, and the admin site
The manual smoke test takes ten minutes and catches what mocks can't: settings, middleware order, and templates.
Summary
✅ Make the upgrade boring by investing in tests first, starting with your most critical path.
✅ Pin existing bugs as expected failures instead of fixing them inside the upgrade.
✅ Upgrade in layers (Python, libraries, infrastructure, then Django), one deployable change at a time.
✅ Read the release notes of DRF and friends, not only Django's.
✅ Add a makemigrations --check test.
✅ Delete unused packages, then prove it with a fresh venv and pip check.
✅ Finish with a real smoke test against real services.
Related: the same approach, applied to a different stack, is in Migrating from Flask to FastAPI.
Related Posts
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.