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 holdsmanage.pyand the apps, and the configuration package holdssettings,urls,asgiandwsgi; naming that packageconfigmakes 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
coreapp (abstract models, template tags, utilities), not in a grab-bagutils.pyat the project root. See Project Structure & Apps. -
Use
AppConfigand keep start-up work inready(). List apps by theirAppConfigpath when it does work at start-up, and import signal handlers fromready()rather than frommodels.py. See Project Structure & Apps and Signals.
from django.apps import AppConfig
class CatalogConfig(AppConfig):
name = "catalog"
def ready(self):
from . import signals # noqa: F401 (registers the receivers)
Source: AppConfig.ready()
-
Start from a template, not from memory.
startprojectwith 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.tomland a lock file. Two Scoops recommended virtualenvwrapper and onerequirements/*.txtfile per environment; that still works, but todaypython -m venvor uv with a lockedpyproject.tomlis 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
ImproperlyConfiguredwith a clear message instead of falling back to an insecure default, so a misconfigured server never starts. See Settings & Configuration.
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(",")
Source: Django settings and ImproperlyConfigured
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
settingspackage withbase.pypluslocal.py,test.pyandproduction.py, selected withDJANGO_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(apathlib.Pathsince 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_CLASSESwas removed in Django 2.0 (useMIDDLEWARE);DEFAULT_FILE_STORAGEandSTATICFILES_STORAGEwere replaced bySTORAGESin 4.2 and removed in 5.1;USE_L10Nwas removed in 5.0;DEFAULT_AUTO_FIELD(3.2) defaults toBigAutoFieldsince 6.0, so new projects need not set it — only older projects that never set it pinAutoFieldto avoid unwanted migrations. See Settings & Configuration and File Uploads & Storage.
Models
-
Share fields through abstract base classes. A
TimeStampedModelwithcreatedandmodifiedfields is still a sound pattern; Django 5.0 addeddb_defaultandGeneratedFieldfor 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
nullandblankdeliberately. Text fields useblank=Trueand store"", nevernull=True(two "empty" values); other fields usenull=Truewhen "unknown" is a real state. See Models & Fields. -
Use enumeration types for choices.
models.TextChoicesandIntegerChoices(Django 3.0) replaced the constants andEnumrecipes in Two Scoops; since 5.0choicesalso accepts the enumeration class directly, a mapping or a callable. See Models & Fields. -
Put reusable queries on a custom
QuerySetand expose it withas_manager(), so thatBook.objects.published()chains like any other filter. See Managers & Custom QuerySets. -
Reference the user model through
settings.AUTH_USER_MODELin relations (see The user model).
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
GenericForeignKeywhen 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) andMeta.indexes;index_togetherwas 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, andBooleanField(null=True)instead of the removedNullBooleanField(4.0). See Models & Fields.
Migrations
-
Commit migrations with the model change and review the generated file;
makemigrations --checkin CI catches a model change that forgot its migration. See Migrations. -
Data migrations use the historical model and stay reversible. Inside
RunPython, get models withapps.get_model()(never import them) and always provide a reverse function, even if it isRunPython.noop.
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),
]
Source: Data migrations and RunPython
-
Squash long histories with
squashmigrationsonce every environment has applied them, and give hand-written migrations a descriptive--name. See Migrations. -
Back up and rehearse before migrating production.
migrate --planshows 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 andprefetch_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, andFETCH_RAISEturns 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 withtransaction.on_commit()so they never fire for a rolled-back change. Wrapping a single ORM call adds nothing.ATOMIC_REQUESTSis simple but holds a transaction open for the whole view; explicit blocks are usually clearer. See Transactions, Raw SQL & Multiple Databases.
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)
Source: F() expressions and Performing actions after commit
-
Use
get_object_or_404()in views only. In models, services and tasks a missing row is a normal case to handle, not an HTTP response. See Function-Based Views & Shortcuts. -
Raw SQL is a last resort, and always parameterised. Pass values as
params, never with string formatting. See Transactions, Raw SQL & Multiple Databases and Security.
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
LoginRequiredMixinruns before the view. Since Django 5.1,LoginRequiredMiddlewarecan make login the default for the whole site, withlogin_not_requiredfor the exceptions. See Class-Based Views and Authentication. -
Name every URL and namespace every app. Set
app_namein the app’s URLconf and usereverse()and the{% url %}tag, never hard-coded paths. Usepath()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 needsapp_name. See URLs & Routing. -
Pass an explicit context, never
locals(). See Function-Based Views & Shortcuts.
# 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"andMeta.excludesilently 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()andadd_error(), so each message is attached to the right field; pass extra context such asrequest.userthroughget_form_kwargs()(shown in Views and URLs) rather than a global. See Form Validation & Validators.
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
Source: Selecting the fields to use and Form.add_error()
-
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 addedas_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.htmland500.htmlmust not depend on context processors or the database, which may be what failed. See Templates.
<h1>{{ book.title|title }}</h1>
<p>{{ book.owner.get_username }} · {{ 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 viaSTORAGES, 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.
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-CSRFTokenheader. 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.
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_filterandshow_full_result_count = Falseon large tables; use@admin.displayand@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 referenceauth.User, is a painful migration. See Custom User Model & Backends. -
Refer to it indirectly:
settings.AUTH_USER_MODELin foreign keys and other definitions evaluated at import time,get_user_model()at run time.
# 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 ofLoginViewand friends; keepPASSWORD_HASHERScurrent (Argon2 or the default PBKDF2) and enable the password validators. See Authentication and Passwords. -
Authorise with permissions and groups, not ad-hoc
is_staffchecks 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:
RequestFactoryfor a view in isolation, the test client for the full stack,assertNumQueries()to pin the query count,captureOnCommitCallbacks()foron_commitwork, and mocks for external services. See Testing.
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)
Source: Testing tools and The request factory
$ 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 thedjango-taskspackage (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.
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
Source: Django’s Tasks framework
-
Monitor the queue: backlog, failure rate and age of the oldest job belong on a dashboard. See Tasks Framework.
Security
-
Run
manage.py check --deployagainst production settings in CI; it flagsDEBUG, a weakSECRET_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).
Source: Deployment checklist
-
HTTPS everywhere:
SECURE_SSL_REDIRECT,SECURE_HSTS_SECONDS,SESSION_COOKIE_SECURE,CSRF_COOKIE_SECURE, andSECURE_PROXY_SSL_HEADERonly when a trusted proxy sets the header. SetALLOWED_HOSTSexplicitly. See Security. -
Build HTML with
format_html(), notmark_safe()plus string formatting;format_html()escapes every argument.
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
ContentSecurityPolicyMiddlewareand theSECURE_CSPandSECURE_CSP_REPORT_ONLYsettings, so the django-csp package recommended by Two Scoops is no longer required; enable thecspcontext processor when the policy uses nonces. See Content Security Policy.
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",
Source: Content Security Policy
-
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
evaluntrusted 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.signingcover 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 (
DEBUGfor development detail,INFOfor business events,WARNINGand above for problems) and uselogger.exception()insideexceptblocks 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
ERRORrecords to an error tracker such as Sentry rather than relying onADMINSemails alone. See Logging & Error Reporting.
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"loggers": {"catalog": {"handlers": ["console"], "level": "INFO"}},
}
Source: Configuring logging
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), passsenderto narrow them, and remember thatQuerySet.update()andbulk_create()do not sendpre_save/post_save. See Signals.
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)
Source: Connecting receiver functions
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 viaPyMemcacheCachesince 3.2). Normalise first and cache before you denormalise.
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 defunder 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_AGEor, 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,migrateandcollectstaticas 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
Source: django-admin migrate and django-admin makemigrations
-
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 -Wdto 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 eagergettext()at run time.ugettext()andugettext_lazy(), used throughout Two Scoops, were removed in Django 4.0.
from django.utils.translation import gettext as _
def welcome(name, count):
return _("Welcome %(name)s, you have %(count)d books.") % {"name": name, "count": count}
Source: Standard translation
-
Store times in UTC and convert at the edges.
USE_TZ = Trueis the default since Django 5.0, andzoneinforeplacedpytzin 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 ( |
Python 3 only; Django 6.1 needs Python 3.12 or newer. Both helpers were removed. |
2.0 / 3.0 |
|
|
2.0 / 4.0 |
|
|
2.0 |
Function-based |
|
2.1 / 5.0 |
|
|
4.0 |
Choices as constants or |
|
3.0 / 4.0 / 3.1 |
|
|
removed 5.1 |
|
|
4.2 (old names removed 5.1) |
|
|
4.0 / 5.0 |
Celery or serverless as the only route to background work |
Built-in Tasks framework ( |
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 |
|
6.0 |
|
Still valid; |
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 |
|
— |
Source: Django deprecation timeline and Release notes