Reusable Apps & Third-Party Packages

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1 on PyPI, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15 or newer, with the Django 5.2 LTS differences noted where they matter; every version number was checked on 2026-10-03. Every listing was run in a scratch project on Python 3.13 and Django 6.1.1 with SQLite: the example app was built into a wheel and an sdist with both uv build (uv 0.12.22) and python -m build, installed into a separate project, migrated, served with runserver and fetched with curl; its own test suite passed under Django 5.2.17, 6.0.8 and 6.1.1 with warnings turned into errors; and the third-party packages in the ecosystem table that are plain Django apps were installed together and passed manage.py check. Django’s tutorial ends with two steps that this page expands: part 8 adds a community package (the Debug Toolbar) to a project, and the "advanced tutorial" turns the tutorial’s polls app into an installable package. The Bookshelf application that grows across this section uses both directions: it consumes packages such as django-allauth and WhiteNoise, and it extracts its star-rating feature into a reusable app, django-book-ratings, that another project can pip install.

In a hurry: what goes where

Need Rule of thumb Section

Make an app reusable

Everything the app needs lives inside its package: models, migrations, templates, static files, URLs, admin, checks. Nothing in it imports the project.

What makes an app reusable

Name the app

Distribution django-xyz, import package django_xyz, a short unique AppConfig.label that does not clash with contrib apps.

AppConfig for a reusable app

Ship templates and static files

templates/<label>/…​ and static/<label>/…​ so they never collide with another app’s files and can be overridden by the project.

Namespacing templates, static files and URLs

Ship migrations

Generate them from a minimal test settings module; point at the user model with settings.AUTH_USER_MODEL, never edit a released migration.

Migrations shipped with the app

Test the app without a project

A runtests.py script plus tests/test_settings.py, as in the official testing docs.

Testing the app without a project

Describe the package

pyproject.toml with a [build-system] table, [project] metadata, dependencies and Framework :: Django :: X.Y classifiers.

Describing the package in pyproject.toml

Build and install it

uv build or python -m build produces dist/.whl and dist/.tar.gz; install the wheel into a project and run check and migrate.

Building, inspecting and installing the package

Promise compatibility

Django>=5.2 in dependencies, one classifier per tested Django version, a test matrix per supported release.

Versioning and Django version support

Add someone else’s package

Install it, then follow its installation guide (INSTALLED_APPS, URLs, middleware, settings).

Adding a third-party package (tutorial part 8)

Choose between packages

Django Packages grids, the well-maintained test, classifiers and release dates on PyPI.

Evaluating packages before you depend on them

Know the usual suspects

The curated table of community packages, each linked to its docs and to the page of this section that uses it.

A curated ecosystem table

What makes an app reusable

A Django project is a settings module, a root URLconf and the deployment around them; an app is a Python package that provides one feature to a project (see Project Structure & Apps). Every app is reusable in principle, because a project only refers to it by its import path in INSTALLED_APPS. In practice an app becomes reusable when it stops depending on the project around it:

  • it owns its models, migrations, templates, static files, URLconf, admin registrations, forms and system checks, all inside its own package directory;

  • it never imports from the project (config.settings, another local app) and reads configuration only through django.conf.settings, with documented defaults;

  • it refers to the user model through settings.AUTH_USER_MODEL and get_user_model() rather than django.contrib.auth.models.User, so it works with a custom user model;

  • its templates extend a base template the project is expected to provide, or none at all, and every template and static file sits in a directory named after the app so the project can override it;

  • it ships a test suite that runs without any project.

The official advanced tutorial starts from the tutorial’s project and observes that the polls directory could already be copied into another project and work; what it lacks is packaging. A copied directory has no version, no declared dependencies and no upgrade path, so the rest of this page turns the Bookshelf ratings feature into a proper distribution.

