Django web applications
SkillAI & modelsUse 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.
No other account needed.
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,BoundFieldcustomization. - 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 inpython.
Version rule: default to 5.2 LTS. Pick 6.0 only for a concrete Tasks/CSP need, and say so.
Route elsewhere
| Situation | Route to |
|---|---|
Async service, fastapi/pydantic/uvicorn, async SQLAlchemy | fastapi |
| Postgres schema design, EXPLAIN ANALYZE, indexing strategy, RLS, pooling | postgresdb |
| Cross-stack OWASP/STRIDE threat modeling | secure-coding |
| Container/Compose/CI, gunicorn prod tuning, collectstatic pipeline | deployment |
| REST contract design (cursor vs offset, status codes, versioning) | api-design |
| ruff/uv/general type hints, packaging | python |
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"](ordjango-environ). Never commit a literalSECRET_KEY— a leaked key forges sessions and signed tokens. - Select env via
DJANGO_SETTINGS_MODULE=config.settings.prod, not anif DEBUGbranch. - One app = one domain. Resist a single
coreapp 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/CheckConstraintis enforced under concurrency; aclean()check is not. Validate-in-Python-only is a foot-gun. on_deleteis mandatory and load-bearing:CASCADEdeletes children,PROTECTblocks the delete,SET_NULLorphans. 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 following | Use | Cost |
|---|---|---|
Forward ForeignKey / OneToOne | select_related(...) | SQL JOIN, 1 query |
Reverse FK, ManyToMany | prefetch_related(...) | 2nd query, joined in Python |
| Prefetch that itself needs filter/order | Prefetch("x", queryset=...) | controlled 2nd query |
- Need existence, not rows?
qs.exists(), neverlen(qs)orif 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.
| Need | Use |
|---|---|
| One bespoke action, custom flow | function-based view (FBV) |
| Standard list/detail/create/update/delete on a model | generic CBV (ListView, DetailView, …) |
| JSON API consumed by a client/SPA | drop 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 fromapps.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.
| Setting | Value | Why |
|---|---|---|
DEBUG | False | True leaks settings + a stack-trace shell to the world |
ALLOWED_HOSTS | explicit domains | ['*'] enables Host-header attacks |
SECRET_KEY | from os.environ | a literal in source forges signed cookies/tokens |
SECURE_SSL_REDIRECT | True | force HTTPS |
SECURE_HSTS_SECONDS | 31536000 (+ include-subdomains, preload) | the check --deploy warning you saw is this being 0 |
SESSION_COOKIE_SECURE / CSRF_COOKIE_SECURE | True | stop cookie leak over HTTP |
SECURE_CSP (Django 6.0) | a real policy + nonce | native 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()andcursor.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-dbto skip rebuilds locally.TestCasewraps each test in a rolled-back transaction (fast). UseTransactionTestCaseonly when you teston_commithooks 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 ofModel.objects.create(...).
Setup, fixtures, transactional DB, coverage → references/testing.md.
Background work
| Need | Use |
|---|---|
| New project on Django 6.0, simple enqueue-and-forget jobs | the built-in Tasks framework |
| Pre-6.0, or you need schedules/retries/fan-out/result backends/workers at scale | Celery |
Either way: enqueue from the model/service layer, never block the request thread.
Anti-patterns
| Anti-pattern | Why it bites | Do instead |
|---|---|---|
| Business logic in the view | untested, duplicated across endpoints | method on the model/manager |
f-string SQL into .raw()/.extra()/cursor.execute | SQL injection | parameterized queries |
Looping rows touching .author | N+1 queries | select_related/prefetch_related |
DEBUG=True in prod | leaks settings + stack traces | DEBUG=False in prod.py |
SECRET_KEY literal in source | forged sessions/tokens | os.environ |
Validation only in clean() | races under concurrency | DB UniqueConstraint/CheckConstraint |
Model.objects.all() in a template loop | one query per iteration | prefetch in the view |
ModelViewSet with no permission_classes | endpoint open to the world | explicit permission class |
| Fat serializer walking relations | N+1 per response | prefetch + assertNumQueries |
| Editing an applied migration | breaks every env that ran it | new migration |
len(qs) / qs.count() to test existence | full fetch/COUNT | qs.exists() |
Swallowing Model.DoesNotExist silently | hidden bugs | get_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