Upgrading Django 2.2 to 5.2 Without a Feature Freeze
A staged approach to a multi-version framework upgrade across a live lending platform — and why the dependency graph is harder than the framework itself.
- Django
- Python
- Modernization
- Migrations
Long-lived platforms accumulate version debt quietly. Nothing breaks, so nothing gets prioritised — until a security advisory lands against a release that stopped receiving patches two years ago.
One version at a time, always deployable
The temptation is to branch, upgrade everything, and merge months later. That branch will never merge cleanly. The alternative is to move one minor version at a time, deploying at every step, so the system is never more than one hop from production-ready.
It feels slower. It is dramatically faster, because each step has a small, attributable blast radius. When something breaks after a single-version bump, you know exactly what changed.
The dependency graph is the real work
Django itself has excellent release notes and a well-signposted deprecation path. The third-party packages built on top of it do not. Some are abandoned, some renamed, some replaced by a standard-library feature three versions ago.
Auditing every dependency first — is it maintained, does it support the target version, is it still needed at all — turns up a surprising amount of code that can simply be deleted.
- Bump one minor version per step and deploy each time
- Audit dependencies before starting, not when they break
- Treat every deprecation warning as an error in CI
- Delete unused packages instead of porting them
Python and Django together
Moving Python 3.6 to 3.12 alongside the framework compounds the risk if done simultaneously. Sequencing them — clearing the Python runtime first where possible, then the framework — keeps each failure attributable to one variable.
Thoughts on this?
Always happy to talk through the engineering trade-offs.