Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Framework · Python 3.14 · Django 6.0 · DRF 3.17 · psycopg 3

Django

Fat models, thin views, DRF serializers — the Django way, enforced.

pythondjangodrformweb

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You 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, and X | None unions.
  • Django 6.0.x (current stable, django==6.0.6). Use recent built-ins: native CSP, the django.tasks framework, 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 — set SPECTACULAR_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, compiled psycopg[c] in prod). Never psycopg2. Money in Postgres numeric.
  • Package/deps: uv (uv add, uv sync, uv run) with pyproject.toml; commit uv.lock. No bare pip install, no hand-edited requirements.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 (or model_bakery) for data, time-machine for time.
  • Serving: gunicorn with gthread workers 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 in selectors.py / custom querysets. Views wire them together.
  • manage.py/wsgi.py/asgi.py default DJANGO_SETTINGS_MODULE to config.settings.dev; prod sets it via env. No local_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), querysets ProductQuerySet, serializers ProductSerializer, viewsets ProductViewSet, services verb_noun() (publish_product).
  • Line length 100. Enable ruff rules E,F,I,UP,B,C4,DJ,S,SIM,PTH,RUF; per-file-ignores S101 in **/tests/* (allow pytest assert) and F403,F405 in **/settings/*. Run ruff format — no manual style debates. Every model, service, and serializer method is type-annotated; run mypy with plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"].

Models

  • Fat models: row behavior as methods (product.mark_sold()); query logic on a custom QuerySet/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 (PROTECT for financial/audit refs, CASCADE only when children are truly owned) and a related_name. Money is DecimalField(max_digits=..., decimal_places=...), never FloatField. Keep USE_TZ=True, store UTC; timestamps via auto_now_add/auto_now.
  • The serializer validates request input; DB constraints are the real guard. In services with non-serializer input, call full_clean() before save().

Migrations discipline:

  • One logical change per migration; commit the generated file. CI runs manage.py makemigrations --check --dry-run to catch missing migrations.
  • Never edit an applied migration — add a new one.
  • Data migrations use RunPython(forward, reverse) — always supply the reverse (or RunPython.noop), and access models via apps.get_model(...), never by importing the real class.
  • Zero-downtime schema changes: add nullable / db_default column, deploy, backfill in batches, then enforce NOT NULL. Use SeparateDatabaseAndState for concurrent renames. Collapse noise with reversible squashmigrations.

Queries (kill N+1)

  • Any relation you serialize needs eager loading. select_related for forward FK / one-to-one (SQL JOIN); prefetch_related for 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 with select_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 with bool(qs)/len(qs).
  • Wrap multi-row writes in transaction.atomic(). Prove query counts in tests (django_assert_num_queries); run django-debug-toolbar / nplusone in 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 def views use aget/acreate/afirst/aupdate and async for … in qs; wrap sync-only sections in sync_to_async. Calling the sync ORM directly in async code raises SynchronousOnlyOperation.

Views (thin)

  • Default to DRF ModelViewSet + a DefaultRouter. View code is wiring only: queryset, serializer_class, permission_classes, get_queryset, get_serializer_class. Drop to APIView/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 in perform_create.
  • Override get_queryset() to scope by request.user and add select_related/prefetch_related. Pick the serializer per action via get_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 in REST_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 PageNumberPagination with max_page_size, or LimitOffsetPagination/cursor pagination for deep scans. An unpaginated list endpoint is a latent full-table scan.
  • Filtering/ordering via DjangoFilterBackend (+ a filterset_class) and DRF OrderingFilter with an explicit ordering_fields allowlist — don't hand-parse request.query_params into .filter(**kwargs).
  • Server-rendered pages: thin CBVs (ListView, DetailView, FormView) with Form/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

  • ModelSerializer with an explicit fields = [...]. Never fields = "__all__" — it leaks every column you add later.
  • Server-owned fields are read_only (id, created_at, owner); secrets are write_only (password). Use extra_kwargs, and source=/many=True for nesting.
  • Validate here, not in the view: validate_<field>(self, value) for one field, validate(self, attrs) for cross-field; raise serializers.ValidationError. Enforce uniqueness with UniqueValidator/UniqueTogetherValidator.
  • Set ownership server-side with HiddenField(default=serializers.CurrentUserDefault()) or serializer.save(user=request.user) in perform_create — never trust a client-supplied owner/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/update explicitly and wrap in transaction.atomic; don't rely on implicit nested behavior. Split read vs write serializers when their shapes diverge.

Settings split

  • config/settings/base.py holds shared config; dev.py, prod.py, test.py do from .base import * and override. Select with DJANGO_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. .env is git-ignored; ship a .env.example.
  • DEBUG = False in prod, always — DEBUG=True in production leaks source and settings and is an incident. ALLOWED_HOSTS set to real hosts.
  • prod.py must 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", and SECURE_PROXY_SSL_HEADER=("HTTP_X_FORWARDED_PROTO","https") if behind a TLS-terminating proxy that strips client-supplied headers. Rotate keys via SECRET_KEY_FALLBACKS.
  • Transaction policy: pick one — either DATABASES["default"]["ATOMIC_REQUESTS"]=True, or wrap writes explicitly in transaction.atomic(). Set CONN_MAX_AGE/conn_health_checks (or OPTIONS={"pool": True} under ASGI) for connection reuse. Cache with Redis (django.core.cache.backends.redis.RedisCache).
  • CI runs manage.py check --deploy and 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-db locally, --create-db in 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, and django_assert_num_queries(n) to lock N+1 out. Cover model constraints (assert IntegrityError on 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 with time-machine; use override_settings for 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() and RawSQL with interpolation are banned.
  • Mass assignment: explicit serializer fields; server-set ownership via CurrentUserDefault, never trusting a client owner/user_id.
  • AuthZ per object, not just per view: set DEFAULT_PERMISSION_CLASSES to IsAuthenticated globally, open up deliberately, and enforce record ownership in has_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_ORIGINS for cross-origin posts). Token/JWT APIs are CSRF-safe by design — never @csrf_exempt to "make it work".
  • CSP: use Django 6.0 native support — add django.middleware.csp.ContentSecurityPolicyMiddleware, define SECURE_CSP with the django.utils.csp constants (e.g. {"default-src": [CSP.SELF], "script-src": [CSP.SELF, CSP.NONCE]}), roll out with SECURE_CSP_REPORT_ONLY first. Prefer nonces over 'unsafe-inline'.
  • XSS: rely on template auto-escaping; use mark_safe/|safe/format_html only on trusted content, and sanitize any user HTML (nh3) before storing.
  • Passwords: Argon2PasswordHasher first (add argon2-cffi); keep AUTH_PASSWORD_VALIDATORS. Throttle/rate-limit login/OTP endpoints (DRF throttles or django-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-headers with an explicit CORS_ALLOWED_ORIGINS allowlist. Never CORS_ALLOW_ALL_ORIGINS=True in 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=...) and Meta.indexes.
  • Wrap multi-step writes in transaction.atomic(); use select_for_update for concurrent updates.
  • Keep views thin: writes in services.py, reads in selectors.py/querysets; scope querysets to request.user.
  • Use TextChoices, DecimalField for money, explicit on_delete + related_name on every relation.
  • Read all config from env; keep prod DEBUG=False with full security headers and CSP.
  • Run ruff check --fix, ruff format, mypy, pytest, makemigrations --check, and check --deploy before finishing. Keep the drf-spectacular schema 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() > 0 for existence → .first() / .exists().
  • Business logic in views / perform_create / signals → services; reserve signals for genuinely decoupled side effects, never core workflow.
  • psycopg2, FloatField for money, unique_together/index_together, CheckConstraint(check=...) (removed in 6.0) → psycopg 3, DecimalField, UniqueConstraint, condition=.
  • objects.get() in a view without handling DoesNotExistget_object_or_404 or catch it.
  • @csrf_exempt, string-built SQL / .extra(), DEBUG=True, ALLOWED_HOSTS=["*"], CORS_ALLOW_ALL_ORIGINS=True, hardcoded SECRET_KEY/committed .env → token auth, ORM/params, env config.
  • Fat/blocking work in the request cycle → django.tasks or Celery. Sync ORM inside async def → the a* methods or sync_to_async.
  • USE_TZ=False / naive datetimes → USE_TZ=True, store UTC. Bare except Exception swallowing 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, and pytest (with a query-count assertion for new endpoints). Run check --deploy when 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_MODEL or 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.

Back to top ↑