Django web applications

SkillAI & models

Use when building, reviewing, securing, testing or shipping a Django app — models, migrations, QuerySets/managers, FBV/CBV views, forms, the admin, settings split, and Django REST Framework (serializers, ModelViewSet, permissions). NOT async FastAPI/Pydantic services (that is `fastapi`), NOT Postgres schema/index work (that is `postgresdb`).

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Django web applications skill

What this skill tells your AI

The instructions your AI receives, as published by ericrisco/rsc-harness in skills/django/SKILL.md and read by ahel’s review.

The single authoritative skill for building, reviewing, securing, testing and shipping a Django app — the batteries-included, ORM-first, request/response Python framework.

Mental model: a Django project is apps composed of fat-but-thin-enough models (domain + query logic on the model/manager), views that orchestrate (FBV/CBV/DRF) and never own SQL, an admin/forms layer, and a settings module split by environment. The ORM, migrations, auth, admin, CSP and the test runner are all first-party. Reach for the framework before you add a dependency.

Pinned stack (2026-06)

  • Django 5.2 LTS — the production default. Released 2025-04-02, security fixes until ~April 2028, supports Python 3.10–3.14. New in 5.2: all models auto-imported in shell, CompositePrimaryKey, BoundField customization.
  • Django 6.0 — released 2025-12-03 (non-LTS, ~8 months until 6.1). Choose it only when you want the new built-in Tasks framework (background jobs without Celery) or native CSP (ContentSecurityPolicyMiddleware, SECURE_CSP) and can take the shorter support window. Drops Python 3.10/3.11; supports 3.12–3.14.
  • Django REST Framework 3.17.1 (2026-03-24) — adds Django 6.0 + Python 3.14 support.
  • Python 3.12+, pytest-django, factory_boy. ruff/uv and type-hint policy live in python.

Version rule: default to 5.2 LTS. Pick 6.0 only for a concrete Tasks/CSP need, and say so.

Route elsewhere

SituationRoute to
Async service, fastapi/pydantic/uvicorn, async SQLAlchemyfastapi
Postgres schema design, EXPLAIN ANALYZE, indexing strategy, RLS, poolingpostgresdb
Cross-stack OWASP/STRIDE threat modelingsecure-coding
Container/Compose/CI, gunicorn prod tuning, collectstatic pipelinedeployment
REST contract design (cursor vs offset, status codes, versioning)api-design
ruff/uv/general type hints, packagingpython

Project shape

Split settings by environment; never ship one settings.py toggled by DEBUG.

src/
  manage.py
  config/
    settings/
      base.py      # shared; reads secrets from os.environ
      dev.py       # from base import *; DEBUG=True; local hosts
      prod.py      # from base import *; DEBUG=False; SECURE_*; CSP
  catalog/         # an app = a bounded domain
    models.py  managers.py  views.py  serializers.py  urls.py  admin.py
    migrations/
    tests/
  • Read secrets with os.environ["SECRET_KEY"] (or django-environ). Never commit a literal SECRET_KEY — a leaked key forges sessions and signed tokens.
  • Select env via DJANGO_SETTINGS_MODULE=config.settings.prod, not an if DEBUG branch.
  • One app = one domain. Resist a single core app that accretes everything.

Models

Put domain and query logic on the model and its manager. The view stays thin.

# managers.py
from django.db import models

class ArticleQuerySet(models.QuerySet):
    def published(self):
        return self.filter(status=Article.Status.PUBLISHED)

    def for_reader(self):  # composes; reused everywhere, tested once
        return self.published().select_related("author")

# models.py
class Article(models.Model):
    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        PUBLISHED = "published", "Published"

    tenant = models.ForeignKey("Tenant", on_delete=models.CASCADE)
    slug = models.SlugField()
    author = models.ForeignKey("Author", on_delete=models.PROTECT)
    status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT)
    published_at = models.DateTimeField(null=True, blank=True)

    objects = ArticleQuerySet.as_manager()

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=["tenant", "slug"], name="uniq_tenant_slug"),
            models.CheckConstraint(
                check=models.Q(status="draft") | models.Q(published_at__isnull=False),
                name="published_needs_date",
            ),
        ]
        indexes = [models.Index(fields=["tenant", "status"])]
  • Constraints live in the DB, not just Python. A UniqueConstraint/CheckConstraint is enforced under concurrency; a clean() check is not. Validate-in-Python-only is a foot-gun.
  • on_delete is mandatory and load-bearing: CASCADE deletes children, PROTECT blocks the delete, SET_NULL orphans. Choosing wrong silently destroys data — pick deliberately.
  • Multi-column PK (5.2+): pk = models.CompositePrimaryKey("tenant_id", "id").
  • Bad→Good for business logic:
# Bad: logic in the view — untested, unreusable, duplicated across endpoints
def publish(request, pk):
    a = Article.objects.get(pk=pk)
    a.status = "published"; a.published_at = timezone.now(); a.save()

# Good: a method on the model — one place, testable, reused by view/admin/command
class Article(models.Model):
    def publish(self):
        self.status = self.Status.PUBLISHED
        self.published_at = timezone.now()
        self.save(update_fields=["status", "published_at"])

QuerySet performance

The N+1 is the single most common Django defect: one query for the list, then one more per row.

# Bad: 1 + N queries — each .author touches the DB inside the loop
for a in Article.objects.all():
    print(a.author.name)

# Good: 2 queries total (FK -> JOIN; reverse/M2M -> second query)
for a in Article.objects.select_related("author").prefetch_related("tags"):
    print(a.author.name, [t.name for t in a.tags.all()])