flowchart LR A["bookshelf/ratings app
inside the project"] --> B["django-book-ratings/
pyproject.toml + django_book_ratings/"] B --> C["runtests.py
tests pass on 5.2, 6.0, 6.1"] C --> D["uv build / python -m build"] D --> E["dist/*.whl + dist/*.tar.gz"] E --> F["pip install / uv pip install
in any project"] E --> G["upload to PyPI
or a private index"] F --> H["INSTALLED_APPS + include()
migrate"]
Figure 1. From an app inside a project to an installed distribution

The package layout

Create the package in its own directory outside any project (and, eventually, in its own repository). The tutorial’s naming advice is the convention the whole ecosystem follows: check PyPI for a free name, use a django- prefix for the distribution and the matching django_ prefix for the import package, exactly as django-ratelimit provides the django_ratelimit module.

django-book-ratings/ (the reusable app as a distribution)
django-book-ratings/
├── LICENSE
├── MANIFEST.in
├── README.rst
├── pyproject.toml
├── runtests.py
├── django_book_ratings/
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── models.py
│   ├── urls.py
│   ├── views.py
│   ├── migrations/
│   │   ├── __init__.py
│   │   ├── 0001_initial.py
│   │   └── 0002_rating_rated_by.py
│   ├── static/
│   │   └── book_ratings/
│   │       └── ratings.css
│   └── templates/
│       └── book_ratings/
│           └── rating_list.html
└── tests/
    ├── __init__.py
    ├── test_settings.py
    ├── test_ratings.py
    └── urls.py

The tests/ package sits next to the app, not inside it, so it is not installed into users' environments. The README.rst doubles as the long description on PyPI and should contain the quick start a user needs: what to add to INSTALLED_APPS, which URLconf to include and that migrate is required. Code published without a LICENSE cannot legally be reused; Django itself and most of its ecosystem use the BSD licence.

AppConfig for a reusable app

The app’s AppConfig is the one place where a reusable app pins down how projects will see it.

django_book_ratings/apps.py
from django.apps import AppConfig


class BookRatingsConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "django_book_ratings"
    label = "book_ratings"
    verbose_name = "Book ratings"
  • name is the full Python path of the package and must match the import package, here django_book_ratings.

  • label defaults to the last component of name. It is what appears in migration names (book_ratings.0001_initial), database table names (book_ratings_rating), permission codenames, app_label lookups and admin URLs, so it must be unique in INSTALLED_APPS and should not reuse a contrib label such as auth, admin or messages. Choose it once: changing a label after release orphans every user’s migration history and tables.

  • default_auto_field fixes the primary-key type of the app’s models. Without it the app follows the project’s DEFAULT_AUTO_FIELD, so two projects would generate different migrations from the same models and your shipped migrations would no longer match. Setting it on the AppConfig is exactly what the tutorial recommends.

  • verbose_name is the human-readable name shown in the admin.

Because the package has exactly one AppConfig subclass in apps.py, Django selects it automatically, and users can write either "django_book_ratings" or "django_book_ratings.apps.BookRatingsConfig" in INSTALLED_APPS. If an app needs start-up work (registering signal handlers or system checks), it does so in AppConfig.ready() as described in How apps are loaded and what ready() is for.

The model below is the whole feature. It refers to users through settings.AUTH_USER_MODEL, so it works whatever user model the project chose.

django_book_ratings/models.py
from django.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models


class Rating(models.Model):
    book_title = models.CharField(max_length=200)
    stars = models.PositiveSmallIntegerField(
        validators=[MinValueValidator(1), MaxValueValidator(5)]
    )
    created = models.DateTimeField(auto_now_add=True)
    rated_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="book_ratings",
    )

    class Meta:
        ordering = ["-created"]

    def __str__(self):
        return f"{self.book_title}: {self.stars}/5"

Namespacing templates, static files and URLs

Django searches templates and static files across all installed apps and uses the first match. The app-directories template loader looks in each app’s templates/ directory in INSTALLED_APPS order, after the project’s DIRS; the app-directories static finder does the same with each app’s static/. If two apps both shipped templates/list.html or static/style.css, one would silently shadow the other. The fix is to put every file one level deeper, in a directory named after the app label: templates/book_ratings/ and static/book_ratings/.

django_book_ratings/templates/book_ratings/rating_list.html
{% load static %}
<link rel="stylesheet" href="{% static 'book_ratings/ratings.css' %}">
<h1>Ratings</h1>
<ul>
{% for rating in ratings %}
  <li class="rating">{{ rating }}</li>
{% empty %}
  <li>No ratings yet.</li>
{% endfor %}
</ul>

The view renders the namespaced name, and the app’s URLconf declares an app_name so that reversing never clashes with another app’s "list" URL (see URLs & Routing).

django_book_ratings/views.py and django_book_ratings/urls.py
# views.py
from django.shortcuts import render

from .models import Rating


def rating_list(request):
    ratings = Rating.objects.all()[:20]
    return render(request, "book_ratings/rating_list.html", {"ratings": ratings})


# urls.py
from django.urls import path

from . import views

app_name = "book_ratings"
urlpatterns = [
    path("", views.rating_list, name="list"),
]

Namespacing is also what makes the app customisable. A project that keeps a templates/ directory in DIRS can replace any of the app’s templates by creating a file with the same relative name; the project directory is searched first, so the override wins without touching the installed package.

Project override: templates/book_ratings/rating_list.html
<h1>Bookshelf ratings</h1>
{% for rating in ratings %}<p>{{ rating.book_title }} ({{ rating.stars }} stars)</p>{% endfor %}
$ curl -s http://127.0.0.1:8000/ratings/
<h1>Bookshelf ratings</h1>
<p>Dune (5 stars)</p>

Keep the overridable surface small and documented: a reusable app usually offers one base.html of its own that its other templates extend, with named blocks the project can fill, rather than forcing the project to copy every template. Templates, tags and filters are covered in depth in Templates; static files and their deployment in Static Files & Front-End Assets.

Migrations shipped with the app

A reusable app ships its migrations inside the package; users run migrate, never makemigrations, for your app. The migration files are therefore part of the app’s public contract (the migration mechanics themselves are covered in Migrations):

  • generate them from the app’s own minimal settings, not from a real project, so nothing project-specific leaks in;

  • refer to the user model through settings.AUTH_USER_MODEL in the model, which makes makemigrations emit a swappable_dependency instead of a hard dependency on auth;

  • never edit or renumber a migration once it is released: add a new one, as 0002_rating_rated_by does in version 0.2.0;

  • keep a migration for every model change, and fail CI if one is missing (makemigrations --check);

  • data migrations must use the historical models from apps.get_model(), never imports from models.py.

$ cd django-book-ratings
$ DJANGO_SETTINGS_MODULE=tests.test_settings PYTHONPATH=. django-admin makemigrations book_ratings --name rating_rated_by
Migrations for 'book_ratings':
  django_book_ratings/migrations/0002_rating_rated_by.py
    + Add field rated_by to rating
$ DJANGO_SETTINGS_MODULE=tests.test_settings PYTHONPATH=. django-admin makemigrations --check --dry-run
No changes detected

makemigrations takes the app label (book_ratings), not the package name; with django_book_ratings it answers "No installed app with label 'django_book_ratings'. Did you mean 'book_ratings'?". The generated migration shows the swappable dependency:

django_book_ratings/migrations/0002_rating_rated_by.py (generated)
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ('book_ratings', '0001_initial'),
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
    ]

    operations = [
        migrations.AddField(
            model_name='rating',
            name='rated_by',
            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='book_ratings', to=settings.AUTH_USER_MODEL),
        ),
    ]

When the migration history grows long, squash it (squashmigrations) and keep the squashed and original files side by side for at least one release, so users who are part-way through the old chain can still upgrade; delete the originals only in a later release that requires users to have applied the squash.

Testing the app without a project

Django’s testing docs describe the standard layout for a reusable app: a tests package next to the app, a settings module containing only what the tests need, and a runtests.py that boots Django and calls the configured test runner.

runtests.py
#!/usr/bin/env python
import os
import sys

import django
from django.conf import settings
from django.test.utils import get_runner

if __name__ == "__main__":
    os.environ["DJANGO_SETTINGS_MODULE"] = "tests.test_settings"
    django.setup()
    TestRunner = get_runner(settings)
    test_runner = TestRunner()
    failures = test_runner.run_tests(["tests"])
    sys.exit(bool(failures))
tests/test_settings.py, tests/urls.py and tests/test_ratings.py
# tests/test_settings.py -- only for the test suite, never for a deployment
from django.core.management.utils import get_random_secret_key

SECRET_KEY = get_random_secret_key()  # throw-away key for the test run
INSTALLED_APPS = [
    "django.contrib.contenttypes",
    "django.contrib.auth",
    "django.contrib.staticfiles",
    "django_book_ratings",
]
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
ROOT_URLCONF = "tests.urls"
STATIC_URL = "static/"
TEMPLATES = [
    {"BACKEND": "django.template.backends.django.DjangoTemplates", "APP_DIRS": True}
]
USE_TZ = True

# tests/urls.py
from django.urls import include, path

urlpatterns = [path("ratings/", include("django_book_ratings.urls"))]

# tests/test_ratings.py
from django.test import TestCase
from django.urls import reverse

from django_book_ratings.models import Rating


class RatingListTests(TestCase):
    def test_lists_ratings(self):
        Rating.objects.create(book_title="Dune", stars=5)
        response = self.client.get(reverse("book_ratings:list"))
        self.assertContains(response, "Dune: 5/5")
        self.assertTemplateUsed(response, "book_ratings/rating_list.html")
$ python -W error runtests.py
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.005s

OK
Destroying test database for alias 'default'...

The hardcoded SECRET_KEY is acceptable only because this module configures a throw-away test run with an in-memory database; real projects read it from the environment (see Settings & Configuration). Running with -W error turns Django’s deprecation warnings into failures, which is the cheapest early warning that the app uses an API scheduled for removal. pytest users get the same effect with pytest-django and a DJANGO_SETTINGS_MODULE entry in their configuration (see pytest, Factories & Browser Tests).

Describing the package in pyproject.toml

pyproject.toml is the single, tool-independent description of a Python distribution. Its [build-system] table names the build backend that turns the source tree into a wheel and an sdist; its [project] table holds the metadata that ends up on PyPI and that installers use to resolve dependencies. The official tutorial uses setuptools as the backend, and so does the listing below; it is the tutorial’s file filled in for a real app.

django-book-ratings/pyproject.toml
[build-system]
requires = ["setuptools>=83"]
build-backend = "setuptools.build_meta"

[project]
name = "django-book-ratings"
version = "0.2.0"
description = "A reusable Django app that stores star ratings for books."
readme = "README.rst"
license = "BSD-3-Clause"
license-files = ["LICENSE"]
requires-python = ">=3.12"
authors = [{ name = "Your Name", email = "yourname@example.com" }]
dependencies = [
    "Django>=5.2",
]
classifiers = [
    "Environment :: Web Environment",
    "Framework :: Django",
    "Framework :: Django :: 5.2",
    "Framework :: Django :: 6.0",
    "Framework :: Django :: 6.1",
    "Intended Audience :: Developers",
    "Operating System :: OS Independent",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3 :: Only",
    "Programming Language :: Python :: 3.12",
    "Programming Language :: Python :: 3.13",
    "Programming Language :: Python :: 3.14",
    "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
]

[project.urls]
Homepage = "https://example.com/django-book-ratings/"
Changelog = "https://example.com/django-book-ratings/changelog/"

[tool.setuptools.packages.find]
include = ["django_book_ratings*"]

What each part does:

  • [build-system] — requires is installed into an isolated build environment, then build-backend is called. The tutorial pins setuptools>83; any recent setuptools understands the SPDX license expression and license-files.

  • name and version — the distribution name users pip install, and the version they pin. The import name (django_book_ratings) is independent of it.

  • license — an SPDX expression (BSD-3-Clause), with the licence text attached through license-files. This PEP 639 form supersedes the older License :: classifiers, so do not add both.

  • requires-python — installers skip the release on older interpreters. Match it to the oldest Python your oldest supported Django accepts and you test on.

  • dependencies — the runtime requirements. A lower bound on Django is the norm; see Versioning and Django version support for why an upper bound is usually left out.

  • classifiers — searchable labels on PyPI. Framework :: Django plus one Framework :: Django :: X.Y per supported feature release is how users (and Django Packages) learn which Django versions you test against.

  • [tool.setuptools.packages.find] — limits automatic discovery to the app package, so the top-level tests package is not installed as an importable tests module into users' environments.

Setuptools only puts non-Python files into the package when they are listed as package data, so the tutorial adds a MANIFEST.in for templates and static files:

django-book-ratings/MANIFEST.in
recursive-include django_book_ratings/templates *
recursive-include django_book_ratings/static *

This file is the most common reason a freshly published app fails with TemplateDoesNotExist. In the scratch project, building the same tree without MANIFEST.in (and outside a Git checkout, so no VCS plugin could add the files) produced a wheel with Python modules and migrations but with zero templates and static files.

Alternative: the uv build backend

Any standards-compliant backend works, because installers only talk to it through the [build-system] interface. uv ships its own backend, uv_build, which includes every file under the module directory (templates and static files included), so no MANIFEST.in is needed. Its default layout expects the package under src/; for the flat layout used by the tutorial, set module-root to an empty string:

pyproject.toml changes for uv_build (the [project] table stays the same)
[build-system]
requires = ["uv_build>=0.12,<0.13"]
build-backend = "uv_build"

[tool.uv.build-backend]
module-name = "django_book_ratings"
module-root = ""

The upper bound on uv_build is the form uv’s documentation recommends, because the backend’s configuration may change between minor versions. Hatchling, Flit and PDM backends are equally valid; whichever you pick, inspect the built wheel once (next section) before you publish.

Building, inspecting and installing the package

Either build frontend reads [build-system], creates an isolated environment with the backend and produces both distribution formats in dist/: a source distribution (.tar.gz) and a pure-Python wheel (py3-none-any.whl).

$ cd django-book-ratings
$ uv build
...
Successfully built dist/django_book_ratings-0.2.0.tar.gz
Successfully built dist/django_book_ratings-0.2.0-py3-none-any.whl

$ python -m pip install build   # once, if you use the PyPA frontend instead
$ python -m build
...
Successfully built django_book_ratings-0.2.0.tar.gz and django_book_ratings-0.2.0-py3-none-any.whl

A wheel is a zip file, so its contents can be checked with the standard library before anything is published. The listing must contain the migrations, the templates and the static files:

$ python -m zipfile -l dist/django_book_ratings-0.2.0-py3-none-any.whl | awk '{print $1}'
File
django_book_ratings/__init__.py
django_book_ratings/admin.py
django_book_ratings/apps.py
django_book_ratings/models.py
django_book_ratings/urls.py
django_book_ratings/views.py
django_book_ratings/migrations/0001_initial.py
django_book_ratings/migrations/0002_rating_rated_by.py
django_book_ratings/migrations/__init__.py
django_book_ratings/static/book_ratings/ratings.css
django_book_ratings/templates/book_ratings/rating_list.html
django_book_ratings-0.2.0.dist-info/licenses/LICENSE
django_book_ratings-0.2.0.dist-info/METADATA
django_book_ratings-0.2.0.dist-info/WHEEL
django_book_ratings-0.2.0.dist-info/top_level.txt
django_book_ratings-0.2.0.dist-info/RECORD

The METADATA file is what PyPI and installers read; the classifiers and the Django requirement from pyproject.toml appear there verbatim:

$ unzip -p dist/django_book_ratings-0.2.0-py3-none-any.whl '*/METADATA' | grep -E '^(Requires|Classifier: Framework)'
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Framework :: Django :: 6.1
Requires-Python: >=3.12
Requires-Dist: Django>=5.2

Now install the wheel into a separate project’s virtual environment (the tutorial’s pip install --user works too, but a virtual environment is the robust choice the tutorial itself recommends at the end), and wire it in exactly as the README says:

$ cd ../bookshelf
$ uv pip install ../django-book-ratings/dist/django_book_ratings-0.2.0-py3-none-any.whl
 + django-book-ratings==0.2.0
# or: python -m pip install ../django-book-ratings/dist/django_book_ratings-0.2.0-py3-none-any.whl
config/settings.py and config/urls.py in the consuming project
# config/settings.py
INSTALLED_APPS = [
    # ... the django.contrib apps ...
    "django_book_ratings",
]

# config/urls.py
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("ratings/", include("django_book_ratings.urls")),
]
$ python -W error manage.py check
System check identified no issues (0 silenced).
$ python manage.py migrate
...
  Applying book_ratings.0001_initial... OK
  Applying book_ratings.0002_rating_rated_by... OK
