Best Practices

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, all checked on 2026-10-03; where Django 5.2 LTS (supported until April 2028) behaves differently, the item says so. Every listing below was assembled into one small scratch project on Python 3.13 and Django 6.1.1 with SQLite: manage.py check passes under -W error, manage.py check --deploy reports only the HTTPS warnings expected of a local run, the data migration applies and reverses, and a suite of seven tests covering the views, form, service, task, cache and helpers passes.

Most of the advice here is distilled from Two Scoops of Django 1.11 (Daniel Roy Greenfeld and Audrey Roy Greenfeld, 4th edition, 2017), the best-practices catalogue consulted for this section, re-checked against the Django 6.1 documentation. The wording and examples are this site’s own. A surprising amount of that book is still right; where it has aged, the item says what replaced it and since which version, and Where Two Scoops 1.11 has aged collects the replacements in one table. Each item is a single rule of one or two sentences, with a link to the page of this section that explains it in depth. The examples use the Bookshelf application that grows across this section: a catalog app with a Book model and an accounts app with the custom user model.

Project layout

  • Keep three roots apart. The repository root holds tooling (pyproject.toml, README, CI files), the project root holds manage.py and the apps, and the configuration package holds settings, urls, asgi and wsgi; naming that package config makes its role obvious. See Project Structure & Apps.

  • One app, one job. An app should be describable in one sentence without "and"; split it when it is not. Plural nouns (reviews, orders) read well as app names. See Project Structure & Apps.

  • Shared helpers live in a core app (abstract models, template tags, utilities), not in a grab-bag utils.py at the project root. See Project Structure & Apps.

  • Use AppConfig and keep start-up work in ready(). List apps by their AppConfig path when it does work at start-up, and import signal handlers from ready() rather than from models.py. See Project Structure & Apps and Signals.

catalog/apps.py
from django.apps import AppConfig


