languages/python/frameworks/django/best_practices.md

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)

LineStatusNotes
6.0Current, supports Python 3.12/3.13/3.14DEFAULTAUTOFIELD now defaults to BigAutoField; dropped Python 3.10/3.11
5.2LTS (released 2025-04-02, supported to ~2028-04)Last line supporting Python 3.10/3.11
4.2LTS, security-only, EOL ~April 2026Do 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:

CVEClassTrap
CVE-2026-1312SQL injection, high severityQuerySet.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-1287SQL injectionControl characters in column aliases bypassed ORM quoting assumptions.
CVE-2026-1207SQL injectionRaster lookups on PostGIS backends with user-controlled input.
CVE-2026-25673DoSURLField.to_python() NFKC-normalizes input; pathologically slow for certain Unicode on Windows hosts.
CVE-2026-1285DoSdjango.utils.text.Truncator / template filters choke on inputs with many unmatched HTML end tags.
CVE-2025-13473Auth / timingdjango.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.

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.

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

  • DEFAULTAUTOFIELD defaulting to BigAutoField changes what a fresh startproject/startapp generates. Existing apps with an explicit DEFAULTAUTOFIELD in AppConfig are 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.EmailMessage API instead of the legacy Compat32 API; EmailMessage.message() return type changed accordingly. Custom code that isinstance-checks against SafeMIMEText/SafeMIMEMultipart breaks.
  • BaseDatabaseSchemaEditor / PostgreSQL backend no longer add CASCADE when 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)

NeedChoose
Pydantic-based validation, async views, OpenAPI feels FastAPI-likeDjango Ninja
Mature ecosystem, browsable API, permission classes, existing DRF investmentDRF
Heavy nested writable serializers with custom validation chainsDRF (Ninja's schema story is thinner here)

Testing

  • pytest-django + pytest-factoryboy or modelbakery; 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_create and asserts the signal-dependent side effect does NOT happen - this documents the gotcha in your own codebase instead of relying on tribal knowledge.

Sources