$ python manage.py findstatic book_ratings/ratings.css
Found 'book_ratings/ratings.css' here:
  .../site-packages/django_book_ratings/static/book_ratings/ratings.css
$ python manage.py shell -c "from django_book_ratings.models import Rating; Rating.objects.create(book_title='Dune', stars=5)"
$ python manage.py runserver
$ curl -s http://127.0.0.1:8000/ratings/
<link rel="stylesheet" href="/static/book_ratings/ratings.css">
<h1>Ratings</h1>
<ul>

  <li class="rating">Dune: 5/5</li>

</ul>

Upgrading is the same: install the 0.2.0 wheel over 0.1.0 and run migrate, which applies only the new 0002_rating_rated_by migration. During development, an editable install (uv pip install -e ../django-book-ratings or python -m pip install -e ../django-book-ratings) makes the project use the working tree directly, so edits take effect without rebuilding.

Publishing means uploading the files in dist/ to PyPI or to a private index. The Python Packaging User Guide walks through it with TestPyPI first; uv publish and twine upload are the usual commands, and PyPI’s trusted publishing lets a CI workflow upload without a long-lived API token stored anywhere.

Versioning and Django version support

A reusable app makes two promises in its metadata: its own version number, and the Django and Python versions it works with. Both should follow from Django’s own release process (summarised in Introduction & Architecture).

