Django Cheat Sheet

This Django cheat sheet covers 26 sections, from startproject to models, QuerySets, views, templates, the admin, DRF and deployment. Search it, filter it by level, copy any line with one click. Written for Django 5.2 LTS, 6.0 and 6.1, with a badge on every feature newer than the LTS.

26sections
246snippets
6.1up to date
0signup needed

Updated September 21, 2026, checked against the Django 6.1 release notes. Print it for a PDF copy.

/
level

0 results

Nothing matches that query. Try a shorter keyword such as queryset, admin or class view. Several words are combined, so every one of them has to match. Keys: / focuses the search, Esc clears it, t switches the theme.

01

Getting Started

install, project layout, first run

Install and Create a Project

core

A virtual environment, Django, a project, an app

python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install django

django-admin startproject shop .   # the dot keeps manage.py at the top level
python manage.py startapp catalog
python manage.py migrate
python manage.py runserver

The faster path with uv

uv init shop && cd shop
uv add django
uv run django-admin startproject shop .
uv run manage.py runserver

Requirements

Django 6.1   Python 3.12 to 3.14   released August 2026, the current release
Django 6.0   Python 3.12 to 3.14   security fixes until April 2027
Django 5.2   Python 3.10 to 3.13   LTS, security fixes until April 2028
Django 4.2   end of life April 2026, upgrade
Databases: PostgreSQL 15+, MySQL 8.4+, MariaDB 10.11+, SQLite 3.37+, Oracle

Register the app, then it exists

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "catalog",                       # your app, or "catalog.apps.CatalogConfig"
]

Project Layout

core

What lives where, after startproject and startapp

PathHolds
manage.pythe command line entry point, wraps django-admin with your settings
shop/settings.pyevery project setting, reads secrets from the environment
shop/urls.pyroot URL configuration, includes each app's urls.py
shop/asgi.py, shop/wsgi.pyentry points for the application server
catalog/models.pydatabase tables as Python classes
catalog/views.pyrequest handlers, functions or classes
catalog/urls.pythe app's routes, created by you
catalog/forms.pyForm and ModelForm classes, created by you
catalog/admin.pyadmin registrations
catalog/apps.pyapp config, ready() for signal registration
catalog/migrations/generated schema history, commit it
catalog/templates/catalog/templates, namespaced by app to avoid collisions
catalog/static/catalog/CSS, JS and images for this app
catalog/tests.py or tests/tests, discovered by manage.py test
templates/, static/project wide templates and assets, listed in settings

Run it, and reach it from another device on the network

python manage.py runserver
python manage.py runserver 0.0.0.0:8000      # add the host to ALLOWED_HOSTS
python manage.py runserver 8080

The Request Cycle

core

URL to view to model to template, the whole framework in one path

path("products/<slug:slug>/", views.product_detail, name="product_detail")

# catalog/views.py
def product_detail(request, slug):
    product = get_object_or_404(Product, slug=slug)          # model + ORM
    return render(request, "catalog/product_detail.html", {"product": product})

# catalog/templates/catalog/product_detail.html
<h1>{{ product.name }}</h1>
<p>{{ product.price|floatformat:2 }} EUR</p>

Wire the app's URLs into the project

from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("catalog.urls")),
]
02

manage.py Commands

the commands you run every day

Everyday Commands

core

Grouped by what you are doing

CommandDoes
runserverdevelopment server with auto reload, never for production
makemigrations [app]write migration files from model changes
migrate [app] [name]apply migrations, or roll back to a named one
showmigrationslist migrations and which are applied
sqlmigrate app 0002print the SQL a migration will run
shella Python shell with Django loaded, models auto imported since 5.2
dbshellthe database's own client, connected
createsuperuseran admin account
changepassword userreset a password from the terminal
test [app.tests.Class]run the test suite
check --deploysystem checks, the deploy flag audits production settings
collectstaticgather static files into STATIC_ROOT for serving
dumpdata app.Model > f.jsonexport rows as a fixture
loaddata f.jsonimport a fixture
flushempty every table, keep the schema
startapp namescaffold an app
inspectdbgenerate models from an existing database
makemessages, compilemessagestranslation catalogs
optimizemigration app 0003simplify a migration's operations
squashmigrations app 0001 0010collapse a range of migrations into one

See where the schema stands

python manage.py showmigrations catalog

The Shell

5.2+everyday

A Python REPL with the project loaded and models imported

python manage.py shell

>>> Product.objects.count()
42
>>> p = Product.objects.first()
>>> p.category.name
'Desks'
>>> Product.objects.filter(price__gt=100).values_list("name", flat=True)[:3]
<QuerySet ['Oak desk', 'Standing desk', 'Corner desk']>

Run one expression and exit

python manage.py shell -c "from catalog.models import Product; print(Product.objects.count())"
python manage.py shell -v 2          # print what was auto imported

A richer shell with django-extensions

pip install django-extensions ipython
# add "django_extensions" to INSTALLED_APPS
python manage.py shell_plus --print-sql      # every query echoed as it runs

Custom Commands

advanced

A command with arguments and options

from django.core.management.base import BaseCommand, CommandError

class Command(BaseCommand):
    help = "Email customers about upcoming renewals"

    def add_arguments(self, parser):
        parser.add_argument("days", type=int, nargs="?", default=7)
        parser.add_argument("--dry-run", action="store_true")

    def handle(self, *args, **options):
        sent = Reminders.send(days=options["days"], dry_run=options["dry_run"])
        if sent < 0:
            raise CommandError("Reminder service unavailable")
        self.stdout.write(self.style.SUCCESS(f"Sent {sent} reminders"))

Run it, and call it from code or tests

python manage.py send_reminders 14 --dry-run

from django.core.management import call_command
call_command("send_reminders", 14, dry_run=True)
03

Settings & Config

settings.py, environments, databases

The Settings That Matter

core

The three that decide whether the site is safe

import os
from pathlib import Path

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

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]                   # never in the repo
DEBUG = os.environ.get("DJANGO_DEBUG", "") == "1"              # False in production
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")
CSRF_TRUSTED_ORIGINS = ["https://shop.example.com"]

Database, PostgreSQL in production and SQLite for a quick start

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ.get("POSTGRES_DB", "shop"),
        "USER": os.environ.get("POSTGRES_USER", "shop"),
        "PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""),
        "HOST": os.environ.get("POSTGRES_HOST", "localhost"),
        "PORT": "5432",
        "CONN_MAX_AGE": 60,                 # reuse connections
        "OPTIONS": {"pool": True},          # psycopg 3 connection pool
    }
}

# the default a new project starts with
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"}}

Templates, static, media, time

TEMPLATES = [{
    "BACKEND": "django.template.backends.django.DjangoTemplates",
    "DIRS": [BASE_DIR / "templates"],
    "APP_DIRS": True,
    "OPTIONS": {"context_processors": [
        "django.template.context_processors.request",
        "django.contrib.auth.context_processors.auth",
        "django.contrib.messages.context_processors.messages",
    ]},
}]

STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"

LANGUAGE_CODE = "en-gb"
TIME_ZONE = "Europe/Bucharest"
USE_TZ = True                                # store UTC, display local

Django 6.0 made BigAutoField the default primary key

# no longer needed in new projects, was required from 3.2 to 5.2
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

Environments

everyday

Read a .env file with django-environ

pip install django-environ

import environ
env = environ.Env(DEBUG=(bool, False))
environ.Env.read_env(BASE_DIR / ".env")        # local only, .env is git ignored

SECRET_KEY = env("SECRET_KEY")
DEBUG = env("DEBUG")
DATABASES = {"default": env.db()}              # DATABASE_URL=postgres://user:pw@host/db
CACHES = {"default": env.cache()}              # CACHE_URL=redis://localhost:6379/1
EMAIL_CONFIG = env.email()                     # EMAIL_URL=smtp://user:pw@host:587

Split settings per environment

shop/settings/
    __init__.py
    base.py          # everything shared
    local.py         # from .base import *; DEBUG = True
    production.py    # from .base import *; SECURE_SSL_REDIRECT = True

export DJANGO_SETTINGS_MODULE=shop.settings.production
python manage.py runserver --settings=shop.settings.local

Read settings in code

from django.conf import settings

if settings.DEBUG: ...
settings.MEDIA_ROOT
getattr(settings, "SHOP_CURRENCY", "EUR")     # your own setting with a default

Audit the configuration before going live

python manage.py check --deploy
# warns about DEBUG, SECRET_KEY strength, HSTS, secure cookies, SSL redirect and more
04

URLs & Routing

path, converters, namespaces, reverse

Defining URLs

core

An app's urls.py with a namespace

from django.urls import path
from . import views

app_name = "catalog"

urlpatterns = [
    path("", views.product_list, name="list"),
    path("products/<int:pk>/", views.product_detail, name="detail"),
    path("products/<slug:slug>/", views.product_by_slug, name="by_slug"),
    path("products/<int:pk>/edit/", views.ProductUpdate.as_view(), name="edit"),
    path("categories/<str:code>/<int:year>/", views.by_category, name="category"),
]

Path converters

ConverterMatchesPython type
<int:pk>zero or positive integerint
<str:name>any non empty string without a slash, the defaultstr
<slug:slug>letters, numbers, hyphens, underscoresstr
<uuid:id>a formatted UUIDUUID
<path:rest>anything, including slashesstr

Regular expressions and custom converters

from django.urls import re_path, register_converter

re_path(r"^archive/(?P<year>[0-9]{4})/$", views.archive, name="archive")

class FourDigitYear:
    regex = "[0-9]{4}"
    def to_python(self, value): return int(value)
    def to_url(self, value): return f"{value:04d}"

register_converter(FourDigitYear, "yyyy")
path("archive/<yyyy:year>/", views.archive)

Reversing URLs

core

Name a route once, generate its URL everywhere

from django.urls import reverse

reverse("catalog:detail", args=[12])
reverse("catalog:by_slug", kwargs={"slug": "oak-desk"})
reverse("catalog:detail", args=[12]) + "?tab=reviews"

In templates, views and class attributes

{% url 'catalog:detail' product.pk %}
{% url 'catalog:by_slug' slug=product.slug as detail_url %}

from django.shortcuts import redirect
return redirect("catalog:detail", pk=product.pk)
return redirect(product)                      # uses product.get_absolute_url()

from django.urls import reverse_lazy
class ProductCreate(CreateView):
    success_url = reverse_lazy("catalog:list") # lazy, evaluated when needed

get_absolute_url, the canonical link for a model

class Product(models.Model):
    def get_absolute_url(self):
        return reverse("catalog:by_slug", kwargs={"slug": self.slug})

# the admin shows a View on site button, sitemaps and redirect() use it
<a href="{{ product.get_absolute_url }}">{{ product.name }}</a>

Includes, Redirects, Errors

everyday

Compose the root URL configuration

from django.urls import include, path
from django.views.generic import RedirectView, TemplateView

urlpatterns = [
    path("admin/", admin.site.urls),
    path("accounts/", include("django.contrib.auth.urls")),
    path("catalog/", include("catalog.urls")),
    path("api/", include("api.urls", namespace="api")),
    path("about/", TemplateView.as_view(template_name="about.html"), name="about"),
    path("old-shop/", RedirectView.as_view(pattern_name="catalog:list", permanent=True)),
]

Serve uploads during development only

from django.conf import settings
from django.conf.urls.static import static

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Custom error handlers, in the root urls.py

handler404 = "shop.views.not_found"
handler500 = "shop.views.server_error"
handler403 = "shop.views.forbidden"

# or just drop templates/404.html and templates/500.html in place

Inspect the routes

pip install django-extensions
python manage.py show_urls

# or in the shell
from django.urls import get_resolver
get_resolver().reverse_dict.keys()
05

Django Views

functions, generic classes, responses

Function Based Views

core

List and detail, the two views every app starts with

from django.shortcuts import get_object_or_404, render

def product_list(request):
    products = Product.objects.filter(active=True).select_related("category")
    return render(request, "catalog/product_list.html", {"products": products})

def product_detail(request, slug):
    product = get_object_or_404(Product, slug=slug, active=True)
    return render(request, "catalog/product_detail.html", {"product": product})

Handle a form, GET shows it, POST saves it, then redirect

from django.contrib import messages
from django.shortcuts import redirect

def product_create(request):
    if request.method == "POST":
        form = ProductForm(request.POST, request.FILES)
        if form.is_valid():
            product = form.save()
            messages.success(request, "Product created.")
            return redirect(product)
    else:
        form = ProductForm()
    return render(request, "catalog/product_form.html", {"form": form})

Decorators that guard a view

from django.contrib.auth.decorators import login_required, permission_required
from django.views.decorators.http import require_http_methods, require_POST
from django.views.decorators.cache import cache_page

@login_required
@permission_required("catalog.change_product", raise_exception=True)
@require_http_methods(["GET", "POST"])
def product_edit(request, pk): ...

@require_POST
def product_delete(request, pk): ...

@cache_page(60 * 15)
def product_list(request): ...

Generic Class Based Views

core

The CRUD set, each one a few attributes

from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView

class ProductList(ListView):
    model = Product
    paginate_by = 20
    template_name = "catalog/product_list.html"      # default: catalog/product_list.html

    def get_queryset(self):
        return Product.objects.filter(active=True).select_related("category")

class ProductDetail(DetailView):
    model = Product
    slug_field = "slug"                                # default is "slug" already

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["related"] = self.object.category.products.exclude(pk=self.object.pk)[:4]
        return ctx

class ProductCreate(LoginRequiredMixin, CreateView):
    model = Product
    form_class = ProductForm
    success_url = reverse_lazy("catalog:list")

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

class ProductUpdate(LoginRequiredMixin, UpdateView):
    model = Product
    fields = ["name", "price", "active"]               # a ModelForm is generated

class ProductDelete(LoginRequiredMixin, DeleteView):
    model = Product
    success_url = reverse_lazy("catalog:list")

What each generic view gives the template

ViewContextDefault templateHandles
TemplateViewkwargs from the URLtemplate_name, requiredGET
ListViewobject_list, product_list, page_obj, paginator, is_paginatedapp/model_list.htmlGET
DetailViewobject, productapp/model_detail.htmlGET by pk or slug
CreateViewformapp/model_form.htmlGET, POST, redirect to get_absolute_url
UpdateViewform, objectapp/model_form.htmlGET, POST
DeleteViewobjectapp/model_confirm_delete.htmlGET confirms, POST deletes
FormViewformtemplate_namea form without a model
RedirectViewurl or pattern_name, permanent

The hooks you override most, in the order they run

dispatch()            # before anything, per request checks
get_queryset()        # what rows this view may see
get_object()          # which row, DetailView and UpdateView
get_context_data()    # extra template variables
get_form_kwargs()     # pass request.user into the form
form_valid() / form_invalid()
get_success_url()     # where to go after a save
get_template_names()  # switch templates at runtime

Responses

everyday

Every kind of response a view can return

from django.http import (FileResponse, Http404, HttpResponse, HttpResponseForbidden,
                         HttpResponseRedirect, JsonResponse, StreamingHttpResponse)

return render(request, "page.html", context)             # 200, HTML
return HttpResponse("plain text", content_type="text/plain")
return JsonResponse({"ok": True, "items": list(qs.values("id", "name"))})
return JsonResponse(rows, safe=False)                    # a list at the top level
return redirect("catalog:list")                          # 302
return redirect("catalog:list", permanent=True)          # 301
return HttpResponse(status=204)
return HttpResponseForbidden()
raise Http404("No such product")
return FileResponse(open(path, "rb"), as_attachment=True, filename="invoice.pdf")

Pagination in a function view

from django.core.paginator import Paginator

def product_list(request):
    paginator = Paginator(Product.objects.order_by("name"), 20)
    page = paginator.get_page(request.GET.get("page"))       # never raises
    return render(request, "catalog/product_list.html", {"page_obj": page})

{# template #}
{% for product in page_obj %} ... {% endfor %}
{% if page_obj.has_next %}<a href="?page={{ page_obj.next_page_number }}">Next</a>{% endif %}
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}

Stream a large export

import csv

class Echo:
    def write(self, value): return value

