Modernising Django Packages Without Breaking Everything

Primary image for Modernising Django Packages Without Breaking Everything

A case study in upgrading django-countries to v8.

I’m the solo maintainer for django-countries, which provides a country field for Django models, along with utilities for forms, admin, and the REST Framework.

Over more than a decade, it’s become a stable and widely used package throughout the Django community. But with time, the tooling had aged. I wanted to modernize it without breaking anything for users.

My goals were simple: better experience for maintainers (me), easier onboarding for contributors, and zero breaking changes for users.

Here’s what I changed and what other Django package maintainers can learn from the process.


Starting Point: Technical Debt Inventory

django-countries started in 2012. Over 13 years, it accumulated the kind of technical debt most mature packages collect:

  • Four config files: setup.py, setup.cfg, MANIFEST.in, tox.ini
  • Manual changelog editing: Merge conflicts every release
  • Slow, manual release process: Many steps, easy to forget something
  • Multiple linters: isort, flake8, pycodestyle, pyflakes, pyupgrade
  • RST-based docs: README.rst, CHANGES.rst
  • Tox-based testing: Slow and hard to remember commands

Nothing was broken, but best practices had evolved. With Python 3.8+ as the baseline, I could finally adopt modern tooling.


Phase 1: Pyproject.toml and build system

I started by consolidating configuration into pyproject.toml following PEP 621. This removed legacy files like setup.py, setup.cfg, MANIFEST.in, and tox.ini.

I migrated the build system to uv-build, a fast packaging backend that’s part of the broader uv toolchain.

[build-system]
requires = ["uv_build>=0.9.6,<0.10.0"]
build-backend = "uv_build"

[project]
name = "django-countries"
version = "8.0.0"
description = "Provides a country field for Django models."

I also centralised configuration for tools like ruff, mypy, and pytest in the same file, creating a single source of truth.

GitHub Actions was simplified too:

- uses: astral-sh/setup-uv@v3
- run: uv sync
- run: just test

Ruff: One Tool to Rule Them All

Before modernisation, I used five linters and formatters. I replaced them all with Ruff, a fast Rust-based tool that handles linting, formatting, and import sorting in one command:

ruff check .
ruff format .

It’s impressively faster and far simpler to configure.


Phase 2: Developer Experience

Next, I focused on developer experience improvements.

Switching to uv

I migrated from pip/virtualenv to uv, a fast Python package and project manager written in Rust. It handles dependency resolution, virtual environments, and Python version management in one tool, and is significantly faster than pip.

Dependencies are managed through pyproject.toml, and contributors simply run:

uv sync

Testing across multiple Python versions becomes trivial with uv’s --python flag, and the --with flag makes it easy to override specific dependencies for testing different Django versions.

Justfile for memorable commands

I added a Justfile — powered by Just — a simple command runner that makes common tasks intuitive.

Instead of remembering long tox commands, contributors now use:

just test latest
just check
just deploy patch

View the full Justfile on GitHub

Just replaces the need for tox or make, providing self-documenting, easy-to-remember commands. Combined with uv’s speed, it’s a small change that dramatically improves contributor productivity.

Testing and Coverage

Testing moved from Tox environments to a justfile script that uses uv --with to override dependencies on the fly:

# justfile snippet
uv run --python 3.13 \
       --with "Django==5.1.*" \
       --with "djangorestframework==3.15.*" \
       --group test \
       coverage run -m pytest

This approach keeps testing simple—one base test dependency group, with version overrides applied as needed. Contributors use intuitive commands:

just test legacy        # Python 3.8 + Django 3.2
just test latest 3.12   # Python 3.12 + Django 5.1

The justfile handles all the version juggling, making it trivial to test across multiple Python and Django versions without maintaining separate dependency groups.


Phase 3: MkDocs Documentation

For years, the README served as the only documentation. I migrated everything to a new site built with MkDocs using the Material theme, a modern Markdown-based documentation system.

Docs: smileychris.github.io/django-countries

Structure:

docs/
├── usage/
│   ├── fields.md
│   ├── settings.md
│   ├── drf.md
│   └── admin.md
└── development/
    ├── contributing.md
    ├── testing.md
    └── releasing.md

The documentation now updates automatically with each release, making it more professional and easier to maintain.


Phase 4: Automation and Changelog Management

Automating releases had the biggest impact. I replaced a multi-step manual process with one command:

just deploy patch

This command runs tests, builds the changelog, bumps the version, publishes to PyPI, and deploys docs.

View the deploy command implementation in the Justfile

Releases are handled through Towncrier, a tool that merges small text fragments into a clean release note:

echo "Fix COUNTRIES_OVERRIDE support for 3-character custom codes" > changes/474.bugfix.md

At release time, towncrier build collects all fragments and generates a complete changelog — no more merge conflicts or forgotten entries.

For a more in-depth look at Towncrier, you can read Dmitriy’s article: Automate your Changelogs with Towncrier.


Results

All of these changes have been tested and shipped in django-countries v8.0, released in November 2025. The modernisation is complete and django-countries is ready to continue working for Django projects throughout the web into the future.

For users:

  • No API breaks
  • Broader version support
  • Easier bug fixes and maintenance

For contributors:

  • Memorable commands (just test, just check)
  • Faster feedback
  • Clear, well-structured documentation

For maintainers:

  • Simpler configuration (one main file)
  • Fast, automated releases
  • Centralised, reproducible workflows

Conclusion

  1. Modernise in Phases: Small, validated steps prevent breakage. Each phase (build system, developer experience, docs, automation) built on the last.

  2. Automate Everything You Can: Automation removes friction and human error. If a task can be scripted, script it.

  3. Consolidate Configuration and Tools: Put everything in pyproject.toml and use unified tools like Ruff. Fewer files and tools mean fewer chances for mistakes.

  4. Optimise Developer Experience: Short, memorable commands mean contributors actually run tests and checks.

  5. Documentation Is Infrastructure: Modern docs reduce support load and attract contributors.

django-countries v8 was a complete infrastructure overhaul achieved without breaking user code. From four config files to one, from five linters to one, from manual steps to automation.

As maintainers, our time is limited. Modern tooling — Just, Ruff, Towncrier, MkDocs, and uv — helps us focus on what matters: writing and maintaining great packages.

It’s time to modernise.

Chris Beaven

About the author

Chris Beaven

It’s hard to get very far in the Django community without bumping into SmileyChris. He was a member of the core developer team for Django. In addition, he maintains several open source third-party Django applications. Chris …

View Chris's profile