--- title: "Django Best Practices (2026)" description: "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." language: python framework: django category: backend_web_framework tags: - best-practices - orm - security-advisories - n-plus-one - transactions - drf - production keywords: - django 6.0 breaking changes - django cve 2026 sql injection order_by - django orm n+1 genericforeignkey bulk_create signals - django transaction on_commit celery race condition - django ninja vs drf 2026 last_updated: "2026-07-08" difficulty: intermediate version: "Django >= 5.1, Python >= 3.11" related: - ../fastapi/best_practices.md - ../../best_practices.md - ../../../../core/architecture/clean_architecture.md search_priority: high status: published --- # Django Best Practices (2026) 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 | `DEFAULT_AUTO_FIELD` 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.order_by()` treated any alias containing a period as `.extra(order_by=...)`-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 `select_related`/`prefetch_related` review These are not "you forgot to optimize" bugs - they surprise developers who already know the standard N+1 fixes. ### 1. `bulk_create()` / `bulk_update()` never fire signals `pre_save`/`post_save`/`pre_delete`/`post_delete` 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 `select_related`/`prefetch_related` structurally You cannot `select_related()` a `GenericForeignKey` at all (no fixed target table to join on), and `prefetch_related()` 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 `my_task.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.on_commit(lambda: my_task.delay(obj.pk))`, or on Celery ≥ 5.4 use `my_task.delay_on_commit(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 - `DEFAULT_AUTO_FIELD` defaulting to `BigAutoField` changes what a fresh `startproject`/`startapp` generates. Existing apps with an explicit `DEFAULT_AUTO_FIELD` 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) | 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-factoryboy` or `model_bakery`; `django_db(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 - [Django security releases issued: 6.0.2, 5.2.11, and 4.2.28](https://www.djangoproject.com/weblog/2026/feb/03/security-releases/) - [CVE-2026-1312 technical detail (Vulert)](https://vulert.com/vuln-db/potential-sql-injection-via-queryset-order-by-and-filteredrelation) - [CVE-2026-1312 fix commit (django/django)](https://github.com/django/django/commit/69065ca869b0970dff8fdd8fafb390bf8b3bf222) - [CVE-2026-25673 (SentinelOne vulnerability DB)](https://www.sentinelone.com/vulnerability-database/cve-2026-25673/) - [Django 6.0 release notes](https://docs.djangoproject.com/en/6.0/releases/6.0/) - [Django 5.2 release notes / LTS status](https://docs.djangoproject.com/en/6.0/releases/5.2/) - [Django `bulk_create`/`bulk_update` do not send signals - django-simple-history common issues](https://django-simple-history.readthedocs.io/en/latest/common_issues.html) - [`GenericForeignKey` prefetch limitations - lukeplant.me.uk](https://lukeplant.me.uk/blog/posts/avoid-django-genericforeignkey/) - [Fixing `on_commit` race condition with async task dispatch - dev.to/k4ml](https://dev.to/k4ml/django-fixing-race-condition-when-queuing-with-oncommit-hook-7ae) - [Celery `delay_on_commit()` (5.4+) - Working with Celery and Django Transactions](https://testdriven.io/blog/celery-database-transactions/)