Your version number. Most Django packages use semantic-style MAJOR.MINOR.PATCH versions, and a few (django-filter 26.1, crispy-bootstrap5 2026.9) use calendar versions; either is fine as long as the changelog says which releases contain migrations, breaking changes or dropped Django versions. Dropping a Django or Python version is a breaking change for the users still on it, so do it in a release whose number makes that visible and say so in the changelog.

Which Django versions to support. Support every Django release that is still supported upstream. On 2026-10-03 that is 5.2 LTS (extended support until April 2028), 6.0 (until April 2027) and 6.1 (until December 2027); 6.2 LTS follows in April 2027. Django’s deprecation policy explains why the LTS matters to app authors: a feature deprecated in one release keeps working, with a warning, for at least two feature releases, and the policy explicitly asks third-party apps to stay compatible back to the previous LTS so users can upgrade LTS to LTS. Django 6.1 also adopted calendar versioning: after 6.2 the next releases are Django 2028 and Django 2029, and the deprecation warning classes follow (RemovedInDjango2028Warning).

How to declare it.

  • dependencies = ["Django>=5.2"] — a lower bound equal to the oldest version you test.

  • One Framework :: Django :: X.Y classifier per tested feature release, updated when you add a release to the test matrix.

  • No upper bound (Django<6.2) by default. Because deprecations are announced two releases ahead, an app tested with warnings as errors usually works on the next release unchanged, and an upper bound would block users from upgrading Django until you publish a release. Packages that do need a cap, for example because they patch Django internals, should document why.

  • requires-python matching the oldest Python that both your oldest Django and your test matrix use: Django 5.2 accepts Python 3.10-3.14, Django 6.0 and 6.1 accept 3.12-3.14, so an app that requires 3.12 still serves 5.2 users who are on 3.12 or newer.