def export(request):
    writer = csv.writer(Echo())
    rows = ((p.id, p.name, p.price) for p in Product.objects.iterator())
    response = StreamingHttpResponse((writer.writerow(r) for r in rows), content_type="text/csv")
    response["Content-Disposition"] = 'attachment; filename="products.csv"'
    return response

The Same Page, Function or Class

core

Function based: list, detail, and an edit form

from django.shortcuts import get_object_or_404, redirect, render
from django.core.paginator import Paginator

def product_list(request):
    qs = Product.objects.filter(status="live").select_related("category").order_by("-created_at")
    page = Paginator(qs, 24).get_page(request.GET.get("page"))
    return render(request, "catalog/product_list.html", {"page_obj": page, "products": page.object_list})

def product_detail(request, slug):
    product = get_object_or_404(Product, slug=slug, status="live")
    return render(request, "catalog/product_detail.html", {"product": product})

@login_required
def product_edit(request, pk):
    product = get_object_or_404(Product, pk=pk, created_by=request.user)
    form = ProductForm(request.POST or None, request.FILES or None, instance=product)
    if request.method == "POST" and form.is_valid():
        form.save()
        messages.success(request, "Saved.")
        return redirect(product)
    return render(request, "catalog/product_form.html", {"form": form, "product": product})

Class based: the same three pages with generic views

from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.views.generic import DetailView, ListView, UpdateView

class ProductList(ListView):
    queryset = Product.objects.filter(status="live").select_related("category").order_by("-created_at")
    paginate_by = 24
    context_object_name = "products"           # template still gets page_obj and is_paginated

class ProductDetail(DetailView):
    queryset = Product.objects.filter(status="live")
    slug_field = "slug"                        # the default, shown for clarity

