Skip to content

Django — The Complete Guide, Illustrated with a Small CRM

Last reviewed: 2026-09-18

Purpose: Every Django concept you need, taught from a single running example — a small CRM — so a beginner can follow one application from first migration to production deployment, and an experienced developer can use the reference tables as a cheat sheet.

Contents


How to Use This Article

Two reading paths, one article:

  • New to Django — read top to bottom. Every concept is introduced through the same CRM, and each section shows the code you would write next in that application.
  • Experienced — jump to the Cheat Sheet for the reference tables (ORM lookups, manage.py commands, decorators, settings), then use the sections as the explanation behind each row.

Editable diagrams. Every illustration is a draw.io file. The .drawio sources ship next to this article in the KB, and a copy you can edit lives in Drive: Django CRM Diagrams (KB).


Overview

Django is a batteries-included web framework: a URL router, a template engine, an ORM, a migration system, a form library, an authentication system, an admin interface and a security layer — all shipped together and all designed to work with each other.

That is the whole argument, and it is worth stating plainly because it explains every design decision you will meet:

Django gives you one documented, supported way to do each thing, so that a new developer can read your codebase and know where everything lives.

Two consequences follow:

  • You spend your time on the domain, not the plumbing. In this article, almost all the code concerns accounts, contacts and deals — not session handling or form validation.
  • You accept the framework's opinions. The admin, the template language, the ORM and the request/response cycle all have a shape. Fighting them costs more than learning them.

The one architectural idea: MTV

Django calls its pattern MTV — Model, Template, View. It is the same separation as MVC with renamed parts:

Layer Responsibility Where it lives
Model Data structure and rules; talks to the database models.py
Template Presentation — how data becomes HTML templates/
View Orchestration — receives a request, fetches data, returns a response views.py
(URLconf) Routing — which view handles which URL urls.py

The important subtlety: in Django the View is what other frameworks call a controller, and the Template is what they call the view. The names matter less than the split: data, presentation, and the glue between them are separate files, and the split is repeated inside every app.

The request path

CRM architecture: browser to Nginx to Django to PostgreSQL and Redis, with Celery out of band

Click the diagram to open it at full resolution.

A request is a straight line through the boxes above, and every Django feature you will learn attaches to one of them:

  1. Nginx terminates TLS and is the only component that serves static files in production.
  2. Gunicorn runs several Django worker processes.
  3. Middleware processes the request — security headers, session loading, the authenticated user, CSRF checks.
  4. The URL resolver matches the path and imports the view.
  5. The view runs: it queries the ORM, validates a form, and returns a response.
  6. The template (or a DRF serializer) turns data into HTML or JSON.
  7. PostgreSQL is the source of truth; Redis holds cache and sessions; Celery runs slow work outside the request.

[!NOTE] Django is a server-rendered framework. The HTML is produced on the server, not assembled in the browser, and the page you ship works without a JavaScript build step. You can layer a JavaScript front-end on top later — but you do not have to, and a CRM built entirely with Django templates is a perfectly good product.

Why it is still a default choice

Strength What it means for the CRM
The admin A usable back office on day one; internal users get create/edit/search/permissions for free
ORM + migrations Schema changes are code, reviewed and reversible
Auth and permissions built in Users, groups, per-object permissions, password hashing, session security
Security defaults CSRF, XSS escaping and SQL-injection protection are on by default, not opt-in
Documentation Extensive, versioned, and the answer to most questions
Deployment story Well-trodden: Gunicorn, Nginx, PostgreSQL, static files, migrations

The Example Application

One example threads through this guide: a small CRM — the sales application a B2B company runs. It is deliberately ordinary, because CRM screens exercise every part of Django you will need in a real project.

The domain

Entity What it is Key relationships
User A sales rep, manager or admin owns accounts, contacts, deals
Account A company you sell to has many contacts and deals
Contact A person at an account belongs to one account
Lead An unqualified prospect converts into a Contact (+ Account + Deal)
PipelineStage A stage of the sales process classifies deals; carries a probability
Deal An opportunity being worked one account, one primary contact, one stage, one owner
Activity A call, email, meeting or note logged against a contact and/or a deal
Tag Free-form labels attached to contacts and deals

The screens

Screen What it does Django features it exercises
Pipeline board Deals grouped by stage, totals per column aggregation, filtering, template logic
Contact list search, filter by account/owner/tag, paginate QuerySets, select_related, Paginator
Contact detail profile, deal history, activity timeline related managers, prefetch_related
Deal detail stage changes, line items, activity log ModelForm, formsets, transactions
Lead inbox unqualified leads, convert to contact a service function, atomic()
CSV import bulk-load a contact list a Celery task, validation, reporting
Team admin users, roles, assignment Django admin, permissions
REST API the same data for a mobile app DRF serializers, viewsets, pagination

The apps

The CRM is not one big app — it is several small ones, each owning one part of the domain:

crm/
├── config/           # settings, root URLconf, WSGI/ASGI entry points
├── accounts/         # Account — the companies
├── contacts/         # Contact, ContactTag, CSV import
├── leads/            # Lead + the conversion service
├── deals/            # Deal, PipelineStage, DealLine
├── activities/       # Activity — calls, emails, meetings, notes
├── users/            # custom User, teams, profile
├── core/             # base templates, mixins, pagination, utilities
└── api/              # DRF router, shared permissions, pagination defaults

That layout is the article's spine: every section below adds files to one of these apps.

The data model

You will meet the full model code in Models, and the relations are worth previewing because everything else depends on them:

CRM data model: User, Account, Contact, Deal, PipelineStage, Activity, Lead and Tag with their ORM relations

Click the diagram to open it at full resolution.

The REST API it exposes

GET    /api/v1/accounts/                        list, filter by industry/owner
GET    /api/v1/contacts/?search=&account=&tag=   search + filter + paginate
POST   /api/v1/contacts/                         create
PATCH  /api/v1/contacts/{id}/                    partial update
GET    /api/v1/deals/?stage=&owner=              the mobile app's pipeline view
POST   /api/v1/deals/{id}/move/                  custom action: change stage
GET    /api/v1/activities/?contact={id}          the timeline
POST   /api/v1/leads/{id}/convert/               custom action: convert a lead
GET    /api/v1/whoami/                           current user + permissions

Setup

Install and create the project

mkdir crm && cd crm
python3 -m venv .venv && source .venv/bin/activate

pip install "Django>=5.0" psycopg[binary] django-environ
pip freeze > requirements.txt

django-admin startproject config .        # note the trailing dot: no nested folder
python manage.py startapp contacts

startproject config . creates the project (settings and entry points) in the current directory, so the layout is flat:

crm/
├── manage.py              # the CLI you will use constantly
├── config/
│   ├── settings.py
│   ├── urls.py            # the root URLconf
│   ├── wsgi.py            # sync entry point
│   └── asgi.py            # async entry point
└── contacts/              # an app
    ├── models.py
    ├── views.py
    ├── admin.py
    ├── apps.py
    ├── migrations/
    └── tests.py
python manage.py migrate          # create Django's own tables
python manage.py createsuperuser
python manage.py runserver        # http://127.0.0.1:8000

Development vs production dependencies

# requirements.in — what you actually chose
Django>=5.0
psycopg[binary]
django-environ
djangorestframework
django-filter
celery[redis]
redis
gunicorn
whitenoise

# requirements-dev.in — never in production
pytest-django
factory-boy
django-debug-toolbar
ruff
coverage

[!TIP] Pin your dependencies and commit the lock file. A Django project that cannot be installed reproducibly in two years is a Django project you cannot patch.

The two commands you will run a thousand times

python manage.py makemigrations        # write migration files from model changes
python manage.py migrate               # apply them to the database
python manage.py shell                 # an interactive shell with the ORM loaded
python manage.py check --deploy        # the security checklist before you ship
python manage.py test                  # run the test suite

Projects and Apps

A project is the deployment; an app is a reusable unit of functionality. The CRM project contains seven apps. This distinction is the first thing that confuses newcomers, so make it concrete:

Project (config/) App (contacts/)
Contains settings, root URLconf, WSGI/ASGI models, views, templates, migrations
How many exactly one as many as the domain needs
Reusable no yes — an app can be copied into another project
Grows with deployment concerns features
# contacts/apps.py
from django.apps import AppConfig

class ContactsConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "contacts"
    verbose_name = "Contacts"

    def ready(self):
        from . import signals        # import signal receivers exactly once
# config/settings.py
INSTALLED_APPS = [
    # Django's own
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    # third party
    "rest_framework",
    "django_filters",
    # local — one line per domain area
    "core",
    "users",
    "accounts.apps.AccountsConfig",
    "contacts.apps.ContactsConfig",
    "leads",
    "deals",
    "activities",
]

How the layers repeat inside every app

MTV mapped onto the CRM apps: models, queries, forms, views and urls per app

Click the diagram to open it at full resolution.

Read the grid by column: every app has the same five files, and each column is one layer of the framework. Read it by row: one row is one feature, complete and self-contained.

How to decide what belongs in a new app:

  • It has its own models and its own screens → new app.
  • It is a single model with no screens of its own → it probably belongs in an existing app.
  • It is reusable across projects (an importer, a notification system) → its own app, with no project-specific imports.

[!WARNING] Do not create utils.py graveyards or a common/ app that everything imports. Shared code that grows without a domain home is how a Django project becomes unreadable. Put it where it is used until a second user appears, then promote it to core/.


Settings

settings.py is the file that decides how everything else behaves. These are the settings that matter, with the values a CRM actually wants.

# config/settings.py
from pathlib import Path
import environ

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

env = environ.Env(DEBUG=(bool, False))
environ.Env.read_env(BASE_DIR / ".env")

# --- secrets and safety -----------------------------------------------------
SECRET_KEY = env("DJANGO_SECRET_KEY")                  # NEVER committed
DEBUG = env("DJANGO_DEBUG")
ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["localhost", "127.0.0.1"])
CSRF_TRUSTED_ORIGINS = env.list("DJANGO_CSRF_TRUSTED_ORIGINS", default=[])

# --- applications -----------------------------------------------------------
INSTALLED_APPS = [...]                                  # see above
AUTH_USER_MODEL = "users.User"                          # set BEFORE the first migrate

# --- middleware (order matters) --------------------------------------------
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",        # static files in production
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.locale.LocaleMiddleware",         # after session, before common
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

# --- URLs and templates -----------------------------------------------------
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"
TEMPLATES = [{
    "BACKEND": "django.template.backends.django.DjangoTemplates",
    "DIRS": [BASE_DIR / "templates"],                    # project-wide templates
    "APP_DIRS": True,                                    # plus every app's templates/
    "OPTIONS": {"context_processors": [
        "django.template.context_processors.debug",
        "django.template.context_processors.request",
        "django.contrib.auth.context_processors.auth",
        "django.contrib.messages.context_processors.messages",
    ]},
}]

# --- database ---------------------------------------------------------------
DATABASES = {"default": env.db("DATABASE_URL")}          # postgres://user:pass@host:5432/crm

# --- identity ---------------------------------------------------------------
LANGUAGE_CODE = "en-us"
TIME_ZONE = "Europe/Paris"
USE_I18N = True                                          # translations enabled
USE_TZ = True                                            # store UTC, display local

