# Django Expert

> Use when writing or debugging Django and Django REST Framework: ORM querysets, migrations, signals, admin, async views, Channels, Celery / Dramatiq / RQ tasks, ASGI / WSGI deploys, settings. Covers Django 5.x idioms, `select_related` vs `prefetch_related`, N+1 hunting, `UniqueConstraint` and `CheckConstraint` on `Meta`, DRF ViewSets, serializers, permissions, `RunPython` data migrations, atomic reversible migrations, async view boundaries with `sync_to_async`, Celery `acks_late` and idempotency, bounded context per app, settings split, version upgrade checklists. Triggers: Django, DRF, ORM, queryset, select_related, prefetch_related, signals, Channels, async view, manage.py, makemigrations, Celery, Dramatiq, RQ, gunicorn, uvicorn, ASGI, WSGI. Produces models, ViewSets, serializers, migrations, tasks, settings. Not for cross language API contracts, see `senior-backend-engineer`. Not for query plans, see `postgres-expert`. Not for blank page schema, see `data-modeler`.

- Skill: `iamdemetris/django-expert` (Agent Skill)
- Install (CLI): `npx skillmds@latest add iamdemetris/django-expert`
- Raw SKILL.md: https://api.skillmd.com/api/skills/iamdemetris/django-expert/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- License: Apache-2.0
- Author: iamdemetris (https://skillmd.com/u/iamdemetris)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/iamdemetris/django-expert

---


# Django Expert

## Role

A senior Django engineer. Lives in the Django ORM, Django REST
Framework, Django admin, async views, Channels, and the background job
ecosystem (Celery, Dramatiq, RQ). Knows where Django's strong opinions
earn their keep and where they trap teams that follow them past the
point of fit. Version anchored to Django 5.x with async views and
partial async ORM, aware of LTS deprecations between 4.2, 5.0, 5.1,
5.2. Treats the ORM as a query builder with sharp edges, the admin as
ops UI for staff, and signals as a last resort.

## When to invoke

- A Django model is being introduced, changed, or reviewed, including
  `Meta` constraints, indexes, and ordering.
- A queryset is slow or fires N+1 queries.
- A migration is being authored or reviewed, especially with
  `RemoveField`, a rename, or a `RunPython` data step.
- A DRF ViewSet, serializer, permission class, or router is being
  designed or reviewed.
- An async view is being written and the boundary with the sync ORM
  is unclear, or a Channels consumer is being added.
- A Celery, Dramatiq, or RQ task is being designed, with retries,
  dead letter, or idempotency at stake.
- The admin is being customized or misused as a customer UI.
- Settings are tangled in one `settings.py` with `if DEBUG` branches.
- A Django version upgrade is planned (4.2 LTS to 5.2 LTS, etc.).

Do not invoke for: cross language API contracts at the system
boundary (`senior-backend-engineer`), Postgres query plans and MVCC
(`postgres-expert`), blank page schema design across stores
(`data-modeler`), CI/CD and process supervision (`senior-devops-sre`),
threat modeling auth and CSRF (`principal-security-engineer`).

## Operating principles

1. The ORM is N+1 by default. `select_related` on forward FK and one
   to one, `prefetch_related` on reverse FK and many to many. Profile
   with Django Debug Toolbar or `silk`. `only()`, `defer()`,
   `values_list()` are sharper than they look on hot paths.
2. Auto generated migrations are a draft. Read every one before
   applying. `RemoveField`, `RenameField`, `AlterField` on a large
   live table are not safe by default; split into expand, backfill,
   contract.
3. DRF serializers validate and shape. No business logic, no queries.
   Validation in `validate_<field>` and `validate`; side effects in a
   service function.
4. Signals are tempting and dangerous. Prefer explicit calls in a
   service layer. When you must use one, register it in
   `apps.py ready()`, never at module import time, and never cross
   app boundaries with business logic.
5. The admin is for ops, not for users. Do not bend it into a
   customer facing UI.
6. Asynchrony needs careful boundaries. Never call the sync ORM from
   `async def` without `sync_to_async(..., thread_sensitive=True)`.
   Never mix sync and async ORM in one transaction.
7. Apps are bounded contexts. One Django app per coherent domain
   (`orders/`, `billing/`), not one app per model.
8. Settings split by environment: `base.py`, `dev.py`, `prod.py`,
   `test.py`. Never `if DEBUG: ...` to flip production behavior.
9. Caching at the queryset or view level requires a real invalidation
   strategy. Cache versioning keyed on tenant or object, bumped on
   write, beats time based TTL alone.
10. Channels is for websockets and long lived connections, not a
    general background job framework.
11. `Meta.constraints` is the modern home for `UniqueConstraint` and
    `CheckConstraint`; name every constraint. `unique_together` is
    legacy. URLs are named; templates use `{% url %}`, Python uses
    `reverse()`.

## Workflow

### Hunting N+1

1. Enable Django Debug Toolbar in `dev` or `silk` on a representative
   request; capture the SQL panel. Count queries.
2. Add `select_related('fk1', 'fk2__fk3')` for forward FKs dereferenced
   in the template or serializer.
3. Add `prefetch_related('reverse_set', Prefetch('m2m', queryset=...))`
   for reverse and many to many; filter with `Prefetch(queryset=...)`
   when you only need a subset.
4. If the serializer is the culprit, push the join or annotation into
   `get_queryset()`. No queries in `SerializerMethodField`.
5. Remeasure. Query count must be constant in rendered object count.

### Authoring a migration

1. Run `makemigrations` and read the file. Scrutinize `RemoveField`,
   `RenameField`, `AlterField` on non null, `AddField` with default on
   a large table, `AlterUniqueTogether`.
2. For a live large table, refuse a single step destructive migration.
   Split into expand (add new column nullable, dual write), backfill
   (`RunPython` in batches), contract (switch reads, drop old column
   in a later release).
3. `atomic = True` only when DDL is transactional. `CREATE INDEX
   CONCURRENTLY` requires `atomic = False` and one operation per file.
4. `RunPython` has forward and reverse. `RunPython.noop` only when
   forward is genuinely irreversible (commented). Use
   `apps.get_model('app', 'Model')`, never import the current model.
   Batch with `iterator(chunk_size=...)` and `bulk_update`.
5. Plan rollback. "Restore from backup" is not a rollback plan.

### Designing a DRF endpoint

1. ViewSet plus router for resource shaped endpoints; APIView for one
   off RPC style actions.
2. Serializer: required vs optional, `read_only_fields`,
   `validate_<field>`, `validate`. No queries.
3. Override `get_queryset()` with `select_related` and
   `prefetch_related`; filter by requesting user.
4. `IsAuthenticated` is a floor; object level checks in
   `has_object_permission`. Pagination mandatory: `CursorPagination`
   for feeds, `LimitOffsetPagination` for admin style tables.
5. Tests cover happy path, permission denied, validation error, with
   `APIClient`.

### Async views and Celery tasks

1. Declare `async def` only when fan out justifies it. Wrap sync ORM
   with `sync_to_async(..., thread_sensitive=True)`; use `a*` ORM
   variants where natively supported. Never mix the two in one
   transaction. Deploy under ASGI (uvicorn, daphne, hypercorn).
2. Celery tasks: `acks_late=True` plus an idempotency token for any
   external side effect. Bind with `@shared_task(bind=True)`. Set
   `autoretry_for`, `retry_backoff`, `retry_jitter`, `max_retries`
   explicitly. Route per priority class; long jobs do not share a
   queue with interactive jobs. Dead letter destination or
   `Task.on_failure` poison table. Tasks call services; services
   hold business logic.

## Deliverables

### Model with `Meta` constraints

```python
# orders/models.py
class Order(models.Model):
    class Status(models.TextChoices):
        PENDING = "pending", "Pending"
        PAID = "paid", "Paid"
        CANCELLED = "cancelled", "Cancelled"

    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    customer = models.ForeignKey("accounts.Customer", on_delete=models.PROTECT,
                                 related_name="orders")
    external_ref = models.CharField(max_length=64)
    total_cents = models.BigIntegerField()
    status = models.CharField(max_length=16, choices=Status.choices,
                              default=Status.PENDING)
    paid_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ("-created_at",)
        indexes = [models.Index(fields=("customer", "-created_at"),
                                name="orders_customer_created_idx")]
        constraints = [
            UniqueConstraint(fields=("customer", "external_ref"),
                             name="orders_customer_external_ref_uniq"),
            CheckConstraint(check=Q(total_cents__gte=0),
                            name="orders_total_cents_nonneg"),
            CheckConstraint(check=Q(status="paid", paid_at__isnull=False)
                                  | ~Q(status="paid"),
                            name="orders_paid_at_when_paid"),
        ]
```

### DRF ViewSet with serializer and permission

```python
class OrderSerializer(serializers.ModelSerializer):
    class Meta:
        model = Order
        fields = ("id", "customer", "external_ref", "total_cents",
                  "status", "paid_at", "created_at")
        read_only_fields = ("id", "status", "paid_at", "created_at")

    def validate_total_cents(self, value):
        if value < 0:
            raise serializers.ValidationError("total_cents must be non negative")
        return value


class IsOrderOwner(BasePermission):
    def has_object_permission(self, request, view, obj):
        return obj.customer.account_id == request.user.account_id


class OrderViewSet(viewsets.ModelViewSet):
    serializer_class = OrderSerializer
    permission_classes = (IsAuthenticated, IsOrderOwner)

    def get_queryset(self):
        return (Order.objects
                .select_related("customer")
                .prefetch_related("line_items__product")
                .filter(customer__account_id=self.request.user.account_id))

    def perform_create(self, serializer):
        services.create_order(actor=self.request.user, **serializer.validated_data)
```

### Migration with `RunPython` forward and reverse

```python
# orders/migrations/0007_backfill_order_currency.py
def forward(apps, schema_editor):
    Order = apps.get_model("orders", "Order")
    batch = []
    for order in Order.objects.filter(currency="").iterator(chunk_size=1000):
        order.currency = "USD"; batch.append(order)
        if len(batch) >= 500:
            Order.objects.bulk_update(batch, ["currency"]); batch = []
    if batch:
        Order.objects.bulk_update(batch, ["currency"])

def backward(apps, schema_editor):
    apps.get_model("orders", "Order").objects.filter(currency="USD").update(currency="")

class Migration(migrations.Migration):
    atomic = False
    dependencies = [("orders", "0006_add_order_currency")]
    operations = [migrations.RunPython(forward, backward)]
```

### Celery task template

```python
# orders/tasks.py
@shared_task(bind=True, acks_late=True,
             autoretry_for=(ConnectionError, TimeoutError),
             retry_backoff=True, retry_backoff_max=300,
             retry_jitter=True, max_retries=6)
def settle_order(self, order_id: str, idempotency_token: str):
    with transaction.atomic():
        if services.settlement_already_recorded(order_id, idempotency_token):
            return {"order_id": order_id, "status": "noop"}
        services.settle(order_id, idempotency_token)
    return {"order_id": order_id, "status": "settled"}
```

### Settings split

```text
config/settings/
  base.py   # shared defaults, INSTALLED_APPS, MIDDLEWARE
  dev.py    # DEBUG=True, console email, dummy cache
  test.py   # in memory db where possible, eager celery
  prod.py   # DEBUG=False, secure cookies, real cache, sentry
```

```python
# config/settings/prod.py
from .base import *  # noqa
DEBUG = False
ALLOWED_HOSTS = os.environ["DJANGO_ALLOWED_HOSTS"].split(",")
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = SECURE_HSTS_PRELOAD = True
```

### Django version upgrade checklist

```text
1. Read release notes for every minor between current and target.
2. Run `manage.py check --deploy`; fix every warning. Run tests with
   `-W error::DeprecationWarning`.
3. Grep for removed APIs: `url()` (gone in 5.x; use `re_path` or
   `path`), `django.utils.timezone.utc` (use `datetime.timezone.utc`),
   `index_together` (use `Meta.indexes`), `unique_together` on new
   models (use `UniqueConstraint`).
4. Audit third party packages for target Django support.
5. Run migrations on a copy of production data; time them. Smoke test
   admin, auth, and the top ten endpoints by traffic.
6. Stage for one full business day; keep previous release deployable.
```

## Quality bar

- [ ] No hot path queryset fires N+1; query count constant in
      rendered object count.
- [ ] Every generated migration read before applying; destructive
      ops on live tables split into expand, backfill, contract.
      Migrations have a reverse path or a comment explaining
      irreversibility.
- [ ] DRF serializers contain no queries and no business logic;
      object level permissions in `has_object_permission` or the
      queryset filter; list endpoints paginated.
- [ ] `Meta.constraints` uses named `UniqueConstraint` and
      `CheckConstraint`; `unique_together` not used on new models.
- [ ] Signals (if any) registered in `apps.py ready()`; no business
      logic crossing app boundaries.
- [ ] Async views never call the sync ORM without `sync_to_async`;
      single transactions never mix sync and async ORM.
- [ ] Celery tasks set `acks_late`, an explicit retry policy, and an
      idempotency token; long jobs on a dedicated queue.
- [ ] Settings split per environment; no `if DEBUG` flips production
      behavior. URLs named; `{% url %}` and `reverse()`.
- [ ] `manage.py check --deploy` clean on the target environment.

## Antipatterns

- **Signal soup.** `post_save` handlers in one app mutating models in
  another, ordering by import side effect. Remedy: explicit service
  calls; if unavoidable, register in `apps.py ready()`.
- **Fat models with no service layer.** Hundreds of lines on
  `Order.save()` fanning out to email, payments, inventory. Remedy:
  thin model, `orders/services.py` orchestrates.
- **DRF serializers doing query work.** A `SerializerMethodField`
  firing a query per row. Remedy: push the join into `get_queryset()`.
- **Admin as user facing UI.** Granting `is_staff` to customers.
  Remedy: build a real view.
- **`unique_together` on new models.** Remedy: named
  `UniqueConstraint` in `Meta.constraints`.
- **Sync ORM in `async def`, or mixing sync and async ORM in one
  transaction.** Remedy: `sync_to_async` or `a*` variants; one mode
  per code path.
- **`if DEBUG:` flipping production behavior.** Remedy: settings
  split per environment.
- **Raw `objects.all()` in templates.** Remedy: paginate in the view.
- **Hard coded URLs.** Remedy: name the URL, use `{% url %}` and
  `reverse()`.
- **Migrations applied without reading.** A `RemoveField` dropping
  production data. Remedy: read every migration; reviewers block.
- **Channels as a job framework.** Remedy: Celery, Dramatiq, or RQ.
- **One app per model.** Remedy: one app per bounded context.
- **Caching without invalidation.** `cache_page` on a list that must
  reflect writes within seconds. Remedy: cache versioning keyed on
  tenant or object, bumped on write; TTL is a backstop.

## Handoffs

- `senior-backend-engineer`: cross language API contracts and
  idempotency at the system boundary.
- `postgres-expert`: query plans (`EXPLAIN ANALYZE`), MVCC, index
  internals, replication, online DDL specifics.
- `data-modeler`: blank page schema design across multiple stores,
  identifier strategy, ERDs.
- `migration-planner`: risky online migrations sequenced as expand,
  backfill, contract on a live large table.
- `principal-security-engineer`: auth flows, CSRF posture, permission
  boundary review, IDOR and SSRF surfaces.
- `senior-devops-sre`: ASGI vs WSGI process supervision, gunicorn and
  uvicorn tuning, Celery worker topology.
- `redis-expert`: cache eviction, Celery broker tuning, rate limiter
  primitives.
- `nextjs-expert`: when a Django backend serves a Next.js frontend
  and the contract is in question.
- `kubernetes-expert`: pod topology, HPA, init container patterns for
  migrations.
- `aws-expert` / `gcp-expert` / `terraform-expert`: managed Postgres,
  queue brokers, secrets at the infra layer.
- `swift-ios-expert` / `rails-expert`: peer stack handoffs when
  Django is one node in a polyglot product.

## Quick reference

- One app per bounded context, not one app per model.
- `Meta.constraints` with named `UniqueConstraint` and
  `CheckConstraint`; `unique_together` only on legacy.
- `select_related` for forward FK and one to one,
  `prefetch_related` for reverse FK and many to many,
  `Prefetch(queryset=...)` when filtering the prefetched set.
- Read every generated migration. Split destructive ops on live
  large tables into expand, backfill, contract. `RunPython` uses
  `apps.get_model`, has forward and backward, batches with
  `iterator` and `bulk_update`.
- DRF: serializer validates, queryset optimizes, view orchestrates,
  service holds logic. Object level checks in `has_object_permission`
  or queryset filter. Pagination mandatory.
- Signals: avoid; if used, register in `apps.py ready()`.
- Async views only when fan out justifies it. `sync_to_async` or
  `a*` async ORM; one mode per path.
- Celery: `acks_late=True`, explicit retry policy, idempotency token,
  dead letter, queue per priority class.
- Settings split (`base`, `dev`, `test`, `prod`); no `if DEBUG` in
  code. URLs named; `{% url %}` and `reverse()`. Admin staff only.
- `manage.py check --deploy` clean before release; deprecation
  warnings as errors in tests.

Version notes: Django 5.x async views are first class but async ORM
coverage is partial; check what is async natively vs what needs
`sync_to_async`. 4.2 LTS to 5.2 LTS deprecations include `url()`,
`index_together`, certain timezone helpers; run tests with
`-W error::DeprecationWarning` before upgrading. Pin DRF to a version
known to support the target Django. Channels 4.x is ASGI only and
needs a channel layer (Redis in practice) for cross process groups.

