Django Best Practices (2026)
Version-specific Django 5.2/6.0 gotchas: verified CVEs, ORM traps that survive select_related/prefetch_related, signal/transaction race conditions, and decision rules for Ninja vs DRF vs FastAPI.
Assumes fluency with Django's ORM, views, and admin. No introductory material - version-specific risk and named production traps only.
Version status (verified July 2026)
| Line | Status | Notes |
|---|---|---|
| 6.0 | Current, supports Python 3.12/3.13/3.14 | DEFAULTAUTOFIELD now defaults to BigAutoField; dropped Python 3.10/3.11 |
| 5.2 | LTS (released 2025-04-02, supported to ~2028-04) | Last line supporting Python 3.10/3.11 |
| 4.2 | LTS, security-only, EOL ~April 2026 | Do not start new projects here |
Pin to 5.2 LTS unless you specifically need a 6.0 feature; 6.0's non-LTS status means shorter support window.
Security advisories you must have patched (Feb-Mar 2026)
Six CVEs fixed across 6.0.2 / 5.2.11 / 4.2.28 (2026-02-03), plus one more in March. If your deployed Django predates these point releases, treat it as an active vulnerability, not a maintenance backlog item:
| CVE | Class | Trap |
|---|---|---|
| CVE-2026-1312 | SQL injection, high severity | QuerySet.orderby() treated any alias containing a period as .extra(orderby=...)-style raw SQL, even when extra() was never called. Any app that lets a client pick a FilteredRelation alias or ordering field via query param was exploitable. Never pass user-controlled strings into order_by() without validating against an explicit allowlist of field names - this was true before the CVE and remains the only real fix. |
| CVE-2026-1287 | SQL injection | Control characters in column aliases bypassed ORM quoting assumptions. |
| CVE-2026-1207 | SQL injection | Raster lookups on PostGIS backends with user-controlled input. |
| CVE-2026-25673 | DoS | URLField.to_python() NFKC-normalizes input; pathologically slow for certain Unicode on Windows hosts. |
| CVE-2026-1285 | DoS | django.utils.text.Truncator / template filters choke on inputs with many unmatched HTML end tags. |
| CVE-2025-13473 | Auth / timing | django.contrib.auth.handlers.modwsgi.check_password() enabled user enumeration via timing. |
Rule of thumb: any code path that forwards a client-supplied string into order_by(), .extra(), annotate() field names, or raw SQL fragments needs an explicit allowlist check - the ORM's own quoting has had exploitable gaps as recently as this year.
ORM traps that survive selectrelated/prefetchrelated review
These are not "you forgot to optimize" bugs - they surprise developers who already know the standard N+1 fixes.
1. bulkcreate() / bulkupdate() never fire signals
presave/postsave/predelete/postdelete are simply not sent for bulk operations - Model.save() itself is bypassed. Any business logic living in a signal handler (recalculated fields, search-index updates, audit logs, cache invalidation) silently doesn't run for bulk-created rows. This is a frequent source of "why is this field stale for rows imported via bulk_create" bugs. If a signal must fire, either loop with .save() (accept the cost) or move the logic out of the signal into an explicit service call that both paths invoke.
2. GenericForeignKey resists selectrelated/prefetchrelated structurally
You cannot selectrelated() a GenericForeignKey at all (no fixed target table to join on), and prefetchrelated() support is limited - it still issues one query per distinct target ContentType, not one query total. A queryset with generic relations across five content types issues at minimum five extra queries no matter how you tune it. If you're building a generic activity/notification feed, budget for K+1 queries (K = distinct content types touched), not O(1), or denormalize the target's summary fields onto the source row instead of using GenericForeignKey for hot-path reads.
3. transaction.on_commit() + async task dispatch is not optional, it's a race-condition fix
Calling mytask.delay(obj.pk) directly inside a view wrapped in transaction.atomic() races the worker: the task can start, query for obj, and find nothing (or stale data) because the transaction hasn't committed yet. This is a real, frequently-hit production bug, not a theoretical one. Fix: transaction.oncommit(lambda: mytask.delay(obj.pk)), or on Celery ≥ 5.4 use mytask.delayoncommit(obj.pk) directly. Also: never serialize a model instance into a task's arguments - pass the PK and re-fetch inside the task, since the in-memory instance may be stale by the time the task runs.
6.0-specific migration notes
DEFAULTAUTOFIELDdefaulting toBigAutoFieldchanges what a freshstartproject/startappgenerates. Existing apps with an explicitDEFAULTAUTOFIELDinAppConfigare unaffected; apps relying on the implicit default will see new migrations wanting to alter PK types if you don't pin it explicitly.- Email now goes through Python's modern
email.message.EmailMessageAPI instead of the legacyCompat32API;EmailMessage.message()return type changed accordingly. Custom code that isinstance-checks againstSafeMIMEText/SafeMIMEMultipartbreaks. BaseDatabaseSchemaEditor/ PostgreSQL backend no longer addCASCADEwhen dropping a column - a migration that used to silently cascade-drop dependent objects will now fail loudly instead. Treat this as a safety improvement, but audit migrations that drop columns on tables with FKs pointing at them.
API layer: Ninja vs DRF (decision table)
| Need | Choose |
|---|---|
| Pydantic-based validation, async views, OpenAPI feels FastAPI-like | Django Ninja |
| Mature ecosystem, browsable API, permission classes, existing DRF investment | DRF |
| Heavy nested writable serializers with custom validation chains | DRF (Ninja's schema story is thinner here) |
Testing
pytest-django+pytest-factoryboyormodelbakery;djangodb(transaction=True)only when you actually need transaction-boundary behavior (it's slower - rollback-per-test is the default and correct choice otherwise).- Test selectors (read/query functions) and services (write/orchestration) directly, without the HTTP layer, for fast feedback; reserve
RequestFactory/APIRequestFactory/full client tests for the thin coordination layer itself. - If you use signals for anything business-critical, add a dedicated test that calls
bulk_createand asserts the signal-dependent side effect does NOT happen - this documents the gotcha in your own codebase instead of relying on tribal knowledge.
Sources
- Django security releases issued: 6.0.2, 5.2.11, and 4.2.28
- CVE-2026-1312 technical detail (Vulert)
- CVE-2026-1312 fix commit (django/django)
- CVE-2026-25673 (SentinelOne vulnerability DB)
- Django 6.0 release notes
- Django 5.2 release notes / LTS status
- Django
bulkcreate/bulkupdatedo not send signals - django-simple-history common issues GenericForeignKeyprefetch limitations - lukeplant.me.uk- Fixing
on_commitrace condition with async task dispatch - dev.to/k4ml - Celery
delayoncommit()(5.4+) - Working with Celery and Django Transactions