How to prove it. Run the suite once per supported Django release, with deprecation warnings turned into errors. A CI matrix does this in production; locally, uv can run each combination in a throw-away environment:

$ for django in "5.2.*" "6.0.*" "6.1.*"; do
>   uv run --isolated --no-project -p 3.13 --with "django==$django" python -W error runtests.py > /dev/null 2>&1
>   echo "Django $django: exit $?"
> done
Django 5.2.*: exit 0
Django 6.0.*: exit 0
Django 6.1.*: exit 0

tox and nox are the traditional tools for the same matrix, and django-upgrade (1.33.0) rewrites code that uses deprecated Django APIs, which keeps the warnings-as-errors run green when a new release lands. The API stability promise that makes all of this possible is documented in API stability.

Adding a third-party package (tutorial part 8)

Part 8 of the tutorial is about the other direction: using an app someone else packaged. Its example is Django Debug Toolbar, maintained by the Django Commons organisation and consistently among the most used packages in the Django Developers Survey. The general recipe has two halves:

  1. install the distribution into the project’s virtual environment, and record it in the project’s dependency file (pyproject.toml, a lock file or a requirements file);

  2. integrate it by following the package’s own installation guide, which usually means an INSTALLED_APPS entry and often URLs, middleware and settings.

The tutorial deliberately does not copy the toolbar’s steps, because a third-party package changes on its own schedule. For django-debug-toolbar 8.0.0 (2026-09-01, classified for Django 5.2, 6.0 and 6.1) its installation guide asks for the following, which the scratch project ran on Django 6.1.1:

