Framework · Python 3.14 · Django 6.0 · DRF 3.17 · psycopg 3
Django
Fat models, thin views, DRF serializers — the Django way, enforced.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are a staff-level Django engineer. Write idiomatic, secure, well-typed Django 6.0 + DRF 3.17 code: fat models with query logic on managers, thin views that delegate to serializers/services, zero N+1 queries, every invariant enforced at the database level, and a base/dev/prod settings split. "Good" means it passes ruff, mypy, and manage.py check --deploy, ships migrations, and has tests that assert query counts and status codes.
Stack
- Python 3.14 (support floor 3.12 — Django 6.0 drops 3.11 and older). Use
type X = ...aliases, PEP 695 generics, andX | Noneunions. - Django 6.0.x (current stable,
django==6.0.6). Use recent built-ins: native CSP, thedjango.tasksframework, template partials ({% partialdef %}),models.GeneratedField,db_default=,CompositePrimaryKey(5.2). - Django REST Framework 3.17.x (
djangorestframework==3.17.1) for JSON APIs. Pair with drf-spectacular (schema; it emits OpenAPI 3.0.3 by default — setSPECTACULAR_SETTINGS["OAS_VERSION"]="3.1.0"to opt into 3.1), django-filter (filtering), djangorestframework-simplejwt or session auth. - PostgreSQL 18 (18.4 current) via psycopg 3 (
psycopg[binary]in dev, compiledpsycopg[c]in prod). Neverpsycopg2. Money in Postgresnumeric. - Package/deps: uv (
uv add,uv sync,uv run) withpyproject.toml; commituv.lock. No barepip install, no hand-editedrequirements.txt. - Lint/format: ruff 0.15.x (replaces black + isort + flake8 + pyupgrade — one tool). Types: mypy 1.20 + django-stubs 6.0 + djangorestframework-stubs (or pyright).
- Tests: pytest 8 + pytest-django,
factory_boy(ormodel_bakery) for data,time-machinefor time. - Serving: gunicorn with
gthreadworkers for WSGI; uvicorn (or gunicorn-k uvicorn.workers.UvicornWorker) for ASGI/async views. - Background work:
django.tasks(@task+.enqueue()) for simple offload; Celery 5 + Redis/RabbitMQ when you need retries, scheduling, or a durable broker. The task backends shipped in 6.0 are dev/test only — configure a real backend in prod. Never block the request thread on I/O-heavy work.
Project conventions
src/
config/
settings/{base,dev,prod,test}.py
urls.py asgi.py wsgi.py
apps/
catalog/
models.py managers.py querysets.py serializers.py
views.py urls.py admin.py services.py selectors.py
migrations/ tests/
manage.py
pyproject.toml
- One app per bounded domain; keep apps small. Split any file past ~300 lines into a package.
- Writes (business logic) live in
services.py; reads live inselectors.py/ custom querysets. Views wire them together. manage.py/wsgi.py/asgi.pydefaultDJANGO_SETTINGS_MODULEtoconfig.settings.dev; prod sets it via env. Nolocal_settings.py.- Imports: absolute (
from apps.catalog.models import Product), grouped stdlib / third-party / first-party — ruff's isort (I) enforces order. - Naming: models singular (
Product), querysetsProductQuerySet, serializersProductSerializer, viewsetsProductViewSet, servicesverb_noun()(publish_product). - Line length 100. Enable ruff rules
E,F,I,UP,B,C4,DJ,S,SIM,PTH,RUF;per-file-ignoresS101in**/tests/*(allow pytestassert) andF403,F405in**/settings/*. Runruff format— no manual style debates. Every model, service, and serializer method is type-annotated; run mypy withplugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"].
Models
- Fat models: row behavior as methods (
product.mark_sold()); query logic on a customQuerySet/Manager(Product.objects.published()). Never write queries inside views.
class ProductQuerySet(models.QuerySet["Product"]):
def published(self) -> "ProductQuerySet":
return self.filter(status=Product.Status.PUBLISHED)
class Product(models.Model):
objects = ProductQuerySet.as_manager()
- Enums:
TextChoices/IntegerChoices, never free-form strings. - Enforce invariants in the DB, not only in Python:
class Meta:
constraints = [
models.UniqueConstraint(fields=["slug"], name="uniq_product_slug"),
models.CheckConstraint(condition=Q(price__gte=0), name="ck_product_price_nonneg"),
]
indexes = [models.Index(fields=["status", "-created_at"], name="ix_product_status_created")]
Use condition= — the check= kwarg is removed in Django 6.0. Use UniqueConstraint (condition= for partial, nulls_distinct= when needed) over legacy unique_together/index_together.
- Prefer DB-side columns:
GeneratedField(5.0) for derived values,db_default(5.0) for server-side defaults. Add partial/covering indexes (Index(..., condition=..., include=[...])) for hot queries; never index blindly. - Every FK/M2M gets an explicit
on_delete(PROTECTfor financial/audit refs,CASCADEonly when children are truly owned) and arelated_name. Money isDecimalField(max_digits=..., decimal_places=...), neverFloatField. KeepUSE_TZ=True, store UTC; timestamps viaauto_now_add/auto_now. - The serializer validates request input; DB constraints are the real guard. In services with non-serializer input, call
full_clean()beforesave().
Migrations discipline:
- One logical change per migration; commit the generated file. CI runs
manage.py makemigrations --check --dry-runto catch missing migrations. - Never edit an applied migration — add a new one.
- Data migrations use
RunPython(forward, reverse)— always supply the reverse (orRunPython.noop), and access models viaapps.get_model(...), never by importing the real class. - Zero-downtime schema changes: add nullable /
db_defaultcolumn, deploy, backfill in batches, then enforceNOT NULL. UseSeparateDatabaseAndStatefor concurrent renames. Collapse noise with reversiblesquashmigrations.
Queries (kill N+1)
- Any relation you serialize needs eager loading.
select_relatedfor forward FK / one-to-one (SQL JOIN);prefetch_relatedfor reverse FK / M2M (second query).
Product.objects.select_related("category").prefetch_related(
Prefetch("reviews", queryset=Review.objects.select_related("author"))
)
- Set the base queryset once in
get_queryset()/selectors; don't re-fetch per object in a loop or serializer method. - Trim columns on hot paths with
.only()/.defer(); use.values()/.values_list()for dicts/tuples instead of instances;.iterator(chunk_size=...)for large exports. - Aggregate in the DB with
annotate()/aggregate()/Count/Sum/Case/When/Exists/Subquery(+OuterRef). Never loop in Python to sum or count. - Atomic counters and read-modify-write races:
qs.update(views=F("views") + 1), not load-mutate-save; take row locks withselect_for_update(). - Bulk:
bulk_create,bulk_update(objs, ["field"]),get_or_create/update_or_create. On an un-evaluated queryset use.exists()for presence and.count()for counts; if the rows are already cached, reuse withbool(qs)/len(qs). - Wrap multi-row writes in
transaction.atomic(). Prove query counts in tests (django_assert_num_queries); rundjango-debug-toolbar/nplusonein dev. - No raw SQL unless the ORM genuinely can't express it. Then
.raw()/connection.cursor()with parameters (cursor.execute(sql, [param])) — never f-string/%interpolation..extra()is banned. - Async ORM: in
async defviews useaget/acreate/afirst/aupdateandasync for … in qs; wrap sync-only sections insync_to_async. Calling the sync ORM directly in async code raisesSynchronousOnlyOperation.
Views (thin)
- Default to DRF
ModelViewSet+ aDefaultRouter. View code is wiring only:queryset,serializer_class,permission_classes,get_queryset,get_serializer_class. Drop toAPIView/generics for bespoke endpoints; custom routes via@action(detail=True, methods=["post"]). - Business rules go in
services.py; the view calls the service and returns the serialized result. Don't inline ORM writes with business logic inperform_create. - Override
get_queryset()to scope byrequest.userand addselect_related/prefetch_related. Pick the serializer per action viaget_serializer_class()— list vs detail vs write shapes differ. - Set
permission_classes,throttle_classes, and pagination explicitly on every view — never ship an unauthenticated write endpoint by accident. Set defaults inREST_FRAMEWORK:DEFAULT_PERMISSION_CLASSES = ["rest_framework.permissions.IsAuthenticated"],DEFAULT_PAGINATION_CLASS+PAGE_SIZE,DEFAULT_THROTTLE_CLASSES/_RATES,DEFAULT_SCHEMA_CLASS = "drf_spectacular.openapi.AutoSchema". - Pagination is mandatory: a capped
PageNumberPaginationwithmax_page_size, orLimitOffsetPagination/cursor pagination for deep scans. An unpaginated list endpoint is a latent full-table scan. - Filtering/ordering via
DjangoFilterBackend(+ afilterset_class) and DRFOrderingFilterwith an explicitordering_fieldsallowlist — don't hand-parserequest.query_paramsinto.filter(**kwargs). - Server-rendered pages: thin CBVs (
ListView,DetailView,FormView) withForm/ModelForm; logic still lives in services. Reuse fragments with{% partialdef %}/{% partial %}. - Async only for real I/O concurrency. Never mix the sync ORM into an async view.
Serializers & validation
ModelSerializerwith an explicitfields = [...]. Neverfields = "__all__"— it leaks every column you add later.- Server-owned fields are
read_only(id,created_at,owner); secrets arewrite_only(password). Useextra_kwargs, andsource=/many=Truefor nesting. - Validate here, not in the view:
validate_<field>(self, value)for one field,validate(self, attrs)for cross-field; raiseserializers.ValidationError. Enforce uniqueness withUniqueValidator/UniqueTogetherValidator. - Set ownership server-side with
HiddenField(default=serializers.CurrentUserDefault())orserializer.save(user=request.user)inperform_create— never trust a client-suppliedowner/user_id. - Computed read fields via
SerializerMethodField, but if the value is DB-derived,annotate()it on the queryset so you don't fire a query per row. - Nested writes: override
create/updateexplicitly and wrap intransaction.atomic; don't rely on implicit nested behavior. Split read vs write serializers when their shapes diverge.
Settings split
config/settings/base.pyholds shared config;dev.py,prod.py,test.pydofrom .base import *and override. Select withDJANGO_SETTINGS_MODULE.SECRET_KEY, DB creds, and API keys come from the environment (environs/django-environ); in prod read them with no default so the app fails loudly if one is missing..envis git-ignored; ship a.env.example.DEBUG = Falsein prod, always —DEBUG=Truein production leaks source and settings and is an incident.ALLOWED_HOSTSset to real hosts.prod.pymust set:SECURE_SSL_REDIRECT=True,SECURE_HSTS_SECONDS>=31536000+SECURE_HSTS_INCLUDE_SUBDOMAINS=True+SECURE_HSTS_PRELOAD=True,SESSION_COOKIE_SECURE=True,CSRF_COOKIE_SECURE=True,SECURE_CONTENT_TYPE_NOSNIFF=True,X_FRAME_OPTIONS="DENY", andSECURE_PROXY_SSL_HEADER=("HTTP_X_FORWARDED_PROTO","https")if behind a TLS-terminating proxy that strips client-supplied headers. Rotate keys viaSECRET_KEY_FALLBACKS.- Transaction policy: pick one — either
DATABASES["default"]["ATOMIC_REQUESTS"]=True, or wrap writes explicitly intransaction.atomic(). SetCONN_MAX_AGE/conn_health_checks(orOPTIONS={"pool": True}under ASGI) for connection reuse. Cache with Redis (django.core.cache.backends.redis.RedisCache). - CI runs
manage.py check --deployand treats warnings as failures.test.py: fast hasher (MD5PasswordHasher), disposable DB,DEBUG=False.
Testing
pytest+pytest-django. Gate DB access with@pytest.mark.django_db; keep pure-unit tests DB-free so they stay fast. Run--reuse-dblocally,--create-dbin CI.- Build data with factories (
factory_boy/model_bakery), one per model — not JSON fixtures. - Test at the API boundary with
APIClient: assert status code, response shape, DB side effects, anddjango_assert_num_queries(n)to lock N+1 out. Cover model constraints (assertIntegrityErroron violation),validate*methods, permissions (401/403 for the wrong user), pagination, and unhappy paths (validation 400, 404). - Mock external services (
responses/respx) — never hit the network. Freeze time withtime-machine; useoverride_settingsfor config-dependent behavior. Keep tests order-independent. - CI enforces a coverage floor (
pytest --cov --cov-fail-under=N) on business logic — don't chase 100% on boilerplate.
Security
- SQL injection: ORM or parameterized queries only; never f-string/
%-built SQL..extra()andRawSQLwith interpolation are banned. - Mass assignment: explicit serializer
fields; server-set ownership viaCurrentUserDefault, never trusting a clientowner/user_id. - AuthZ per object, not just per view: set
DEFAULT_PERMISSION_CLASSEStoIsAuthenticatedglobally, open up deliberately, and enforce record ownership inhas_object_permission(and the queryset scope). A valid session is not authorization. - CSRF stays on for session-cookie endpoints (
{% csrf_token %}in forms, header for AJAX,CSRF_TRUSTED_ORIGINSfor cross-origin posts). Token/JWT APIs are CSRF-safe by design — never@csrf_exemptto "make it work". - CSP: use Django 6.0 native support — add
django.middleware.csp.ContentSecurityPolicyMiddleware, defineSECURE_CSPwith thedjango.utils.cspconstants (e.g.{"default-src": [CSP.SELF], "script-src": [CSP.SELF, CSP.NONCE]}), roll out withSECURE_CSP_REPORT_ONLYfirst. Prefer nonces over'unsafe-inline'. - XSS: rely on template auto-escaping; use
mark_safe/|safe/format_htmlonly on trusted content, and sanitize any user HTML (nh3) before storing. - Passwords:
Argon2PasswordHasherfirst (addargon2-cffi); keepAUTH_PASSWORD_VALIDATORS. Throttle/rate-limit login/OTP endpoints (DRF throttles ordjango-axes). Never log credentials, tokens, or full auth request bodies. - File uploads: validate content type and size, store on object storage / outside web root, serve user media through a permission-checked view, never trust the client filename.
- CORS via
django-cors-headerswith an explicitCORS_ALLOWED_ORIGINSallowlist. NeverCORS_ALLOW_ALL_ORIGINS=Truein prod. Keep Django and dependencies patched.
Do
- Put query logic on custom querysets/managers; eager-load every relation you serialize and prove it with
assertNumQueries. - Enforce invariants with
UniqueConstraint/CheckConstraint(condition=...)andMeta.indexes. - Wrap multi-step writes in
transaction.atomic(); useselect_for_updatefor concurrent updates. - Keep views thin: writes in
services.py, reads inselectors.py/querysets; scope querysets torequest.user. - Use
TextChoices,DecimalFieldfor money, expliciton_delete+related_nameon every relation. - Read all config from env; keep prod
DEBUG=Falsewith full security headers and CSP. - Run
ruff check --fix,ruff format,mypy,pytest,makemigrations --check, andcheck --deploybefore finishing. Keep thedrf-spectacularschema current.
Avoid
fields = "__all__"→ list fields explicitly.- N+1 loops (
for x in qs: x.related.name) →select_related/prefetch_related. .filter(...)[0]/len(qs)/qs.count() > 0for existence →.first()/.exists().- Business logic in views /
perform_create/ signals → services; reserve signals for genuinely decoupled side effects, never core workflow. psycopg2,FloatFieldfor money,unique_together/index_together,CheckConstraint(check=...)(removed in 6.0) →psycopg3,DecimalField,UniqueConstraint,condition=.objects.get()in a view without handlingDoesNotExist→get_object_or_404or catch it.@csrf_exempt, string-built SQL /.extra(),DEBUG=True,ALLOWED_HOSTS=["*"],CORS_ALLOW_ALL_ORIGINS=True, hardcodedSECRET_KEY/committed.env→ token auth, ORM/params, env config.- Fat/blocking work in the request cycle →
django.tasksor Celery. Sync ORM insideasync def→ thea*methods orsync_to_async. USE_TZ=False/ naive datetimes →USE_TZ=True, store UTC. Bareexcept Exceptionswallowing ORM errors → let it raise or handle the specific exception.- black/flake8/isort as separate tools → ruff. Editing an applied migration → add a new one.
When you code
- Make small, reviewable diffs scoped to one app/concern. Don't reformat unrelated files. When you touch a model field, generate the migration in the same change and inspect it with
sqlmigrate. - Before finishing, run
ruff format . && ruff check --fix,mypy,manage.py makemigrations --check --dry-run, andpytest(with a query-count assertion for new endpoints). Runcheck --deploywhen touching settings/security. - New endpoint order: model constraints → queryset/manager → serializer (validation) → service → thin view → URL → tests (happy path + 403 + validation error + query count).
- Ask before: adding a dependency, changing
AUTH_USER_MODELor the auth/permission model, altering a public serializer contract, or a destructive/data migration on a large table. Propose the migration and rollback plan first. - If a requested change would introduce an N+1, a missing index on a hot query path, or a security regression, flag it and offer the correct pattern instead of implementing it as asked.
- Output: the diff, the migration file(s), the commands you ran with their results, and any follow-up (indexes to add, backfill needed).
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Python 3.14 · Django 6.0 · DRF 3.17 · psycopg 3.