# --- static and media -------------------------------------------------------
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"                   # collectstatic target
STATICFILES_DIRS = [BASE_DIR / "static"]
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
STORAGES = {
    "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
    "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}

# --- auth redirects ---------------------------------------------------------
LOGIN_URL = "users:login"
LOGIN_REDIRECT_URL = "deals:board"
LOGOUT_REDIRECT_URL = "users:login"

# --- email ------------------------------------------------------------------
EMAIL_BACKEND = env("EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend")

# --- production hardening (harmless in dev, required in production) ---------
SECURE_SSL_REDIRECT = not DEBUG
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUG
SECURE_HSTS_SECONDS = 0 if DEBUG else 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = not DEBUG
SECURE_HSTS_PRELOAD = not DEBUG
X_FRAME_OPTIONS = "DENY"

The handful of settings that bite

Setting Why it bites
AUTH_USER_MODEL Must be set before the first migrate. Changing it later is a painful manual migration — decide on day one
DEBUG Must be False in production. With ALLOWED_HOSTS empty, Django then refuses all requests (this is a feature)
SECRET_KEY Rotating it invalidates sessions and signed tokens. Keep it in the environment, never in git
ALLOWED_HOSTS Include the real domain; a wrong value produces a bare 400 with no explanation
INSTALLED_APPS order admin before the apps it inspects; AUTH_USER_MODEL's app is fine anywhere
MIDDLEWARE order SecurityMiddleware first, SessionMiddleware before AuthenticationMiddleware, LocaleMiddleware after session
USE_TZ Leave it True and work in aware datetimes; naive datetimes are the cause of the classic "my report is off by two hours" bug
STATIC_ROOT Different from STATICFILES_DIRS. Confusing them breaks collectstatic

Split settings, environment variables, and secrets

# config/settings/base.py      — everything shared
# config/settings/dev.py       — DEBUG=True, console email, sqlite optional
# config/settings/prod.py      — DEBUG=False, real email, strict cookies, sentry
# .env — never committed; .env.example is
DJANGO_SECRET_KEY=...
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=crm.example.com
DATABASE_URL=postgres://crm:...@localhost:5432/crm
REDIS_URL=redis://localhost:6379/0
# config/settings/prod.py
from .base import *          # noqa: F403
from .base import env

DEBUG = False
SENTRY_DSN = env("SENTRY_DSN", default="")
if SENTRY_DSN:
    import sentry_sdk
    sentry_sdk.init(dsn=SENTRY_DSN, traces_sample_rate=0.1)

[!WARNING] from .base import * is the accepted idiom in split settings, but only because base.py contains no logic worth hiding. Never put a SECRET_KEY literal in any of these files — the value comes from the environment, and the environment is not in git.


URLs and the Request Lifecycle

One request, step by step

One HTTP request through Django: middleware, URL resolver, view, response, middleware out

Click the diagram to open it at full resolution.

The lifecycle is the mental model for everything else: middleware wraps a URL resolver that calls a view that returns a response, and the response then unwinds back through the middleware.

Two details worth keeping:

  • A view is just a callable taking a request and returning a response. Class-based views, viewsets and decorators are all conveniences over that contract.
  • A TemplateResponse is lazy. The view returns it un-rendered; context processors and middleware can still modify it afterwards. A JSON response, by contrast, is already computed.

The root URLconf

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

urlpatterns = [
    path("admin/", admin.site.urls),
    path("accounts/", include("accounts.urls")),
    path("contacts/", include("contacts.urls")),
    path("leads/", include("leads.urls")),
    path("deals/", include("deals.urls")),
    path("activities/", include("activities.urls")),
    path("users/", include("users.urls")),
    path("api/v1/", include("api.urls")),
]

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

App URLconfs, namespaces and converters

# deals/urls.py
from django.urls import path
from . import views

app_name = "deals"                      # namespacing: this is what makes reverse() safe

urlpatterns = [
    path("", views.DealBoardView.as_view(), name="board"),
    path("<int:pk>/", views.DealDetailView.as_view(), name="detail"),
    path("new/", views.DealCreateView.as_view(), name="create"),
    path("<int:pk>/edit/", views.DealUpdateView.as_view(), name="update"),
    path("<int:pk>/stage/", views.DealStageUpdateView.as_view(), name="stage"),
    path("<int:pk>/delete/", views.DealDeleteView.as_view(), name="delete"),
]
Converter Matches Example URL
<int:pk> a non-negative integer /deals/42/
<slug:slug> letters, numbers, hyphens, underscores /accounts/acme-corp/
<uuid:token> a UUID /invites/9f1c…/
<str:name> any non-empty string without / /tags/vip/
<path:rest> anything, including / /files/2026/q1/report.csv

Never build URLs by hand in a template or view. Use {% url %} and reverse():

<a href="{% url 'deals:detail' deal.pk %}">{{ deal.title }}</a>
from django.urls import reverse
from django.shortcuts import redirect

def confirm_deal(request, pk):
    ...
    return redirect("deals:detail", pk=pk)

# models: a canonical URL per object, used by the admin and by get_success_url
class Deal(models.Model):
    def get_absolute_url(self):
        return reverse("deals:detail", kwargs={"pk": self.pk})

[!TIP] A name= on every route and a {% url %} in every template means renaming a URL is a one-line change. Hard-coded /deals/42/ anywhere is a bug waiting for the next refactor.


Views

A view receives a HttpRequest and returns an HttpResponse (or raises). Everything else is style.

Function-based views: explicit and readable

# contacts/views.py
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q
from django.shortcuts import get_object_or_404, render

from .models import Contact

@login_required
def contact_list(request):
    term = request.GET.get("search", "").strip()
    account_id = request.GET.get("account")
    owner = request.GET.get("owner")

    contacts = (
        Contact.objects
        .select_related("account", "owner")          # one query, not one per row
        .filter(is_active=True)
        .order_by("account__name", "last_name")
    )
    if term:
        contacts = contacts.filter(
            Q(first_name__icontains=term) | Q(last_name__icontains=term) | Q(email__icontains=term)
        )
    if account_id:
        contacts = contacts.filter(account_id=account_id)
    if owner == "me":
        contacts = contacts.filter(owner=request.user)

    page = Paginator(contacts, 25).get_page(request.GET.get("page"))

    return render(request, "contacts/contact_list.html", {
        "page": page,
        "term": term,
        "total": page.paginator.count,
    })

Use an FBV when: the logic is short, the flow has branches, or you want the whole thing visible in one screen. Most views in a CRM are like this — and most real Django codebases are a healthy mix.

Class-based views: reuse through configuration

# deals/views.py
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.views.generic import DetailView, ListView, UpdateView

from .models import Deal

class DealBoardView(LoginRequiredMixin, ListView):
    model = Deal
    template_name = "deals/board.html"
    context_object_name = "deals"
    paginate_by = 50

    def get_queryset(self):
        qs = (Deal.objects
              .select_related("account", "contact", "stage", "owner")
              .prefetch_related("activities"))
        if not self.request.user.role == "manager":
            qs = qs.filter(owner=self.request.user)     # reps see only their own deals
        if stage := self.request.GET.get("stage"):
            qs = qs.filter(stage__slug=stage)
        return qs

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["stages"] = PipelineStage.objects.order_by("order")
        ctx["totals_by_stage"] = {
            row["stage__name"]: row["total"]
            for row in self.get_queryset().values("stage__name")
                                        .annotate(total=Sum("amount"))
        }
        return ctx

class DealDetailView(LoginRequiredMixin, DetailView):
    model = Deal
    template_name = "deals/deal_detail.html"

    def get_queryset(self):
        return Deal.objects.select_related("account", "contact", "stage", "owner")

class DealUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
    model = Deal
    form_class = DealForm
    template_name = "deals/deal_form.html"
    permission_required = "deals.change_deal"

The generic views encode three decades of common patterns. The full family, by purpose:

Purpose Generic view What it does for you
Show one object DetailView fetch by pk/slug, 404, context
Show a list ListView queryset, context, paginate_by
Create CreateView GET form, POST validation, save, redirect
Update UpdateView load instance, bind form, save
Delete DeleteView confirm page, delete, redirect
Show a template TemplateView nothing else
Process a form FormView form on GET, handle valid/invalid on POST
Dates ArchiveIndexView, YearArchiveView, MonthArchiveView, DayArchiveView date-based archives
Edit a subformset FormView + BaseInlineFormSet parent + children in one submit
# The full inheritance chain, so you know where to change behaviour:
#   View  →  TemplateResponseMixin + ContextMixin
#         →  BaseDetailView / BaseListView
#         →  SingleObjectMixin / MultipleObjectMixin
#         →  DetailView / ListView
# Override a get_*() method rather than copying the whole view.

Which to use

Function-based view Class-based view
Readability for irregular logic high — top to bottom lower — behaviour is spread across get_* methods
Reuse decorators, helper functions mixins, inheritance
CRUD fine, but repeats excellent — three lines per view
Traceability obvious requires knowing the MRO
Testability equally good equally good

The pragmatic rule used throughout this article: CBVs for standard CRUD, FBVs for everything irregular. Neither is "more correct", and a codebase that uses only one of them is usually fighting itself somewhere.

Permissions on a view

from django.contrib.auth.decorators import login_required, permission_required

@login_required
@permission_required("contacts.change_contact", raise_exception=True)
def contact_edit(request, pk): ...

# class-based equivalent
class ContactUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
    permission_required = "contacts.change_contact"
    raise_exception = True

Templates

Django's template language (DTL) is deliberately limited: it can display data and branch on it, and it is hard to put business logic in it. That is the point — presentation logic stays in templates/, and everything else stays in Python.

Template discovery, inheritance and inclusion

{# templates/base.html — project-wide #}
{% load static %}
<!doctype html>
<html lang="{{ LANGUAGE_CODE }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{% block title %}CRM{% endblock %}</title>
  <link rel="stylesheet" href="{% static 'css/app.css' %}">
</head>
<body class="app">
  <nav class="sidebar">{% include "partials/_nav.html" %}</nav>

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

    {% block content %}{% endblock %}
  </main>
</body>
</html>
{# deals/templates/deals/board.html — an app template #}
{% extends "base.html" %}
{% load humanize %}

{% block title %}Pipeline — CRM{% endblock %}

{% block content %}
  <h1>Pipeline</h1>

  <form method="get" class="filters">
    <input type="search" name="search" value="{{ request.GET.search }}" placeholder="Search deals…">
    <select name="stage">
      <option value="">All stages</option>
      {% for stage in stages %}
        <option value="{{ stage.slug }}" {% if request.GET.stage == stage.slug %}selected{% endif %}>
          {{ stage.name }}
        </option>
      {% endfor %}
    </select>
    <button type="submit">Filter</button>
  </form>

  <div class="board">
    {% for stage, deals in grouped.items %}
      <section class="column">
        <h2>{{ stage }} <small>{{ totals_by_stage|get_item:stage|default:0|floatformat:0 }} €</small></h2>
        {% for deal in deals %}
          {% include "deals/_deal_card.html" with deal=deal only %}
        {% empty %}
          <p class="empty">No deals in this stage.</p>
        {% endfor %}
      </section>
    {% endfor %}
  </div>

  {% include "partials/_pagination.html" with page=page %}
{% endblock %}
Concept Syntax Notes
Inheritance {% extends "base.html" %} + {% block %} one base layout, per-page blocks
Inclusion {% include "x.html" with a=1 only %} only prevents context leakage — use it
Variables {{ deal.title }} dots walk attributes, dict keys and list indices
Filters {{ amount\|floatformat:2 }}, {{ name\|title }} pipe-chained, left to right
Conditionals {% if %}…{% elif %}…{% else %}…{% endif %} no parentheses; and/or/not
Loops {% for %} with forloop.counter, empty {% empty %} is the "no results" branch
URLs {% url 'deals:detail' deal.pk %} never hard-code a path
Static {% static 'css/app.css' %} respects STATIC_URL and hashing
CSRF {% csrf_token %} mandatory in every POST form
Escaping {{ value }} escapes HTML automatically \|safe opts out — treat it as a code smell

Escaping, and the one time you turn it off

DTL auto-escapes every variable, which is why XSS is rare in Django. |safe and {% autoescape off %} disable that protection for a value, so they may only ever wrap your own generated HTML:

{# Safe: our own markup from a trusted source #}
{{ rendered_rich_text_from_our_editor|safe }}

{# Never: anything a user typed #}
{{ contact.notes|safe }}          {# ← stored XSS waiting to happen #}

Context processors

A context processor injects a variable into every template. Django ships four; a CRM usually adds one or two:

# core/context_processors.py
def crm_globals(request):
    if not request.user.is_authenticated:
        return {}
    return {
        "open_deals_count": Deal.objects.open().filter(owner=request.user).count(),
        "nav_active": request.resolver_match.namespace if request.resolver_match else "",
    }
# settings.py — TEMPLATES[0]["OPTIONS"]["context_processors"]
"core.context_processors.crm_globals",

[!WARNING] A context processor runs on every template render, including emails and error pages. A query in one is a query on every page — cache it or drop it.

Custom filters and tags

# deals/templatetags/deal_extras.py
from django import template

register = template.Library()

@register.filter
def money(value):
    """€ 1 234,50 — and blank rather than 'None' for a missing amount."""
    if value is None:
        return "—"
    return f"{value:,.2f} €".replace(",", " ").replace(".", ",").replace(" ", " ", 1)

@register.simple_tag
def stage_progress(stage):
    return f"{stage.probability * 100:.0f}%"
{% load deal_extras %}
<td>{{ deal.amount|money }}</td>

templatetags/ must contain an __init__.py, and the app must be in INSTALLED_APPS.

Partials and server-rendered interactivity

Split templates the way you split components: small files named _partial.html for fragments. A single partial can serve both a full page and an AJAX/HTMX update:

# activities/views.py — returns a fragment for HTMX, a full page for a normal request
class ActivityCreateView(LoginRequiredMixin, CreateView):
    model = Activity
    form_class = ActivityForm
    template_name = "activities/_timeline_item.html"

    def get_template_names(self):
        return ([self.template_name] if self.request.headers.get("HX-Request")
                else ["activities/activity_form.html"])

That one pattern gives you a fast, interactive CRM without a JavaScript build step — the server renders HTML fragments, the browser swaps them in.


Models

A model is a Python class that describes a database table. Everything the database enforces — types, nullability, uniqueness, relations, indexes — belongs here, not in a form and not in a view.

# accounts/models.py
from django.conf import settings
from django.db import models
from django.urls import reverse

class AccountQuerySet(models.QuerySet):
    def active(self):
        return self.filter(is_active=True)

    def with_deal_totals(self):
        return self.annotate(
            open_deal_total=models.Sum("deals__amount",
                                       filter=models.Q(deals__closed_at__isnull=True)),
            contact_count=models.Count("contacts", distinct=True),
        )

class AccountManager(models.Manager.from_queryset(AccountQuerySet)):
    pass

class Account(models.Model):
    class Industry(models.TextChoices):
        RETAIL = "retail", "Retail"
        INDUSTRY = "industry", "Industry"
        SERVICES = "services", "Services"
        PUBLIC = "public", "Public sector"

    name = models.CharField(max_length=200, db_index=True)
    industry = models.CharField(max_length=20, choices=Industry.choices, default=Industry.SERVICES)
    website = models.URLField(blank=True)
    annual_revenue = models.DecimalField(max_digits=14, decimal_places=2, null=True, blank=True)
    owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
                              related_name="owned_accounts")
    is_active = models.BooleanField(default=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    objects = AccountManager()

    class Meta:
        ordering = ["name"]
        constraints = [
            models.UniqueConstraint(fields=["name"], name="unique_account_name"),
        ]
        indexes = [
            models.Index(fields=["industry", "is_active"]),
        ]

    def __str__(self):
        return self.name

    def get_absolute_url(self):
        return reverse("accounts:detail", kwargs={"pk": self.pk})
# contacts/models.py
class Contact(models.Model):
    first_name = models.CharField(max_length=80)
    last_name = models.CharField(max_length=80)
    email = models.EmailField(unique=True)
    phone = models.CharField(max_length=40, blank=True)
    account = models.ForeignKey("accounts.Account", on_delete=models.PROTECT,
                                related_name="contacts")
    owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
                              related_name="owned_contacts")
    tags = models.ManyToManyField("contacts.Tag", blank=True, related_name="contacts")
    is_active = models.BooleanField(default=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["last_name", "first_name"]
        indexes = [models.Index(fields=["account", "last_name"])]

    def __str__(self):
        return f"{self.first_name} {self.last_name}"

Fields you will actually use

Field Use it for Watch out
CharField(max_length) short text max_length is enforced by the database
TextField long text, notes no length limit; use for rich text
EmailField, URLField, SlugField validated strings EmailField on the user model is what makes email login work
IntegerField, PositiveIntegerField counters, quantities
DecimalField(max_digits, decimal_places) money never FloatField for money — floats do not round like money
BooleanField flags prefer this over a nullable boolean
DateTimeField(auto_now_add / auto_now) created / updated auto_now_add is set once; auto_now on every save
DateField business dates expected_close, closed_at
FileField, ImageField uploads upload_to is a callable for a reason; validate content type
JSONField flexible, rarely-queried data do not model your domain in one
ForeignKey "one account, many contacts" on_delete is a required decision, not a formality
OneToOneField "extends" or "at most one" the row shares the primary key
ManyToManyField "tags" needs through= as soon as the link has its own fields

on_delete — decide on purpose

Value Meaning Use for
PROTECT refuse to delete the parent while children exist Account ← Contact: never silently delete a company's people
RESTRICT like PROTECT, but allows deletion if the child is deleted in the same operation softer version of the same idea
CASCADE delete children with the parent Deal ← Activity: an activity without its deal is noise
SET_NULL keep the child, null the relation owner when an employee leaves
SET_DEFAULT / SET(value) reassign reassigning to a "former employee" account
DO_NOTHING leave it to the database only with a database-level constraint

Null versus blank, and why it confuses everyone

name = models.CharField(max_length=100, blank=True)          # "" is a legal value: this is what you want
notes = models.TextField(null=True, blank=True)              # NULL and "" both allowed
price = models.DecimalField(..., null=True, blank=True)      # NULL means "unknown", not zero
  • blank is a validation concept: may the form field be empty?
  • null is a database concept: may the column store NULL?

On a text field, prefer blank=True alone — Django's own convention is that an empty string is the right representation of "no value" for text, and having both "" and NULL means every query must handle two kinds of empty.

Meta options worth setting

Option Effect
ordering default order of every queryset — set it, or your lists shuffle randomly
constraints database-enforced rules: unique together, check constraints
indexes explicit indexes for the columns your list views filter and sort on
verbose_name / verbose_name_plural the admin's labels
permissions custom permissions beyond add/change/delete/view
abstract = True a base class that creates no table
class Meta:
    constraints = [
        models.UniqueConstraint(fields=["deal", "position"], name="unique_line_position"),
        models.CheckConstraint(check=models.Q(quantity__gte=1), name="line_quantity_positive"),
        models.CheckConstraint(check=models.Q(amount__gte=0), name="deal_amount_non_negative"),
    ]

[!TIP] Constraints in Meta are the difference between "we validate it in the form" and "the data cannot be wrong". Every rule you can push into the database is a class of bug you never debug again — and it also protects the admin, a management command and a data import.

Model rules that belong on the model

class Deal(models.Model):
    ...
    clean(self):
        from django.core.exceptions import ValidationError
        if self.stage and self.stage.is_won and self.amount <= 0:
            raise ValidationError({"amount": "A won deal must have a positive amount."})

    def save(self, *args, **kwargs):
        self.amount = (self.amount or 0).quantize(Decimal("0.01"))
        super().save(*args, **kwargs)

    @property
    def is_open(self):
        return self.closed_at is None and not self.stage.is_lost

    def close(self, *, won: bool):
        """Business action — called from a view, a task or a command."""
        self.stage = PipelineStage.objects.get(is_won=won)
        self.closed_at = timezone.now()
        self.save(update_fields=["stage", "closed_at", "updated_at"])

clean() is called by forms and the admin, save() on every write. Put validation in clean() (so it produces a form error) and normalisation in save() — and remember neither runs on QuerySet.update(), which is one reason bulk writes go through a service function.


The ORM in Depth

The ORM is where Django code is won or lost: almost every performance problem in a Django CRM is a query problem, and almost every query problem is an N+1 in disguise. The diagram is the whole section in one picture.

ORM in the CRM: querysets are lazy, the N+1 trap, select_related and prefetch_related, and the tools that fix it

Click the diagram to open it at full resolution.

A QuerySet is lazy

Building a queryset does no database work at all. It is a description of a query, and each chained method returns a new description:

qs = (Contact.objects                      # nothing yet
      .filter(account__industry="retail")  # still nothing
      .exclude(is_active=False)            # still nothing
      .order_by("-created_at")             # still nothing
      [:20])                               # still nothing — LIMIT 20

It runs when you evaluate it:

list(qs)          # evaluate → list of model instances
for c in qs:      # evaluate → iteration
len(qs)           # evaluate (and fetch every row!) — prefer qs.count()
qs.exists()       # a cheap SELECT 1 LIMIT 1
qs[0]             # evaluate, LIMIT 1
bool(qs)          # evaluate

Two consequences:

  • Compose freely. Filters can be added by the view, the manager and a helper function; the SQL is only built at the end.
  • Cache deliberately. Once evaluated, a queryset caches its results — iterating twice does not re-query if you iterate the same queryset object.
contacts = Contact.objects.filter(is_active=True)
len(contacts)                       # runs the query, caches all rows
[c.last_name for c in contacts]     # no second query — cached

contacts = Contact.objects.filter(is_active=True)
contacts.count()                    # SELECT COUNT(*)
[c.last_name for c in contacts]     # a SECOND query, fetching every row

Lookups, F(), Q()

Deal.objects.filter(stage__name="Proposal")                 # follow a FK in the lookup
Deal.objects.filter(amount__gte=1000, amount__lt=5000)       # range
Deal.objects.filter(title__icontains="renewal")              # case-insensitive contains
Deal.objects.filter(created_at__date=date.today())           # date part
Deal.objects.filter(expected_close__isnull=True)             # no value
Deal.objects.filter(owner__email__endswith="@acme.com")      # deep joins
Deal.objects.filter(id__in=[1, 2, 3])
Suffix Meaning
__exact (default), __iexact equality, case-insensitive equality
__contains, __icontains substring
__startswith, __istartswith, __endswith prefix / suffix
__gt, __gte, __lt, __lte comparison
__in, __range, __isnull membership, range, null check
__date, __year, __month, __day, __week_day date parts
__regex regular expression (rarely the right answer)
from django.db.models import F, Q

# F(): reference a column in the database, no read-modify-write race
Deal.objects.filter(pk=pk).update(amount=F("amount") + 500)
Contact.objects.filter(pk=pk).update(last_called_at=F("created_at"))

# Q(): compose OR / NOT, which filter() alone cannot express
Contact.objects.filter(Q(account__industry="retail") | Q(tags__name="vip")).distinct()
Contact.objects.filter(~Q(email__endswith="@internal.acme.com"))
Contact.objects.filter(Q(owner=user) & (Q(is_active=True) | Q(account__is_active=True)))

Aggregation and annotation

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

# aggregate — one row, one number for the whole queryset
Deal.objects.open().aggregate(total=Sum("amount"), average=Avg("amount"), count=Count("id"))
# {'total': Decimal('184500.00'), 'average': Decimal('6150.00'), 'count': 30}

# annotate — one value PER row, computed in SQL
Account.objects.annotate(
    open_total=Sum("deals__amount", filter=Q(deals__closed_at__isnull=True)),
    contact_count=Count("contacts", distinct=True),
).filter(open_total__gt=10000).order_by("-open_total")

# Group by, the ORM way
PipelineStage.objects.annotate(
    deal_count=Count("deals"),
    total=Sum("deals__amount"),
).order_by("order")

# values() when you only need a report, not model instances
Deal.objects.values("stage__name").annotate(total=Sum("amount"), n=Count("id"))

[!IMPORTANT] Sum over a reverse relation plus another aggregation in the same annotate() multiplies rows and inflates both numbers. Add distinct=True to the counts, or split the query. This is the most common silent-wrong-number bug in Django reporting code.

# ❌ 1 + N queries: one per row when the template touches contact.account
for contact in Contact.objects.all():
    print(contact.account.name)

# ✅ 1 query, with a JOIN — for ForeignKey and OneToOneField
for contact in Contact.objects.select_related("account", "owner"):
    print(contact.account.name)

# ✅ 2 queries, joined in Python — for ManyToMany and reverse FK
for account in Account.objects.prefetch_related("contacts"):
    for contact in account.contacts.all():
        ...

# ✅ combine them; and prefetch a filtered/nested set when needed
Deal.objects.select_related("account", "stage", "owner").prefetch_related(
    Prefetch("activities", queryset=Activity.objects.filter(kind="call").order_by("-occurred_at"),
             to_attr="call_activities"),
)
Situation Tool Queries Notes
FK / OneToOne, forward select_related 1 (JOIN) cheap, always prefer it
reverse FK (account.contact_set) prefetch_related 2 one query, joined in Python
ManyToMany prefetch_related 2
Filtered/sorted children Prefetch(queryset=…) 2 with to_attr= gives you a list, not a manager
Deep chain of FKs select_related("a__b__c") 1 joins multiply rows: keep it to what you display
A count per row annotate(Count(...)) 1 never loop COUNT(*) in Python

How to notice you have a problem: django-debug-toolbar shows the query count per request. A contact list that runs 1 query for 25 contacts is right; one that runs 26 is an N+1.

# and lock it in with a test
def test_contact_list_query_count(self):
    self.client.force_login(self.user)
    with self.assertNumQueries(4):          # tune the number, then never regress
        self.client.get(reverse("contacts:list"))

Writes: bulk, transactions, races

from django.db import transaction
from django.db.models import F

# bulk_create / bulk_update — one query for many rows
Contact.objects.bulk_create([Contact(**row) for row in rows], batch_size=500)
Contact.objects.bulk_update(contacts, ["phone"], batch_size=500)

# update() bypasses save() and signals — cheap and deliberate
Contact.objects.filter(account=account).update(owner=new_owner)

# atomic(): all or nothing
with transaction.atomic():
    account = Account.objects.create(name="Acme", owner=user)
    Contact.objects.bulk_create([...])
    deal = Deal.objects.create(account=account, ...)      # any exception rolls all of it back

# select_for_update(): lock the rows you are about to change (inside a transaction)
with transaction.atomic():
    deal = Deal.objects.select_for_update().get(pk=pk)
    deal.amount = compute(deal)
    deal.save(update_fields=["amount"])

# atomic per-request, when almost everything in a view is a write
@transaction.atomic
def convert_lead(request, pk): ...
Tool Use it when
transaction.atomic() several writes must succeed or fail together
select_for_update() two requests could change the same row; needs a matching index and a short transaction
F() expressions incrementing or comparing against the current value without a race
update_fields=[...] you are saving one field and want to avoid a full-row UPDATE and its race
bulk_* importing or updating hundreds of rows; remember it skips save()/signals
on_commit() the side effect must wait until the transaction has actually committed
from django.db import transaction

def convert_lead(lead, user):
    with transaction.atomic():
        account = Account.objects.create(name=lead.company_name, owner=user)
        contact = Contact.objects.create(
            first_name=lead.contact_name.split()[0], last_name=lead.contact_name.split()[-1],
            email=lead.email, account=account, owner=user,
        )
        lead.converted_contact = contact
        lead.status = Lead.Status.CONVERTED
        lead.save(update_fields=["converted_contact", "status"])
        transaction.on_commit(lambda: send_welcome_email.delay(contact.pk))
    return contact

[!WARNING] Side effects inside atomic() are a classic footgun: the email is sent (or the Celery task queued) and then the transaction rolls back. Wrap external effects in transaction.on_commit() so they only happen once the data is durable.

Raw SQL and the escape hatch

# still returns model instances, and you can still filter it
Contact.objects.raw("SELECT * FROM contacts_contact WHERE email ILIKE %s", ["%@acme.com"])

# parameters everywhere: never format SQL with f-strings
from django.db import connection
with connection.cursor() as cur:
    cur.execute("SELECT stage_id, SUM(amount) FROM deals_deal GROUP BY stage_id")
    rows = cur.fetchall()

The ORM covers ~95% of a CRM. When it does not — a window function, a recursive CTE — use RawSQL, .extra() on an existing queryset, or raw SQL with bound parameters. Never build SQL by string interpolation: that is exactly the SQL injection the ORM exists to prevent.


Migrations

Migrations are Django's schema-as-code system: model changes are captured as Python files, applied in order, and reversible.

python manage.py makemigrations contacts        # one app
python manage.py makemigrations                 # everything that changed
python manage.py migrate                        # apply
python manage.py showmigrations                 # what exists, what is applied
python manage.py sqlmigrate contacts 0003       # the SQL this migration will run
python manage.py migrate contacts 0002          # roll back to 0002
python manage.py makemigrations --check --dry-run   # CI: fail if models drifted from migrations

The workflow that avoids pain

  1. Change the models.
  2. makemigrations and read the generated file — you are the reviewer of your own schema.
  3. Run sqlmigrate when the change is destructive or touches a big table.
  4. Apply locally, run the test suite (tests create their own database from migrations).
  5. Commit the migration with the model change, in the same commit.
  6. Deploy: run migrate before the new code starts serving.

Data migrations

When a schema change needs data moved, write a second migration that does the move:

# deals/migrations/0007_backfill_stage_probability.py
from django.db import migrations

def backfill(apps, schema_editor):
    PipelineStage = apps.get_model("deals", "PipelineStage")
    for stage in PipelineStage.objects.filter(probability=0):
        stage.probability = {"Lead": 0.1, "Proposal": 0.5, "Won": 1.0}.get(stage.name, 0.2)
        stage.save(update_fields=["probability"])

def noop(apps, schema_editor):
    pass

class Migration(migrations.Migration):
    dependencies = [("deals", "0006_deal_currency")]
    operations = [migrations.RunPython(backfill, noop)]

[!IMPORTANT] Inside a data migration use apps.get_model(), never from deals.models import PipelineStage. The historical model is the one that matches the schema at that point in time — importing the live model couples an old migration to today's code and breaks replays.

Pitfalls, in order of how often they bite

Pitfall What happens Fix
Editing an applied migration Environments silently diverge add a new migration instead
A migration that is slow on a big table deploy blocks, or locks the table add the column nullable, backfill in batches, then tighten
Non-nullable column without a default Django prompts, and the prompt is easy to answer badly add null=True, backfill, then AlterField
Renaming a field Django asks "rename or delete+create?" — the wrong answer destroys data answer carefully; verify with sqlmigrate
--fake to get unblocked the schema and the migration state disagree from then on fix the migration; use --fake only for a migration you know is already applied
Squashing without care harder to review, and the old migrations still exist for existing databases squash only on a project you can migrate from scratch
Model drift makemigrations --check fails in CI commit the migration with the model change

Zero-downtime changes, in one table

Change Safe sequence
Add a column add it null=True → deploy → backfill → (optionally) tighten
Remove a column stop using it in code → deploy → drop it in a later migration
Rename a column add new → backfill → switch code → drop old
Add an index AddIndexConcurrently (PostgreSQL) outside a transaction: atomic = False
class Migration(migrations.Migration):
    atomic = False        # required for CREATE INDEX CONCURRENTLY

    operations = [
        migrations.AddIndex(
            model_name="deal", index=models.Index(fields=["stage", "expected_close"],
                                                  name="deal_stage_close_idx"),
            concurrent=True,
        ),
    ]

Forms

Forms do three jobs: render HTML, validate input, and convert it to Python types. Django's form layer is the reason you rarely write a manual request.POST.get(...).

# deals/forms.py
from django import forms
from django.core.exceptions import ValidationError

from .models import Deal, PipelineStage

class DealForm(forms.ModelForm):
    class Meta:
        model = Deal
        fields = ["title", "account", "contact", "stage", "amount", "currency", "expected_close", "notes"]
        widgets = {
            "expected_close": forms.DateInput(attrs={"type": "date"}),
            "notes": forms.Textarea(attrs={"rows": 4}),
        }
        labels = {"amount": "Amount (excluding tax)"}

    def __init__(self, *args, user=None, **kwargs):
        super().__init__(*args, **kwargs)
        # limit the choices a rep can pick, per request
        self.fields["account"].queryset = Account.objects.active()
        self.fields["stage"].queryset = PipelineStage.objects.order_by("order")
        if self.instance.pk:
            self.fields["account"].disabled = True          # cannot move a deal to another company
        self.user = user

    def clean(self):
        cleaned = super().clean()
        stage = cleaned.get("stage")
        amount = cleaned.get("amount")
        if stage and stage.is_won and (amount or 0) <= 0:
            self.add_error("amount", "A won deal needs a positive amount.")
        return cleaned

    def clean_expected_close(self):
        value = self.cleaned_data["expected_close"]
        if value and value < date.today() and not self.instance.closed_at:
            raise ValidationError("A date in the past is only valid for a closed deal.")
        return value

    def save(self, commit=True):
        deal = super().save(commit=False)
        deal.amount = deal.amount or Decimal("0.00")
        if commit:
            deal.save()
            self.save_m2m()
        return deal
Hook Runs Use it for
__init__ when the form is built restricting querysets, changing labels, injecting request.user
clean_<field>() after that field's validators one-field rules; returns the cleaned value
clean() after all fields cross-field rules; self.add_error(field, msg)
validate_unique() during is_valid() overridden rarely; Meta.unique_together usually suffices
save(commit=False) in the view set fields the user must not (owner, created_by)
save_m2m() after commit=False save must be called explicitly

Using a form in a view

def deal_create(request):
    if request.method == "POST":
        form = DealForm(request.POST, user=request.user)
        if form.is_valid():
            deal = form.save(commit=False)
            deal.owner = request.user                     # never trust the browser for this
            deal.save()
            form.save_m2m()
            messages.success(request, f"Deal “{deal.title}” created.")
            return redirect(deal.get_absolute_url())
    else:
        form = DealForm(initial={"currency": "EUR"}, user=request.user)

    return render(request, "deals/deal_form.html", {"form": form})
{# deals/templates/deals/deal_form.html — no manual <input> needed #}
<form method="post" novalidate>
  {% csrf_token %}
  {{ form.non_field_errors }}
  {% for field in form %}
    <div class="field {% if field.errors %}has-error{% endif %}">
      {{ field.label_tag }}
      {{ field }}
      {% if field.help_text %}<p class="help">{{ field.help_text }}</p>{% endif %}
      {% for error in field.errors %}<p class="error">{{ error }}</p>{% endfor %}
    </div>
  {% endfor %}
  <button type="submit">Save</button>
</form>

Formsets: one deal, many line items

from django.forms import inlineformset_factory

DealLineFormSet = inlineformset_factory(
    Deal, DealLine,
    fields=["product_name", "quantity", "unit_price"],
    extra=1, can_delete=True, min_num=1, validate_min=True,
)

def deal_edit(request, pk):
    deal = get_object_or_404(Deal, pk=pk)
    if request.method == "POST":
        form = DealForm(request.POST, instance=deal, user=request.user)
        formset = DealLineFormSet(request.POST, instance=deal)
        if form.is_valid() and formset.is_valid():
            with transaction.atomic():
                form.save()
                formset.save()
            messages.success(request, "Deal updated.")
            return redirect(deal.get_absolute_url())
    else:
        form = DealForm(instance=deal, user=request.user)
        formset = DealLineFormSet(instance=deal)

    return render(request, "deals/deal_form.html", {"form": form, "formset": formset})
{{ formset.management_form }}      {# mandatory: the hidden bookkeeping fields #}
{% for line_form in formset %}
  {{ line_form.id }}
  {{ line_form.product_name }} {{ line_form.quantity }} {{ line_form.unit_price }}
  {% if line_form.instance.pk %}{{ line_form.DELETE }}{% endif %}
  {{ line_form.errors }}
{% endfor %}

Validation lives in the form, not the view

class ContactImportForm(forms.Form):
    csv_file = forms.FileField(
        help_text="Header row required: first_name,last_name,email,account",
        widget=forms.ClearableFileInput(attrs={"accept": ".csv"}),
    )

    def clean_csv_file(self):
        f = self.cleaned_data["csv_file"]
        if f.size > 5 * 1024 * 1024:
            raise ValidationError("Maximum 5 MB.")
        if not f.name.lower().endswith(".csv"):
            raise ValidationError("Expected a .csv file.")
        try:
            f.read(1024).decode("utf-8")
            f.seek(0)
        except UnicodeDecodeError:
            raise ValidationError("The file must be UTF-8 encoded.")
        return f

[!TIP] A form is a pure function from request.POST to either errors or clean Python. That makes it the best-tested object in a Django codebase: no HTTP request needed, no database needed for the validation rules themselves.


The Django Admin

The admin is not a toy. For an internal CRM it is the fastest way to give managers a back office, and customised properly it is genuinely pleasant.

# contacts/admin.py
from django.contrib import admin
from django.db.models import Count
from django.utils.html import format_html

from .models import Contact, Tag

@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
    list_display = ("name", "colour", "contact_count")
    search_fields = ("name",)

    def get_queryset(self, request):
        return super().get_queryset(request).annotate(_contacts=Count("contacts"))

    @admin.display(description="Contacts", ordering="_contacts")
    def contact_count(self, obj):
        return obj._contacts

class DealInline(admin.TabularInline):
    model = "deals.Deal"
    extra = 0
    fields = ("title", "stage", "amount", "expected_close")
    readonly_fields = ("created_at",)
    show_change_link = True

@admin.register(Contact)
class ContactAdmin(admin.ModelAdmin):
    list_display = ("full_name", "email_link", "account", "owner", "is_active")
    list_filter = ("is_active", "account__industry", "tags", "owner")
    search_fields = ("first_name", "last_name", "email", "account__name")
    autocomplete_fields = ("account", "owner")     # searchable FK widgets instead of a huge <select>
    list_select_related = ("account", "owner")     # kills the N+1 on the changelist
    inlines = [DealInline]
    date_hierarchy = "created_at"
    readonly_fields = ("created_at", "updated_at")
    actions = ["mark_inactive", "assign_to_me"]

    @admin.display(description="Name", ordering="last_name")
    def full_name(self, obj):
        return f"{obj.first_name} {obj.last_name}"

    @admin.display(description="Email")
    def email_link(self, obj):
        return format_html('<a href="mailto:{}">{}</a>', obj.email, obj.email)

    @admin.action(description="Mark selected contacts inactive")
    def mark_inactive(self, request, queryset):
        updated = queryset.update(is_active=False)
        self.message_user(request, f"{updated} contact(s) marked inactive.")

    @admin.action(description="Assign selected contacts to me")
    def assign_to_me(self, request, queryset):
        queryset.update(owner=request.user)

    def get_queryset(self, request):
        qs = super().get_queryset(request)
        # a rep sees only their own contacts; managers see everything
        return qs if request.user.role == "manager" else qs.filter(owner=request.user)

    def save_model(self, request, obj, form, change):
        if not change:
            obj.owner = obj.owner or request.user
        super().save_model(request, obj, form, change)
Admin tool Effect
list_display the columns; methods on the ModelAdmin allowed with @admin.display
list_filter sidebar filters (__ traversal works, and a custom SimpleListFilter is easy)
search_fields the search box (icontains on each; ^ for prefix, = for exact)
list_select_related the admin performance setting — removes changelist N+1
autocomplete_fields searchable FK widget; requires search_fields on the target admin
inlines child rows edited on the parent page (TabularInline, StackedInline)
actions bulk operations from the dropdown
readonly_fields audit fields
date_hierarchy drill-down bar by date
raw_id_fields plain id input for very large foreign keys
get_queryset() per-user row filtering (an admin is not a security boundary by itself — combine with permissions)

[!WARNING] The admin bypasses your forms and your services. save_model runs, but clean() only runs if the ModelForm validates it, and your service-layer rules do not run at all. If a rule must hold for data created in the admin, put it on the model (clean(), Meta.constraints) rather than only in a view or a service.


Authentication and Permissions

Start with a custom user model

Do this on day one — changing it later is one of the few genuinely painful Django operations.

# users/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models

class User(AbstractUser):
    """Email is the login identifier; username is kept for the admin."""
    class Role(models.TextChoices):
        REP = "rep", "Sales rep"
        MANAGER = "manager", "Sales manager"
        ADMIN = "admin", "Administrator"

    email = models.EmailField(unique=True)
    role = models.CharField(max_length=20, choices=Role.choices, default=Role.REP)
    phone = models.CharField(max_length=40, blank=True)
    manager = models.ForeignKey("self", null=True, blank=True, on_delete=models.SET_NULL,
                                related_name="reports")

    USERNAME_FIELD = "email"          # log in with email
    REQUIRED_FIELDS = ["username"]

    class Meta:
        permissions = [("view_all_deals", "Can view every deal, not only their own")]

    @property
    def is_manager(self):
        return self.role in {self.Role.MANAGER, self.Role.ADMIN}
# settings.py — BEFORE the first migrate
AUTH_USER_MODEL = "users.User"

Login, logout, and protected views

# config/urls.py
from django.contrib.auth import views as auth_views

urlpatterns += [
    path("login/", auth_views.LoginView.as_view(template_name="users/login.html"), name="login"),
    path("logout/", auth_views.LogoutView.as_view(), name="logout"),
    # password reset is four views, all shipped — wire them and be done
    path("password-reset/", auth_views.PasswordResetView.as_view(), name="password_reset"),
    path("password-reset/done/", auth_views.PasswordResetDoneView.as_view(), name="password_reset_done"),
    path("reset/<uidb64>/<token>/", auth_views.PasswordResetConfirmView.as_view(), name="password_reset_confirm"),
    path("reset/done/", auth_views.PasswordResetCompleteView.as_view(), name="password_reset_complete"),
]
@login_required                                  # FBV
def contact_list(request): ...

class DealDetailView(LoginRequiredMixin, DetailView): ...     # CBV

class ManagerOnlyMixin(LoginRequiredMixin, UserPassesTestMixin):
    def test_func(self):
        return self.request.user.is_manager

class TeamReportView(ManagerOnlyMixin, TemplateView):
    template_name = "users/team_report.html"

Groups, permissions and object-level access

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

# model-level permissions are created automatically: add/change/delete/view
user.has_perm("deals.change_deal")
user.has_perms(["deals.change_deal", "deals.view_deal"])

# group them so you assign roles, not 40 individual permissions
managers, _ = Group.objects.get_or_create(name="Sales managers")
managers.permissions.set(Permission.objects.filter(
    codename__in=["change_deal", "delete_deal", "view_all_deals"]
))
user.groups.add(managers)

# custom permission declared on the model's Meta
class Deal(models.Model):
    class Meta:
        permissions = [("export_deals", "Can export the deal list")]
# row-level access belongs in the queryset, not in an if-statement
class DealQuerySet(models.QuerySet):
    def visible_to(self, user):
        if user.is_superuser or user.has_perm("deals.view_all_deals"):
            return self
        return self.filter(Q(owner=user) | Q(account__owner=user))
# and a custom test for DRF object permissions
from rest_framework import permissions

class IsOwnerOrManager(permissions.BasePermission):
    def has_object_permission(self, request, view, obj):
        return request.user.is_manager or obj.owner_id == request.user.id
Layer Answer to "may this user do this?"
login_required / LoginRequiredMixin are they authenticated?
permission_required / PermissionRequiredMixin do they hold this model permission?
Group membership role assignment, in the admin — no code
get_queryset() filtered by user which rows they may see at all — the most important layer
DRF permission_classes + has_object_permission the same three questions for the API
Template {% if perms.deals.change_deal %} hide a button (never the only protection)

[!IMPORTANT] Hiding a button is not authorisation. Every write path must re-check on the server: a filtered queryset for reads, and a permission check (plus a filtered get_object) for writes. The template only decides what to offer.

Passwords and sessions

  • Passwords are hashed with PBKDF2 by default, and Django re-hashes on login when you raise the iteration count. Never store, log, or email a password.
  • Sessions are stored server-side (database by default, Redis in production) with a signed cookie holding only the session key.
  • SESSION_COOKIE_SECURE, CSRF_COOKIE_SECURE, SESSION_COOKIE_HTTPONLY and SESSION_EXPIRE_AT_BROWSER_CLOSE are the settings that matter.
  • For a shared machine (a sales desk laptop), set SESSION_COOKIE_AGE to something like 8 hours and force re-login.
# customise the user in templates and views without extra queries
request.user.get_full_name()
{{ user.get_full_name|default:user.email }}

Class-Based Views in Practice

The CRUD of a CRM is where CBVs pay off. Here is the complete, idiomatic set for one entity.

# contacts/views.py
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.db.models import Q
from django.urls import reverse_lazy
from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView

from .forms import ContactForm
from .models import Contact

class ContactListView(LoginRequiredMixin, ListView):
    model = Contact
    paginate_by = 25
    template_name = "contacts/contact_list.html"
    context_object_name = "contacts"

    def get_queryset(self):
        qs = (Contact.objects
              .select_related("account", "owner")           # 1 query, not 1+N
              .prefetch_related("tags")
              .visible_to(self.request.user))
        term = self.request.GET.get("search", "").strip()
        if term:
            qs = qs.filter(Q(first_name__icontains=term) | Q(last_name__icontains=term)
                           | Q(email__icontains=term))
        return qs

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["search"] = self.request.GET.get("search", "")
        return ctx

class ContactDetailView(LoginRequiredMixin, DetailView):
    model = Contact
    template_name = "contacts/contact_detail.html"

    def get_queryset(self):
        return (Contact.objects
                .select_related("account", "owner")
                .prefetch_related("tags", "activities__owner"))

class ContactCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
    model = Contact
    form_class = ContactForm
    template_name = "contacts/contact_form.html"
    permission_required = "contacts.add_contact"

    def form_valid(self, form):
        form.instance.owner = self.request.user           # server-side, not from the form
        messages.success(self.request, "Contact created.")
        return super().form_valid(form)

class ContactUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
    model = Contact
    form_class = ContactForm
    template_name = "contacts/contact_form.html"
    permission_required = "contacts.change_contact"

class ContactDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView):
    model = Contact
    template_name = "contacts/contact_confirm_delete.html"
    permission_required = "contacts.delete_contact"
    success_url = reverse_lazy("contacts:list")

    def form_valid(self, form):
        # soft delete: keep the row, keep the history
        self.object = self.get_object()
        self.object.is_active = False
        self.object.save(update_fields=["is_active"])
        messages.info(self.request, "Contact archived.")
        return redirect(self.get_success_url())
Mixin Adds
LoginRequiredMixin redirect unauthenticated users to LOGIN_URL
PermissionRequiredMixin permission_required, raise_exception
UserPassesTestMixin arbitrary predicate via test_func()
SuccessMessageMixin success_message on a successful form
FormMixin form_class, get_form_kwargs(), form_valid/invalid
SingleObjectMixin get_object(), slug_field, query_pairs
MultipleObjectMixin queryset, paginate_by, ordering
PrefetchRelatedMixin prefetch_related as an attribute

The methods you will override, and what they are for:

def get_queryset(self):        # which rows, with optimisations applied
def get_object(self):          # which row, after a permission-safe lookup
def get_context_data(self, **kw):   # extra template variables
def get_form_kwargs(self):     # inject request/user into the form
def form_valid(self, form):    # set server-side fields, then save
def get_success_url(self):     # where to go after saving (default: get_absolute_url)
def get_template_names(self):  # full page or fragment (HTMX-friendly)
def get_paginate_by(self, qs): # per-page from a query parameter

Middleware

Middleware is a hook around every request and response. Order matters, and each one is a small, testable class.

# core/middleware.py
import time
import logging

logger = logging.getLogger("crm.timing")

class RequestTimingMiddleware:
    """Log slow requests, so that 'the CRM is slow' becomes a specific route."""

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        started = time.perf_counter()
        response = self.get_response(request)
        elapsed_ms = (time.perf_counter() - started) * 1000
        if elapsed_ms > 800:
            logger.warning("slow request", extra={
                "path": request.path, "method": request.method, "ms": round(elapsed_ms),
                "user": getattr(request.user, "id", None), "status": response.status_code,
            })
        response.headers["Server-Timing"] = f"app;dur={elapsed_ms:.0f}"
        return response

class CurrentUserMiddleware:
    """Thread-local current user — for audit trails where passing it is impractical."""

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        _thread_locals.user = getattr(request, "user", None)
        try:
            return self.get_response(request)
        finally:
            _thread_locals.user = None
Hook Called Typical use
__init__(get_response) once, at startup validate configuration, raise on misconfiguration
__call__(request) → response every request the modern style; anything you need
process_view(request, view, args, kwargs) after URL resolution, before the view feature flags, logging with the view name
process_exception(request, exception) when a view raises custom error pages, extra logging
process_template_response(request, response) when the response has a render() inject extra template context

Reusable middleware shipped with Django, in the order they normally appear:

Middleware Job
SecurityMiddleware HTTPS redirect, HSTS, content-type nosniff, referrer policy
WhiteNoiseMiddleware static files in production (or use Nginx)
SessionMiddleware load/save the session
LocaleMiddleware parse Accept-Language / URL prefix for i18n
CommonMiddleware APPEND_SLASH, PREPEND_WWW, DISALLOWED_USER_AGENTS
CsrfViewMiddleware CSRF protection on unsafe methods
AuthenticationMiddleware attach request.user (lazily — no query unless used)
MessageMiddleware flash messages
XFrameOptionsMiddleware clickjacking protection

[!WARNING] An N+1 in middleware is paid on every request. Keep middleware to headers, logging and policy; put data work in the view where a query or a cache is visible and testable.


Signals

Signals let a sender notify receivers without knowing about them. They are powerful and overused: most things people use signals for are better as a service call or a model method.

# contacts/signals.py
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver

from .models import Contact

@receiver(post_save, sender=Contact)
def queue_welcome_email(sender, instance, created, **kwargs):
    if created and instance.email:
        # import inside the function: avoids a circular import at app load
        from .tasks import send_welcome_email
        from django.db import transaction
        transaction.on_commit(lambda: send_welcome_email.delay(instance.pk))
# contacts/apps.py — connect them exactly once
class ContactsConfig(AppConfig):
    name = "contacts"

    def ready(self):
        from . import signals    # noqa: F401
Signal Fires Common use
pre_save / post_save around every save() audit trail, derived fields, cache invalidation
pre_delete / post_delete around every delete() cleanup of external files
m2m_changed tag/relation changes cache invalidation
user_logged_in / user_logged_out auth events activity logging
request_started / request_finished per request metrics

When not to use a signal

You want to… Do this instead
Send an email when a deal is won call services.close_deal() which sends it — visible in the code path
Prevent an invalid save clean() + Meta.constraints
Set a default owner the view, the form, or Model.save()
Update related rows a service function inside transaction.atomic()
Know who did something pass the user explicitly, or use django-simple-history

Signals are right when the sender genuinely should not know about the receiver — a third-party app, several unrelated reactions, or a plugin point. They are wrong when they hide a required step: a post_save that emails the customer makes every data import send email, and nothing in the calling code tells you that.

[!WARNING] QuerySet.update(), bulk_create() and bulk_update() do not fire pre_save/post_save. Code that relies on a signal and then gets a bulk path is a silent bug — which is exactly what the import task below must not do.


The REST API with DRF

An internal CRM eventually needs an API: a mobile app, a partner integration, or a JavaScript widget. Django REST Framework is the standard answer, and it maps cleanly onto what you already know — serializers play the role of forms, viewsets the role of class-based views.

pip install djangorestframework django-filter
# settings.py
INSTALLED_APPS += ["rest_framework", "django_filters"]

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.SessionAuthentication",   # the web app
        "rest_framework.authentication.TokenAuthentication",     # mobile / integrations
    ],
    "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_CLASSES": ["rest_framework.throttling.UserRateThrottle"],
    "DEFAULT_THROTTLE_RATES": {"user": "1000/day"},
}

Serializers

# contacts/serializers.py
from rest_framework import serializers

from accounts.models import Account
from .models import Contact, Tag

class TagSerializer(serializers.ModelSerializer):
    class Meta:
        model = Tag
        fields = ["id", "name", "colour"]

class ContactSerializer(serializers.ModelSerializer):
    account_name = serializers.CharField(source="account.name", read_only=True)
    owner_name = serializers.CharField(source="owner.get_full_name", read_only=True)
    tags = TagSerializer(many=True, read_only=True)
    tag_ids = serializers.PrimaryKeyRelatedField(
        many=True, write_only=True, queryset=Tag.objects.all(), source="tags",
    )
    full_name = serializers.SerializerMethodField()

    class Meta:
        model = Contact
        fields = ["id", "first_name", "last_name", "full_name", "email", "phone",
                  "account", "account_name", "owner", "owner_name",
                  "tags", "tag_ids", "is_active", "created_at"]
        read_only_fields = ["id", "created_at"]

    def get_full_name(self, obj):
        return f"{obj.first_name} {obj.last_name}"

    def validate_email(self, value):
        value = value.lower().strip()
        qs = Contact.objects.filter(email__iexact=value)
        if self.instance:
            qs = qs.exclude(pk=self.instance.pk)
        if qs.exists():
            raise serializers.ValidationError("A contact with this email already exists.")
        return value

    def validate(self, attrs):
        if attrs.get("is_active") is False and not self.instance:
            raise serializers.ValidationError({"is_active": "New contacts must start active."})
        return attrs
Serializer feature Equivalent form concept
serializers.CharField(source=...) a renamed field
fields / read_only_fields / write_only which fields go in, which come out
validate_<field>() clean_<field>()
validate() clean() — cross-field rules
SerializerMethodField a computed, output-only field
nested serializer (many=True) a formset, read side
PrimaryKeyRelatedField(write_only=True, source=...) an FK input without echoing the object
serializers.Serializer (plain) an action endpoint's input contract

Viewsets and routers

# contacts/api.py
from rest_framework import filters, permissions, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response

from .models import Contact
from .serializers import ContactSerializer

class ContactViewSet(viewsets.ModelViewSet):
    serializer_class = ContactSerializer
    permission_classes = [permissions.IsAuthenticated]
    filter_backends = [filters.SearchFilter, filters.OrderingFilter]
    search_fields = ["first_name", "last_name", "email", "account__name"]
    ordering_fields = ["last_name", "created_at", "account__name"]
    ordering = ["last_name"]

    def get_queryset(self):
        # select_related/prefetch_related belong in the API too — this is where N+1 hides
        qs = (Contact.objects
              .select_related("account", "owner")
              .prefetch_related("tags")
              .visible_to(self.request.user))
        if account := self.request.query_params.get("account"):
            qs = qs.filter(account_id=account)
        if tag := self.request.query_params.get("tag"):
            qs = qs.filter(tags__slug=tag)
        return qs

    def perform_create(self, serializer):
        serializer.save(owner=self.request.user)      # the owner is never client-supplied

    @action(detail=True, methods=["post"], url_path="assign")
    def assign(self, request, pk=None):
        contact = self.get_object()
        owner_id = request.data.get("owner")
        contact.owner_id = owner_id or request.user.id
        contact.save(update_fields=["owner"])
        return Response(ContactSerializer(contact).data)

    @action(detail=False, methods=["get"], url_path="export")
    def export(self, request):
        from .tasks import export_contacts
        task = export_contacts.delay(user_id=request.user.id)
        return Response({"task_id": task.id}, status=202)
# api/urls.py
from django.urls import include, path
from rest_framework.routers import DefaultRouter

from accounts.api import AccountViewSet
from activities.api import ActivityViewSet
from contacts.api import ContactViewSet
from deals.api import DealViewSet

router = DefaultRouter()
router.register("accounts", AccountViewSet, basename="account")
router.register("contacts", ContactViewSet, basename="contact")
router.register("deals", DealViewSet, basename="deal")
router.register("activities", ActivityViewSet, basename="activity")

urlpatterns = [
    path("", include(router.urls)),
    path("auth/", include("rest_framework.urls")),
    path("whoami/", WhoAmIView.as_view()),
]

A ModelViewSet gives you list, create, retrieve, update, partial_update, destroy — six endpoints from one class, with the same permission and queryset rules you already wrote.

Viewset Endpoints
ViewSet none by default; you declare every action
GenericViewSet + mixins only the mixins you list (ListModelMixin, CreateModelMixin, …)
ReadOnlyModelViewSet list + retrieve only
ModelViewSet the full CRUD set
@action(detail=True/False) one extra endpoint, at a URL you name

Pagination, filtering, errors and versioning

class DealPagination(PageNumberPagination):
    page_size = 50
    page_size_query_param = "page_size"
    max_page_size = 200

class DealFilter(django_filters.FilterSet):
    stage = django_filters.CharFilter(field_name="stage__slug")
    owner = django_filters.CharFilter(method="filter_owner")
    expected_close_after = django_filters.DateFilter(field_name="expected_close", lookup_expr="gte")
    min_amount = django_filters.NumberFilter(field_name="amount", lookup_expr="gte")

    class Meta:
        model = Deal
        fields = ["stage", "currency", "is_active"]

    def filter_owner(self, queryset, name, value):
        return queryset.filter(owner=self.request.user if value == "me" else value)
GET /api/v1/deals/?stage=proposal&min_amount=5000&ordering=-amount&page=2
→ 200 {
    "count": 143, "next": "…?page=3", "previous": "…?page=1",
    "results": [ { "id": 42, "title": "Acme renewal", "amount": "12000.00", … } ]
  }
Concern Tool
Wrong input serializer validation → 400 with a field-keyed body
Not authenticated 401 (or 403 for session auth), from the authentication class
Not permitted 403, from permission_classes / has_object_permission
Missing object 404 — return it for objects the user may not see, so existence does not leak
Throttled 429, from throttle classes
Server error 500, logged; DRF deliberately does not leak the traceback
Versioning keep it in the URL (/api/v1/) — it is explicit and cacheable

[!TIP] Version in the path. URLPathVersioning with /api/v1/ is the least surprising choice for an internal CRM, and it lets you keep v1 alive while v2 stabilises. Do not version until you have a consumer you cannot redeploy.


Background Tasks, Caching and Async

Anything slow leaves the request

Email, CSV import, PDF generation, external API calls, nightly recomputation: if it takes more than a few hundred milliseconds, it belongs in a task.

# contacts/tasks.py
from celery import shared_task
import csv, io
from django.core.mail import send_mail

@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def import_contacts(self, csv_text: str, user_id: int):
    from django.contrib.auth import get_user_model
    from accounts.models import Account
    from .models import Contact

    user = get_user_model().objects.get(pk=user_id)
    created, skipped, errors = 0, 0, []

    reader = csv.DictReader(io.StringIO(csv_text))
    rows = list(reader)
    # validate first, write once — a half-imported file is worse than a failed one
    prepared = []
    for i, row in enumerate(rows, start=2):
        try:
            account = Account.objects.get(name=row["account"])
        except Account.DoesNotExist:
            errors.append(f"line {i}: unknown account {row['account']!r}")
            continue
        prepared.append(Contact(first_name=row["first_name"], last_name=row["last_name"],
                                email=row["email"].lower(), account=account, owner=user))

    Contact.objects.bulk_create(prepared, ignore_conflicts=True, batch_size=500)
    created = len(prepared)

    send_mail(
        subject=f"Import finished: {created} contacts",
        message="\n".join(errors[:50]) or "No errors.",
        from_email=None, recipient_list=[user.email],
    )
    return {"created": created, "skipped": skipped, "errors": errors[:50]}
# config/celery.py
import os
from celery import Celery

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.prod")
app = Celery("crm")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
# settings.py
CELERY_BROKER_URL = env("REDIS_URL")
CELERY_RESULT_BACKEND = env("REDIS_URL")
CELERY_TASK_ALWAYS_EAGER = DEBUG      # run inline in dev; never in production
CELERY_TASK_TIME_LIMIT = 600
CELERY_BEAT_SCHEDULE = {
    "recompute-pipeline-totals": {"task": "deals.tasks.recompute_totals", "schedule": 900},
    "stale-deal-reminders": {"task": "deals.tasks.remind_stale", "schedule": crontab(hour=7, minute=30)},
}
Rule Why
Pass ids, not model instances an instance serialised today may be gone when the worker runs
Make tasks idempotent retries happen; if already_done(): return
One write per phase validate everything, then bulk_create / one transaction
Report failure by email/notification a silent failed import is worse than a crash
Keep them short a task holding a transaction for minutes blocks everyone
transaction.on_commit to enqueue never queue work for data that has not committed

Caching

# settings.py
CACHES = {"default": {
    "BACKEND": "django.core.cache.backends.redis.RedisCache",
    "LOCATION": env("REDIS_URL"),
    "KEY_PREFIX": "crm",
}}
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
from django.core.cache import cache
from django.views.decorators.cache import cache_page

# 1. per-view cache for a manager-only, non-personalised report
@cache_page(60 * 5)
def team_report(request):
    ...

# 2. low-level cache for an expensive computation
def pipeline_totals():
    key = "deals:pipeline_totals"
    if (cached := cache.get(key)) is not None:
        return cached
    totals = list(Deal.objects.values("stage__name").annotate(total=Sum("amount")))
    cache.set(key, totals, timeout=300)
    return totals

# 3. invalidate where the data changes — in the service, not in a signal
def move_deal(deal, stage, *, user):
    deal.stage = stage
    deal.save(update_fields=["stage", "updated_at"])
    cache.delete("deals:pipeline_totals")
{% load cache %}
{% cache 600 nav_counts request.user.id %}      {# the key must carry the varying part #}
  … per-user navigation counts …
{% endcache %}
Level Reach for it when Watch out
Template fragment a heavy block repeated on many pages the cache key must include the user/site
Per-view a full page identical for all users not for personalised pages
Low-level cache.get/set an expensive query or external call invalidation is your job now
Database query cache never — that is what the cache layer is for
Django's cached_property per-instance, per-request stale after a write within the same request

[!WARNING] A cache you do not invalidate is a bug with a delay. Decide, for every cached value, exactly which write invalidates it — and put that cache.delete() in the service function that performs the write.

Async views and ASGI

# an async view, for I/O concurrency rather than CPU: several HTTP calls at once
async def customer_360(request, pk):
    import httpx
    async with httpx.AsyncClient(timeout=5) as client:
        account, invoices, tickets = await asyncio.gather(
            client.get(f"{API}/accounts/{pk}"),
            client.get(f"{API}/invoices/?account={pk}"),
            client.get(f"{API}/tickets/?account={pk}"),
        )
    return JsonResponse({"account": account.json(), "invoices": invoices.json(),
                         "tickets": tickets.json()})

[!IMPORTANT] Django's ORM is synchronous. In an async view, wrap ORM calls with sync_to_async() (or use the async ORM methods added in recent versions) — calling a plain queryset from async code either raises SynchronousOnlyOperation or blocks the event loop. Async Django pays off for I/O fan-out, not for making the ORM faster.


Internationalisation

Django's i18n is built in and worth configuring early — especially for a CRM used across French- and English-speaking teams.

# settings.py
USE_I18N = True
LANGUAGE_CODE = "en"
LANGUAGES = [("en", "English"), ("fr", "Français")]
LOCALE_PATHS = [BASE_DIR / "locale"]
USE_L10N = True                      # default in modern Django; formats follow the locale
django-admin makemessages -l fr         # extract strings into locale/fr/LC_MESSAGES/
django-admin compilemessages            # compile .po → .mo (run on deploy)
# mark strings for translation
from django.utils.translation import gettext_lazy as _

class Deal(models.Model):
    class Meta:
        verbose_name = _("deal")
        verbose_name_plural = _("deals")

class DealForm(forms.ModelForm):
    class Meta:
        labels = {"amount": _("Amount (excluding tax)")}
# model fields whose content is per-locale (django-modeltranslation, or explicit fields)
class Account(models.Model):
    name = models.CharField(max_length=200)
    name_fr = models.CharField(max_length=200, blank=True)
{% load i18n %}
{% get_current_language as LANGUAGE_CODE %}
<html lang="{{ LANGUAGE_CODE }}">

{% blocktranslate with name=user.first_name %}Welcome back, {{ name }}{% endblocktranslate %}
<a href="{% url 'set_language' %}?language=fr">Français</a>
Piece Where
Translatable strings in code _("…") inside Python, {% translate %} in templates
Translations locale/<lang>/LC_MESSAGES/django.po
Language in the URL i18n_patterns(...) in the root URLconf, so /fr/deals/ and /en/deals/ coexist
Language in a session LocaleMiddleware + django.views.i18n.set_language
Dates and numbers {{ value\|date:"SHORT_DATE_FORMAT" }}, locale-aware floatformat
Time zones store UTC (USE_TZ=True), convert per user with timezone.localtime()
# urls.py — URL-prefixed languages, the SEO- and share-friendly option
from django.conf.urls.i18n import i18n_patterns

urlpatterns = [
    path("api/v1/", include("api.urls")),          # API is not prefixed
]
urlpatterns += i18n_patterns(
    path("deals/", include("deals.urls")),
    prefix_default_language=False,                 # /deals/ and /fr/deals/
)

[!TIP] Mark strings as you write them. Retrofitting i18n into a finished codebase means a hunt through every template and every form label — and the strings you miss are the ones users notice.


Security

Most of Django's security is on by default. What remains is a short list of decisions only you can make.

python manage.py check --deploy        # run this in CI: it fails on the common mistakes

The defaults you get for free

Threat Django's protection
SQL injection parameterised queries from the ORM; raw SQL only via cursor.execute(sql, params)
XSS automatic escaping in templates; \|safe is explicit and reviewable
CSRF CsrfViewMiddleware + {% csrf_token %} on every POST
Clickjacking XFrameOptionsMiddleware (X_FRAME_OPTIONS = "DENY")
Password storage PBKDF2 hashing, upgradeable, never reversible
Session hijacking signed session cookie, HttpOnly, Secure when DEBUG=False
Host header attacks ALLOWED_HOSTS is enforced
Open redirects redirect() to a user-supplied URL is the one place you must validate

Your checklist

Item Setting / action
Secret key in the environment SECRET_KEY = env("DJANGO_SECRET_KEY"), rotate on leak, never in git
Debug off in production DEBUG = False — a traceback page leaks settings, paths and SQL
HTTPS everywhere SECURE_SSL_REDIRECT, SESSION_COOKIE_SECURE, CSRF_COOKIE_SECURE
HSTS SECURE_HSTS_SECONDS = 31536000, INCLUDE_SUBDOMAINS, PRELOAD
Trusted origins CSRF_TRUSTED_ORIGINS for each real hostname
Uploads validated check content type and size in the form; store outside the code directory; never serve user uploads from MEDIA_ROOT by name without a permission check
Permissions on every write permission_required/PermissionRequiredMixin + a user-filtered get_queryset()
Sensitive data in logs never log passwords, tokens or full card numbers; scrub request.POST
Dependencies patched pip-audit in CI; Django security releases are worth immediate upgrades
Backups tested a backup you have never restored is not a backup
Admin protected change its URL, require staff, put it behind IP allow-list or SSO
Rate limiting throttle login and API endpoints
Error reporting Sentry (or similar) with send_default_pii=False
# password validators — the default four are good; do not lower them
AUTH_PASSWORD_VALIDATORS = [
    {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
    {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
     "OPTIONS": {"min_length": 12}},
    {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
    {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]

[!WARNING] DEBUG = True in production is the single worst Django misconfiguration: the yellow error page shows settings, environment variables, the URL patterns, and the SQL of the failing query. python manage.py check --deploy exists to catch it — put it in your pipeline.


Performance

Performance work in Django is mostly three things: query count, query cost, and caching. In that order.

# 1. Query count — the biggest win, and the easiest to verify
Deal.objects.select_related("account", "contact", "stage", "owner").prefetch_related("activities")

# 2. Query cost — index what you filter and sort on
class Deal(models.Model):
    class Meta:
        indexes = [
            models.Index(fields=["stage", "expected_close"], name="deal_stage_close_idx"),
            models.Index(fields=["owner", "-created_at"], name="deal_owner_recent_idx"),
        ]

# 3. Do less work — select only what the page shows
Contact.objects.only("id", "first_name", "last_name", "email").values_list("id", flat=True)

# 4. Let the database count
Account.objects.annotate(n=Count("deals")).filter(n__gt=5)

# 5. Paginate everything, always
page = Paginator(qs, 25).get_page(request.GET.get("page"))

# 6. Stream big exports instead of building a list in memory
def export_csv(request):
    response = StreamingHttpResponse(rows(), content_type="text/csv")
    response["Content-Disposition"] = 'attachment; filename="contacts.csv"'
    return response

def rows():
    yield "first_name,last_name,email\n"
    for c in Contact.objects.values_list("first_name", "last_name", "email").iterator(chunk_size=2000):
        yield ",".join(c) + "\n"
Symptom Likely cause Fix
Page time grows with row count N+1 select_related / prefetch_related
One page runs 300 queries template touching relations same, plus list_select_related in the admin
A list page is slow but the query is fast too many rows fetched paginate; only()/defer(); values_list
A report query takes seconds missing index or Python-side aggregation annotate/aggregate, index the filter columns
Everything is slow under load too few workers, or no connection reuse Gunicorn workers = 2×cores+1; CONN_MAX_AGE; PgBouncer
Writes block reads long transactions keep transactions short; select_for_update on the fewest rows
# database connection reuse, and a health check that tells you the truth
DATABASES["default"]["CONN_MAX_AGE"] = 60
DATABASES["default"]["CONN_HEALTH_CHECKS"] = True

def health(request):
    from django.db import connection
    with connection.cursor() as cur:
        cur.execute("SELECT 1")
        cur.fetchone()
    cache.set("health", "ok", 10)
    return JsonResponse({"db": "ok", "cache": "ok"})

[!TIP] Install django-debug-toolbar in development and check the query panel on every page you build. A list view that runs 4 queries stays fast for years; one that runs 400 does not — and you will not notice until a customer has 10,000 rows.


Testing

Django's test runner creates a fresh database per run and wraps each test in a transaction, so tests are isolated by default.

# contacts/tests/test_views.py
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse

from accounts.models import Account
from contacts.models import Contact

class ContactListViewTests(TestCase):
    @classmethod
    def setUpTestData(cls):                    # runs once per class, not per test
        cls.user = get_user_model().objects.create_user(
            email="rep@acme.com", password="x", role="rep")
        cls.account = Account.objects.create(name="Acme", owner=cls.user)
        cls.contacts = [
            Contact.objects.create(first_name="Ann", last_name="Roy", email=f"a{i}@acme.com",
                                   account=cls.account, owner=cls.user)
            for i in range(30)
        ]

    def setUp(self):
        self.client.force_login(self.user)

    def test_requires_login(self):
        self.client.logout()
        response = self.client.get(reverse("contacts:list"))
        self.assertEqual(response.status_code, 302)
        self.assertIn("/login/", response["Location"])

    def test_paginates_25_per_page(self):
        response = self.client.get(reverse("contacts:list"))
        self.assertEqual(response.status_code, 200)
        self.assertEqual(len(response.context["contacts"]), 25)
        self.assertEqual(response.context["page"].paginator.count, 30)

    def test_search_filters_by_email(self):
        response = self.client.get(reverse("contacts:list"), {"search": "a7@"})
        self.assertEqual([c.email for c in response.context["contacts"]], ["a7@acme.com"])

    def test_query_count_does_not_regress(self):
        with self.assertNumQueries(3):          # session + count + page — pinned on purpose
            self.client.get(reverse("contacts:list"))

    def test_a_rep_cannot_see_another_reps_contacts(self):
        other = get_user_model().objects.create_user(email="other@acme.com", password="x")
        Contact.objects.create(first_name="Hidden", last_name="Person", email="h@acme.com",
                               account=self.account, owner=other)
        response = self.client.get(reverse("contacts:list"), {"search": "Hidden"})
        self.assertEqual(response.context["page"].paginator.count, 0)
# services and models: the cheapest, most valuable tests — no HTTP, no templates
class ConvertLeadTests(TestCase):
    def setUp(self):
        self.user = get_user_model().objects.create_user(email="m@acme.com", password="x")
        self.lead = Lead.objects.create(company_name="Acme", contact_name="Ann Roy",
                                        email="ann@acme.com", source="web")

    def test_conversion_creates_account_contact_and_deal(self):
        contact = services.convert_lead(self.lead, user=self.user)
        self.lead.refresh_from_db()
        self.assertEqual(self.lead.status, Lead.Status.CONVERTED)
        self.assertEqual(contact.account.name, "Acme")
        self.assertTrue(Deal.objects.filter(contact=contact).exists())

    def test_conversion_is_atomic_when_a_step_fails(self):
        with mock.patch("deals.services.create_initial_deal", side_effect=RuntimeError):
            with self.assertRaises(RuntimeError):
                services.convert_lead(self.lead, user=self.user)
        self.assertFalse(Account.objects.filter(name="Acme").exists())   # rolled back
# forms: pure functions — no database needed for the rules themselves
class DealFormTests(TestCase):
    def test_won_deal_requires_positive_amount(self):
        form = DealForm(data={"title": "X", "stage": won_stage.id, "amount": 0})
        self.assertFalse(form.is_valid())
        self.assertIn("amount", form.errors)
Test type Tool Use for
Model / service TestCase business rules, atomicity, derived values
Form TestCase validation, cleaning, constraints
View (server-rendered) django.test.Client status codes, context, redirects, permissions
Template rendering assertContains, assertTemplateUsed the right fragment appears
Query count assertNumQueries lock in performance
API DRF APIClient / APITestCase serialisation, status codes, permissions
Browser-level Playwright / Selenium a handful of critical end-to-end flows only

There is a real speed difference: setUpTestData runs once per class, setUp per test. New tests slow down a suite more than slow code does, so share fixtures and avoid --keepdb as a crutch.

python manage.py test                                   # the default runner
pytest                                                  # pytest-django, if you prefer fixtures
coverage run manage.py test && coverage report --fail-under=80

Management Commands

A management command is a script with Django bootstrapped: settings loaded, ORM available, and --flags parsed for you. Every recurring operation in a CRM should be one.

# contacts/management/commands/prune_contacts.py
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from datetime import timedelta

from contacts.models import Contact

class Command(BaseCommand):
    help = "Archive contacts with no activity for N days (dry-run by default)."

    def add_arguments(self, parser):
        parser.add_argument("--days", type=int, default=365)
        parser.add_argument("--apply", action="store_true", help="actually write changes")
        parser.add_argument("--account", help="limit to one account name")

    def handle(self, *args, **options):
        cutoff = timezone.now() - timedelta(days=options["days"])
        qs = Contact.objects.filter(is_active=True, activities__occurred_at__lt=cutoff)
        if options["account"]:
            qs = qs.filter(account__name=options["account"])
        qs = qs.distinct()

        count = qs.count()
        if not options["apply"]:
            self.stdout.write(self.style.WARNING(f"DRY RUN: {count} contacts would be archived"))
            return

        updated = qs.update(is_active=False)
        self.stdout.write(self.style.SUCCESS(f"Archived {updated} contacts"))
python manage.py prune_contacts --days 365              # preview
python manage.py prune_contacts --days 365 --apply      # do it
Command What it is for
shell_plus (django-extensions) a shell with every model already imported
dumpdata / loaddata fixtures and small data exports
dbshell a psql prompt using your settings
collectstatic gather static files for deployment
clearsessions prune expired sessions (schedule it)
check --deploy the security checklist
showmigrations / sqlmigrate migration inspection
# cron / systemd timers: scheduled work outside Celery
0 3 * * *  cd /srv/crm && .venv/bin/python manage.py prune_contacts --days 365 --apply
30 2 * * * cd /srv/crm && .venv/bin/python manage.py clearsessions

[!TIP] Write the destructive path as a dry run that prints what it would do, and gate the write behind --apply. It is the difference between a maintenance command and an incident.


Deployment

# production: a release is four steps, in this order
git pull                       # 1. new code (no process restart yet)
pip install -r requirements.txt
python manage.py migrate       # 2. schema first
python manage.py collectstatic --noinput
supervisorctl restart crm      # 3. then the new code starts serving
# Gunicorn behind Nginx
gunicorn config.wsgi:application \
  --bind 127.0.0.1:8001 \
  --workers 5 --threads 2 \
  --timeout 60 --graceful-timeout 30 \
  --access-logfile - --error-logfile -
server {
    listen 443 ssl http2;
    server_name crm.example.com;

    ssl_certificate     /etc/letsencrypt/live/crm.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/crm.example.com/privkey.pem;

    client_max_body_size 20M;

    location /static/ { alias /srv/crm/staticfiles/; expires 30d; }
    location /media/  { alias /srv/crm/media/;      expires 7d;  }

    location / {
        proxy_pass http://127.0.0.1:8001;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
# docker-compose.yml — the whole CRM in one file
services:
  db:
    image: postgres:16
    environment: { POSTGRES_DB: crm, POSTGRES_USER: crm, POSTGRES_PASSWORD: secret }
    volumes: [ "pgdata:/var/lib/postgresql/data" ]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U crm"]
      interval: 10s

  redis:
    image: redis:7-alpine
    healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 10s }

  web:
    build: .
    command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 5
    env_file: .env
    depends_on:
      db: { condition: service_healthy }
      redis: { condition: service_healthy }
    volumes: [ "static:/app/staticfiles", "media:/app/media" ]

  worker:
    build: .
    command: celery -A config worker -l info
    env_file: .env
    depends_on: [ redis, db ]

  beat:
    build: .
    command: celery -A config beat -l info
    env_file: .env
    depends_on: [ redis ]

volumes: { pgdata: {}, static: {}, media: {} }
Concern Answer
Migrations on deploy run migrate before the new code serves; make them backwards-compatible
Static files collectstatic + Nginx (or WhiteNoise); hashed names give cache-busting for free
Media files object storage (S3-compatible) — never the container filesystem
Secrets environment variables injected by the platform, or a secrets manager
Health checks a /health/ view that checks the database and the cache
Logs structured to stdout; the platform collects them
Errors Sentry with send_default_pii=False
Rollback keep the previous release; migrations must be reversible or additive
Backups pg_dump on a schedule, and a restore you have actually tested

Project Structure

Where to put what: the file for every job in a Django CRM

Click the diagram to open it at full resolution.

# deals/services.py — the layer that keeps views thin and rules testable
from django.db import transaction
from django.utils import timezone

from .models import Deal, PipelineStage

def move_deal_to_stage(deal: Deal, stage: PipelineStage, *, user) -> Deal:
    """Business action. Callable from a view, a task, the admin or a command."""
    with transaction.atomic():
        old_stage = deal.stage
        deal.stage = stage
        if stage.is_won:
            deal.closed_at = timezone.now()
        deal.save(update_fields=["stage", "closed_at", "updated_at"])

        Activity.objects.create(
            deal=deal, kind=Activity.Kind.NOTE, owner=user,
            subject=f"Stage changed: {old_stage.name}{stage.name}",
        )

    cache.delete("deals:pipeline_totals")
    transaction.on_commit(lambda: notify_deal_owner.delay(deal.pk, old_stage.slug, stage.slug))
    return deal
# deals/views.py — and the view becomes four readable lines
class DealStageUpdateView(LoginRequiredMixin, PermissionRequiredMixin, View):
    permission_required = "deals.change_deal"

    def post(self, request, pk):
        deal = get_object_or_404(Deal.objects.visible_to(request.user), pk=pk)
        stage = get_object_or_404(PipelineStage, slug=request.POST.get("stage"))
        services.move_deal_to_stage(deal, stage, user=request.user)
        messages.success(request, f"Moved to {stage.name}.")
        return redirect(deal.get_absolute_url())
# deals/selectors.py — read-side queries, named and reusable
def pipeline_for(user):
    """The pipeline board's data, in two queries."""
    deals = (Deal.objects.open()
             .select_related("account", "contact", "stage", "owner")
             .order_by("stage__order", "-amount"))
    if not user.has_perm("deals.view_all_deals"):
        deals = deals.filter(owner=user)
    return deals

def contact_timeline(contact):
    return (Activity.objects.filter(contact=contact)
            .select_related("owner", "deal")
            .order_by("-occurred_at")[:50])
Layer File Contains Must not contain
Model models.py fields, constraints, small derived properties, clean() HTTP, request, rendering
Query (read) selectors.py named, optimised, reusable querysets writes
Service (write) services.py one business operation, atomic(), side effects on commit HTTP, templates
Form / serializer forms.py, serializers.py validation and input shaping business rules that other callers need
View views.py permission, fetch, call a service, respond business rules, raw SQL, loops over querysets
Task tasks.py slow or scheduled work, idempotent logic that should be reusable synchronously

The pattern is not framework-mandated — Django does not ship a services.py — but it is the structure that keeps a CRM maintainable past its first year, and it is what makes the same rule reachable from a view, the API, a Celery task, the admin and a management command.


Anti-Patterns

Anti-pattern What goes wrong Do this instead
Business logic in the view untestable without HTTP; unreachable from tasks and the admin services.py, called by the view
Fat models with save() doing everything every write has surprising side effects, including imports explicit service functions
Queries in a loop N+1; the page slows as data grows select_related / prefetch_related / annotate
len(queryset) to count rows fetches every row .count() / .exists()
A template that calls methods on objects hidden queries per row, invisible in the view prepare the data in the view or a selector
if request.user.is_superuser scattered everywhere permissions drift and become unenforceable groups, model permissions, a user-filtered queryset
Trusting a hidden form field for owner/role privilege escalation set it server-side in form_valid/perform_create
Signals for required steps invisible control flow; skipped by bulk operations call the service explicitly
CharField(null=True) two kinds of empty in every query blank=True (and null=True only for dates/numbers)
Money as FloatField rounding errors in totals DecimalField
No Meta.ordering list order changes between requests set ordering, or order explicitly
Editing an applied migration environments silently diverge a new migration
DEBUG = True in production tracebacks leak settings, SQL and paths check --deploy in CI
select * on a wide model for a list page wasted I/O and memory only() / values_list()
|safe on user content stored XSS never; sanitise and render as text
One app called core with 40 models no domain boundaries, no reuse one app per domain area
Tests that hit the network or the clock flaky suite, slow feedback mock, freezegun, setUpTestData
No pagination on list views a 100k-row table kills the browser and the DB Paginator everywhere
Cache without invalidation stale data, intermittently decide the invalidating write and put it there
pip install without pinning unreproducible builds; a surprise upgrade at deploy lock file, pip-audit in CI

Cheat Sheet

manage.py

python manage.py runserver 0.0.0.0:8000          # dev server
python manage.py makemigrations [app]            # models → migration files
python manage.py migrate [app] [migration]       # apply (or roll back to) migrations
python manage.py showmigrations                  # state of every migration
python manage.py sqlmigrate app 0003             # the SQL a migration will run
python manage.py shell                           # ORM shell
python manage.py dbshell                         # psql with your settings
python manage.py createsuperuser
python manage.py startapp <name>
python manage.py test [app.tests.ClassName.test] # run tests
python manage.py collectstatic --noinput         # gather static files
python manage.py check --deploy                  # security check
python manage.py shell -c "from deals.models import Deal; print(Deal.objects.count())"
python manage.py dumpdata deals --indent 2 > deals.json
python manage.py loaddata deals.json
python manage.py clearsessions                   # schedule this
python manage.py prune_contacts --days 365 --apply   # a custom command

ORM lookups and methods

Need Call
All rows Model.objects.all()
One row or 404 get_object_or_404(Model, pk=pk)
Create Model.objects.create(**kwargs)
Update many qs.update(field=value) (no signals, no save())
Delete many qs.delete()
Get or create Model.objects.get_or_create(defaults={...}, **lookup)
Update or create Model.objects.update_or_create(defaults={...}, **lookup)
Exists qs.exists()
Count qs.count()
First / last qs.first() / qs.last() — add Meta.ordering or they are arbitrary
Filter qs.filter(a=1), qs.exclude(b=2)
OR / NOT Q(a=1) \| Q(b=2), ~Q(c=3)
Column reference F("amount") + 1
Aggregate qs.aggregate(Sum("amount"), Count("id"))
Per-row value qs.annotate(total=Sum("deals__amount"))
FK follow select_related("account")
M2M / reverse prefetch_related("tags")
Distinct .distinct() (needed after filtering on a M2M)
Slice qs[:10], qs[10:20] (no negative indices)
Raw Model.objects.raw(sql, [params])

Model fields, quickly

CharField(max_length=200)                 TextField()
IntegerField()  PositiveIntegerField()    DecimalField(max_digits=12, decimal_places=2)
BooleanField(default=False)               SlugField(unique=True)
EmailField()  URLField()  FileField(upload_to=...)  ImageField()
DateField()  DateTimeField(auto_now_add=True)  DateTimeField(auto_now=True)
ForeignKey(Model, on_delete=PROTECT, related_name="…")
OneToOneField(Model, on_delete=CASCADE)   ManyToManyField(Model, blank=True)
JSONField()                               UUIDField(default=uuid4)
ChoiceField via models.TextChoices
class Meta:
    ordering = ["name"]
    unique_together = [("stage", "position")]           # or UniqueConstraint in constraints
    constraints = [CheckConstraint(condition=Q(quantity__gte=1), name="qty_positive")]
    indexes = [models.Index(fields=["stage", "-created_at"])]
    permissions = [("export_deals", "Can export deals")]
    verbose_name = "deal"

Views, decorators and mixins

Task FBV CBV
Require login @login_required LoginRequiredMixin
Require a permission @permission_required("app.perm") PermissionRequiredMixin
Require staff @staff_member_required UserPassesTestMixin
Require a method @require_POST View + def post()
Cache the response @cache_page(60) method_decorator(cache_page(60))
Vary on a header @vary_on_headers("HX-Request") same
CSRF-exempt (webhooks only) @csrf_exempt same — and verify the signature instead

Settings you will actually change

SECRET_KEY · DEBUG · ALLOWED_HOSTS · CSRF_TRUSTED_ORIGINS
INSTALLED_APPS · MIDDLEWARE · ROOT_URLCONF · TEMPLATES
DATABASES · CONN_MAX_AGE · CACHES · SESSION_ENGINE
AUTH_USER_MODEL · AUTH_PASSWORD_VALIDATORS · LOGIN_URL · LOGIN_REDIRECT_URL
LANGUAGE_CODE · LANGUAGES · TIME_ZONE · USE_I18N · USE_TZ
STATIC_URL · STATIC_ROOT · STATICFILES_DIRS · MEDIA_URL · MEDIA_ROOT · STORAGES
EMAIL_BACKEND · DEFAULT_FROM_EMAIL
SECURE_SSL_REDIRECT · SECURE_HSTS_SECONDS · SESSION_COOKIE_SECURE · CSRF_COOKIE_SECURE

The five rules that prevent most Django bugs

  1. Optimise queries deliberatelyselect_related for FKs, prefetch_related for M2M and reverse relations, and check the count with the debug toolbar.
  2. Put business rules in a service or on the model, never in a view body — so every caller, including tasks and the admin, obeys them.
  3. Validate with forms/serializers (including server-side fields like owner), and push what you can into Meta.constraints so the database enforces it.
  4. Migrations are code: commit them with the model change, review them, and never edit one that has been applied.
  5. Permissions are enforced server-side on every write, with a user-filtered queryset for reads — the template only decides what to show.

Further Reading