You are followingUseCost
Forward ForeignKey / OneToOneselect_related(...)SQL JOIN, 1 query
Reverse FK, ManyToManyprefetch_related(...)2nd query, joined in Python
Prefetch that itself needs filter/orderPrefetch("x", queryset=...)controlled 2nd query
  • Need existence, not rows? qs.exists(), never len(qs) or if qs.count().
  • Need a few columns of a wide row? .only("id", "slug") / .defer("body").
  • Computed totals belong in the DB: annotate(...) / aggregate(...), not a Python loop.
  • Many inserts: bulk_create(objs) — one round-trip, not N .save() calls.
  • Never Model.objects.all() then slice/filter in Python; push it into the QuerySet.

Deeper recipes (assertNumQueries, Prefetch, .explain(), ORM indexing) → references/orm-performance.md.

Views & URLs

Keep views thin: validate input, call a model/manager method, return a response. No SQL.

NeedUse
One bespoke action, custom flowfunction-based view (FBV)
Standard list/detail/create/update/delete on a modelgeneric CBV (ListView, DetailView, …)
JSON API consumed by a client/SPAdrop to DRF (do not hand-roll JsonResponse CRUD)

For the DRF surface — serializers, ModelViewSet, routers, permissions, throttling, pagination, filtering, nested-serializer N+1, versioning — see references/drf.md. The thin-view rule still holds: a fat serializer that walks relations per row is just an N+1 wearing a tie.

Migrations

python manage.py makemigrations catalog   # generate from model diff
python manage.py migrate                   # apply
python manage.py makemigrations --check    # CI gate: fail if a model drifts from migrations
  • Never edit a migration that has been applied anywhere. Add a new one. Editing rewrites history and breaks every environment that already ran it.
  • Data backfills go through migrations.RunPython(forward, reverse) with a reverse, not a one-off script. Use the historical model from apps.get_model(...), not the imported class.
  • Schema changes on a live table that you cannot afford to lock are expand-and-contract; the Postgres-side mechanics (lock modes, batching) live in postgresdb.

Security

Set these in prod.py. Then prove it: python manage.py check --deploy must come back clean.

SettingValueWhy
DEBUGFalseTrue leaks settings + a stack-trace shell to the world
ALLOWED_HOSTSexplicit domains['*'] enables Host-header attacks
SECRET_KEYfrom os.environa literal in source forges signed cookies/tokens
SECURE_SSL_REDIRECTTrueforce HTTPS
SECURE_HSTS_SECONDS31536000 (+ include-subdomains, preload)the check --deploy warning you saw is this being 0
SESSION_COOKIE_SECURE / CSRF_COOKIE_SECURETruestop cookie leak over HTTP
SECURE_CSP (Django 6.0)a real policy + noncenative CSP; pre-6.0 use django-csp
  • CSRF protection is on by default — keep CsrfViewMiddleware; do not blanket-exempt views.
  • The ORM parameterizes queries. Only .raw(), .extra() and cursor.execute() with an f-string/%-built string reopen SQL injection. Pass params, never interpolate.

Full SECURE_* checklist, CSP nonce/report-only, upload/SSRF, ORM-injection → references/security.md.

Testing

import pytest
from rest_framework.test import APIClient

@pytest.mark.django_db
def test_owner_only(article, owner):
    client = APIClient()
    assert client.get(f"/api/articles/{article.pk}/").status_code == 403  # anon
    client.force_authenticate(owner)
    assert client.get(f"/api/articles/{article.pk}/").status_code == 200
  • pytest-django + @pytest.mark.django_db; run with --reuse-db to skip rebuilds locally.
  • TestCase wraps each test in a rolled-back transaction (fast). Use TransactionTestCase only when you test on_commit hooks or real commit behavior.
  • Lock in N+1 fixes with assertNumQueries(2) — it fails the build when a relation regresses.
  • Build instances with factory_boy, not 30 lines of Model.objects.create(...).

Setup, fixtures, transactional DB, coverage → references/testing.md.

Background work

NeedUse
New project on Django 6.0, simple enqueue-and-forget jobsthe built-in Tasks framework
Pre-6.0, or you need schedules/retries/fan-out/result backends/workers at scaleCelery

Either way: enqueue from the model/service layer, never block the request thread.

Anti-patterns

Anti-patternWhy it bitesDo instead
Business logic in the viewuntested, duplicated across endpointsmethod on the model/manager
f-string SQL into .raw()/.extra()/cursor.executeSQL injectionparameterized queries
Looping rows touching .authorN+1 queriesselect_related/prefetch_related
DEBUG=True in prodleaks settings + stack tracesDEBUG=False in prod.py
SECRET_KEY literal in sourceforged sessions/tokensos.environ
Validation only in clean()races under concurrencyDB UniqueConstraint/CheckConstraint
Model.objects.all() in a template loopone query per iterationprefetch in the view
ModelViewSet with no permission_classesendpoint open to the worldexplicit permission class
Fat serializer walking relationsN+1 per responseprefetch + assertNumQueries
Editing an applied migrationbreaks every env that ran itnew migration
len(qs) / qs.count() to test existencefull fetch/COUNTqs.exists()
Swallowing Model.DoesNotExist silentlyhidden bugsget_object_or_404 or handle explicitly

Verify

scripts/verify.sh [TARGET] greps tracked Django source for high-signal foot-guns: FAIL on a literal SECRET_KEY, ALLOWED_HOSTS = ['*'], or f-string SQL in .raw()/.extra()/cursor.execute; WARN on DEBUG = True outside a dev settings file and a ModelViewSet/APIView with no permission_classes. Read-only, exit 0 on a clean or empty target. It is a lint, not a substitute for manage.py check --deploy or the test suite.

Signals

GitHub stars
82
Forks
3
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
django-ericrisco
Source
github.com/ericrisco/rsc-harness