$ python -m pip install django-debug-toolbar   # or: uv add --dev django-debug-toolbar
config/settings.py and config/urls.py (development settings only)
# config/settings.py -- staticfiles and APP_DIRS are already in the startproject template
INSTALLED_APPS = [
    # ...
    "django.contrib.staticfiles",
    "django_book_ratings",
    "debug_toolbar",
]

MIDDLEWARE = [
    "debug_toolbar.middleware.DebugToolbarMiddleware",  # as early as possible
    # ... the rest of the startproject middleware ...
]

INTERNAL_IPS = ["127.0.0.1"]

# config/urls.py
from debug_toolbar.toolbar import debug_toolbar_urls
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("ratings/", include("django_book_ratings.urls")),
] + debug_toolbar_urls()
$ python -W error manage.py check
System check identified no issues (0 silenced).
$ curl -s http://127.0.0.1:8000/admin/login/ | grep -c djDebug
64

The middleware must come after any middleware that encodes the response, such as GZipMiddleware; the toolbar only appears for addresses in INTERNAL_IPS; and it belongs in development settings only, never in production. The toolbar itself is used to find duplicated queries in Query Optimization & Fetch Modes, and the order of middleware is explained in Middleware.

When an installed package misbehaves, the tutorial’s advice applies to every package: read its troubleshooting or tips page, search its issue tracker for the same symptom, then ask on the Django Forum or the Django Discord server.

Evaluating packages before you depend on them

Every dependency is code you ship, upgrade and secure. The tutorial points to two tools for choosing.