class CatalogConfig(AppConfig):
    name = "catalog"

    def ready(self):
        from . import signals  # noqa: F401  (registers the receivers)
  • Start from a template, not from memory. startproject with a custom --template, or a community template such as cookiecutter-django, gives every project the same layout. See Getting Started and Project Structure & Apps.

  • Manage dependencies with pyproject.toml and a lock file. Two Scoops recommended virtualenvwrapper and one requirements/*.txt file per environment; that still works, but today python -m venv or uv with a locked pyproject.toml is the norm, and Django 6.1 itself needs Python 3.12 or newer. See Getting Started.

Settings

  • Commit every setting, never a secret. Settings files belong in version control; the secret key, database password and API keys come from the environment or a secret manager. See Settings & Configuration.

  • Fail fast when configuration is missing. Raise ImproperlyConfigured with a clear message instead of falling back to an insecure default, so a misconfigured server never starts. See Settings & Configuration.

config/settings.py (excerpt)
import os
from pathlib import Path

from django.core.exceptions import ImproperlyConfigured

BASE_DIR = Path(__file__).resolve().parent.parent


def env(name, default=None):
    value = os.environ.get(name, default)
    if value is None:
        raise ImproperlyConfigured(f"Set the {name} environment variable.")
    return value


SECRET_KEY = env("DJANGO_SECRET_KEY")  # no default: a missing key must stop start-up
DEBUG = env("DJANGO_DEBUG", "False") == "True"
ALLOWED_HOSTS = env("DJANGO_ALLOWED_HOSTS", "localhost").split(",")

A suitable value for DJANGO_SECRET_KEY can be generated with python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())".

  • Split per environment only as far as needed. A settings package with base.py plus local.py, test.py and production.py, selected with DJANGO_SETTINGS_MODULE, is still a good shape; libraries such as django-environ (0.14.0) read typed values from the environment. Two Scoops' JSON secrets file remains possible but is now the exception. See Settings & Configuration.

  • Build paths from BASE_DIR (a pathlib.Path since Django 3.1) instead of hard-coding absolute paths. See Settings & Configuration.

  • Keep environments alike. Use the same database engine (PostgreSQL for most projects) in development, CI and production, ideally in containers, so that a query that works locally works in production. See PostgreSQL & Database Backends.

  • Use the current settings names. MIDDLEWARE_CLASSES was removed in Django 2.0 (use MIDDLEWARE); DEFAULT_FILE_STORAGE and STATICFILES_STORAGE were replaced by STORAGES in 4.2 and removed in 5.1; USE_L10N was removed in 5.0; DEFAULT_AUTO_FIELD (3.2) defaults to BigAutoField since 6.0, so new projects need not set it — only older projects that never set it pin AutoField to avoid unwanted migrations. See Settings & Configuration and File Uploads & Storage.

Models

  • Share fields through abstract base classes. A TimeStampedModel with created and modified fields is still a sound pattern; Django 5.0 added db_default and GeneratedField for values the database should compute. See Model Inheritance & Meta API.

  • Avoid multi-table inheritance for anything queried often: every access joins the parent table. Prefer an abstract base, a proxy model or an explicit OneToOneField. See Model Inheritance & Meta API.

  • Decide null and blank deliberately. Text fields use blank=True and store "", never null=True (two "empty" values); other fields use null=True when "unknown" is a real state. See Models & Fields.

  • Use enumeration types for choices. models.TextChoices and IntegerChoices (Django 3.0) replaced the constants and Enum recipes in Two Scoops; since 5.0 choices also accepts the enumeration class directly, a mapping or a callable. See Models & Fields.

  • Put reusable queries on a custom QuerySet and expose it with as_manager(), so that Book.objects.published() chains like any other filter. See Managers & Custom QuerySets.

  • Reference the user model through settings.AUTH_USER_MODEL in relations (see The user model).

catalog/models.py
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _


class TimeStampedModel(models.Model):
    created = models.DateTimeField(auto_now_add=True)
    modified = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True


class BookQuerySet(models.QuerySet):
    def published(self):
        return self.filter(status=Book.Status.PUBLISHED)


class Book(TimeStampedModel):
    class Status(models.TextChoices):
        DRAFT = "draft", _("Draft")
        PUBLISHED = "published", _("Published")

    title = models.CharField(_("title"), max_length=200)
    subtitle = models.CharField(max_length=200, blank=True)  # "" not NULL
    pages = models.PositiveIntegerField(null=True, blank=True)  # unknown is NULL
    status = models.CharField(max_length=10, choices=Status, default=Status.DRAFT)
    owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    views = models.PositiveIntegerField(default=0)

    objects = BookQuerySet.as_manager()

    def __str__(self):
        return self.title
  • Keep "fat models" thin enough. Behaviour that belongs to one row goes on the model; workflows that touch several models or external services go into plain service functions (see Views and URLs), not a thousand-line model class. See Models & Fields.

  • Avoid GenericForeignKey when a real foreign key will do. Generic relations cannot be enforced by the database and are harder to query; use them only for genuinely polymorphic links such as tags or comments. See ContentTypes & Generic Relations.

  • Let the database guard invariants. Declare Meta.constraints (UniqueConstraint, CheckConstraint, Django 2.2) and Meta.indexes; index_together was removed in 5.1, and composite primary keys arrived in 5.2. Add indexes when a measured query needs them, not by reflex. See Constraints, Indexes & Composite Keys.

  • Use the cross-database models.JSONField (Django 3.1) instead of the PostgreSQL-only field Two Scoops describes, and BooleanField(null=True) instead of the removed NullBooleanField (4.0). See Models & Fields.

Migrations

  • Commit migrations with the model change and review the generated file; makemigrations --check in CI catches a model change that forgot its migration. See Migrations.

  • Data migrations use the historical model and stay reversible. Inside RunPython, get models with apps.get_model() (never import them) and always provide a reverse function, even if it is RunPython.noop.

catalog/migrations/0002_title_case_titles.py
from django.db import migrations


def title_case(apps, schema_editor):
    Book = apps.get_model("catalog", "Book")  # historical model, not an import
    for book in Book.objects.filter(title__regex=r"^[a-z]"):
        book.title = book.title.capitalize()
        book.save(update_fields=["title"])


class Migration(migrations.Migration):
    dependencies = [("catalog", "0001_initial")]

    operations = [
        migrations.RunPython(title_case, migrations.RunPython.noop),
    ]
  • Squash long histories with squashmigrations once every environment has applied them, and give hand-written migrations a descriptive --name. See Migrations.

  • Back up and rehearse before migrating production. migrate --plan shows what will run; a schema change on a large table may need to be split into several deploys. See Migrations.

  • Prefer data migrations or factories to fixtures for reference data that must exist everywhere. See Fixtures, Serialization & Initial Data.

Queries

  • Let the database do the arithmetic. Use F() for updates that depend on the current value (no race between read and write), Q() for complex conditions, and database functions and aggregates before reaching for raw SQL. See Making Queries and Aggregation, Expressions & Functions.

  • Kill N+1 queries with select_related() for forward foreign keys and prefetch_related() for many-valued relations, and assert the query count in tests. Django 6.1 adds fetch modes: QuerySet.fetch_mode(models.FETCH_PEERS) loads a missing relation for the whole batch at once, and FETCH_RAISE turns an accidental lazy load into an error. See Query Optimization & Fetch Modes.

  • Group related writes in transaction.atomic(), and start side effects such as emails or tasks with transaction.on_commit() so they never fire for a rolled-back change. Wrapping a single ORM call adds nothing. ATOMIC_REQUESTS is simple but holds a transaction open for the whole view; explicit blocks are usually clearer. See Transactions, Raw SQL & Multiple Databases.

catalog/services.py
from django.db import transaction
from django.db.models import F

from .models import Book
from .tasks import notify_owner


def publish_book(book_id):
    with transaction.atomic():
        book = Book.objects.select_for_update().get(pk=book_id)
        book.status = Book.Status.PUBLISHED
        book.save(update_fields=["status", "modified"])
        transaction.on_commit(lambda: notify_owner.enqueue(book_id))
    return book


def record_view(book_id):
    Book.objects.filter(pk=book_id).update(views=F("views") + 1)

Views and URLs

  • Keep business logic out of views and URLconfs. A view parses the request, calls a model method or service function and returns a response; that keeps the logic reusable from the admin, management commands, APIs and tasks. See Function-Based Views & Shortcuts.

  • Choose the simplest view that stays clear. A generic class-based view for standard CRUD, a function for anything unusual; Two Scoops' FBV-or-CBV decision guide is still a good one. See Class-Based Views and Generic Views.

  • Keep CBV inheritance shallow and put mixins on the left, so LoginRequiredMixin runs before the view. Since Django 5.1, LoginRequiredMiddleware can make login the default for the whole site, with login_not_required for the exceptions. See Class-Based Views and Authentication.

  • Name every URL and namespace every app. Set app_name in the app’s URLconf and use reverse() and the {% url %} tag, never hard-coded paths. Use path() with converters; django.conf.urls.url() from the Two Scoops era was removed in Django 4.0 (re_path() remains for genuine regular expressions), and since 2.0 a namespace needs app_name. See URLs & Routing.

  • Pass an explicit context, never locals(). See Function-Based Views & Shortcuts.

catalog/urls.py and catalog/views.py
# catalog/urls.py
from django.urls import path

from . import views

app_name = "catalog"
urlpatterns = [
    path("books/<int:pk>/", views.book_detail, name="book-detail"),
    path("books/new/", views.BookCreateView.as_view(), name="book-create"),
]

# catalog/views.py
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import get_object_or_404, render
from django.urls import reverse_lazy
from django.views.generic import CreateView

from .forms import BookForm
from .models import Book
from .services import record_view


def book_detail(request, pk):
    book = get_object_or_404(Book.objects.published().select_related("owner"), pk=pk)
    record_view(book.pk)
    return render(request, "catalog/book_detail.html", {"book": book})


class BookCreateView(LoginRequiredMixin, CreateView):  # mixin first
    model = Book
    form_class = BookForm
    success_url = reverse_lazy("catalog:book-create")

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs["user"] = self.request.user
        return kwargs

    def form_valid(self, form):
        form.instance.owner = self.request.user
        return super().form_valid(form)

Forms

  • Validate all input through a form or serializer, including query parameters and data from trusted partners. See Forms and Form Validation & Validators.

  • List model form fields explicitly. fields = "all" and Meta.exclude silently expose any field added later (mass assignment); set server-side values such as the owner in the view. See Model Forms & Formsets and Security.

  • Cross-field errors go through clean() and add_error(), so each message is attached to the right field; pass extra context such as request.user through get_form_kwargs() (shown in Views and URLs) rather than a global. See Form Validation & Validators.

catalog/forms.py
from django import forms
from django.utils.translation import gettext_lazy as _

from .models import Book


class BookForm(forms.ModelForm):
    class Meta:
        model = Book
        fields = ["title", "subtitle", "pages"]  # never "__all__" or exclude

    def __init__(self, *args, user, **kwargs):
        self.user = user
        super().__init__(*args, **kwargs)

    def clean(self):
        cleaned = super().clean()
        title = cleaned.get("title")
        if title and Book.objects.filter(owner=self.user, title=title).exists():
            self.add_error("title", _("You already have a book with this title."))
        return cleaned
  • State changes use POST with {% csrf_token %}; GET must be safe to repeat. Since Django 5.0 even logout requires POST. See Security and Authentication.

  • Customise rendering with templates, not Python string building. Django 4.0 made widgets and forms template-based (FORM_RENDERER), 5.0 made the <div> layout the default and added as_field_group; Two Scoops' widget subclassing for markup is rarely needed now. See Form Rendering & Widgets.

Templates

  • Keep templates "stupid". No queries, aggregation or API calls triggered from a template: prepare the data in the view (with select_related() and friends) and let the template only display it. See Templates and Query Optimization & Fetch Modes.

  • Prefer a filter to a tag, and write a custom tag only when a filter cannot express it. See Custom Template Tags & Filters.

  • Keep inheritance shallow: a base layout, a section layout, then pages. Django 6.0’s template partials ({% partialdef %}) cover the reusable fragments that used to need extra include files, and pair well with HTMX. See Template Partials & HTMX.

  • Leave auto-escaping on and build HTML in Python with format_html() (see Security). See Templates.

  • Make error templates static. 404.html and 500.html must not depend on context processors or the database, which may be what failed. See Templates.

catalog/templates/catalog/book_detail.html
<h1>{{ book.title|title }}</h1>
<p>{{ book.owner.get_username }} &middot; {{ book.pages|default:"?" }} pages</p>
<a href="{% url 'catalog:book-create' %}">Add a book</a>
  • Serve front-end assets through the staticfiles pipeline (collectstatic, hashed file names via STORAGES, WhiteNoise 6.12 or a CDN); the jQuery and django-compressor advice in Two Scoops has given way to Vite, HTMX and similar tools. See Static Files & Front-End Assets.

APIs

  • Keep API views thin and reuse the same services as the HTML views; serializers (DRF 3.18) or schemas (Django Ninja 1.7) validate input exactly as forms do. See Django REST Framework and Django Ninja.

  • Version the API from the first release, and retire old versions with a published timeline and finally 410 Gone. See Django REST Framework.

  • Throttle every public endpoint and document the limits. See Django REST Framework.

config/settings.py (DRF throttling)
REST_FRAMEWORK = {
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.AnonRateThrottle",
        "rest_framework.throttling.UserRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {"anon": "100/hour", "user": "1000/hour"},
}

Source: DRF: Throttling

  • Publish an OpenAPI schema. DRF’s Core API schemas, which Two Scoops shows, were removed in DRF 3.17; use drf-spectacular (0.30) with DRF, while Django Ninja generates OpenAPI itself. See Django REST Framework and Django Ninja.

  • Test every endpoint, including permission failures. JavaScript clients of session-authenticated APIs send the CSRF token in the X-CSRFToken header. See Testing and Security.

  • Choose GraphQL only for a reason (many clients with different data needs); it changes caching, permissions and query-cost concerns. See GraphQL with Django.

Admin

  • The admin is for trusted staff, not a front end for customers; build real views for end users. See Admin Site.

  • Move it off /admin/, serve it only over HTTPS, and consider restricting it by network or adding multi-factor authentication (for example django-allauth’s MFA). The admin-honeypot packages Two Scoops mentions are largely unmaintained. See Admin Site and Security.

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

# settings.py: ADMIN_URL = os.environ.get("BOOKSHELF_ADMIN_URL", "admin/") -- the real prefix lives in the environment
urlpatterns = [
    path(settings.ADMIN_URL, admin.site.urls),  # not hard-coded in the repository
    path("", include("catalog.urls")),
]
  • Make list pages cheap: list_select_related, search_fields, list_filter and show_full_result_count = False on large tables; use @admin.display and @admin.action (Django 3.2) for computed columns and bulk actions. See Admin Customization.

The user model

  • Start every project with a custom user model, even an empty subclass of AbstractUser; switching later, once tables reference auth.User, is a painful migration. See Custom User Model & Backends.

  • Refer to it indirectly: settings.AUTH_USER_MODEL in foreign keys and other definitions evaluated at import time, get_user_model() at run time.

accounts/models.py, settings and usage
# accounts/models.py
from django.contrib.auth.models import AbstractUser


class User(AbstractUser):
    pass


# config/settings.py
AUTH_USER_MODEL = "accounts.User"

# anywhere at run time (tests, services, commands)
from django.contrib.auth import get_user_model

user = get_user_model().objects.create_user("ana", "ana@example.com", "a-long-test-password")
  • Use the built-in auth views and password machinery. The function-based login()/logout() views were removed in Django 2.1 in favour of LoginView and friends; keep PASSWORD_HASHERS current (Argon2 or the default PBKDF2) and enable the password validators. See Authentication and Passwords.

  • Authorise with permissions and groups, not ad-hoc is_staff checks scattered through views. See Permissions & Authorization.

Testing

  • Test one behaviour per test, including the failure paths (404, permission denied, invalid form), and name tests after the behaviour. See Testing.

  • Build data with factories, not fixtures. factory_boy (3.3) or model_bakery (1.24; the renamed model_mommy that Two Scoops mentions) keep test data next to the test and survive schema changes; setUpTestData() creates shared rows once per class. See pytest, Factories & Browser Tests.

  • Use the cheapest tool that proves the point: RequestFactory for a view in isolation, the test client for the full stack, assertNumQueries() to pin the query count, captureOnCommitCallbacks() for on_commit work, and mocks for external services. See Testing.

catalog/tests.py (excerpt)
from django.contrib.auth import get_user_model
from django.core import mail
from django.test import RequestFactory, TestCase
from django.urls import reverse

from .models import Book
from .services import publish_book
from .views import book_detail


class BookTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = get_user_model().objects.create_user("ana", "ana@example.com", "pw-test-1")

    def test_draft_is_404(self):
        book = Book.objects.create(title="Draft", owner=self.user)
        response = self.client.get(reverse("catalog:book-detail", args=[book.pk]))
        self.assertEqual(response.status_code, 404)

    def test_published_counts_view(self):
        book = Book.objects.create(title="Dune", owner=self.user, status=Book.Status.PUBLISHED)
        request = RequestFactory().get("/")
        with self.assertNumQueries(2):  # one SELECT with the JOIN, one UPDATE
            response = book_detail(request, pk=book.pk)
        self.assertEqual(response.status_code, 200)

    def test_publish_sends_mail_after_commit(self):
        book = Book.objects.create(title="Dune", owner=self.user)
        with self.captureOnCommitCallbacks(execute=True):
            publish_book(book.pk)
        self.assertEqual(len(mail.outbox), 1)
$ python manage.py test catalog
Found 7 test(s).
...
Ran 7 tests in 0.515s

OK
  • Measure coverage and run the suite in CI on every push. Travis CI and nose, from the Two Scoops era, have given way to GitHub Actions or GitLab CI with pytest-django (4.14) or the built-in runner, and nox or tox for matrices. See pytest, Factories & Browser Tests.

Background tasks

  • Move slow work out of the request (emails, image processing, partner APIs). Two Scoops pointed to Celery or serverless functions; Django 6.0 added the built-in Tasks framework (django.tasks, @task, enqueue()), which defines the API while a backend such as django-tasks-db or django-tasks-rq runs the worker. Celery (5.6) remains the choice for complex workflows, and the django-tasks package (0.12) backports the API to 5.2 LTS. See Tasks Framework and Celery Integration.

  • Keep tasks thin and pass IDs, not model instances. Arguments cross a process boundary as JSON; the task re-reads the row, which may have changed or disappeared. See Tasks Framework.

  • Make tasks idempotent so a retry or duplicate delivery does no harm, enqueue on commit (see Queries), and log failures with logger.exception() before re-raising so the backend records them. See Celery Integration.

catalog/tasks.py
import logging

from django.core.mail import send_mail
from django.tasks import task

logger = logging.getLogger(__name__)


@task
def notify_owner(book_id):
    from .models import Book

    book = Book.objects.select_related("owner").filter(pk=book_id).first()
    if book is None or book.status != Book.Status.PUBLISHED:
        return  # nothing to do: safe to run twice
    try:
        send_mail("Published", f"{book.title} is live.", None, [book.owner.email])
    except OSError:
        logger.exception("Could not notify owner of book %s", book_id)
        raise
  • Monitor the queue: backlog, failure rate and age of the oldest job belong on a dashboard. See Tasks Framework.

Security

  • Run manage.py check --deploy against production settings in CI; it flags DEBUG, a weak SECRET_KEY, missing HTTPS settings and more. See System Checks and Security.

$ DJANGO_DEBUG=False DJANGO_SECRET_KEY="$(python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())')" \
    python manage.py check --deploy
System check identified some issues:

WARNINGS:
?: (security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting. ...
?: (security.W008) Your SECURE_SSL_REDIRECT setting is not set to True. ...
?: (security.W012) SESSION_COOKIE_SECURE is not set to True. ...
?: (security.W016) You have 'django.middleware.csrf.CsrfViewMiddleware' in your MIDDLEWARE, but you have not set CSRF_COOKIE_SECURE to True. ...

System check identified 4 issues (0 silenced).
  • HTTPS everywhere: SECURE_SSL_REDIRECT, SECURE_HSTS_SECONDS, SESSION_COOKIE_SECURE, CSRF_COOKIE_SECURE, and SECURE_PROXY_SSL_HEADER only when a trusted proxy sets the header. Set ALLOWED_HOSTS explicitly. See Security.

  • Build HTML with format_html(), not mark_safe() plus string formatting; format_html() escapes every argument.

catalog/utils.py (excerpt)
from django.utils.html import format_html


def owner_badge(user):
    return format_html('<span class="badge">{}</span>', user.get_username())

Source: format_html()

  • Add a Content Security Policy with the built-in support. Django 6.0 added ContentSecurityPolicyMiddleware and the SECURE_CSP and SECURE_CSP_REPORT_ONLY settings, so the django-csp package recommended by Two Scoops is no longer required; enable the csp context processor when the policy uses nonces. See Content Security Policy.

config/settings.py (CSP excerpt)
from django.utils.csp import CSP

MIDDLEWARE = [
    # ...
    "django.middleware.csp.ContentSecurityPolicyMiddleware",
]
SECURE_CSP = {
    "default-src": [CSP.SELF],
    "script-src": [CSP.SELF, CSP.NONCE],
}
# and in TEMPLATES[0]["OPTIONS"]["context_processors"]:
#     "django.template.context_processors.csp",
  • Treat uploads as hostile: limit size, validate type by content, store them outside the code tree with generated names, and never serve them from the application domain without care. See File Uploads & Storage.

  • Do not expose sequential primary keys in public URLs when enumeration matters; use a slug or a UUID field. Never unpickle or eval untrusted data, and load YAML with a safe loader. See Security.

  • Keep sessions and signed values server-trusted: the default database-backed sessions and django.core.signing cover most needs. See Sessions, Cookies, Messages & Signing.

  • Stay on a supported release, update dependencies promptly, publish a security contact and keep an incident-response plan. See Security.

Logging

  • One logger per module, created with logging.getLogger(name), so configuration can target an app or a module. See Logging & Error Reporting.

  • Pick levels deliberately (DEBUG for development detail, INFO for business events, WARNING and above for problems) and use logger.exception() inside except blocks to keep the traceback; pass values as arguments (logger.info("New user %s", pk)) rather than pre-formatting the string. The task above shows the pattern; see Logging & Error Reporting.

  • Centralise errors: send ERROR records to an error tracker such as Sentry rather than relying on ADMINS emails alone. See Logging & Error Reporting.

config/settings.py (LOGGING excerpt)
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {"console": {"class": "logging.StreamHandler"}},
    "loggers": {"catalog": {"handlers": ["console"], "level": "INFO"}},
}

Signals

  • Use signals as a last resort. They hide control flow; when you own both sides, call a service function or override save() instead. Signals fit when one app must react to another app’s events without importing it, for example reacting to a third-party model.

  • Register receivers in AppConfig.ready() (shown in Project layout), pass sender to narrow them, and remember that QuerySet.update() and bulk_create() do not send pre_save/post_save. See Signals.

catalog/signals.py
import logging

from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver

logger = logging.getLogger(__name__)


@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def log_new_user(sender, instance, created, **kwargs):
    if created:
        logger.info("New user %s", instance.pk)

Performance

  • Measure before optimising. Profile with django-debug-toolbar (8.0) in development and with query counts in tests; most slow Django pages are a query problem. See Performance & Optimization and Query Optimization & Fetch Modes.

  • Cache in layers: per-view with cache_page, template fragments with {% cache %}, expensive values with the low-level cache API, and a shared backend in production (the built-in Redis backend arrived in Django 4.0; Memcached via PyMemcacheCache since 3.2). Normalise first and cache before you denormalise.

catalog/urls.py (cached list view)
from django.urls import path
from django.views.decorators.cache import cache_page

from . import views

app_name = "catalog"
urlpatterns = [
    # ...
    path("books/", cache_page(60 * 15)(views.book_list), name="book-list"),
]

See Caching.

  • Serve static files efficiently: hashed names with long cache lifetimes, compression, and a CDN where traffic justifies one. See Static Files & Front-End Assets.

  • Use async where it helps: views that wait on several network calls benefit from async def under an ASGI server; CPU-bound or ORM-heavy views do not. Native async views arrived in Django 3.1 and the async ORM interface in 4.1, so Channels (4.3) is now needed only for WebSockets and similar long-lived connections. See Async Support and Channels & WebSockets.

  • Reuse database connections with CONN_MAX_AGE or, on PostgreSQL with psycopg 3, the connection pool option added in Django 5.1. See PostgreSQL & Database Backends.

Operations

  • Automate deployments so they are repeatable: build an image or artefact once, run check --deploy, migrate and collectstatic as explicit steps, and keep a staging environment that mirrors production. Two Scoops' Heroku free tier and Chef/Puppet recipes have given way to containers and platforms such as Fly.io, Render or Cloud Run, with ASGI or WSGI servers such as Uvicorn (0.54), Gunicorn (26.2), Granian (2.8) or Daphne (4.2). See Deployment, Docker & Kubernetes and Settings & Configuration.

$ python manage.py makemigrations --check --dry-run
No changes detected
$ python manage.py migrate --plan
Planned operations:
catalog.0002_title_case_titles
    Raw Python operation
  • Back up the database and test the restore; a rollback plan is only real once it has been rehearsed. See PostgreSQL & Database Backends.

  • Put recurring maintenance in management commands, not in ad-hoc shell sessions, so it is reviewed, tested and schedulable. See Management Commands & Shell.

  • Ship risky changes behind feature flags and remove the flag once the change is settled; a flag is just a setting read from the environment, see Settings & Configuration.

  • Upgrade one feature release at a time, running the test suite with python -Wd to surface deprecation warnings first; Django 6.1 introduced calendar versioning, so the release after 6.2 LTS is Django 2028. See What’s Changed & Upgrading.

Internationalisation

  • Never concatenate translated fragments. Translate whole sentences with named placeholders, so translators can reorder them. See Internationalization & Translation.

  • Use lazy translation in definitions evaluated at import time (model fields, form labels, choices) and eager gettext() at run time. ugettext() and ugettext_lazy(), used throughout Two Scoops, were removed in Django 4.0.

catalog/utils.py (excerpt)
from django.utils.translation import gettext as _


def welcome(name, count):
    return _("Welcome %(name)s, you have %(count)d books.") % {"name": name, "count": count}
  • Store times in UTC and convert at the edges. USE_TZ = True is the default since Django 5.0, and zoneinfo replaced pytz in 4.0. See Localization & Time Zones.

Where Two Scoops 1.11 has aged

The rules above keep the spirit of Two Scoops of Django 1.11 (2017). The concrete APIs it uses have moved on; this table lists the replacements that matter for the checklist.

Two Scoops 1.11 advice Current replacement Since

Python 2.7 compatibility (six, python_2_unicode_compatible, unicode())

Python 3 only; Django 6.1 needs Python 3.12 or newer. Both helpers were removed.

2.0 / 3.0

url() with regular expressions; include(namespace=…​) without app_name

path() with converters (re_path() for real regexes); app_name in the included URLconf

2.0 / 4.0

MIDDLEWARE_CLASSES

MIDDLEWARE with new-style middleware

2.0

Function-based login()/logout() views; logout by GET

LoginView/LogoutView; logout requires POST

2.1 / 5.0

ugettext(), ugettext_lazy(), force_text()

gettext(), gettext_lazy(), force_str()

4.0

Choices as constants or Enum; NullBooleanField; PostgreSQL-only JSONField

TextChoices/IntegerChoices; BooleanField(null=True); models.JSONField

3.0 / 4.0 / 3.1

index_together

Meta.indexes and Meta.constraints

removed 5.1

DEFAULT_FILE_STORAGE, STATICFILES_STORAGE

STORAGES

4.2 (old names removed 5.1)

pytz; USE_L10N

zoneinfo; USE_TZ = True default, USE_L10N removed

4.0 / 5.0

Celery or serverless as the only route to background work

Built-in Tasks framework (django.tasks) with a pluggable backend; Celery 5.6 still common

6.0

Channels as Django’s async story

Native ASGI, async views, async ORM interface; Channels for WebSockets

3.0 / 3.1 / 4.1

django-csp for Content Security Policy

ContentSecurityPolicyMiddleware, SECURE_CSP

6.0

LoginRequiredMixin on every view

Still valid; LoginRequiredMiddleware can make login the default

5.1

DRF Core API schema docs

OpenAPI with drf-spectacular

DRF 3.17 (removed)

model_mommy, nose, Travis CI

model_bakery, pytest-django or the built-in runner, GitHub Actions/GitLab CI

 — 

virtualenvwrapper and requirements/*.txt

python -m venv or uv with pyproject.toml and a lock file

 —