Imported from souliane/skills (
ac-django/SKILL.md) via skills.sh. Install upstream withnpx skills add souliane/skills --skill ac-django. Copyright stays with the author.
Django Bible (Django 6 · optional DRF)
Targets: Django 6.1 on Python 3.14 · Previous line: Django 5.2 LTS (trailing section) · API: DRF when you choose it
Version Policy
This skill documents one Django line: the current one. Keep it that way when you update it.
- Main path = the current feature release. Today that is Django 6.1. When a newer feature release lands, the main path moves to it in the same edit.
- Unmarked prose means the first release of the current line — Django 6.0.
Anything the line gained after that carries a
(6.1+)marker placed right after the API or feature it qualifies:`QuerySet.fetch_mode()` (6.1+). One form only. Do not write "Django X.Y+ adds", "new in X.Y", "as of X.Y", or a### Django X.Y noteheading — the marker is the whole convention, and it is only useful if it is the only one. - Older lines get one trailing section each, at the end of
SKILL.md, and nothing anywhere else — the reference files carry the current line only and get no trailing section of their own. Today that is## Previous line: Django 5.2 LTS. It carries the diff a reader on that version needs: what is missing, what to use instead, and what to change on upgrade. A version note interrupting the main path is a defect — move it down. - Research order when refreshing. Release notes and official docs first
(
docs.djangoproject.com/en/<version>/releases/<version>/and the topic pages they link). Then the authors already cited under Canonical Sources. Blogs and newsletters last, briefly, for what the docs do not cover. - Rotation. When Django releases the next line, the current trailing section
is replaced by the line being retired, unmarked prose is re-based on the new
line's first release, and markers below that release are dropped. Django 6.2 is
the last release under the
A.Bscheme; feature releases after it use a calendarYYYYformat, one every January. "Current line" then means the newestYYYYrelease, and the same rules apply unchanged.
Canonical Sources
- Django docs index: https://docs.djangoproject.com/en/6.1/
- Django 6.1 release notes: https://docs.djangoproject.com/en/6.1/releases/6.1/
- Django 6.0 release notes — what unmarked content in this skill assumes: https://docs.djangoproject.com/en/6.0/releases/6.0/
- Django docs — fetch modes: https://docs.djangoproject.com/en/6.1/topics/db/fetch-modes/
- Django docs — migrating email to mailers: https://docs.djangoproject.com/en/6.1/topics/email/#migrating-email-to-mailers
- Django docs — squashing migrations: https://docs.djangoproject.com/en/6.1/topics/migrations/#migration-squashing
- Adam Johnson —
django-upgrade: https://adamj.eu/tech/2021/09/16/introducing-django-upgrade/ - Adam Johnson —
django-linear-migrations: https://adamj.eu/tech/2020/12/10/introducing-django-linear-migrations/ - Adam Johnson — Test for pending migrations: https://adamj.eu/tech/2024/06/23/django-test-pending-migrations/
- Adam Johnson — Model field choices that can change without a migration: https://adamj.eu/tech/2025/05/03/django-choices-change-without-migration/
- Haki Benita — "How to Get Foreign Keys Horribly Wrong": https://hakibenita.com/django-foreign-keys
- Haki Benita — Reliable Django Signals (django-tasks-db production pattern): https://hakibenita.com/django-reliable-signals
- James Bennett — Fat Model / "no service layer" (default) + followup on breaking up god-methods: https://www.b-list.org/weblog/2020/mar/16/no-service/ · https://www.b-list.org/weblog/2020/mar/23/still-no-service/
- James Bennett — "Litestar is worth a look" (reaffirms no-service-layer specifically for Django, while allowing it for less-opinionated frameworks): https://www.b-list.org/weblog/2025/aug/06/litestar/
- DabApps — model encapsulation (never write a field /
save()from outside): https://www.dabapps.com/insights/django-models-and-encapsulation/ - HackSoft Django Styleguide — the service-layer /
selectors.pycamp (one option, not this skill's default): https://github.com/HackSoftware/Django-Styleguide - DRF docs: https://www.django-rest-framework.org/
- Factory Boy best practices: https://github.com/camilamaia/factory-boy-best-practices
Reference Files (load as needed)
| File | Covers | When to load |
|---|---|---|
references/models-and-schema.md |
Models, fields, constraints, QuerySets, managers, ORM performance | Model changes, query optimization, schema design |
references/transactions-and-migrations.md |
Transactions, locking, idempotency, migration safety, FK index ops | Multi-step writes, migrations, schema changes |
references/squashing-migrations.md |
Squashing an app's migrations: regenerate and keep replaces (default), squashmigrations, or a django_migrations rewrite without replaces; refusing part-way databases; rollback |
Shortening a long migration history that deployed databases already ran |
references/views-and-templates.md |
Views, forms, templates, partials, HTMX, file uploads, i18n, middleware, management commands, connection pooling, reference snippets | View/template work, form handling, HTMX, uploads |
references/background-and-infra.md |
Background tasks, security, settings, observability, caching, async | Tasks, deployment, logging, caching, async views |
references/admin-and-drf.md |
Django Admin, DRF serializers, viewsets, permissions, pagination, versioning | Admin customization, API endpoints |
references/testing-and-tooling.md |
Testing bible, Factory Boy, tooling, DX enforcement | Writing tests, CI setup, linting |
references/antipatterns.md |
django-antipatterns.com tips, patterns | Code review, avoiding common mistakes |
references/troubleshooting.md |
Common Django errors and fixes | Diagnosing migration, N+1, on_commit issues |
Dependencies
Requires ac-python (declared via the requires: frontmatter field). This skill layers Django-specific rules on top of ac-python's generic Python guidelines — see "Overrides When Loaded Alongside ac-python" below.
When used alongside lifecycle skills, provides Django best practices context for Django projects using the worktree workflow.
Overrides When Loaded Alongside ac-python
When both ac-django and ac-python are loaded, the following Django-specific rules take precedence over the generic Python guidelines:
| Topic | ac-python (generic) | ac-django (wins) |
|---|---|---|
| Test base class | Plain pytest classes (class TestFoo:) |
django.test.TestCase (or TransactionTestCase when needed) |
| Parametrization | pytest.mark.parametrize |
unittest_parametrize |
| Test data factories | build_...() plain functions |
Factory Boy with DjangoModelFactory, traits, build() / create() |
| Shared setup | pytest fixtures | setUpTestData() (class-level, faster for DB-backed tests) |
| Time mocking | any (freezegun, time_machine, etc.) |
time_machine (house default) |
All other ac-python guidelines (style, typing, OOP, imports, ruff config) apply unchanged in Django projects.
Trigger QA (Release Gate)
Before shipping skill changes, validate activation behavior with sample prompts:
- Should trigger:
- "Add a Django model field and migration."
- "Review this DRF serializer and queryset for performance issues."
- "Fix this Django transaction/on_commit bug."
- Should NOT trigger:
- "Set up git worktrees for a ticket."
- "Implement project delivery workflow and create an MR."
- "Create a Notion research summary."
If behavior under-triggers or over-triggers, tighten description cues before release.
Example: Adding a new model field
User says: "Add a postal_code field to the Address model"
- Load
references/models-and-schema.mdfor field types and constraints - Load
references/transactions-and-migrations.mdfor migration safety - Add field to model with appropriate validators, constraints, and
db_indexif queried - Create migration, verify with
makemigrations --check - Add factory trait for the new field in tests
Prime Directives
Django docs first (always)
- Use Django the way Django documents it.
- Prefer built-ins over dependencies unless Django has a clear documented gap.
Fat Model is the default (not the only answer)
Fat models win for small and medium models — the default, not a dogma.
- Default home for business rules and invariants:
- model instance methods (single-object behavior — Django's "row-level" home)
- QuerySet/Manager methods (collection behavior — Django's "table-level" home)
- This matches the Django docs and James Bennett's "no service layer" position: the models, with their managers/querysets, are the API other code talks to.
- No
services.pyfor domain logic by default.
Fat models stop being good once a model turns into a god object. A fat model is a model with rich, cohesive behavior over its own data; a god object is one model that has accreted unrelated concerns and orchestration until no one can read it end to end. See "When a fat model becomes a god object" below for the signals and the escalation ladder — the escapes are not a reflex service layer.
Locality of behavior (anti-octopus)
- Co-locate behavior with:
- the data (models/querysets)
- the UI (partials inside the template that uses them)
Coordination vs business logic
Allowed at boundaries (views/forms/serializers):
- sequencing calls
- selecting aggregate root objects
- transaction bracketing when spanning multiple domain calls
Not allowed at boundaries:
- invariants
- workflow rules
- state transitions that define correctness
Deprecation Watch (flagged in 6.1, removal in the next major)
Avoid writing new code against these:
- Flat
EMAIL_*settings (EMAIL_BACKEND,EMAIL_HOST,EMAIL_PORT, etc.) are deprecated in favor of aMAILERSdict setting (mirrorsDATABASES/CACHES); new code should not add to the flat settings. mail.get_connection(), and theconnection/fail_silently/auth_user/auth_passwordarguments to the mail helpers — passusing=with aMAILERSalias instead.QuerySet.select_related()called with no arguments, andModelAdmin.list_select_related = True— always pass explicit field names.QuerySet.values_list(flat=True)without an explicit field name.Model.from_db()implementations that do not accept thefetch_modekeyword argument.Noneas the right-hand side of an exact lookup on aJSONField, meaning the JSON scalarnull— query withJSONNull()instead. Querying only: storingNonestill writes SQLNULLand is unaffected, as are key and index lookups.- Double-dot template lookups (
{{ book..title }}), which resolve to a lookup of the empty string. django.db.transaction.savepoint()— usesavepoint_create().- The default HMAC algorithm for
salted_hmac()/base64_hmac()flips fromsha1tosha256— don't hardcodesha1expectations.
Django's 6.1 notes name Django 7.0 as the removal release. Django's release page
states that 6.2 is the last release under the A.B scheme and that feature
releases after it use a YYYY format. Upstream does not spell out how "7.0" maps
onto that scheme.
Project Layout & Boundaries
Domain-first apps
- Split apps by domain capability, not by technical layer.
- Each app should be readable end-to-end.
Public vs internal vs admin
- Separate public API/HTML surfaces from internal/admin surfaces.
- Prefer separate modules over one giant
views.py.
"core/" discipline
- Allowed: request-id middleware, logging helpers, settings checks, small shared base mixins
- Forbidden: hiding domain behavior "because it's shared"
Naming
- Use business language.
- Prefer verb methods:
approve(),reject(),settle(),cancel().
Team Style Conventions (ask once, remember)
Some style choices are equally valid — the "right" answer depends on the team. When you encounter one of these for the first time in a project, ask the user for their team's preference and save it to the project's MEMORY.md (auto-memory) so it persists across sessions.
| Topic | Option A | Option B | What to ask |
|---|---|---|---|
| Attribute/relation checks | LBYL: hasattr(), isinstance(), getattr(obj, attr, default) |
EAFP: try/except AttributeError |
"Does your team prefer LBYL (hasattr/getattr) or EAFP (try/except) for optional attribute and reverse-relation checks?" |
| Queryset existence checks | if qs.exists(): then qs.first() |
obj = qs.first(); if obj: |
"Single query (first() + None check) or explicit exists() + first()?" |
| Null handling in serializers | Explicit allow_null=True on every nullable field |
Rely on model field null=True inference |
"Explicit allow_null on serializer fields, or infer from model?" |
Rules:
- Only ask on the first occurrence in a project — never re-ask if already saved.
- If a project overlay skill already documents the preference, treat that as the answer — don't ask again.
- Save the answer as a
projecttype memory with a descriptive title (e.g., "Django team convention: LBYL for attribute checks").
Fat Model Doctrine (where logic lives)
Placement table (strict)
| Concern | Home | Notes |
|---|---|---|
| invariants, transitions, domain calculations | model methods | "tell, don't ask" |
| collection logic | QuerySet methods | chainable |
| graph loading | QuerySet methods | for_api(), for_list(), with_*() |
| request validation | forms / DRF serializers | boundary validation |
| authorization | views / DRF permissions | check early |
| rendering | templates / serializers | no DB access |
Encapsulate mutation
Expose domain methods that: validate state → perform mutation → persist changes → schedule side effects safely (after commit).
Encapsulation rule that keeps fat models honest (DabApps): never write to a model field or call save()/create()/bulk ops directly from outside the model — go through a model method or manager method. View/template code may read any field, but state changes always go through one of those methods. This is what makes "fat model" mean "the model owns its invariants", not "the model has lots of code".
When a fat model becomes a god object
Fat models are good for small/medium models. The rule does not scale to a model that has swallowed unrelated concerns. Treat the following as god-object signals — when two or more fire on one model, stop adding to it and climb the escalation ladder below:
- Size: model class body over ~200 LOC, or more than ~15-20 public methods.
- Mixed domains: methods on one model touch concerns that aren't that model's own data (e.g.
Orderwith billing-provider calls and shipping-label generation and loyalty-points math). - Orchestration in
save():save()(or a single method) drives a multi-step workflow across several other models / sends notifications / calls external APIs — the "cancelling a billing agreement updates a half-dozen other things in one method" smell. - Method count by responsibility: clusters of methods that obviously belong to different responsibilities (a
Userthat is also auth, also profile, also billing, also feature-flagging). - Tests scream: unit-testing one method forces you to set up half the schema, or a single test module for one model grows unmanageable.
A long model that is still entirely about its own data and reads cleanly is not a god object — don't refactor for LOC alone. The trigger is mixed concerns / orchestration, not size by itself.
Escalation ladder (climb only as far as a signal forces you)
Each rung has a WHEN. Take the lowest rung that fixes the signal — do not skip to a service layer.
-
(a) QuerySet / Manager methods. WHEN: the logic is collection- or table-level (filtering, aggregation, bulk state changes, alternate constructors like
Account.objects.create_trial()). This is the first place table-level logic goes — Django's own guidance. Most "the model is getting fat" pressure is really table-level logic that belongs here, not on the instance. -
(b) Cohesive model mixins or a bounded-context model split. WHEN: the signals are mixed domains / method-count-by-responsibility on a single model — the model is doing several cohesive jobs.
- First reach: extract each cohesive cluster into an abstract model mixin (
BillingMixin,AuditMixin) so each concern is its own readable unit while the table stays one row. Mixins must be genuinely cohesive — a mixin that is just "the rest of the methods" is the god object with extra files. - Bigger reach: if the concerns are really separate aggregates, split the model (and often the app) along the bounded context — e.g. pull
Subscription/Invoiceout of a godAccount. Splitting apps by domain capability is the real scaling move; see "Domain-first apps" above.
- First reach: extract each cohesive cluster into an abstract model mixin (
-
(c) Stateless processor functions (module-level), or a dedicated processor class. WHEN: the work orchestrates several models and has no natural single aggregate root to own it — the cross-aggregate /
save()-doing-orchestration signals. This is the "broke the over-complex method up" answer (Bennett's followup), not a relocation of the mess.- Default form: stateless module-level functions in the app (e.g.
app/operations.py/app/processors.py) that take the participating objects as arguments, do the sequencing inside onetransaction.atomic(), and schedule side effects viaon_commit. Functions over classes when there is no state to hold (Luke Plant). - Reach for a dedicated processor/handler class only when there genuinely is multi-step state to carry across the orchestration (a builder, a multi-phase import). Name it for the operation (
OrderCheckoutProcessor), keep it stateless-by-default, and keep the invariants on the models it drives — the processor sequences, the models still own correctness. - This rung is coordination, not a domain service layer: it calls model/manager methods, it does not re-implement the business rules that live on them.
- Default form: stateless module-level functions in the app (e.g.
-
(d) Service layer — last resort, external-API orchestration only. WHEN: the orchestration is dominated by talking to the outside world (Stripe, AWS, a third-party API) and the sequencing/error-handling/retry of those calls is the actual complexity. Then a
services.pyscoped to that external integration is fine.- Constraint:
services.pyholds no DB business logic / no invariants — those still live on the models. The service orchestrates external calls and hands results to model/manager methods. - This is deliberately the top of the ladder: a general "all business logic goes in
services.py" layer (the HackSoft / enterprise pattern) is a real, defensible style, but it is not this skill's default — it trades the model-as-API for a parallel layer, and for most apps the rungs above cover the need. Adopt a project-wide service layer only as an explicit, documented team decision, not as a default reflex when one model got fat.
- Constraint:
Companion reads (selectors.py). Independently of the write-side ladder, complex cross-model read operations (reporting/dashboards) that don't belong on any one QuerySet can live in selectors.py returning typed DTOs. Selectors are read-only; they never mutate.
Sources for this doctrine
- Django docs — Managers: row-level → Model methods, table-level → Manager/QuerySet methods: https://docs.djangoproject.com/en/6.1/topics/db/managers/#adding-extra-manager-methods
- James Bennett — "Against service layers in Django" + followup ("More on service layers"): https://www.b-list.org/weblog/2020/mar/16/no-service/ · https://www.b-list.org/weblog/2020/mar/23/still-no-service/
- James Bennett — "Litestar is worth a look" (reaffirms the no-service-layer position specifically for Django): https://www.b-list.org/weblog/2025/aug/06/litestar/
- DabApps — "Django models, encapsulation and data integrity" (never write a field /
save()from outside): https://www.dabapps.com/insights/django-models-and-encapsulation/ - Luke Plant — "Django Views — The Right Way" (functions over classes, anti-over-abstraction): https://spookylukey.github.io/django-views-the-right-way/
- HackSoft Django Styleguide — the service-layer /
selectors.pycamp (the rung-(d) style, presented as one option not the default): https://github.com/HackSoftware/Django-Styleguide - Carlton Gibson / Vinta — "beyond the Fat Models vs. service-layer binary" (module-level functions as the middle ground): https://www.vintasoftware.com/lessons-learned/djangoservicelayersbeyondfatmodelsvsenterprisepatterns
Review Checklists
Domain rules / Fat Model
- business rules live on models/querysets (the default home)
- boundaries only orchestrate
- no field writes /
save()/ bulk ops from outside model + manager methods - no god-object: a fat model touching mixed domains, or a
save()/method driving multi-model orchestration, has climbed the escalation ladder (queryset → mixin/split → processor function/class → service for external APIs only) rather than just growing
ORM performance
- no N+1
- queryset shaped for serializer/template needs
- constraints/indexes reviewed
Transactions and side effects
- multi-step write flows wrapped in
atomic() - side effects scheduled via
on_commit
Migrations
- linear migrations enforced (one leaf per app —
django-linear-migrationsor manual review) -
makemigrations --checkpasses - migration safe/reviewable
- a squash that deletes migration files keeps
replacesuntil every database has recorded it and refuses part-way databases beforemigrate(withoutreplaces: names its backup, row-count, schema-diff, rewrite and rollback plan) — seereferences/squashing-migrations.md - comments-as-code (per ac-python): no signature-echo docstring, no inline comment restating the RunPython body — a data migration's intent is its function name, not a 6-line docstring
Tasks
- idempotent
- enqueued after commit
Security
- authz checked early
- CSRF preserved
- CSP configured correctly
Architectural Health (Module-Level)
Apply to full files touched by the diff, not just changed lines:
- Fat Model is the default: business logic on models/querysets, not in views/commands/CLI
- views and management commands only orchestrate — no invariants, no workflow rules
- no god-module (single file mixing unrelated concerns) and no god-object (single model that has accreted unrelated concerns / multi-model orchestration)
- multi-model orchestration sits in a stateless processor function / class (or on an aggregate-root method), not crammed onto one model's
save() - no complexity rule suppressions (
C901,PLR09xx) inpyproject.tomlbeyond thepython-boilerplatebaseline -
services.py(if present) is scoped to external API orchestration (Stripe, AWS) and holds no DB business logic — a project-wide service layer is a documented team decision, not the default
Testing
- traits used for nullable/optional data
- tests explicit about preconditions
Enforcement via prek
The testing conventions above that are deterministically checkable ship as prek
hooks in this repo (.pre-commit-hooks.yaml + ac-django/rules/). The AST-shaped
checks are ast-grep YAML rules; the
pyproject.toml surface (which ast-grep cannot parse — no TOML language) is a
tiny standalone Python hook. A consuming repo references them by URL and rev:
- repo: https://github.com/souliane/skills
rev: <commit-sha>
hooks:
- id: ac-django-no-pytest-django-db
- id: ac-django-testcase-no-pytest-parametrize
- id: ac-django-no-complexity-suppressions
- id: ac-django-no-pyproject-complexity
| Hook id | Engine | Fails on |
|---|---|---|
ac-django-no-pytest-django-db |
ast-grep | any pytest.mark.django_db use — the @pytest.mark.django_db decorator and a module-level pytestmark = pytest.mark.django_db (or list). Use django.test.TestCase. |
ac-django-testcase-no-pytest-parametrize |
ast-grep | @pytest.mark.parametrize on a method inside a TestCase subclass (pytest silently ignores it there) — use unittest_parametrize. Module-level pytest-style functions are not flagged. |
ac-django-no-complexity-suppressions |
ast-grep | a # noqa: C901/PLR09xx comment in source/tests |
ac-django-no-pyproject-complexity |
standalone | a C901/PLR09xx entry in a pyproject.toml ruff lint.ignore / lint.extend-ignore / lint.per-file-ignores list |
ast-grep is pinned to 0.44.1. The wrapper (ac-django/rules/astgrep_scan.py)
resolves it hermetically via uvx --from ast-grep-cli==0.44.1 ast-grep when uv
is on PATH (no system install needed), falling back to a system ast-grep.
They fail-closed, grandfathered INLINE. With nothing grandfathered every violation fails (a fresh consumer gets full enforcement immediately). There is no baseline file and no count cap — existing violations are grandfathered in place:
- ast-grep rules — add ast-grep's native opt-out comment on the line above
the violation:
# ast-grep-ignore[<rule-id>](e.g.# ast-grep-ignore[ac-django-no-pytest-django-db]). It silences only that one rule on that one node; every new (un-ignored) occurrence still fails. ac-django-no-pyproject-complexity— list each existing entry inline in the consuming repo's hookargsas--grandfather <code>@<location>(e.g.--grandfather C901@lint.per-file-ignores.scripts/**/*.py). Any new entry not on the list fails; the list only shrinks as the code improves.
This keeps the ratchet's "tighten-only" property without a data file: the
grandfather record lives where the violation lives (an inline comment, or the
consumer's own .pre-commit-config.yaml), so it can never drift out of sync with
the tree.
Review-only conventions (deliberately not hard-gated). Factory Boy usage
and setUpTestData() for shared setup are house conventions, but a hook on them
is too noisy — there are many legitimate non-factory create() calls and
per-test setUp() needs. These stay review checklist items, not blocking hooks.
Previous line: Django 5.2 LTS
Django 5.2 is the LTS, in extended support until April 2028, so it is still what a lot of production code runs. Everything above targets Django 6. This section is the diff.
What Django 6 has that 5.2 does not
| Capability | Django 6 | On 5.2 instead |
|---|---|---|
| Template partials | Native {% partialdef %} / {% partial %} |
django-template-partials |
| Background tasks | Native django.tasks (@task, .enqueue()) |
Celery / Huey / RQ |
| CSP | Native CSP middleware + SECURE_CSP |
django-csp |
| Fetch modes (6.1+) | QuerySet.fetch_mode() |
Explicit select_related() / prefetch_related() |
DB-level on_delete (6.1+) |
DB_CASCADE / DB_SET_NULL / DB_SET_DEFAULT |
Python-level on_delete only |
| Multiple mailers (6.1+) | MAILERS |
Flat EMAIL_* settings |
| Squashing a squash | squashmigrations accepts a squashed migration |
Turn the earlier squash into a normal migration first (delete its replaced files, remove replaces) |
Upgrading
- Run
django-upgradebefore any manual refactor. - Leave explicit upgrade TODOs where a shim is in use:
# TODO(Django6): switch @shared_task -> @task and .delay() -> .enqueue()# TODO(Django6): remove {% load partials %} (partials become native)# TODO(Django6): replace django-csp with built-in CSP middleware
- Django 5.2 is the last line supporting Python 3.10 and 3.11; Django 6.x needs Python 3.12+.