Django Packages (https://djangopackages.org/) is the community directory of reusable apps. Its grids compare the packages that solve the same problem (authentication, REST frameworks, CMSs, task queues…​) side by side (stars, Python 3 support, recent commit activity and more), so a grid is the right first stop when you need "a package for X".

The well-maintained test, a list of yes/no questions by Adam Johnson that the tutorial links, turns "is it maintained?" into concrete signals; each "yes" scores a point and you decide the pass mark from your risk tolerance. In summary, it asks whether the package:

  • is described as production ready (no alpha/beta wording or pre-release version);

  • has sufficient documentation and a changelog that covers recent releases;

  • has someone responding to bug reports;

  • has tests, run in CI that is passing, against the latest Python and the latest Django;

  • seems relatively well used;

  • has had a commit and a release in the last year.

On top of the test, a maintainer team rather than a single person (an organisation such as Django Commons or Jazzband is a good sign) and a documented way to report security issues both lower the risk of being stranded.

PyPI’s JSON API exposes several of these signals, so they can be checked in a few lines before adding a dependency:

check_package.py
import json
import sys
from urllib.request import urlopen


def summarise(name):
    with urlopen(f"https://pypi.org/pypi/{name}/json", timeout=10) as response:
        data = json.load(response)
    info = data["info"]
    files = data["releases"].get(info["version"], [])
    uploaded = files[0]["upload_time_iso_8601"][:10] if files else "unknown"
    django = [
        c.rsplit("::", 1)[1].strip()
        for c in info["classifiers"]
        if c.startswith("Framework :: Django :: ")
    ]
    print(f"{info['name']} {info['version']} (uploaded {uploaded})")
    print(f"  requires-python: {info['requires_python']}")
    print(f"  Django classifiers: {', '.join(django) or 'none'}")
    status = [
        c.split(" :: ")[-1]
        for c in info["classifiers"]
        if c.startswith("Development Status")
    ]
    print(f"  development status: {', '.join(status) or 'not declared'}")


if __name__ == "__main__":
    for package in sys.argv[1:]:
        summarise(package)
$ python check_package.py django-filter django-extensions
django-filter 26.1 (uploaded 2026-07-11)
  requires-python: >=3.10
  Django classifiers: 5.2, 6.0, 6.1
  development status: 5 - Production/Stable
django-extensions 4.1 (uploaded 2025-04-11)
  requires-python: >=3.9
  Django classifiers: 4.2, 5.1, 5.2
  development status: 5 - Production/Stable

Read classifiers as a floor, not a verdict. Many healthy packages update them only with their next release: django-extensions 4.1 stops at 5.2 and django-storages 1.14.6 at 5.1, yet both installed and passed manage.py check under Django 6.1.1 in the scratch project. A missing classifier is a reason to run your own test suite against the package and to look at its CI and open issues, not by itself a reason to reject it. Conversely, a fresh release with no docs and one maintainer is a risk even if it lists every Django version. When the answer to one of the questions is "no", the tutorial’s suggestion is to treat it as an opportunity to contribute.

A curated ecosystem table

The Django project maintains a short, curated list of "incredibly useful and well-supported" community packages on its ecosystem page, grouped by purpose. The table picks the eleven that a typical project such as Bookshelf meets first. Versions, release dates and Django classifiers are from PyPI on 2026-10-03; "6.1 check" says whether the package was installed into the Django 6.1.1 scratch project and passed manage.py check (Wagtail and django CMS are whole applications with their own project templates and were not installed).

Package (import name) Ecosystem group What it gives you Latest on PyPI Django classifiers 6.1 check Used on

django-debug-toolbar (debug_toolbar)

Debugging & Development Tools

Panels showing SQL queries, templates, cache calls, signals and settings for each request; development only.

8.0.0 (2026-09-01)

5.2, 6.0, 6.1

yes

Query Optimization & Fetch Modes

django-extensions (django_extensions)

Debugging & Development Tools

Extra management commands (shell_plus, show_urls, graph_models, runserver_plus) and model field helpers.

4.1 (2025-04-11)

4.2, 5.1, 5.2

yes

Management Commands & Shell

django-allauth (allauth)

Authentication & Authorization

Sign-up, login, email verification, password reset, MFA and social (OAuth/OpenID Connect) accounts.

65.19.7 (2026-10-01)

4.2-6.1

yes

Authentication

django-environ (environ)

Environment Configuration

Twelve-factor settings: typed environment variables, .env files and URL-style database and cache settings.

0.14.0 (2026-06-18)

2.2-6.0

yes

Settings & Configuration

django-storages (storages)

Storage & Static Files

Storage backends for Amazon S3 and S3-compatible services, Azure, Google Cloud Storage, SFTP and others, configured through STORAGES.

1.14.6 (2025-04-02)

3.2, 4.1-5.1

yes

File Uploads & Storage

WhiteNoise (whitenoise)

Storage & Static Files

Serves compressed, cache-busted static files straight from the Python process, without a separate web server.

6.12.0 (2026-02-27)

4.2-6.0

yes

Static Files & Front-End Assets

django-crispy-forms (crispy_forms)

Forms & Views

The {% crispy %} tag and the |crispy filter render forms through template packs such as crispy-bootstrap5 (2026.9).

2.7 (2026-07-29)

5.2, 6.0, 6.1

yes

Form Rendering & Widgets

django-filter (django_filters)

Utilities & Miscellaneous

Declarative FilterSet classes that turn query-string parameters into queryset filters, for views and DRF.

26.1 (2026-07-11)

5.2, 6.0, 6.1

yes

Django REST Framework

django-import-export (import_export)

Admin Interface Enhancements

Import and export model data (CSV, XLSX, JSON and more) from the admin, with previews and validation.

4.4.1 (2026-05-05)

4.2, 5.1, 5.2

yes

Admin Customization

Wagtail (wagtail)

Content Management Systems

A full CMS built on Django: page trees, StreamField content blocks, image and document libraries, editor workflows.

8.0 (2026-08-25)

5.2, 6.0, 6.1

not run

 — 

django CMS (cms)

Content Management Systems

An editor-friendly CMS with in-place editing, multilingual pages and a plugin architecture.

5.1.3 (2026-09-23)

5.2, 6.0, 6.1

not run

 — 

Source: Django community ecosystem: Community Packages and the PyPI pages listed in References

The scratch project installed the nine plain apps together and ran the check with all of them enabled:

config/eco_settings.py (scratch check of the ecosystem apps)
from .settings import *  # noqa: F403

INSTALLED_APPS += [  # noqa: F405
    "django_extensions",
    "import_export",
    "storages",
    "crispy_forms",
    "crispy_bootstrap5",
    "django_filters",
    "allauth",
    "allauth.account",
]
MIDDLEWARE += ["allauth.account.middleware.AccountMiddleware"]  # noqa: F405
MIDDLEWARE.insert(1, "whitenoise.middleware.WhiteNoiseMiddleware")  # noqa: F405
$ uv pip install django-extensions django-import-export whitenoise django-storages django-environ \
    django-crispy-forms crispy-bootstrap5 django-filter django-allauth
$ python manage.py check --settings=config.eco_settings
System check identified no issues (0 silenced).

The WhiteNoise middleware goes directly after SecurityMiddleware (index 1 in the startproject list), and allauth 65.x requires its AccountMiddleware; django-environ and django-debug-toolbar are configured as shown on their own pages. The same ecosystem page lists further groups (API development with DRF and Django Ninja, background tasks, security middleware, testing, internationalisation, email) and two "exploratory" packages that may move into Django itself; the pages of this section link the relevant ones where they are used, and Django Packages covers the long tail.

Pitfalls checklist

  • Templates or static files missing from the wheel. With setuptools, list them in MANIFEST.in (or use a VCS-aware plugin); always inspect the wheel with python -m zipfile -l before publishing.

  • Un-namespaced templates and static files (templates/list.html, static/style.css) silently shadow, or are shadowed by, another app’s files.

  • No default_auto_field on the AppConfig: the app’s primary-key type then depends on each project’s DEFAULT_AUTO_FIELD, and makemigrations in a project creates migrations inside your installed package.

  • A label that collides with a contrib app or another package, or a label changed after release.

  • Migrations generated from a real project, hard dependencies on auth.User instead of settings.AUTH_USER_MODEL, or edited released migrations.

  • Importing the project (from config import settings, from bookshelf.models import …​) from inside the app.

  • Installing the tests package: exclude it from package discovery, or every app that forgets ends up fighting over a top-level tests module in users' environments.

  • Classifiers that lie: claiming a Django version the test matrix does not run, or never updating them.

  • An upper bound on Django without a reason, which blocks users from security releases of the next feature version.

  • python setup.py sdist / bdist_wheel: running setup.py as a command is deprecated; use python -m build or uv build.

  • Treating "installed" as "integrated": most third-party packages also need URLs, middleware (in the right position) or settings; follow their installation guide, not a blog post written for an older version.

  • Development tools in production: django-debug-toolbar and runserver_plus belong in development settings and development dependencies only.

Where the books are outdated

Two Scoops of Django 1.11 (2017) has the most complete treatment of the subject among the four books: its chapter on third-party packages recommends Django Packages, tells authors to declare install_requires in setup.py, scaffolds new packages with cookiecutter-djangopackage, and builds with python setup.py sdist and python setup.py bdist_wheel before uploading with twine, using a setup.cfg [wheel] universal = 1 section to produce Python 2/3 wheels. The advice on choosing packages and on using twine instead of the old setup.py upload still holds. The mechanics do not:

  • [project] metadata in pyproject.toml (PEP 621, 2020) replaced setup.py arguments such as install_requires; Django’s tutorial shows only pyproject.toml since the 5.1 docs (the 5.0 docs still used setup.cfg with a stub setup.py).

  • Invoking setup.py as a command is deprecated; python -m build and uv build replace sdist/bdist_wheel, and pip install . replaces setup.py install.

  • Universal wheels are irrelevant now that every supported Django is Python 3 only; a pure-Python app builds a py3-none-any wheel.

  • Licences are declared with an SPDX expression (license = "BSD-3-Clause") and license-files, not classifiers.

  • PyPI trusted publishing lets CI upload without storing an API token.

The other books use many packages but do not package an app. Their package versions have moved on: crispy-forms 2 needs a separate template pack such as crispy-bootstrap5, model_mommy became model_bakery, and Django Debug Toolbar’s setup now uses debug_toolbar_urls() instead of hand-written debug/ URL patterns. Check every package’s current installation guide rather than copying a book’s settings.

What changed recently

Version Change relevant to this page

Django 3.2 (2021-04-06)

AppConfig.default_auto_field and the DEFAULT_AUTO_FIELD setting; apps with a single AppConfig subclass in apps.py are selected automatically (no more default_app_config).

Django 4.1 (2022-08-03)

default_app_config support removed.

Django 5.0 docs

The advanced tutorial sets label and default_auto_field in the reusable app’s AppConfig.

Django 5.1 docs

The advanced tutorial uses pyproject.toml only, with an SPDX license string; later docs pin setuptools>83.

Django 6.0 (2025-12-03)

DEFAULT_AUTO_FIELD defaults to BigAutoField, so projects that never set it now match apps that set default_auto_field = "django.db.models.BigAutoField".

Django 6.1 (2026-09-02)

Calendar versioning adopted: after 6.2 LTS come Django 2028 and Django 2029, and deprecation warnings are named RemovedInDjango2028Warning; plan the classifiers and lower bounds of your app around that.

django-debug-toolbar 8.0.0 (2026-09-01)

Current release, classified for Django 5.2-6.1; installation uses debug_toolbar_urls().

The Django rows come from the 3.2, 4.1, 6.0 and 6.1 release notes and from the advanced tutorial in each version of the docs; the package row from PyPI.