class ProductEdit(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
    model = Product
    form_class = ProductForm
    success_message = "Saved."
    def get_queryset(self):
        return Product.objects.filter(created_by=self.request.user)
    # success_url defaults to get_absolute_url() on the saved object
06

Django Templates

tags, filters, inheritance, partials

Variables and Control Flow

core

Output, with the dot doing all the work

{{ product.name }}                 {# attribute #}
{{ product.get_absolute_url }}     {# method, called with no arguments #}
{{ prices.0 }}                     {# list index #}
{{ config.currency }}              {# dictionary key #}
{{ product.description|default:"No description" }}
{{ product.price|floatformat:2 }}
{{ product.created|date:"j M Y" }}
{{ product.name|truncatechars:40|upper }}

Conditions and loops

{% if product.stock > 0 and product.active %}
    In stock
{% elif product.stock == 0 %}
    Sold out
{% else %}
    Unavailable
{% endif %}

{% for item in items %}
    <li class="{% cycle 'odd' 'even' %}">{{ forloop.counter }}. {{ item.name }}</li>
{% empty %}
    <li>No items.</li>
{% endfor %}

{# forloop.counter, counter0, revcounter, first, last, parentloop, length (6.0) #}

The tags you will type most

{% url 'catalog:detail' product.pk %}
{% static 'catalog/css/shop.css' %}          {# needs {% load static %} #}
{% csrf_token %}                             {# inside every POST form #}
{% with total=order.items.count %} {{ total }} items {% endwith %}
{% now "Y" %}
{% comment %} hidden from the output {% endcomment %}
{% querystring page=2 %}                     {# keeps the other GET params, 5.1 #}
{% spaceless %} ... {% endspaceless %}
{% verbatim %} {{ left for Vue }} {% endverbatim %}

Built in Filters

everyday

By what they do

GroupFilters
Textupper, lower, title, capfirst, truncatechars:40, truncatewords:10, wordcount, linebreaks, linebreaksbr, striptags, slugify, urlize, escape, safe, escapejs, cut:" "
Numbersfloatformat:2, add:5, divisibleby:3, filesizeformat, intcomma and naturaltime (django.contrib.humanize)
Datesdate:"Y-m-d", time:"H:i", timesince, timeuntil, naturalday (humanize)
Listslength, first, last, join:", ", slice:":3", dictsort:"name", random, make_list, unordered_list
Logicdefault:"n/a", default_if_none, yesno:"yes,no,maybe", pluralize, pluralize:"y,ies"
Encodingurlencode, json_script:"data", iriencode, addslashes

Pass data to JavaScript safely

{{ chart_data|json_script:"chart-data" }}

<script>
    const data = JSON.parse(document.getElementById("chart-data").textContent);
</script>

Inheritance and Partials

6.0+core

A base template and a page that fills its blocks

{# templates/base.html #}
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
    <title>{% block title %}Shop{% endblock %}</title>
    <link rel="stylesheet" href="{% static 'css/shop.css' %}">
</head>
<body>
    {% include "partials/nav.html" %}
    <main>{% block content %}{% endblock %}</main>
    {% block scripts %}{% endblock %}
</body>
</html>

{# catalog/product_list.html #}
{% extends "base.html" %}
{% block title %}Products | {{ block.super }}{% endblock %}
{% block content %}
    {% for product in products %}{% include "catalog/partials/card.html" with p=product only %}{% endfor %}
{% endblock %}

Template partials, define a fragment inline and reuse it, Django 6.0

{# catalog/product_list.html #}
{% partialdef product-row inline %}
    <tr id="row-{{ product.pk }}"><td>{{ product.name }}</td><td>{{ product.price }}</td></tr>
{% endpartialdef %}

{% for product in products %}
    {% partial product-row %}
{% endfor %}

# a view can render just the partial, ideal for an HTMX swap
return render(request, "catalog/product_list.html#product-row", {"product": product})

Where templates are found

# DIRS first, then each app's templates/ folder in INSTALLED_APPS order
templates/base.html                      → "base.html"
catalog/templates/catalog/list.html      → "catalog/list.html"   # the app prefix avoids clashes

Custom Tags and Filters

advanced

A filter and a simple tag

# catalog/templatetags/shop_tags.py   (the folder needs an __init__.py)
from django import template

register = template.Library()

@register.filter
def money(value, currency="EUR"):
    return f"{value:,.2f} {currency}"

@register.simple_tag(takes_context=True)
def active_class(context, url_name):
    return "active" if context["request"].resolver_match.url_name == url_name else ""

{% load shop_tags %}
{{ product.price|money:"RON" }}
<a class="{% active_class 'list' %}">Products</a>

An inclusion tag renders its own template

@register.inclusion_tag("catalog/partials/cart_badge.html", takes_context=True)
def cart_badge(context):
    return {"count": context["request"].session.get("cart_count", 0)}

{% cart_badge %}

Context processors add a variable to every template

def shop_settings(request):
    return {"currency": settings.SHOP_CURRENCY, "cart_count": request.session.get("cart_count", 0)}

# settings: TEMPLATES[0]["OPTIONS"]["context_processors"] += ["catalog.context_processors.shop_settings"]

HTMX With Partials

6.0+everyday

One view, full page or fragment

pip install django-htmx
INSTALLED_APPS += ["django_htmx"];  MIDDLEWARE += ["django_htmx.middleware.HtmxMiddleware"]

{# catalog/product_list.html #}
<input name="q" hx-get="{% url 'catalog:list' %}" hx-trigger="input changed delay:300ms"
       hx-target="#rows" hx-push-url="true">
<tbody id="rows">{% partial rows %}</tbody>

{% partialdef rows %}
  {% for p in products %}<tr><td>{{ p.name }}</td><td>{{ p.price }}</td></tr>{% endfor %}
{% endpartialdef %}

# views.py
def product_list(request):
    products = Product.objects.filter(name__icontains=request.GET.get("q", ""))
    template = "catalog/product_list.html#rows" if request.htmx else "catalog/product_list.html"
    return render(request, template, {"products": products})

Response headers, out of band swaps, redirects

from django_htmx.http import HttpResponseClientRedirect, trigger_client_event, retarget

response = render(request, "catalog/cart.html#line", ctx)
trigger_client_event(response, "cart:updated", {"count": cart.count})   # HX-Trigger header
return retarget(response, "#errors")                                     # HX-Retarget
return HttpResponseClientRedirect(reverse("checkout"))                  # full page redirect from HTMX

{# swap a second element in the same response #}
<span id="cart-count" hx-swap-oob="true">{{ cart.count }}</span>

<form hx-post="{% url 'catalog:create' %}" hx-target="this" hx-swap="outerHTML">{% csrf_token %}
  {{ form }} <button>Save</button>
</form>
<button hx-delete="{% url 'catalog:delete' p.pk %}" hx-confirm="Delete?" hx-target="closest tr" hx-swap="delete">

Recent Template Additions

5.1+everyday

Keep the query string, change one key, Django 5.1

<a href="{% querystring page=page_obj.next_page_number %}">next</a>
<a href="{% querystring sort='price' page=None %}">cheapest first</a>
{% querystring my_query_dict %}                     {# from an explicit QueryDict #}

forloop.length, Django 6.0

{% for p in products %}
  {{ forloop.counter }} of {{ forloop.length }}
  {% if forloop.counter == forloop.length %}last{% endif %}    {# same as forloop.last #}
{% endfor %}
07

Django Forms

Form, ModelForm, validation, rendering

Form and ModelForm

core

A ModelForm mirrors a model, a Form stands alone

from django import forms
from .models import Product

class ProductForm(forms.ModelForm):
    class Meta:
        model = Product
        fields = ["name", "category", "price", "description", "image"]   # never "__all__" on user facing forms
        widgets = {"description": forms.Textarea(attrs={"rows": 4})}
        labels = {"price": "Price (EUR)"}
        help_texts = {"name": "Shown in the catalogue"}

class ContactForm(forms.Form):
    name = forms.CharField(max_length=80)
    email = forms.EmailField()
    message = forms.CharField(widget=forms.Textarea)
    urgent = forms.BooleanField(required=False)

The lifecycle in a view

form = ProductForm(request.POST or None, request.FILES or None, instance=product)
if request.method == "POST" and form.is_valid():
    product = form.save()                  # commit=False to change it first
    return redirect(product)

form.cleaned_data["price"]                 # typed, validated values
form.errors                                # {"price": ["Enter a number."]}
form.instance                              # the bound model on a ModelForm

Save with extra data the form does not know about

product = form.save(commit=False)
product.created_by = request.user
product.save()
form.save_m2m()                            # needed after commit=False when the form has M2M fields

Validation

core

Per field, then across fields

from django.core.exceptions import ValidationError

class ProductForm(forms.ModelForm):
    def clean_name(self):
        name = self.cleaned_data["name"].strip()
        if Product.objects.filter(name__iexact=name).exclude(pk=self.instance.pk).exists():
            raise ValidationError("A product with this name already exists.")
        return name

    def clean(self):
        cleaned = super().clean()
        if cleaned.get("sale_price") and cleaned["sale_price"] >= cleaned.get("price", 0):
            self.add_error("sale_price", "The sale price must be lower than the price.")
        return cleaned

Reusable validators, on a field or a model

from django.core.validators import MinValueValidator, RegexValidator, FileExtensionValidator

sku = forms.CharField(validators=[RegexValidator(r"^[A-Z]{2}-\d{4}$", "Use the format AB-1234")])
price = forms.DecimalField(validators=[MinValueValidator(0)])
image = forms.ImageField(validators=[FileExtensionValidator(["jpg", "png", "webp"])])

Pass the request or user into the form

class OrderForm(forms.ModelForm):
    def __init__(self, *args, user=None, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["address"].queryset = Address.objects.filter(user=user)

form = OrderForm(request.POST or None, user=request.user)

# in a class based view
def get_form_kwargs(self):
    return {**super().get_form_kwargs(), "user": self.request.user}

Rendering

5.0+everyday

Whole form, or field by field

<form method="post" enctype="multipart/form-data">
    {% csrf_token %}
    {{ form }}                       {# div layout by default since 5.0 #}
    {{ form.as_p }}   {{ form.as_table }}   {{ form.as_ul }}
    <button>Save</button>
</form>

{# by hand #}
{{ form.non_field_errors }}
<div>
    {{ form.name.label_tag }} {{ form.name }}
    {{ form.name.errors }}
    <small>{{ form.name.help_text }}</small>
</div>
{% for field in form %}{{ field.as_field_group }}{% endfor %}     {# 5.0, label + widget + errors + help #}

Widgets and attributes

name = forms.CharField(widget=forms.TextInput(attrs={"class": "input", "placeholder": "Oak desk"}))
category = forms.ModelChoiceField(queryset=Category.objects.all(), empty_label="Pick one")
tags = forms.ModelMultipleChoiceField(queryset=Tag.objects.all(), widget=forms.CheckboxSelectMultiple)
starts = forms.DateField(widget=forms.DateInput(attrs={"type": "date"}))
size = forms.ChoiceField(choices=Product.Size.choices, widget=forms.RadioSelect)
secret = forms.CharField(widget=forms.PasswordInput)
notes = forms.CharField(widget=forms.HiddenInput, required=False)

Tailwind or Bootstrap markup without writing HTML

pip install django-crispy-forms crispy-tailwind      # or crispy-bootstrap5
CRISPY_TEMPLATE_PACK = "tailwind"

{% load crispy_forms_tags %}
{{ form|crispy }}

Formsets

advanced

Several rows of one form, tied to a parent

from django.forms import inlineformset_factory

LineFormSet = inlineformset_factory(Order, OrderLine, fields=["product", "qty"], extra=1, can_delete=True)

def order_edit(request, pk):
    order = get_object_or_404(Order, pk=pk)
    formset = LineFormSet(request.POST or None, instance=order)
    if request.method == "POST" and formset.is_valid():
        formset.save()
        return redirect(order)
    return render(request, "orders/edit.html", {"formset": formset})

<form method="post">{% csrf_token %}
    {{ formset.management_form }}
    {% for form in formset %}{{ form.as_p }}{% endfor %}
</form>
08

Django Models

fields, options, managers, methods

A Model From Top to Bottom

core

Fields, choices, Meta, methods

from django.db import models
from django.urls import reverse
from django.utils.text import slugify

class Product(models.Model):
    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        LIVE = "live", "Live"
        ARCHIVED = "archived", "Archived"

    name = models.CharField(max_length=120)
    slug = models.SlugField(max_length=140, unique=True, blank=True)
    category = models.ForeignKey("Category", on_delete=models.PROTECT, related_name="products")
    price = models.DecimalField(max_digits=10, decimal_places=2)
    stock = models.PositiveIntegerField(default=0)
    status = models.CharField(max_length=10, choices=Status.choices, default=Status.DRAFT)
    description = models.TextField(blank=True)
    image = models.ImageField(upload_to="products/%Y/%m/", blank=True)
    tags = models.ManyToManyField("Tag", blank=True)
    meta = models.JSONField(default=dict, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-created_at"]
        indexes = [models.Index(fields=["status", "-created_at"])]
        constraints = [models.CheckConstraint(condition=models.Q(price__gte=0), name="price_positive")]
        verbose_name_plural = "products"

    def __str__(self):
        return self.name

    def get_absolute_url(self):
        return reverse("catalog:by_slug", kwargs={"slug": self.slug})

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.name)
        super().save(*args, **kwargs)

    @property
    def is_live(self):
        return self.status == self.Status.LIVE

Choices in use

p = Product.objects.create(name="Oak desk", category=desk_cat, price=199)
print(p)
print(p.status)
print(p.get_status_display())
print(p.status == Product.Status.DRAFT)

Product.objects.filter(status=Product.Status.LIVE)

Field Types and Options

core

The fields that cover nearly every table

FieldColumnNotes
CharField(max_length=)varcharmax_length is required; use TextField for long text
TextField, SlugField, EmailField, URLFieldtext, varcharSlugField and EmailField validate their format
IntegerField, PositiveIntegerField, BigIntegerField, SmallIntegerFieldintegerPositive variants add a check constraint
DecimalField(max_digits=, decimal_places=)numericmoney; never FloatField for currency
FloatField, BooleanFielddouble, booleanBooleanField(null=True) for unknown
DateField, DateTimeField, TimeField, DurationFielddate, timestampauto_now_add on create, auto_now on every save
ForeignKey, OneToOneField, ManyToManyFieldfk column, join tableon_delete is required on the first two
FileField(upload_to=), ImageFieldvarchar paththe file lives in MEDIA_ROOT; ImageField needs Pillow
JSONFieldjsonb, jsonqueryable with key lookups on PostgreSQL
UUIDField, BigAutoField, AutoFielduuid, bigserialUUIDField(default=uuid.uuid4, primary_key=True) for public ids
GeneratedField(expression=, output_field=, db_persist=)generated columnDjango 5.0, computed by the database
ArrayField, HStoreField, CICharFieldPostgreSQL onlydjango.contrib.postgres.fields

Options every field accepts

null=True            # column allows NULL, for non text fields
blank=True           # validation allows empty, for forms and the admin
default=0            # or a callable: default=timezone.now, default=dict
db_default=Now()     # a default set by the database itself, 5.0
unique=True          # index plus constraint
db_index=True        # plain index
choices=Status.choices
verbose_name="Unit price"    help_text="Shown in the admin"
editable=False       # hidden from forms and the admin
validators=[MinValueValidator(0)]
primary_key=True     # replaces the automatic id

null versus blank, the pair everyone gets wrong once

notes = models.TextField(blank=True)                        # optional text: blank only, stores ""
shipped_at = models.DateTimeField(null=True, blank=True)     # optional date: both
manager = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL)

Meta and Constraints

everyday

Table level options

class Meta:
    db_table = "shop_products"
    ordering = ["-created_at", "name"]
    get_latest_by = "created_at"
    verbose_name = "product"
    verbose_name_plural = "products"
    indexes = [models.Index(fields=["category", "status"], name="prod_cat_status_idx")]
    constraints = [
        models.UniqueConstraint(fields=["category", "slug"], name="unique_slug_per_category"),
        models.UniqueConstraint(fields=["sku"], condition=models.Q(status="live"), name="unique_live_sku"),
        models.CheckConstraint(condition=models.Q(stock__gte=0), name="stock_non_negative"),
    ]
    permissions = [("publish_product", "Can publish product")]
    abstract = True        # base class, no table
    proxy = True           # same table, different Python behaviour

An abstract base for the columns every table wants

class TimeStamped(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True

class Product(TimeStamped): ...
class Order(TimeStamped): ...

Composite primary keys, Django 5.2

class OrderLine(models.Model):
    pk = models.CompositePrimaryKey("order_id", "product_id")
    order = models.ForeignKey(Order, on_delete=models.CASCADE)
    product = models.ForeignKey(Product, on_delete=models.CASCADE)
    qty = models.PositiveIntegerField()

Managers and Instances

everyday

Reusable query methods on a custom QuerySet

class ProductQuerySet(models.QuerySet):
    def live(self):
        return self.filter(status=Product.Status.LIVE)

    def cheaper_than(self, limit):
        return self.filter(price__lt=limit)

class Product(models.Model):
    objects = ProductQuerySet.as_manager()

Product.objects.live().cheaper_than(50)
category.products.live()

Instance methods you will call

p = Product(name="Desk", price=99)   # not saved
p.save()                              # INSERT, then UPDATE on later calls
p.save(update_fields=["price"])       # only that column
p.refresh_from_db()
p.delete()
p.pk   p.id   p.full_clean()          # run validators without saving
Product.objects.create(...)           # build and save in one call
p2 = copy.copy(p); p2.pk = None; p2.save()   # duplicate a row

Field choices as an enum with extra behaviour

class Size(models.IntegerChoices):
    S = 1, "Small"
    M = 2, "Medium"
    L = 3, "Large"

    @property
    def surcharge(self):
        return {self.S: 0, self.M: 5, self.L: 12}[self]

size = models.IntegerField(choices=Size.choices, default=Size.M)
Product.Size(p.size).surcharge

Newer Field Features

5.0+everyday

Database defaults and generated columns, Django 5.0

from django.db.models import F, GeneratedField
from django.db.models.functions import Now

class Product(models.Model):
    created_at = models.DateTimeField(db_default=Now())
    status = models.CharField(max_length=10, db_default="draft")
    price = models.DecimalField(max_digits=10, decimal_places=2)
    cost = models.DecimalField(max_digits=10, decimal_places=2)
    margin = GeneratedField(
        expression=F("price") - F("cost"),
        output_field=models.DecimalField(max_digits=10, decimal_places=2),
        db_persist=True,                     # stored, so it can be indexed
    )

Product.objects.filter(margin__gt=30).order_by("-margin")

UUID7 keys and JSON null, Django 6.1

from django.db.models.functions import UUID7
from django.db.models import JSONNull

class Order(models.Model):
    id = models.UUIDField(primary_key=True, db_default=UUID7())      # time ordered ids
    meta = models.JSONField(default=dict)

Order.objects.filter(meta__note=JSONNull())          # rows whose "note" is JSON null
Order.objects.update(meta=JSONNull())                # store JSON null, not SQL NULL
09

Relationships

foreign keys, many to many, on_delete

Defining Relationships

6.1+core

The three kinds

class Category(models.Model):
    name = models.CharField(max_length=80)

class Product(models.Model):
    category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name="products")
    tags = models.ManyToManyField("Tag", related_name="products", blank=True)

class ProductDetail(models.Model):
    product = models.OneToOneField(Product, on_delete=models.CASCADE, related_name="detail")
    weight_kg = models.DecimalField(max_digits=6, decimal_places=2)

# a string avoids import order problems: "catalog.Category", "self" for a tree

on_delete, and the database level variants from Django 6.1

OptionWhen the parent is deleted
CASCADEchildren are deleted too, in Python, signals fire
PROTECTProtectedError, the parent cannot go while children exist
RESTRICTRestrictedError, unless the children are deleted in the same operation
SET_NULLcolumn set to NULL, needs null=True
SET_DEFAULTcolumn set to its default
SET(callable)column set to the callable's return value
DO_NOTHINGnothing, the database decides, usually an integrity error
DB_CASCADE, DB_SET_NULL, DB_SET_DEFAULTDjango 6.1: the constraint does it in the database, faster, no signals

A through model for data on the relationship itself

class Membership(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    team = models.ForeignKey("Team", on_delete=models.CASCADE)
    role = models.CharField(max_length=20)
    joined = models.DateField(auto_now_add=True)

class Team(models.Model):
    members = models.ManyToManyField(User, through=Membership, related_name="teams")

team.members.add(user, through_defaults={"role": "admin"})
Membership.objects.filter(team=team, role="admin")

Using Relationships

core

Forward is an attribute, reverse is a manager

product.category                     # the Category instance, one query if not cached
product.category_id                  # the raw id, no query
product.detail.weight_kg             # one to one

category.products.all()              # reverse FK, via related_name
category.products.filter(price__lt=50).count()
tag.products.all()                   # reverse M2M
# without related_name the reverse manager is category.product_set

Many to many operations

product.tags.add(tag)   product.tags.add(t1, t2)   product.tags.add(tag_id)
product.tags.remove(tag)
product.tags.set([t1, t2])           # exactly these
product.tags.clear()
product.tags.create(name="sale")     # new tag, attached
tag in product.tags.all()
product.tags.exists()

Create through the reverse manager

category.products.create(name="Desk", price=99)      # category set for you
category.products.add(product)                        # only when the FK allows null
category.products.set(products, bulk=False)

Filter across relationships with double underscores

Product.objects.filter(category__name="Desks")
Product.objects.filter(category__parent__slug="furniture")
Product.objects.filter(tags__name__in=["sale", "new"]).distinct()
Category.objects.filter(products__price__gt=500)      # categories with an expensive product
Category.objects.filter(products__isnull=True)        # categories with no products

Generic relations, one table pointing at any model

from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType

class Comment(models.Model):
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    object_id = models.PositiveIntegerField()
    target = GenericForeignKey("content_type", "object_id")

class Product(models.Model):
    comments = GenericRelation(Comment)

product.comments.create(body="Great desk")
10

Migrations

schema history, data migrations, rollbacks

The Workflow

core

Change a model, generate, apply

python manage.py makemigrations catalog
python manage.py makemigrations catalog --name add_product_stock   # readable file names
python manage.py migrate
python manage.py migrate catalog 0002        # roll back to this migration
python manage.py migrate catalog zero        # roll everything in the app back
python manage.py showmigrations
python manage.py sqlmigrate catalog 0003     # print the SQL first

What a generated migration looks like

from django.db import migrations, models

class Migration(migrations.Migration):
    dependencies = [("catalog", "0002_product_slug")]

    operations = [
        migrations.AddField(
            model_name="product",
            name="stock",
            field=models.PositiveIntegerField(default=0),
        ),
    ]

Adding a required field to a table with rows

# makemigrations asks for a one off default, or give one in the model
sku = models.CharField(max_length=20, default="")         # then fill it in a data migration
# or make it nullable first, backfill, then make it required in a third migration

Data Migrations

everyday

Change rows, not columns, with RunPython

python manage.py makemigrations catalog --empty --name backfill_slugs

from django.db import migrations
from django.utils.text import slugify

def forwards(apps, schema_editor):
    Product = apps.get_model("catalog", "Product")     # the historical model, never an import
    for p in Product.objects.filter(slug="").iterator():
        p.slug = slugify(p.name)
        p.save(update_fields=["slug"])

class Migration(migrations.Migration):
    dependencies = [("catalog", "0003_product_stock")]
    operations = [migrations.RunPython(forwards, migrations.RunPython.noop)]

Raw SQL, and operations the autodetector cannot write

migrations.RunSQL("CREATE INDEX CONCURRENTLY ...", reverse_sql="DROP INDEX ...")
migrations.RenameField("product", "title", "name")          # instead of remove + add
migrations.RenameModel("Item", "Product")
migrations.SeparateDatabaseAndState(database_operations=[...], state_operations=[...])

Housekeeping and Recovery

advanced

Squash a long history, merge a conflict

python manage.py squashmigrations catalog 0001 0020
python manage.py makemigrations --merge                 # two branches added migration 0007
python manage.py makemigrations --check --dry-run       # CI: fail if a migration is missing

Fake, and when it is honest to

python manage.py migrate catalog 0004 --fake             # schema already changed by hand
python manage.py migrate --fake-initial                  # tables exist, history does not

Migrations that cannot run in one transaction

class Migration(migrations.Migration):
    atomic = False          # required for CREATE INDEX CONCURRENTLY on PostgreSQL

Reading What Migrations Will Do

everyday

Which migrations are applied

python manage.py showmigrations catalog sessions
python manage.py showmigrations --plan          # the order they will run in, across apps

The SQL a migration will run

python manage.py sqlmigrate catalog 0002
python manage.py sqlmigrate catalog 0002 --backwards

Catch a missing migration in CI

python manage.py makemigrations --check --dry-run    # exit code 1 when a migration is missing
python manage.py migrate --plan                       # what migrate would do, without doing it
11

QuerySets & Lookups

filter, lookups, Q, writing rows

Reading

core

Building a query, and when it actually runs

qs = Product.objects.filter(status="live")           # nothing runs yet
qs = qs.exclude(stock=0).order_by("-price", "name")  # still nothing
qs = qs.select_related("category")[:20]              # still nothing

for p in qs: ...                                     # runs here, one query
list(qs)   len(qs)   bool(qs)   qs[0]                # each of these evaluates

One row, or a value

Product.objects.get(pk=12)                     # DoesNotExist or MultipleObjectsReturned
Product.objects.filter(slug=slug).first()      # None when missing
Product.objects.filter(slug=slug).last()
Product.objects.earliest("created_at")   .latest("created_at")
get_object_or_404(Product, slug=slug)          # 404 in a view
Product.objects.filter(stock=0).exists()       # cheaper than count() > 0
Product.objects.count()
Product.objects.in_bulk([1, 2, 3])             # {1: <Product>, 2: ...}

Columns instead of objects

Product.objects.values("id", "name")                        # dicts
Product.objects.values_list("id", "name")                   # tuples
Product.objects.values_list("name", flat=True)              # ["Desk", "Chair"]
Product.objects.values("category__name").distinct()
Product.objects.only("name", "price")                       # defer the rest
Product.objects.defer("description")

Field Lookups

core

field__lookup=value, the i prefix ignores case

LookupSQLExample
exact, iexact=name__iexact="oak desk"
contains, icontainsLIKE %x%name__icontains="desk"
startswith, endswith, istartswith, iendswithLIKE x%sku__startswith="AB-"
inIN (...)status__in=["live", "draft"], pk__in=subquery
gt, gte, lt, lte> >= < <=price__gte=100
rangeBETWEENcreated_at__range=(start, end)
isnullIS NULLshipped_at__isnull=True
date, year, month, day, week_day, hourdate partscreated_at__year=2026, created_at__date=today
regex, iregex~ REGEXPsku__regex=r"^[A-Z]{2}-\d+$"
contained_by, has_key, has_keysJSON and arraysmeta__has_key="color", meta__color="red"
search, trigram_similarfull text, PostgreSQLname__search="oak"

OR, NOT and dynamic conditions with Q

from django.db.models import Q

Product.objects.filter(Q(name__icontains=q) | Q(sku__icontains=q))
Product.objects.filter(Q(status="live") & ~Q(stock=0))
Product.objects.filter(Q(price__lt=50) | Q(category__name="Sale"), status="live")   # Q first

conditions = Q()
for word in query.split():
    conditions &= Q(name__icontains=word)
Product.objects.filter(conditions)

Ordering, slicing, distinct

.order_by("-price", "name")   .order_by("?")   .order_by(Lower("name"))
.order_by(F("shipped_at").desc(nulls_last=True))
.reverse()   .distinct()   .distinct("category")     # DISTINCT ON, PostgreSQL
qs[:10]   qs[10:20]   qs[::2]                          # LIMIT/OFFSET, the step evaluates

Writing

core

Create, update, delete

p = Product.objects.create(name="Desk", price=99, category=cat)
p.price = 89; p.save()
Product.objects.filter(status="draft").update(status="live")     # one UPDATE, no save(), no signals
Product.objects.filter(stock=0).delete()                          # returns (count, {model: count})
p.delete()

Find or create in one call

tag, created = Tag.objects.get_or_create(name="sale")
tag, created = Tag.objects.get_or_create(name="sale", defaults={"color": "red"})
p, created = Product.objects.update_or_create(sku="AB-1", defaults={"price": 79})

Bulk operations, one statement instead of a thousand

Product.objects.bulk_create([Product(name=n, price=9) for n in names], batch_size=500)
Product.objects.bulk_create(rows, update_conflicts=True, unique_fields=["sku"], update_fields=["price"])   # upsert

products = list(Product.objects.filter(status="draft"))
for p in products: p.price *= 1.1
Product.objects.bulk_update(products, ["price"], batch_size=500)

Raw SQL, always with parameters

Product.objects.raw("SELECT * FROM catalog_product WHERE price > %s", [100])   # model instances

from django.db import connection
with connection.cursor() as cursor:
    cursor.execute("SELECT category_id, COUNT(*) FROM catalog_product GROUP BY category_id")
    rows = cursor.fetchall()
12

Django ORM Performance

N+1, annotate, F, subqueries, transactions

The N+1 Problem

6.1+core

The mistake, and the two fixes

# 101 queries
for p in Product.objects.all()[:100]:
    print(p.category.name)

# 1 query, JOIN: ForeignKey and OneToOne
for p in Product.objects.select_related("category", "category__parent")[:100]:
    print(p.category.name)

# 2 queries: ManyToMany and reverse relations
for p in Product.objects.prefetch_related("tags")[:100]:
    print([t.name for t in p.tags.all()])

Django 6.1: fetch modes, a safety net for the loop you forgot

from django.db import models

for p in Product.objects.fetch_mode(models.FETCH_PEERS)[:100]:
    print(p.category.name)          # 2 queries total, the peers are fetched together

Product.objects.fetch_mode(models.FETCH_RAISE)   # FieldFetchBlocked on any lazy load, use in tests

Prefetch with a filtered or ordered queryset

from django.db.models import Prefetch

Category.objects.prefetch_related(
    Prefetch("products", queryset=Product.objects.filter(status="live").order_by("-price"), to_attr="live_products")
)
# category.live_products is a plain list, already filtered

Aggregate and Annotate

everyday

aggregate returns one dict, annotate adds a column to each row

from django.db.models import Avg, Count, Max, Min, Sum

Order.objects.aggregate(total=Count("id"), revenue=Sum("total"), avg_price=Avg("total"))

Category.objects.annotate(n=Count("products")).filter(n__gt=5).order_by("-n")
Product.objects.annotate(tag_count=Count("tags", distinct=True))
Order.objects.values("status").annotate(n=Count("id"), revenue=Sum("total"))   # GROUP BY status

F expressions, compute in the database and avoid races

from django.db.models import F

Product.objects.filter(pk=pk).update(stock=F("stock") - 1)           # atomic decrement
Product.objects.filter(sale_price__lt=F("price") * 0.5)               # compare two columns
Product.objects.annotate(margin=F("price") - F("cost")).order_by("-margin")
Order.objects.update(total=F("subtotal") + F("shipping"))

Conditional expressions

from django.db.models import Case, When, Value, CharField, Q

Product.objects.annotate(
    tier=Case(
        When(price__gte=500, then=Value("premium")),
        When(price__gte=100, then=Value("standard")),
        default=Value("budget"),
        output_field=CharField(),
    )
)
Order.objects.aggregate(paid=Count("id", filter=Q(status="paid")))

Subqueries and window functions

from django.db.models import Exists, OuterRef, Subquery, Window
from django.db.models.functions import RowNumber

latest = Order.objects.filter(user=OuterRef("pk")).order_by("-created_at")
User.objects.annotate(last_total=Subquery(latest.values("total")[:1]))
User.objects.annotate(has_orders=Exists(Order.objects.filter(user=OuterRef("pk")))).filter(has_orders=True)

Product.objects.annotate(rank=Window(RowNumber(), partition_by=[F("category")], order_by=F("price").desc()))

Transactions and Locks

everyday

All or nothing

from django.db import transaction

@transaction.atomic
def place_order(request): ...

with transaction.atomic():
    order = Order.objects.create(user=user, total=total)
    OrderLine.objects.bulk_create(lines)
    Product.objects.filter(pk__in=ids).update(stock=F("stock") - 1)

transaction.on_commit(lambda: send_receipt.enqueue(order.pk))   # only after a successful commit

Lock rows while you change them

with transaction.atomic():
    product = Product.objects.select_for_update().get(pk=pk)      # other transactions wait
    if product.stock > 0:
        product.stock -= 1
        product.save(update_fields=["stock"])

Product.objects.select_for_update(skip_locked=True)   # queue workers picking jobs
Product.objects.select_for_update(nowait=True)

Measuring and Memory

advanced

See the SQL and the plan

qs = Product.objects.filter(status="live").order_by("-price")[:20]
print(qs.query)
print(qs.explain(analyze=True))

from django.db import connection, reset_queries
reset_queries(); list(qs); print(len(connection.queries), connection.queries[-1])   # DEBUG only

Large result sets without loading them all

for p in Product.objects.iterator(chunk_size=2000):   # server side cursor on PostgreSQL
    export(p)

Product.objects.filter(...).count()                   # COUNT(*), not len(qs)
Product.objects.filter(...).exists()                  # not bool(qs) on a big set

Indexes for the queries you actually run

class Meta:
    indexes = [
        models.Index(fields=["status", "-price"]),                  # matches the filter and the order
        models.Index(Lower("name"), name="prod_name_lower_idx"),    # for name__iexact and icontains prefixes
        GinIndex(fields=["meta"]),                                  # JSONField, django.contrib.postgres
    ]

Fetch Modes in Practice

6.1+everyday

The three modes side by side

from django.db.models import FETCH_ONE, FETCH_PEERS, FETCH_RAISE

products = Product.objects.filter(status="live")                       # FETCH_ONE by default
for p in products: p.category.name                                     # 1 + N

products = Product.objects.filter(status="live").fetch_mode(FETCH_PEERS)
for p in products: p.category.name                                     # 2 queries total

products = Product.objects.filter(status="live").fetch_mode(FETCH_RAISE)
for p in products: p.category.name                                     # raises FieldFetchBlocked

# make it the default for a model through its manager
class ProductManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().fetch_mode(FETCH_PEERS)

class Product(models.Model):
    objects = ProductManager()
13

Django Admin

register, list options, inlines, actions

ModelAdmin

core

A configured admin for a model

from django.contrib import admin
from .models import Category, Product, ProductImage

@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    list_display = ["name", "category", "price", "stock", "status", "created_at"]
    list_display_links = ["name"]
    list_editable = ["price", "stock"]
    list_filter = ["status", "category", ("created_at", admin.DateFieldListFilter)]
    search_fields = ["name", "sku", "category__name"]
    ordering = ["-created_at"]
    list_per_page = 50
    date_hierarchy = "created_at"
    prepopulated_fields = {"slug": ["name"]}
    autocomplete_fields = ["category"]          # the Category admin needs search_fields
    readonly_fields = ["created_at", "updated_at"]
    list_select_related = ["category"]           # avoid N+1 on the change list
    save_on_top = True

    fieldsets = [
        (None, {"fields": ["name", "slug", "category", "status"]}),
        ("Pricing", {"fields": [("price", "sale_price"), "stock"]}),
        ("Content", {"fields": ["description", "image", "tags"], "classes": ["collapse"]}),
        ("Timestamps", {"fields": ["created_at", "updated_at"]}),
    ]

admin.site.register(Category)                    # the plain form

Computed columns and links in the list

from django.utils.html import format_html

@admin.display(description="Margin", ordering="price")
def margin(self, obj):
    return f"{obj.price - obj.cost:.2f}"

@admin.display(boolean=True, description="Live")
def is_live(self, obj):
    return obj.status == "live"

@admin.display(description="Preview")
def thumb(self, obj):
    return format_html('<img src="{}" height="40">', obj.image.url) if obj.image else ""

list_display = ["name", "margin", "is_live", "thumb"]

Inlines and Actions

6.1+everyday

Edit child rows on the parent's page

class ProductImageInline(admin.TabularInline):     # or StackedInline
    model = ProductImage
    extra = 1
    fields = ["image", "alt", "position"]
    max_num = 8

class ProductAdmin(admin.ModelAdmin):
    inlines = [ProductImageInline]

A bulk action on the change list

@admin.action(description="Mark selected products as live")
def make_live(modeladmin, request, queryset):
    updated = queryset.update(status="live")
    modeladmin.message_user(request, f"{updated} products marked as live.")

class ProductAdmin(admin.ModelAdmin):
    actions = [make_live]

Restrict rows and fields per user

def get_queryset(self, request):
    qs = super().get_queryset(request)
    return qs if request.user.is_superuser else qs.filter(created_by=request.user)

def get_readonly_fields(self, request, obj=None):
    return ["price"] if not request.user.has_perm("catalog.change_price") else []

def save_model(self, request, obj, form, change):
    if not change:
        obj.created_by = request.user
    super().save_model(request, obj, form, change)

Branding and a second admin site

admin.site.site_header = "Shop administration"
admin.site.site_title = "Shop admin"
admin.site.index_title = "Dashboard"

class StaffSite(admin.AdminSite):
    site_header = "Staff tools"
staff_site = StaffSite(name="staff")
staff_site.register(Order, OrderAdmin)
path("staff/", staff_site.urls)

More List and Form Options

6.1+everyday

Sort by an annotation, search your own way, override widgets

from django.db.models import Count

class ProductAdmin(admin.ModelAdmin):
    def get_queryset(self, request):
        return super().get_queryset(request).annotate(n_images=Count("images"))

    @admin.display(ordering="n_images", description="Images")
    def image_count(self, obj):
        return obj.n_images

    def get_search_results(self, request, queryset, search_term):
        qs, distinct = super().get_search_results(request, queryset, search_term)
        if search_term.isdigit():
            qs |= self.model.objects.filter(sku=int(search_term))
        return qs, distinct

    formfield_overrides = {models.TextField: {"widget": forms.Textarea(attrs={"rows": 4})}}
    raw_id_fields = ["supplier"]                 # a lookup popup instead of a 10,000 row select
    list_max_show_all = 500
    show_full_result_count = False               # skips the COUNT(*) on huge tables

Actions on the change form, Django 6.1

@admin.action(description="Duplicate this product", location="changeform")
def duplicate(modeladmin, request, queryset):
    for product in queryset:
        product.pk = None; product.slug += "-copy"; product.save()
    modeladmin.message_user(request, "Duplicated.", messages.SUCCESS)

class ProductAdmin(admin.ModelAdmin):
    actions = [make_live, duplicate]        # location="changelist" is the default, "both" shows it in both places

Permissions per page, and the audit log

def has_add_permission(self, request):
    return request.user.groups.filter(name="Editors").exists()
def has_delete_permission(self, request, obj=None):
    return request.user.is_superuser
def has_module_permission(self, request):           # hide the whole app from the index
    return request.user.is_staff

# every admin change is recorded
from django.contrib.admin.models import LogEntry
LogEntry.objects.filter(user=request.user).order_by("-action_time")[:20]
entry.get_change_message()   entry.object_repr   entry.action_flag   # ADDITION, CHANGE, DELETION

A different look without rewriting it

pip install django-unfold          # Tailwind based, dark mode, dashboards
INSTALLED_APPS = ["unfold", "django.contrib.admin", ...]        # before admin
class ProductAdmin(unfold.admin.ModelAdmin): ...

pip install django-jazzmin         # AdminLTE based, one settings dict
# or override templates/admin/base_site.html for a logo and colours only
14

Auth & Permissions

users, login views, permissions

Users

core

A custom user model, before the first migration

from django.contrib.auth.models import AbstractUser

class User(AbstractUser):
    phone = models.CharField(max_length=30, blank=True)

# settings.py
AUTH_USER_MODEL = "accounts.User"

# everywhere else, never import User directly
from django.contrib.auth import get_user_model
User = get_user_model()

# foreign keys
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)

The current user and its flags

request.user                       # a User, or AnonymousUser
request.user.is_authenticated      # True for a logged in user
request.user.is_staff              # may enter the admin
request.user.is_superuser          # every permission
request.user.is_active
request.user.get_full_name()   request.user.email
{% if user.is_authenticated %} Hi {{ user.get_short_name }} {% endif %}

Create users and check passwords correctly

User.objects.create_user("ana", "ana@example.com", "s3cret")     # hashed
User.objects.create_superuser("admin", "admin@example.com", "s3cret")
user.set_password("new one"); user.save()
user.check_password("new one")
# never: User(password="plain").save()

Login, Logout, Registration

5.1+core

The built in views, eight routes in one include

path("accounts/", include("django.contrib.auth.urls"))
# login/  logout/  password_change/  password_change/done/
# password_reset/  password_reset/done/  reset/<uidb64>/<token>/  reset/done/

# settings
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "catalog:list"
LOGOUT_REDIRECT_URL = "login"

# templates it expects: registration/login.html, registration/password_reset_form.html, ...
# logout must be a POST since 5.0
<form method="post" action="{% url 'logout' %}">{% csrf_token %}<button>Log out</button></form>

A signup view with the stock form

from django.contrib.auth import login
from django.contrib.auth.forms import UserCreationForm

class SignUpForm(UserCreationForm):
    class Meta(UserCreationForm.Meta):
        model = User
        fields = ["username", "email"]

def signup(request):
    form = SignUpForm(request.POST or None)
    if request.method == "POST" and form.is_valid():
        user = form.save()
        login(request, user)
        return redirect("catalog:list")
    return render(request, "registration/signup.html", {"form": form})

Log in and out by hand

from django.contrib.auth import authenticate, login, logout

user = authenticate(request, username=u, password=p)    # None on failure
if user is not None:
    login(request, user)
logout(request)

Require login everywhere with one middleware, Django 5.1

MIDDLEWARE += ["django.contrib.auth.middleware.LoginRequiredMiddleware"]

from django.contrib.auth.decorators import login_not_required

@login_not_required
def landing(request): ...

Permissions

everyday

Guard views, and check in code

from django.contrib.auth.decorators import login_required, permission_required, user_passes_test
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin, UserPassesTestMixin

@login_required
@permission_required("catalog.change_product", raise_exception=True)
def product_edit(request, pk): ...

class ProductUpdate(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
    permission_required = ["catalog.change_product"]

class OwnerOnly(UserPassesTestMixin, UpdateView):
    def test_func(self):
        return self.get_object().created_by == self.request.user

request.user.has_perm("catalog.publish_product")
request.user.has_perms(["catalog.add_product", "catalog.change_product"])
{% if perms.catalog.change_product %} ... {% endif %}

Groups as roles

from django.contrib.auth.models import Group, Permission

editors, _ = Group.objects.get_or_create(name="Editors")
editors.permissions.add(Permission.objects.get(codename="change_product"))
user.groups.add(editors)
user.groups.filter(name="Editors").exists()

Social login and two factor with django-allauth

pip install "django-allauth[socialaccount,mfa]"
INSTALLED_APPS += ["allauth", "allauth.account", "allauth.socialaccount", "allauth.socialaccount.providers.google", "allauth.mfa"]
MIDDLEWARE += ["allauth.account.middleware.AccountMiddleware"]
path("accounts/", include("allauth.urls"))

ACCOUNT_LOGIN_METHODS = {"email"}
ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
15

Django REST Framework

serializers, viewsets, auth, pagination

Serializers

everyday

A ModelSerializer with a nested relation

pip install djangorestframework
INSTALLED_APPS += ["rest_framework"]

from rest_framework import serializers

class CategorySerializer(serializers.ModelSerializer):
    class Meta:
        model = Category
        fields = ["id", "name"]

class ProductSerializer(serializers.ModelSerializer):
    category = CategorySerializer(read_only=True)
    category_id = serializers.PrimaryKeyRelatedField(queryset=Category.objects.all(), source="category", write_only=True)
    url = serializers.HyperlinkedIdentityField(view_name="product-detail")

    class Meta:
        model = Product
        fields = ["id", "url", "name", "slug", "price", "status", "category", "category_id", "created_at"]
        read_only_fields = ["slug", "created_at"]

    def validate_price(self, value):
        if value < 0:
            raise serializers.ValidationError("Price cannot be negative.")
        return value

Use it directly

ProductSerializer(product, context={"request": request}).data
ProductSerializer(Product.objects.all(), many=True).data

s = ProductSerializer(data=request.data)
s.is_valid(raise_exception=True)           # 400 with field errors
product = s.save(created_by=request.user)

Views and Routers

everyday

A ViewSet plus a router gives you the whole resource

from rest_framework import permissions, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.select_related("category").order_by("-created_at")
    serializer_class = ProductSerializer
    permission_classes = [permissions.IsAuthenticatedOrReadOnly]
    filterset_fields = ["status", "category"]
    search_fields = ["name", "sku"]
    ordering_fields = ["price", "created_at"]

    @action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
    def publish(self, request, pk=None):
        product = self.get_object()
        product.status = "live"; product.save(update_fields=["status"])
        return Response({"status": product.status})

# api/urls.py
from rest_framework.routers import DefaultRouter
router = DefaultRouter()
router.register("products", ProductViewSet, basename="product")
urlpatterns = router.urls

Smaller building blocks when a ViewSet is too much

from rest_framework import generics, status
from rest_framework.views import APIView

class ProductList(generics.ListCreateAPIView):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer

class ProductDetail(generics.RetrieveUpdateDestroyAPIView): ...

class Health(APIView):
    def get(self, request):
        return Response({"ok": True}, status=status.HTTP_200_OK)

@api_view(["GET"])
def stats(request): return Response({"products": Product.objects.count()})

Auth, Pagination, Filtering

everyday

Project wide defaults in settings

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework_simplejwt.authentication.JWTAuthentication",
        "rest_framework.authentication.SessionAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 25,
    "DEFAULT_FILTER_BACKENDS": [
        "django_filters.rest_framework.DjangoFilterBackend",
        "rest_framework.filters.SearchFilter",
        "rest_framework.filters.OrderingFilter",
    ],
    "DEFAULT_THROTTLE_RATES": {"anon": "100/hour", "user": "1000/hour"},
    "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}

JWT with simplejwt

pip install djangorestframework-simplejwt

from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
path("api/token/", TokenObtainPairView.as_view()),
path("api/token/refresh/", TokenRefreshView.as_view()),

# client: POST /api/token/ {"username": ..., "password": ...}  -> {"access": ..., "refresh": ...}
# then:   Authorization: Bearer <access>

A custom permission

from rest_framework.permissions import BasePermission, SAFE_METHODS

class IsOwnerOrReadOnly(BasePermission):
    def has_object_permission(self, request, view, obj):
        return request.method in SAFE_METHODS or obj.created_by == request.user

OpenAPI docs, and the lighter alternative

pip install drf-spectacular
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema")),

# django-ninja: type hints instead of serializers, FastAPI style, docs built in
pip install django-ninja
@api.get("/products/{pk}", response=ProductOut)
def product(request, pk: int): return get_object_or_404(Product, pk=pk)

What the API Returns

everyday

A paginated list

GET /api/products/?status=live&search=oak&ordering=-price&page=1
Accept: application/json
Authorization: Bearer eyJ...

Validation and permission errors, as clients see them

POST /api/products/
{"name": "Chair", "price": "-5"}

# customise the envelope for every error
REST_FRAMEWORK["EXCEPTION_HANDLER"] = "api.exceptions.handler"
def handler(exc, context):
    response = exception_handler(exc, context)
    if response is not None:
        response.data = {"errors": response.data, "status": response.status_code}
    return response
16

Static & Media Files

assets, uploads, storage backends

Static Files

core

Development finds them, production collects them

STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"]       # project level assets
STATIC_ROOT = BASE_DIR / "staticfiles"         # collectstatic target, served in production

{% load static %}
<link rel="stylesheet" href="{% static 'css/shop.css' %}">
<img src="{% static 'catalog/img/logo.svg' %}" alt="Shop">

python manage.py collectstatic --noinput

Serve them from Django with WhiteNoise, hashed for caching

pip install whitenoise

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",       # right after SecurityMiddleware
    ...
]
STORAGES = {
    "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
    "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}

Tailwind or Vite alongside Django

pip install django-tailwind-cli          # Tailwind without Node
python manage.py tailwind build

pip install django-vite                  # a Vite dev server for JS
{% vite_asset 'src/main.ts' %}

Uploads and Media

everyday

A file field, from form to disk to page

pip install Pillow                                  # for ImageField

class Product(models.Model):
    image = models.ImageField(upload_to="products/%Y/%m/", blank=True)
    manual = models.FileField(upload_to="manuals/", blank=True)

# the form needs enctype="multipart/form-data" and request.FILES
form = ProductForm(request.POST, request.FILES)

# in a template
{% if product.image %}<img src="{{ product.image.url }}" width="{{ product.image.width }}">{% endif %}
product.image.name   product.image.path   product.image.size

Media settings, and serving uploads locally

MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"

# urls.py, development only
if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# in production Nginx serves MEDIA_ROOT, or uploads go to object storage

S3 or any object store with django-storages

pip install "django-storages[s3]"

STORAGES["default"] = {
    "BACKEND": "storages.backends.s3.S3Storage",
    "OPTIONS": {"bucket_name": "shop-media", "region_name": "eu-central-1", "querystring_auth": False},
}
# upload_to, .url and .open() keep working; the files just live in the bucket

Validate uploads

def clean_image(self):
    image = self.cleaned_data.get("image")
    if image and image.size > 2 * 1024 * 1024:
        raise ValidationError("Keep images under 2 MB.")
    return image

image = models.ImageField(validators=[FileExtensionValidator(["jpg", "png", "webp"])])
17

Internationalisation & Time Zones

translations, locale routing, time zones, formats

Translating Strings

everyday

Mark text in Python, lazily where it runs at import time

from django.utils.translation import gettext as _, gettext_lazy, ngettext

# models, forms, settings: lazy
class Product(models.Model):
    name = models.CharField(_("name"), max_length=120)
    class Meta:
        verbose_name = gettext_lazy("product")

# views: immediate
def checkout(request):
    messages.success(request, _("Your order was placed."))
    label = ngettext("%(n)d item", "%(n)d items", count) % {"n": count}
    return render(request, "shop/thanks.html", {"title": _("Thank you")})

Mark text in templates

{% load i18n %}
<h1>{% translate "Your cart" %}</h1>
{% translate "Checkout" as btn %}<button>{{ btn }}</button>

{% blocktranslate count n=items|length %}
  {{ n }} item in your cart
{% plural %}
  {{ n }} items in your cart
{% endblocktranslate %}

{% blocktranslate with name=user.first_name %}Hello {{ name }}{% endblocktranslate %}
{% get_current_language as LANGUAGE_CODE %}

Extract, translate, compile

pip install Babel     # or system gettext tools

# settings.py
LANGUAGE_CODE = "en"
LANGUAGES = [("en", "English"), ("ro", "Romana"), ("de", "Deutsch")]
LOCALE_PATHS = [BASE_DIR / "locale"]

python manage.py makemessages -l ro -l de --ignore=.venv     # writes .po files
python manage.py makemessages -d djangojs -l ro               # strings in JavaScript
# edit locale/ro/LC_MESSAGES/django.po, then
python manage.py compilemessages                              # writes .mo files, commit both

Picking the Language

everyday

Language in the URL with i18n_patterns

MIDDLEWARE = [
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.locale.LocaleMiddleware",        # after sessions, before common
    "django.middleware.common.CommonMiddleware",
    ...
]

# urls.py
from django.conf.urls.i18n import i18n_patterns
urlpatterns = [path("i18n/", include("django.conf.urls.i18n"))]     # the set_language view
urlpatterns += i18n_patterns(
    path("", include("catalog.urls")),
    path("admin/", admin.site.urls),
    prefix_default_language=False,        # /products/ for en, /ro/products/ for ro
)

A language switcher

{% load i18n %}
<form action="{% url 'set_language' %}" method="post">{% csrf_token %}
  <input name="next" type="hidden" value="{{ request.get_full_path }}">
  <select name="language" onchange="this.form.submit()">
    {% get_current_language as cur %}
    {% get_available_languages as langs %}
    {% for code, name in langs %}
      <option value="{{ code }}"{% if code == cur %} selected{% endif %}>{{ name }}</option>
    {% endfor %}
  </select>
</form>

Switch languages in code

from django.utils import translation

translation.get_language()                   # "ro"
with translation.override("de"):
    subject = _("Your receipt")              # rendered in German, for an email
translation.activate("ro")                   # for the rest of this thread, rare outside tasks

# the same for a URL in another language
from django.urls import translate_url
translate_url(request.path, "de")

Time Zones and Formats

core

Aware datetimes, always

TIME_ZONE = "Europe/Bucharest"       # the default zone for display and forms
USE_TZ = True                        # store UTC, convert on the way out; default since 5.0

from django.utils import timezone
timezone.now()                       # aware, UTC: use this, never datetime.now()
timezone.localtime(order.created_at) # in the current zone
timezone.localdate()
timezone.make_aware(naive_dt)        # attach the current zone
timezone.is_aware(dt)

# per user zone, in a middleware or a view
timezone.activate(zoneinfo.ZoneInfo(request.user.tz))
timezone.deactivate()

Templates: local time and formats

{% load tz %}
{{ order.created_at }}                      {# converted to the active zone automatically #}
{{ order.created_at|localtime|date:"j F Y, H:i" }}
{% timezone "America/New_York" %} {{ order.created_at }} {% endtimezone %}
{{ order.created_at|utc }}

{% load l10n %}
{{ price|localize }}      {{ value|unlocalize }}
{{ total|floatformat:2 }}   {{ created|date:"SHORT_DATE_FORMAT" }}   {{ created|timesince }}

Format settings and form parsing

FORMAT_MODULE_PATH = ["shop.formats"]        # shop/formats/ro/formats.py
# DATE_FORMAT = "j N Y";  DATE_INPUT_FORMATS = ["%d.%m.%Y", "%Y-%m-%d"];  DECIMAL_SEPARATOR = ","
USE_THOUSAND_SEPARATOR = True

forms.DateField(input_formats=["%d.%m.%Y"], localize=True)
forms.DecimalField(localize=True)             # accepts "1.234,50" in a ro locale
18

Contrib Apps

sitemaps, feeds, humanize, flatpages, postgres, GIS

Sitemaps and Feeds

everyday

An XML sitemap from a QuerySet

INSTALLED_APPS += ["django.contrib.sitemaps", "django.contrib.sites"]
SITE_ID = 1                                  # set the domain in admin, Sites

# catalog/sitemaps.py
from django.contrib.sitemaps import Sitemap

class ProductSitemap(Sitemap):
    changefreq = "weekly"
    priority = 0.6
    def items(self):
        return Product.objects.filter(status="live")
    def lastmod(self, obj):
        return obj.updated_at

# urls.py
from django.contrib.sitemaps.views import sitemap
sitemaps = {"products": ProductSitemap, "static": StaticViewSitemap}
path("sitemap.xml", sitemap, {"sitemaps": sitemaps}, name="sitemap")

An RSS or Atom feed

from django.contrib.syndication.views import Feed
from django.utils.feedgenerator import Atom1Feed

class LatestProducts(Feed):
    title = "New products"
    link = "/products/"
    description = "The latest additions to the shop."

    def items(self):
        return Product.objects.filter(status="live").order_by("-created_at")[:20]
    def item_title(self, item): return item.name
    def item_description(self, item): return item.description[:200]
    def item_pubdate(self, item): return item.created_at
    # item_link falls back to get_absolute_url()

class LatestProductsAtom(LatestProducts):
    feed_type = Atom1Feed
    subtitle = LatestProducts.description

path("feed/", LatestProducts()),  path("feed/atom/", LatestProductsAtom())

Humanize, Flatpages, Redirects

everyday

Human friendly numbers and dates

INSTALLED_APPS += ["django.contrib.humanize"]

{% load humanize %}
{{ 1234567|intcomma }}
{{ 1200000|intword }}
{{ order.created_at|naturaltime }}
{{ order.created_at|naturalday }}
{{ 2|ordinal }}
{{ 4|apnumber }}

Editable pages and redirects from the admin

INSTALLED_APPS += ["django.contrib.flatpages", "django.contrib.redirects", "django.contrib.sites"]
MIDDLEWARE += [
    "django.contrib.flatpages.middleware.FlatpageFallbackMiddleware",   # /about/, /terms/ from the database
    "django.contrib.redirects.middleware.RedirectFallbackMiddleware",   # old path to new path, 301
]
path("pages/", include("django.contrib.flatpages.urls"))
# template: flatpages/default.html with {{ flatpage.title }} and {{ flatpage.content }}

Generic relations with contenttypes

from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType

class Comment(models.Model):
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    object_id = models.PositiveBigIntegerField()
    target = GenericForeignKey("content_type", "object_id")
    body = models.TextField()

class Product(models.Model):
    comments = GenericRelation(Comment)          # product.comments.all()

Comment.objects.create(target=product, body="Solid desk.")

PostgreSQL Extras and GIS

advanced

Full text search

INSTALLED_APPS += ["django.contrib.postgres"]
from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank, TrigramSimilarity

vector = SearchVector("name", weight="A") + SearchVector("description", weight="B")
query = SearchQuery("standing desk", search_type="websearch")
Product.objects.annotate(rank=SearchRank(vector, query)).filter(rank__gte=0.1).order_by("-rank")

# typo tolerant, needs the pg_trgm extension (TrigramExtension migration)
Product.objects.annotate(sim=TrigramSimilarity("name", "wallnut desk")).filter(sim__gt=0.3).order_by("-sim")

# stored vector with an index for big tables
search = SearchVectorField(null=True)
class Meta: indexes = [GinIndex(fields=["search"])]
Product.objects.update(search=SearchVector("name", "description"))

PostgreSQL fields and lookups

from django.contrib.postgres.fields import ArrayField, HStoreField
from django.contrib.postgres.indexes import GinIndex, BrinIndex

tags = ArrayField(models.CharField(max_length=30), default=list, blank=True)
attrs = HStoreField(default=dict)                # needs HStoreExtension()

Product.objects.filter(tags__contains=["oak"])
Product.objects.filter(tags__overlap=["oak", "walnut"])
Product.objects.filter(tags__len__gt=2)
Product.objects.filter(attrs__has_key="finish")
Product.objects.filter(name__unaccent__icontains="Sèvres")    # UnaccentExtension
Product.objects.filter(meta__specs__depth__gt=60)              # JSONField key path

GeoDjango in five lines

INSTALLED_APPS += ["django.contrib.gis"]
DATABASES["default"]["ENGINE"] = "django.contrib.gis.db.backends.postgis"

from django.contrib.gis.db import models
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import D

class Store(models.Model):
    location = models.PointField(geography=True)

Store.objects.filter(location__distance_lte=(Point(26.10, 44.43), D(km=10)))
Store.objects.annotate(d=Distance("location", here)).order_by("d")[:5]
19

Requests & Middleware

request object, sessions, messages, middleware

The Request Object

core

Everything a view can read

request.method                    # "GET", "POST"
request.GET.get("q", "")          # query string, QueryDict
request.GET.getlist("tag")        # ?tag=a&tag=b
request.POST.get("name")          # form body
request.FILES["avatar"]
request.body                      # raw bytes, for JSON APIs
json.loads(request.body)
request.headers["X-Requested-With"]   request.headers.get("Accept")
request.COOKIES.get("theme")
request.META["REMOTE_ADDR"]       request.META["HTTP_USER_AGENT"]
request.path   request.get_full_path()   request.build_absolute_uri()
request.is_secure()   request.user   request.session   request.resolver_match.url_name

Detect an HTMX or fetch request

if request.headers.get("HX-Request"):
    return render(request, "catalog/product_list.html#product-row", ctx)   # a partial, 6.0
if request.headers.get("Accept") == "application/json":
    return JsonResponse(data)

Sessions and Messages

core

Per visitor state, stored server side

request.session["cart"] = {"12": 2}
request.session.get("cart", {})
request.session.pop("coupon", None)
request.session.set_expiry(60 * 60 * 24 * 14)    # two weeks
request.session.flush()                          # log out and drop everything
request.session.modified = True                  # after mutating a nested dict in place

SESSION_ENGINE = "django.contrib.sessions.backends.cache"   # db is the default; cache or cached_db for speed
SESSION_COOKIE_AGE = 1209600

Flash messages across a redirect

from django.contrib import messages

messages.success(request, "Product saved.")
messages.error(request, "Payment failed.")
messages.info(request, "3 items in your cart.")   # debug, info, success, warning, error

{% if messages %}
  <ul>{% for message in messages %}<li class="{{ message.tags }}">{{ message }}</li>{% endfor %}</ul>
{% endif %}

# class based views
from django.contrib.messages.views import SuccessMessageMixin
class ProductCreate(SuccessMessageMixin, CreateView):
    success_message = "%(name)s was created"

Cookies

response = render(request, "page.html")
response.set_cookie("theme", "dark", max_age=60 * 60 * 24 * 365, samesite="Lax", secure=True, httponly=True)
response.delete_cookie("theme")
response.set_signed_cookie("uid", user.pk, salt="uid")
request.get_signed_cookie("uid", salt="uid", default=None)

Middleware

everyday

Write one, and where it goes in the list

import time, logging
log = logging.getLogger(__name__)

class TimingMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response          # once, at startup

    def __call__(self, request):
        start = time.perf_counter()               # before the view
        response = self.get_response(request)     # the view runs here
        response["Server-Timing"] = f"app;dur={(time.perf_counter() - start) * 1000:.1f}"
        return response                           # after the view

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
    "shop.middleware.TimingMiddleware",
]

Hooks for the view and for exceptions

def process_view(self, request, view_func, view_args, view_kwargs):
    return None                          # or an HttpResponse to short circuit

def process_exception(self, request, exception):
    log.exception("view failed"); return None

def process_template_response(self, request, response):
    response.context_data["build"] = settings.BUILD_ID; return response

CSRF for JavaScript clients

// read the cookie and send it as a header on every state changing fetch
const csrftoken = document.cookie.match(/csrftoken=([^;]+)/)[1];
fetch("/api/cart/", {method: "POST", headers: {"X-CSRFToken": csrftoken, "Content-Type": "application/json"}, body: JSON.stringify(item)});

# HTMX: one attribute on the body
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
# a genuine webhook endpoint: @csrf_exempt, then verify the provider's signature instead

Response Helpers

5.2+everyday

response.text, Django 5.2

response.text          # decoded str, using the response charset
response.content       # raw bytes, as before
response.json()        # test client only
response.headers["Content-Type"]   response.status_code   response.cookies

Every response type you will return

from django.http import (HttpResponse, JsonResponse, FileResponse, StreamingHttpResponse,
                         HttpResponseRedirect, HttpResponsePermanentRedirect, HttpResponseNotFound,
                         HttpResponseForbidden, HttpResponseBadRequest, Http404)

HttpResponse("ok", status=201, content_type="text/plain", headers={"X-Build": settings.BUILD_ID})
JsonResponse({"items": items})   JsonResponse([1, 2], safe=False)   JsonResponse(data, json_dumps_params={"indent": 2})
FileResponse(open(path, "rb"), as_attachment=True, filename="invoice.pdf")
redirect("catalog:detail", slug=p.slug)   redirect(p)   redirect("https://new.example/", permanent=True)
raise Http404("No such product")   raise PermissionDenied   raise SuspiciousOperation
StreamingHttpResponse(csv_rows(), content_type="text/csv", headers={"Content-Disposition": 'attachment; filename="export.csv"'})
20

Caching & Signals

cache backends, per view, fragments, signals

Caching

everyday

A Redis backend, built in

pip install redis

CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/1",
        "TIMEOUT": 300,
    }
}
# also: memcached.PyMemcacheCache, db.DatabaseCache, filebased.FileBasedCache, locmem.LocMemCache (dev only)

The low level API

from django.core.cache import cache

stats = cache.get_or_set("dashboard:stats", compute_stats, timeout=600)
cache.set("product:12", product, 3600)
cache.get("product:12")   cache.get("missing", default=None)
cache.delete("product:12")   cache.delete_many(["a", "b"])
cache.incr("hits")   cache.touch("key", 900)
cache.get_many(["a", "b"])   cache.set_many({"a": 1, "b": 2}, 60)
cache.clear()

Per view and per template fragment

from django.views.decorators.cache import cache_page
from django.views.decorators.vary import vary_on_cookie

@cache_page(60 * 15)
def product_list(request): ...

path("products/", cache_page(900)(ProductList.as_view()))

{% load cache %}
{% cache 600 sidebar request.user.pk %}
    ... expensive fragment, keyed per user ...
{% endcache %}

Invalidate when the data changes

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

@receiver(post_save, sender=Product)
def drop_product_cache(sender, instance, **kwargs):
    cache.delete_many([f"product:{instance.pk}", "dashboard:stats"])

Signals

advanced

React to model events

from django.db.models.signals import post_delete, post_save, pre_save
from django.dispatch import receiver

@receiver(pre_save, sender=Product)
def fill_slug(sender, instance, **kwargs):
    if not instance.slug:
        instance.slug = slugify(instance.name)

@receiver(post_save, sender=Product)
def index_product(sender, instance, created, **kwargs):
    search.index(instance)

@receiver(post_delete, sender=Product)
def remove_image(sender, instance, **kwargs):
    instance.image.delete(save=False)

# catalog/apps.py
class CatalogConfig(AppConfig):
    name = "catalog"
    def ready(self):
        from . import signals   # noqa: F401

Built in signals and your own

pre_save, post_save, pre_delete, post_delete, m2m_changed, pre_migrate, post_migrate
django.contrib.auth.signals: user_logged_in, user_logged_out, user_login_failed
django.core.signals: request_started, request_finished, got_request_exception

from django.dispatch import Signal
order_paid = Signal()
order_paid.send(sender=Order, order=order)
@receiver(order_paid)
def on_paid(sender, order, **kwargs): ...

HTTP Caching and Cached Properties

everyday

Cache headers per view

from django.views.decorators.cache import cache_control, never_cache
from django.views.decorators.http import condition, etag, last_modified

@cache_control(public=True, max_age=600, s_maxage=3600, stale_while_revalidate=60)
def product_detail(request, slug): ...

@never_cache
def cart(request): ...

@condition(last_modified_func=lambda r, slug: Product.objects.filter(slug=slug).values_list("updated_at", flat=True).first())
def product_detail(request, slug): ...          # 304 when unchanged

MIDDLEWARE += ["django.middleware.http.ConditionalGetMiddleware"]   # ETag on every response
from django.utils.cache import patch_vary_headers; patch_vary_headers(response, ["Cookie"])

The whole site through the cache, and cached_property

MIDDLEWARE = [
    "django.middleware.cache.UpdateCacheMiddleware",      # first
    ...,
    "django.middleware.cache.FetchFromCacheMiddleware",   # last
]
CACHE_MIDDLEWARE_SECONDS = 300
CACHE_MIDDLEWARE_KEY_PREFIX = "shop"

from django.utils.functional import cached_property
class Order(models.Model):
    @cached_property
    def total(self):                 # computed once per instance, not per template access
        return sum(i.subtotal for i in self.items.all())
del order.total                      # forget it after a change

m2m_changed, the signal people forget exists

from django.db.models.signals import m2m_changed

@receiver(m2m_changed, sender=Product.tags.through)
def tags_changed(sender, instance, action, pk_set, **kwargs):
    if action in ("post_add", "post_remove", "post_clear"):
        cache.delete(f"product:{instance.pk}:tags")
# actions: pre_add, post_add, pre_remove, post_remove, pre_clear, post_clear
21

Tasks & Email

django.tasks, Celery, sending email

Background Tasks

6.0+everyday

Define and enqueue a task, Django 6.0

from django.tasks import task

@task
def send_receipt(order_id):
    order = Order.objects.get(pk=order_id)
    ...

result = send_receipt.enqueue(order.pk)       # returns immediately
result.id   result.status                     # READY, RUNNING, SUCCESSFUL, FAILED
send_receipt.using(priority=10, queue_name="mail").enqueue(order.pk)

# settings.py
TASKS = {
    "default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"},   # dev and tests
    # production: a worker backed backend, for example from django-tasks
    # "default": {"BACKEND": "django_tasks.backends.database.DatabaseBackend"},
}

Celery, when you need a real queue

pip install "celery[redis]"

# shop/celery.py
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shop.settings")
app = Celery("shop")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()

# shop/__init__.py
from .celery import app as celery_app

# settings.py
CELERY_BROKER_URL = "redis://localhost:6379/0"
CELERY_TASK_ALWAYS_EAGER = DEBUG             # run inline in dev

# catalog/tasks.py
from celery import shared_task

@shared_task(bind=True, max_retries=3, autoretry_for=(ConnectionError,), retry_backoff=True)
def sync_stock(self, product_id): ...

# call it after the transaction commits, not inside it
transaction.on_commit(lambda: sync_stock.delay(product.pk))

celery -A shop worker -l info
celery -A shop beat -l info                   # periodic tasks

Periodic jobs

from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = {
    "nightly-report": {"task": "reports.tasks.nightly", "schedule": crontab(hour=2, minute=0)},
    "every-5-min": {"task": "catalog.tasks.sync_stock_all", "schedule": 300.0},
}
# or a management command run by cron / systemd timer
python manage.py sync_stock

Email

6.1+everyday

Backends: print in dev, SMTP or a provider in prod

# development
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"

# production
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.postmarkapp.com"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = env("EMAIL_USER")
EMAIL_HOST_PASSWORD = env("EMAIL_PASSWORD")
DEFAULT_FROM_EMAIL = "Shop <hello@shop.example>"
SERVER_EMAIL = "errors@shop.example"       # for ADMINS error mails

Send one

from django.core.mail import send_mail, EmailMultiAlternatives
from django.template.loader import render_to_string

send_mail("Your order", "Thanks for ordering.", None, [order.email])   # returns the count sent

html = render_to_string("emails/receipt.html", {"order": order})
msg = EmailMultiAlternatives("Your receipt", strip_tags(html), to=[order.email], reply_to=["support@shop.example"])
msg.attach_alternative(html, "text/html")
msg.attach_file(order.invoice.path)
msg.send()

Several configured mailers, Django 6.1

MAILERS = {
    "default": {"BACKEND": "django.core.mail.backends.smtp.EmailBackend", "OPTIONS": {"host": "smtp.postmarkapp.com"}},
    "marketing": {"BACKEND": "anymail.backends.sendgrid.EmailBackend"},
}

from django.core.mail import get_mailer
get_mailer("marketing").send_messages([msg])

Providers via django-anymail, and mass mail

pip install "django-anymail[postmark]"
EMAIL_BACKEND = "anymail.backends.postmark.EmailBackend"
ANYMAIL = {"POSTMARK_SERVER_TOKEN": env("POSTMARK_TOKEN")}

from django.core.mail import get_connection, send_mass_mail
with get_connection() as conn:
    for m in messages: m.connection = conn; m.send()   # one SMTP session

The Modern Email API

6.0+everyday

Attachments and alternatives as objects, Django 6.0

from django.core.mail import EmailMultiAlternatives, EmailAttachment, EmailAlternative

msg = EmailMultiAlternatives(
    subject="Your receipt", body="Thanks for ordering.",
    to=[order.email], reply_to=["support@shop.example"],
    alternatives=[EmailAlternative(html, "text/html")],
    attachments=[EmailAttachment("invoice.pdf", pdf_bytes, "application/pdf")],
    headers={"X-Order": str(order.pk)},
)
msg.send()

m = msg.message()                     # email.message.EmailMessage
m["Subject"]   m.get_body(("html",))   [p.get_filename() for p in m.iter_attachments()]
22

Async & Channels

async views, async ORM, WebSockets

Async Views and ORM

advanced

An async view that fans out to two APIs

import asyncio, httpx
from django.http import JsonResponse

async def prices(request, sku):
    async with httpx.AsyncClient(timeout=5) as client:
        a, b = await asyncio.gather(
            client.get(f"https://a.example/price/{sku}"),
            client.get(f"https://b.example/price/{sku}"),
        )
    return JsonResponse({"a": a.json(), "b": b.json()})

class ProductApi(View):
    async def get(self, request, pk):
        product = await Product.objects.aget(pk=pk)
        return JsonResponse({"name": product.name})

# run under ASGI
pip install uvicorn
uvicorn shop.asgi:application --reload

The async ORM: an a prefix on every call

product = await Product.objects.aget(pk=1)
await Product.objects.acreate(name="Desk", price=100)
n = await Product.objects.filter(status="live").acount()
async for p in Product.objects.filter(status="live"):
    ...
await product.asave()   await product.adelete()
await product.category   # no: related access needs select_related or sync_to_async
product = await Product.objects.select_related("category").aget(pk=1)
await cache.aget("key")   await cache.aset("key", value)
user = await request.auser()

Cross the boundary explicitly

from asgiref.sync import sync_to_async, async_to_sync

# sync code from an async view
items = await sync_to_async(list)(Product.objects.filter(status="live"))
send_receipt_sync = sync_to_async(send_receipt, thread_sensitive=True)

# async code from a sync view
result = async_to_sync(fetch_prices)(sku)

# an async middleware
from django.utils.decorators import sync_and_async_middleware

Channels and WebSockets

advanced

Install and route

pip install "channels[daphne]" channels-redis

INSTALLED_APPS = ["daphne", ...]
ASGI_APPLICATION = "shop.asgi.application"
CHANNEL_LAYERS = {"default": {"BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": {"hosts": ["redis://localhost:6379/2"]}}}

# shop/asgi.py
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    "websocket": AuthMiddlewareStack(URLRouter([
        path("ws/orders/<int:pk>/", OrderConsumer.as_asgi()),
    ])),
})

A consumer, and pushing to it from a view

from channels.generic.websocket import AsyncJsonWebsocketConsumer

class OrderConsumer(AsyncJsonWebsocketConsumer):
    async def connect(self):
        self.group = f"order_{self.scope['url_route']['kwargs']['pk']}"
        await self.channel_layer.group_add(self.group, self.channel_name)
        await self.accept()

    async def disconnect(self, code):
        await self.channel_layer.group_discard(self.group, self.channel_name)

    async def receive_json(self, content):
        await self.send_json({"echo": content})

    async def order_update(self, event):            # handler for type "order.update"
        await self.send_json(event["data"])

# anywhere in sync code
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
async_to_sync(get_channel_layer().group_send)(f"order_{order.pk}", {"type": "order.update", "data": {"status": order.status}})

Server sent events without Channels

from django.http import StreamingHttpResponse

async def order_stream(request, pk):
    async def events():
        while True:
            order = await Order.objects.aget(pk=pk)
            yield f"data: {order.status}\n\n"
            await asyncio.sleep(2)
    return StreamingHttpResponse(events(), content_type="text/event-stream")

Async Middleware, Iteration, Concurrency

advanced

Middleware that works in both modes

from asgiref.sync import iscoroutinefunction, markcoroutinefunction

class TimingMiddleware:
    sync_capable = True
    async_capable = True

    def __init__(self, get_response):
        self.get_response = get_response
        if iscoroutinefunction(get_response):
            markcoroutinefunction(self)

    def __call__(self, request):
        if iscoroutinefunction(self):
            return self.__acall__(request)
        response = self.get_response(request)
        return response

    async def __acall__(self, request):
        response = await self.get_response(request)
        return response

Iterate and fan out without blocking

async for p in Product.objects.filter(status="live").aiterator(chunk_size=500):
    ...

import asyncio
async def enrich(products):
    async with asyncio.TaskGroup() as tg:                 # Python 3.11+
        tasks = [tg.create_task(fetch_price(p.sku)) for p in products]
    return [t.result() for t in tasks]

# with a limit on concurrency
sem = asyncio.Semaphore(10)
async def fetch_price(sku):
    async with sem, httpx.AsyncClient() as client:
        return (await client.get(f"https://a.example/price/{sku}")).json()

Startup and shutdown under ASGI

# Django's ASGI handler does not implement the lifespan protocol; the server logs a warning and continues.
# Put startup work in AppConfig.ready() or in the server's own hooks:
uvicorn shop.asgi:application --lifespan off

# Channels does support lifespan if you route it
application = ProtocolTypeRouter({"http": get_asgi_application(), "lifespan": LifespanApp(), "websocket": ...})
23

Testing Django

TestCase, the client, pytest, factories

TestCase and the Client

core

A view test with the built in runner

from django.test import TestCase
from django.urls import reverse

class ProductViewTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.category = Category.objects.create(name="Desks")
        cls.product = Product.objects.create(name="Oak desk", price=199, category=cls.category, status="live")
        cls.user = User.objects.create_user("ana", password="pw")

    def test_list_shows_live_products(self):
        response = self.client.get(reverse("catalog:list"))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "Oak desk")
        self.assertTemplateUsed(response, "catalog/product_list.html")
        self.assertEqual(len(response.context["products"]), 1)

    def test_create_requires_login(self):
        response = self.client.get(reverse("catalog:create"))
        self.assertRedirects(response, f"/accounts/login/?next={reverse('catalog:create')}")

    def test_create(self):
        self.client.force_login(self.user)
        response = self.client.post(reverse("catalog:create"), {"name": "Chair", "price": "49", "category": self.category.pk, "status": "draft"})
        self.assertEqual(response.status_code, 302)
        self.assertTrue(Product.objects.filter(name="Chair").exists())

python manage.py test
python manage.py test catalog.tests.test_views --keepdb --parallel

Client and assertion toolbox

self.client.get(url, {"q": "desk"}, HTTP_ACCEPT="application/json")
self.client.post(url, data, content_type="application/json")
self.client.login(username="ana", password="pw")   self.client.force_login(user)
self.client.logout()
response.json()   response.content   response.context["form"].errors   response.templates
self.assertContains(response, "text", status_code=200)   self.assertNotContains(...)
self.assertFormError(response.context["form"], "price", "Price cannot be negative.")
self.assertQuerySetEqual(qs, [p1, p2], ordered=False)
with self.assertNumQueries(2): ...
with self.assertRaises(ValidationError): ...
from django.core import mail;  self.assertEqual(len(mail.outbox), 1)   # emails are captured in tests

Settings, time and outside services

from django.test import override_settings
from unittest.mock import patch

@override_settings(STORAGES={"default": {"BACKEND": "django.core.files.storage.InMemoryStorage"}})
class UploadTests(TestCase): ...

@patch("catalog.services.stripe.Charge.create")
def test_pay(self, charge):
    charge.return_value = {"id": "ch_1"}

pip install time-machine
@time_machine.travel("2026-09-21 09:00")
def test_expiry(self): ...

pytest and Factories

everyday

pytest-django setup and a test

pip install pytest pytest-django factory-boy

# pyproject.toml
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "shop.settings"
python_files = ["test_*.py"]
addopts = "--reuse-db -q"

# catalog/tests/test_models.py
import pytest

@pytest.mark.django_db
def test_slug_is_generated():
    product = ProductFactory(name="Oak desk")
    assert product.slug == "oak-desk"

def test_price_label(client, django_user_model):     # built in fixtures
    user = django_user_model.objects.create_user("ana", password="pw")
    client.force_login(user)
    assert client.get("/products/").status_code == 200

pytest -x --lf              # stop on first failure, rerun last failed
pytest -k "slug" -n auto    # pytest-xdist parallel

Factories

import factory
from factory.django import DjangoModelFactory

class CategoryFactory(DjangoModelFactory):
    class Meta:
        model = Category
        django_get_or_create = ["name"]
    name = factory.Sequence(lambda n: f"Category {n}")

class ProductFactory(DjangoModelFactory):
    class Meta:
        model = Product
    name = factory.Faker("catch_phrase")
    price = factory.Faker("pydecimal", left_digits=3, right_digits=2, positive=True)
    category = factory.SubFactory(CategoryFactory)
    status = "live"

ProductFactory()   ProductFactory(status="draft")   ProductFactory.create_batch(5)
ProductFactory.build()          # not saved

API tests and coverage

from rest_framework.test import APIClient
client = APIClient()
client.force_authenticate(user)
r = client.post("/api/products/", {"name": "Desk", "price": "10", "category_id": c.pk}, format="json")
assert r.status_code == 201

pip install coverage
coverage run -m pytest && coverage report --fail-under=85 && coverage html

The Other Test Cases

everyday

Pick the base class for the job

from django.test import SimpleTestCase, TestCase, TransactionTestCase, LiveServerTestCase

class SlugTests(SimpleTestCase):              # no database, fastest
    def test_slugify(self): ...

class OrderTests(TestCase):                   # transaction per test, rolled back
    def test_total(self): ...
    def test_on_commit(self):
        with self.captureOnCommitCallbacks(execute=True) as callbacks:
            place_order(...)                  # runs the on_commit hooks inside the test
        self.assertEqual(len(callbacks), 1)

class WebhookTests(TransactionTestCase):      # real commits, tables truncated after
    reset_sequences = True

class CheckoutFlowTests(LiveServerTestCase):  # a real server for a browser
    def test_checkout(self):
        page.goto(f"{self.live_server_url}/products/")

Pin the query count so N+1 cannot creep back

def test_list_queries(self):
    ProductFactory.create_batch(3)
    with self.assertNumQueries(2):
        self.client.get(reverse("catalog:list"))

# subTest keeps going after a failure and names the case
for status, expected in [("draft", 0), ("live", 1)]:
    with self.subTest(status=status):
        self.assertEqual(Product.objects.filter(status=status).count(), expected)

Browser tests with Playwright

pip install pytest-playwright && playwright install chromium

@pytest.mark.django_db(transaction=True)                # live_server needs real commits
def test_search(live_server, page):
    ProductFactory(name="Oak desk", status="live")
    page.goto(f"{live_server.url}/products/")
    page.fill("input[name=q]", "oak")
    page.wait_for_selector("text=Oak desk")
    assert page.locator("tbody tr").count() == 1

Parallel runs and settings in pytest

python manage.py test --parallel auto     # one database per process; needs tblib for tracebacks
pytest -n auto                            # pytest-xdist; --reuse-db keeps the databases between runs

def test_flag(settings):                  # the pytest-django settings fixture
    settings.FEATURE_CHECKOUT_V2 = True
    ...
def test_no_mail(mailoutbox, client):     # captured emails
    client.post("/signup/", data)
    assert len(mailoutbox) == 1
# shared: TEST = {"NAME": "test_shop", "SERIALIZE": False} on the DATABASES entry to skip serialization
24

Security & Deployment

production settings, CSP, servers, containers

Production Settings

6.0+core

The settings that must change, and the command that checks them

DEBUG = False
SECRET_KEY = env("SECRET_KEY")                       # 50+ random chars, never in git
ALLOWED_HOSTS = ["shop.example", "www.shop.example"]
CSRF_TRUSTED_ORIGINS = ["https://shop.example"]

SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")   # behind Nginx or a load balancer
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_CONTENT_TYPE_NOSNIFF = True                   # default on
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
X_FRAME_OPTIONS = "DENY"
SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"

CONN_MAX_AGE = 60                                    # in DATABASES["default"]
ADMINS = [("Ops", "ops@shop.example")]

python manage.py check --deploy

Content Security Policy, built in since Django 6.0

MIDDLEWARE += ["django.middleware.csp.ContentSecurityPolicyMiddleware"]

from django.utils.csp import CSP

SECURE_CSP = {
    "default-src": [CSP.SELF],
    "script-src": [CSP.SELF, CSP.NONCE, "https://cdn.example"],
    "style-src": [CSP.SELF, CSP.NONCE],
    "img-src": [CSP.SELF, "data:", "https://media.shop.example"],
    "frame-ancestors": [CSP.NONE],
}
# trial a stricter policy first: reported to the endpoint, not enforced
SECURE_CSP_REPORT_ONLY = {
    "default-src": [CSP.SELF],
    "script-src": [CSP.SELF, CSP.NONCE],
    "style-src": [CSP.SELF, CSP.NONCE],
    "report-uri": ["/csp-report/"],
}

<script nonce="{{ csp_nonce }}">...</script>         # inline scripts need the nonce

Secrets from the environment

pip install django-environ

import environ
env = environ.Env(DEBUG=(bool, False))
environ.Env.read_env(BASE_DIR / ".env")             # local only, .env is gitignored

DEBUG = env("DEBUG")
SECRET_KEY = env("SECRET_KEY")
DATABASES = {"default": env.db()}                   # DATABASE_URL=postgres://user:pw@host:5432/shop
CACHES = {"default": env.cache()}                   # CACHE_URL=rediscache://host:6379/1
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS")

What Django Protects, and What You Must

everyday

Built in, as long as you do not bypass it

# XSS: templates escape by default. Dangerous escape hatches:
{{ value|safe }}   mark_safe(html)   {% autoescape off %}
# use format_html() to build markup with user data
format_html("<b>{}</b>", user_input)

# SQL injection: the ORM parameterises everything. Dangerous escape hatches:
Product.objects.raw("... WHERE name = '%s'" % name)      # never
Product.objects.raw("... WHERE name = %s", [name])        # parameters, fine
.extra()   RawSQL()   cursor.execute(f"...")

# CSRF: CsrfViewMiddleware plus {% csrf_token %}. Escape hatch: @csrf_exempt
# Clickjacking: XFrameOptionsMiddleware. Host header: ALLOWED_HOSTS
# Open redirect: url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()})

Yours to handle

get_object_or_404(Order, pk=pk, user=request.user)   # authorisation, not just authentication
form.cleaned_data                                     # validate every input, size limits on uploads
DATA_UPLOAD_MAX_MEMORY_SIZE = 2_621_440
pip install django-ratelimit          @ratelimit(key="ip", rate="10/m", block=True)
pip install pip-audit                 pip-audit                # known vulnerable dependencies
python -m pip install --upgrade django   # patch releases fix security issues, follow the security list

Serve It

everyday

Gunicorn or Uvicorn behind Nginx

pip install gunicorn
gunicorn shop.wsgi:application --bind 0.0.0.0:8000 --workers 4 --timeout 60
# ASGI, for async views or Channels
gunicorn shop.asgi:application -k uvicorn.workers.UvicornWorker --workers 4

# nginx site
server {
    listen 443 ssl http2;  server_name shop.example;
    location /static/ { alias /srv/shop/staticfiles/; expires 1y; }
    location /media/  { alias /srv/shop/media/; }
    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Docker

# Dockerfile
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN python manage.py collectstatic --noinput
EXPOSE 8000
CMD ["gunicorn", "shop.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]

# compose.yaml: web + postgres + redis + worker
services:
  web:
    build: .
    command: gunicorn shop.wsgi:application -b 0.0.0.0:8000
    env_file: .env
    ports: ["8000:8000"]
    depends_on: [db, redis]
  worker:
    build: .
    command: celery -A shop worker -l info
    env_file: .env
    depends_on: [db, redis]
  db:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD: pw
    volumes: ["pgdata:/var/lib/postgresql/data"]
  redis:
    image: redis:7
volumes:
  pgdata:

The release checklist

python manage.py check --deploy
python manage.py migrate --noinput
python manage.py collectstatic --noinput
python manage.py createcachetable          # only for the database cache
# then restart the app server, then the workers
# health endpoint for the load balancer
path("healthz/", lambda r: HttpResponse("ok"))

Logging and error tracking

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {"json": {"()": "pythonjsonlogger.json.JsonFormatter"}},
    "handlers": {"console": {"class": "logging.StreamHandler", "formatter": "json"}},
    "root": {"handlers": ["console"], "level": "INFO"},
    "loggers": {"django.request": {"level": "WARNING"}, "django.db.backends": {"level": "DEBUG" if DEBUG else "INFO"}},
}

pip install sentry-sdk
import sentry_sdk
sentry_sdk.init(dsn=env("SENTRY_DSN"), traces_sample_rate=0.1, send_default_pii=False)
25

Debugging & Tooling

toolbar, shell tricks, linting, upgrades

Debugging

everyday

django-debug-toolbar

pip install django-debug-toolbar

INSTALLED_APPS += ["debug_toolbar"]
MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware")
INTERNAL_IPS = ["127.0.0.1"]
# in Docker: INTERNAL_IPS from the gateway, or
DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: settings.DEBUG}

# urls.py
from debug_toolbar.toolbar import debug_toolbar_urls
urlpatterns += debug_toolbar_urls()

See the SQL without the toolbar

print(Product.objects.filter(status="live").query)     # the SQL a QuerySet will run
qs.explain(analyze=True)                               # the database plan

from django.db import connection, reset_queries
reset_queries(); view(request); print(len(connection.queries)); print(connection.queries[-1])

from django.test.utils import CaptureQueriesContext
with CaptureQueriesContext(connection) as ctx: ...
print(len(ctx.captured_queries))

LOGGING["loggers"]["django.db.backends"] = {"handlers": ["console"], "level": "DEBUG"}   # log every query

Breakpoints and the shell

breakpoint()                             # pdb inside a view or test; pytest needs -s
pip install ipdb;  import ipdb; ipdb.set_trace()

python manage.py shell                   # 5.2 auto imports every model
python manage.py shell -v 2              # print what was imported
pip install django-extensions;  python manage.py shell_plus --print-sql
python manage.py show_urls               # from django-extensions
python manage.py runserver_plus          # Werkzeug debugger in the browser

Template debugging

{{ product|pprint }}   {% debug %}
TEMPLATES[0]["OPTIONS"]["string_if_invalid"] = "MISSING:%s"    # dev only, breaks the admin
# a variable that renders empty is almost always a missing context key or a typo

Project Tooling

everyday

Formatting, linting, types

pip install ruff django-stubs mypy pre-commit

# pyproject.toml
[tool.ruff]
line-length = 110
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "DJ", "UP"]        # DJ = flake8-django rules
[tool.mypy]
plugins = ["mypy_django_plugin.main"]
[tool.django-stubs]
django_settings_module = "shop.settings"

ruff check --fix . && ruff format .
mypy .
pre-commit install

Dependencies with uv

pip install uv
uv init && uv add django psycopg[binary] gunicorn
uv add --dev pytest pytest-django ruff
uv sync                          # from the lockfile
uv run python manage.py runserver
uv pip compile requirements.in -o requirements.txt   # pip-tools style

Upgrading Django safely

python -W error::DeprecationWarning -W error::PendingDeprecationWarning manage.py test
pip install django-upgrade
django-upgrade --target-version 6.1 $(git ls-files '*.py')
pip install --upgrade "django~=6.1.0"
python manage.py makemigrations --check --dry-run    # some upgrades change field defaults
python manage.py check

Custom management commands

from django.core.management.base import BaseCommand

class Command(BaseCommand):
    help = "Pull stock levels from the warehouse API"

    def add_arguments(self, parser):
        parser.add_argument("--dry-run", action="store_true")

    def handle(self, *args, **options):
        n = sync(dry_run=options["dry_run"])
        self.stdout.write(self.style.SUCCESS(f"Updated {n} products"))

python manage.py sync_stock --dry-run
26

Errors & Switching

common errors, coming from other frameworks, version highlights

Errors You Will Meet

core
ErrorUsual causeFix
Product.DoesNotExist.get() found nothingget_object_or_404(), or .filter().first()
MultipleObjectsReturned.get() matched several rowsfilter on a unique field, or use .filter()
NoReverseMatchwrong name, missing namespace or argument in {% url %} or reverse()check app_name, use 'catalog:detail', pass every path converter
TemplateDoesNotExistfile not under an app's templates/ or DIRS, or the app is not installedcheck the traceback's list of tried paths
DisallowedHostthe Host header is not in ALLOWED_HOSTSadd the domain, or ["*"] only in dev
CSRF verification failedform without {% csrf_token %}, or an origin not in CSRF_TRUSTED_ORIGINSadd the tag, add the https origin with scheme
ImproperlyConfigureda setting or an app is missing, or the settings module is not setread the message, set DJANGO_SETTINGS_MODULE
AppRegistryNotReadymodels imported before django.setup(), often at module level in settings or scriptsimport inside functions, or call django.setup() in standalone scripts
IntegrityErrorunique or not null constraint, or a foreign key to a missing rowvalidate first, get_or_create, catch it inside transaction.atomic()
OperationalError: no such tablemigrations not applied, or a migration missing in gitmigrate; commit migrations
Conflicting migrations detectedtwo branches each added a migration with the same parentmakemigrations --merge
SynchronousOnlyOperationORM call from async codeuse the a prefixed method or sync_to_async
ImportError: cannot import name (circular)two modules import each other at the topstring references "catalog.Product" in fields, import inside functions
TransactionManagementErrora query after an error inside atomic()wrap the failing call in its own atomic() block
Static file 404 in productioncollectstatic not run, or nothing serves STATIC_ROOTrun it, add WhiteNoise or an Nginx location
Form shows nothing after POSTview re-renders without passing the bound form, or returns 200 instead of redirectingPRG pattern: redirect() on success

Coming from Flask, Rails or Laravel

everyday
ConceptDjangoFlask / FastAPIRailsLaravel
Routingpath() in urls.py@app.route / @app.getroutes.rb, resourcesRoute::get()
Handlerview function or classview functioncontroller actioncontroller method
ORMbuilt in, Active Record styleSQLAlchemy, SQLModelActive RecordEloquent
Model filemodels.py, fields on the classdeclarative classesschema in migrations onlyschema in migrations only
Migrationsgenerated from modelsAlembic, written or autogeneratedgenerated, written by handwritten by hand
QueryProduct.objects.filter()session.query(Product) / select()Product.where()Product::where()
TemplatesDTL, or Jinja2Jinja2ERBBlade
Forms and validationforms.Form, ModelFormWTForms, Pydanticstrong params, model validationsForm Requests
Adminbuilt inFlask-AdminActiveAdmin, AvoNova, Filament
Authbuilt in users, sessions, permissionsFlask-Login, roll your ownDeviseBreeze, Fortify, Sanctum
CLImanage.pyflask CLIbin/railsartisan
Consolemanage.py shellflask shellrails consoleartisan tinker
Background jobsdjango.tasks 6.0, CeleryCelery, RQ, ARQActive Job, SidekiqQueues, Horizon
Configsettings.py + envapp.configconfig/*.yml, credentials.env, config/
Test clientself.clientapp.test_client() / TestClientrequest specs$this->get()
Reusable moduleappblueprint / routerenginepackage

Version Highlights, 4.2 to 6.1

6.1+everyday
VersionReleasedPythonHighlightsSupport
6.1August 20263.12 to 3.14QuerySet.fetch_mode() with FETCH_PEERS and FETCH_RAISE, database level DB_CASCADE / DB_SET_NULL / DB_SET_DEFAULT, MAILERS setting, UUID7(), JSONNull, admin action locationuntil April 2027
6.0December 20253.12 to 3.14django.tasks background task framework, template {% partialdef %} / {% partial %}, CSP middleware and SECURE_CSP, modern email API on Python's EmailMessage, BigAutoField default, forloop.lengthuntil August 2026, then security only to December 2026
5.2 LTSApril 20253.10 to 3.14automatic model imports in shell, CompositePrimaryKey, BoundField customisation, HttpResponse.text, QuerySet.bulk_create with update_fields on more backendsuntil April 2028
5.1August 20243.10 to 3.13{% querystring %} tag, LoginRequiredMiddleware, PostgreSQL connection pools, Model.save(force_insert=...) improvementsended December 2025
5.0December 20233.10 to 3.12db_default, GeneratedField, as_field_group form rendering, Choices enums as callables, async cookie and session helpersended April 2025
4.2 LTSApril 20233.8 to 3.12psycopg 3 support, STORAGES setting, comments on columns and tables, Q and F XORended April 2026

Reading the Debug Page

core

What each block tells you

Exception Type / Value      the class and message, read these first
Exception Location          the file and line that raised, expand Local vars there
Traceback                   outermost first; click a frame for its locals; "Switch to copy-and-paste view" for tickets
Template error              only for template exceptions: the tag or variable, and the file line
Request information         GET, POST, FILES, COOKIES, META, and the user
Settings                    the effective settings, with passwords and keys masked
# an exception raised inside a template shows the template frame first and the view frame below it

Turn warnings into errors before they become breakage

python -X dev manage.py runserver                     # dev mode: all warnings, asyncio debug, faulthandler
python -W error::DeprecationWarning manage.py test    # fail on anything deprecated
python -W error::RuntimeWarning manage.py test        # catches naive datetimes on aware fields

# pyproject.toml, for pytest
[tool.pytest.ini_options]
filterwarnings = ["error::DeprecationWarning", "error::django.utils.deprecation.RemovedInDjango70Warning"]

What Is a Django Cheat Sheet?

A Django cheat sheet is a condensed reference that lists the framework's commands, model syntax, and configuration patterns in one place.

Developers reach for it in the middle of real software development, not while working through a tutorial line by line.

What it typically covers:

  • Command syntax for manage.py and django-admin
  • Model field types and relationship shortcuts
  • URL routing patterns and view signatures
  • Template tags, filters, and settings blocks

It is not a replacement for Django's official documentation on edge cases.

It skips the reasoning behind a feature and jumps straight to the syntax, which is exactly what you want when you already know what you're building.

Django itself is a high-level Python framework for back-end development, built around the Model-View-Template pattern.

Adrian Holovaty and Simon Willison built it in 2003 at the Lawrence Journal-World newspaper, where fast newsroom publishing shaped its focus on speed and a strong admin interface.

Django is now maintained by the Django Software Foundation and distributed through PyPI, with a release roughly every eight months.

Setting Up a Django Project Environment

JetBrains' 2026 State of Django survey found 63% of Django developers still manage environments with venv, while uv, released in 2024, has already reached 43% adoption.

Both tools isolate a project's dependencies from whatever else is installed on the machine.

Setup sequence:

  1. Create and activate a virtual environment for the project
  2. Install Django with pip and freeze the versions into requirements.txt
  3. Run django-admin startproject to scaffold the project folder
  4. Run manage.py startapp for each app inside the project
  5. Start the local server with manage.py runserver to confirm everything loads

This sequence lays out a codebase you can navigate later without guessing where settings or URLs live.

It is also the natural point to run git init, especially if a git cheat sheet is already open in another tab.

Disqus built its comment platform on Django and leaned on this same fast bootstrap to ship its first version quickly.

Essential manage.py Commands

Django's command line tool wraps the most common project tasks into a single manage.py entry point.

CommandCategoryWhat it does
startprojectProjectScaffolds a new Django project folder
startappProjectCreates a new app inside the project
makemigrationsDatabaseWrites migration files from model changes
migrateDatabaseApplies migrations to the database schema

Beyond that core four, a handful of commands come up constantly once a project is running.

Everyday commands:

  • runserver: starts the local development server, usually on port 8000
  • createsuperuser: sets up an admin account for the Django admin site
  • shell: opens the interactive Django shell with your models already loaded
  • collectstatic: gathers static files into one directory for deployment
  • test: runs the project's test suite

Running manage.py help lists every command available for the Django version currently installed.

Django Model Field Types and Migrations

Model fields define what a database column stores and how Django validates it before saving.

Each field type maps to a specific column type in the underlying database.

Common Field Types

Core field types:

  • CharField for short text with a required max_length
  • IntegerField for whole numbers
  • DateTimeField for timestamps
  • BooleanField for true or false flags

Relationship fields connect one model to another instead of storing a plain value.

FieldRelationshipTypical use
ForeignKeyMany to oneOrder belongs to one customer
ManyToManyFieldMany to manyArticle has many tags
OneToOneFieldOne to oneProfile extends the User model

Defining validation once at the field level follows the same software development principles that keep a Django codebase from repeating itself.

Running and Reversing Migrations

makemigrations compares your models against the last recorded state and writes the difference to a new, numbered file.

  1. Edit a model in models.py
  2. Run python manage.py makemigrations to generate the migration file
  3. Run python manage.py migrate to apply it to the database
  4. Run python manage.py migrate app_name 0002 to roll the schema back to an earlier migration

Instagram's engineering team has written publicly about scaling its Django ORM layer against a sharded Postgres backend as its user base grew.

Which Database Backend Fits Your Django Project

Django ships with support for four database backends, and the choice mostly comes down to how the app will actually be used.

JetBrains' 2026 State of Django survey found PostgreSQL has been the top choice for 76 to 79% of Django developers for five straight years.

DatabaseDefault useConcurrencyProduction ready
SQLiteLocal development, small toolsOne writer at a timeRarely, file-based limits
PostgreSQLGeneral production workloadsStrong concurrent writesYes, default recommendation
MySQLExisting MySQL infrastructureGood with tuningYes, widely used
MariaDBMySQL-compatible deploymentsGood with tuningYes, smaller footprint

The 2025 edition of the same survey put SQLite usage at 42%, MySQL at 27%, and MariaDB at 9% among Django developers, mostly running alongside PostgreSQL rather than replacing it.

Switching backends later usually means updating one setting, not rewriting models, since the Django ORM abstracts the SQL dialect away.

Postgres also supports the kind of software scalability a growing Django app eventually needs, including native JSON fields and full-text search.

Pinterest ran its early back end on Django paired with MySQL before rebuilding its data layer for scale.

Anyone running raw queries against these databases for admin reports will get more out of a SQL cheat sheet than out of the ORM documentation alone.

Django URL Routing and View Patterns

Django matches an incoming request to a view by walking through a list of URL patterns in order.

The first pattern that matches wins, so pattern order inside urls.py matters.

path() and re_path() Syntax

path() handles the common cases:

  • Static routes like path('about/', views.about)
  • Typed parameters like path('post/<int:post_id>/', views.detail)
  • Slug and string converters for readable URLs

re_path() drops down to full regular expressions for patterns path() cannot express.

Most Django projects use path() almost exclusively and reserve re_path() for legacy URL schemes carried over from another codebase.

include() and URL Namespacing

Large projects rarely define every route in one urls.py file.

include() lets the root URLconf hand off a whole block of routes to an app's own urls.py.

Why namespacing matters: two apps can both define a route called detail without colliding, as long as each app sets its own app_name.

  • reverse('blog:detail', args=[post.id]) resolves a namespaced URL in Python code
  • {% url 'blog:detail' post.id %} does the same thing inside a template

Class-Based Views vs Function-Based Views

Both approaches satisfy the same contract: accept a request, return a response.

The difference shows up in how much boilerplate you write and how easy the view is to extend later.

Function-based views:

  • Explicit, top-to-bottom control flow
  • Faster to write for a one-off page
  • Harder to reuse across similar views without copy-pasting

Class-based views:

  • Built-in generics like ListView and DetailView cut boilerplate for CRUD pages
  • Easier to extend through mixins and inheritance
  • Requires knowing which method to override, which slows down debugging at first

Rewriting a function-based view as a class-based one later is a common piece of code refactoring as a Django app grows past its first few pages.

Neither option is wrong on its own, and most production codebases mix both depending on how much a given page needs to do.

Choosing case by case, rather than committing the whole project to one style, lines up with general software development best practices around not over-engineering simple pages.

Django Template Tags and Filters Reference

Django templates render Python data inside HTML using a restricted set of tags and filters.

The restriction is deliberate: template logic stays simple, and real logic stays in views.

TagPurposeExample
{% for %}Loops over a queryset or list{% for post in posts %}
{% if %}Conditional rendering{% if user.is_authenticated %}
{% block %}Marks an overridable section{% block content %}
{% extends %}Inherits a parent template{% extends "base.html" %}

Filters transform a value inline, right where it gets displayed.

  • |date formats a datetime object
  • |safe skips HTML autoescaping for trusted content
  • |length returns the size of a list or string
  • |default shows a fallback when a value is empty

Template inheritance keeps a project's front-end development work in one base layout instead of duplicating a header and footer across pages.

Mozilla's Add-ons site runs on Django and renders its pages server-side rather than through a separate JavaScript front end.

JetBrains' 2026 State of Django survey found Django's built-in template engine has held steady at around 80% adoption even as HTMX and Alpine.js have grown around it.

Customizing the Django Admin and Authentication

Django's admin site is generated automatically from your models, and most customization happens by writing a ModelAdmin class.

Authentication is built on the same app, sharing the same User model and permission system.

ModelAdmin Customization Options

Common ModelAdmin options:

  • list_display shows chosen fields as table columns
  • search_fields adds a search box across the fields listed
  • list_filter adds a sidebar filter for the fields listed
  • inlines edits related objects on the same page as the parent

Registering a model takes two lines: admin.site.register(Model, ModelAdmin).

National Geographic uses Django's admin tooling to manage editorial content across its site.

Authentication and Permissions

Django's auth app ships with a User model, login views, and a permission system out of the box.

ComponentWhat it controls
User modelUsername, password hash, email, active status
GroupsReusable sets of permissions
PermissionsPer-model add, change, delete, view rights

Session-based login covers most server-rendered sites, but an API consumed by a mobile app usually needs token-based authentication instead.

Configuring Django Settings for Development and Production

Django loads all configuration from one settings module, and that module needs to behave differently depending on the environment.

Splitting settings into a base file plus dev and prod overrides keeps secrets and debug flags from leaking into the wrong place.

Development Settings

Typical development flags:

  • DEBUG = True for detailed error pages
  • ALLOWED_HOSTS left open or set to localhost
  • SQLite as the default database
  • Console email backend instead of a real mail server

None of these settings belong in a live deployment, even temporarily.

Production Settings

DEBUG = True in a live deployment exposes stack traces, installed packages, and settings values to anyone who triggers an error.

SettingDevelopmentProduction
DEBUGTrueFalse
ALLOWED_HOSTSOpen or emptyExplicit domain list
DatabaseSQLitePostgreSQL or MySQL

Keeping close environment parity between staging and production catches configuration bugs before real users do.

A production environment also needs a real secret key pulled from an environment variable, never hardcoded in settings.py.

Choosing a Django Version (4.2 LTS vs 5.1 vs 5.2)

Django ships a feature release roughly every eight months, with an LTS release every two years that gets three years of security support.

Picking the wrong one for a cheat sheet means half the commands are already outdated.

VersionTypeSupport window
4.2LTSEnds April 2026
5.2LTSEnds April 2028
6.0StandardShorter, non-LTS window

Django's own release notes confirm 5.2 is designated long-term support and will receive security updates for at least three years after release.

JetBrains' 2026 survey found 43% of respondents were already running Django 6.0 within months of its release, well ahead of the usual upgrade curve.

Version numbers follow semantic versioning, so a jump from 5.2 to 6.0 can carry breaking changes that a 5.1-to-5.2 update would not.

Testing and Deploying Django Applications

Testing and deployment sit outside the core framework, but Django ships enough tooling to handle both without extra setup.

Testing Commands and Tools

manage.py test runs Django's built-in test runner against any TestCase classes in the project.

  • pytest-django replaces the built-in runner with pytest's fixtures and plugins
  • Django Debug Toolbar profiles queries and template rendering time in the browser
  • coverage.py reports which lines of code the test suite actually exercises

Automated unit testing catches a broken model method before it reaches a deployed branch.

Deployment Stack Options

The Django Developers Survey found 45% of Django developers use GitHub Actions for continuous integration, and 39% implement infrastructure as code (JetBrains, 2024).

ComponentRoleCommon choice
Application serverRuns the Django processGunicorn
Web serverHandles TLS and static filesNginx
Static hostingServes collected static filesWhitenoise or a CDN

Nginx typically sits in front of Gunicorn as a reverse proxy, handling TLS termination before passing requests to Django.

Dropbox has used Django to power parts of its web application, one of several large-scale deployments that show the framework holding up past side-project scale.

When a Django Cheat Sheet Is Not Enough

A cheat sheet covers syntax, not architecture, and some problems only surface once a project outgrows the basics.

Where the syntax alone runs out:

  • Migration conflicts from two developers editing the same model at once
  • CSRF and session errors that trace back to a settings misconfiguration, not the view code
  • Static file 404s in production despite collectstatic running without errors

JetBrains' 2024 State of Django survey found 61% of Django developers already use asynchronous technologies, a layer that a syntax reference barely touches.

Async views, Celery task queues, Django Channels, and a full RESTful API built with Django REST Framework all need their own dedicated references.

The same goes for packaging a project with containerization tools like Docker, which sits outside anything manage.py handles directly.

At that point, a cheat sheet is still useful for the commands typed daily. It just stops being the only document open on the screen.

FAQ on Django

What Is Django, and Who Created It?

Django is a high-level Python web framework built for fast, secure web development. Adrian Holovaty and Simon Willison created it in 2003 at the Lawrence Journal-World newspaper.

The Django Software Foundation now maintains it and distributes it through PyPI.

What Is the Difference Between a Cheat Sheet and the Official Documentation?

A cheat sheet compresses common syntax into a quick reference for people who already know Django. The official documentation explains reasoning, edge cases, and every configuration option in depth.

Use the cheat sheet for speed, and the docs for anything unfamiliar.

Is Django Better Than Flask or FastAPI for a New Project?

Django suits projects that need a full stack out of the box: ORM, admin, authentication, and templating included. Flask and FastAPI stay lighter and more flexible, with FastAPI favoring async-first APIs.

Choose Django when built-in structure saves more time than it costs.

Can Django Handle Asynchronous or Real-Time Features?

Yes. Django supports async views, async ORM methods, and ASGI deployment alongside its synchronous core.

Django Channels adds WebSocket support for real-time features like chat or live notifications, pushing Django well past its original request-response design.

Do You Need Django REST Framework to Build an API With Django?

No, plain Django views can return JSON directly without any extra package. Django REST Framework adds serializers, authentication classes, browsable APIs, and pagination that would otherwise take custom code.

Most teams building anything beyond a tiny API adopt it anyway.

How Often Should a Django Cheat Sheet Be Updated?

Update it whenever Django ships a new release, roughly every eight months, since command syntax and defaults shift.

LTS transitions, like the move from 4.2 to 5.2, deserve a dedicated review pass beyond routine edits.