Django Apps skill for Claude Code
Constant query counts and migrations safe on live data. Claude measures queries before touching a Django view, then fixes N+1 with select_related, prefetch_related and annotations, and locks the result in with assertNumQueries. Migrations are read as SQL and planned for live tables, tasks are enqueued in on_commit, and DRF viewsets are scoped per user so list endpoints never leak other customers' rows.
- name
django-apps - size178 lines · ≈1,950 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds and reviews Django 5.x projects - model design, safe migrations, ORM query efficiency with select_related and prefetch_related, transactions and on_commit, Django REST Framework serializers and viewsets, admin performance, and environment-driven settings with deploy checks. Use when writing Django models, views, DRF APIs or migrations, when a Django page or endpoint issues too many queries, or when preparing Django settings for production. Try asking
- The orders admin page takes 8 seconds. Find the slow queries
- Add a NOT NULL column to this 50M-row table without downtime
- Build a DRF endpoint for invoices scoped to the current user
Use it when
- Writing or reviewing Django models, views or DRF serializers
- A Django page or API endpoint issues dozens of queries
- Shipping migrations against large production tables
- Hardening Django settings before a production deploy
Skip it when
- The service is FastAPI or another ASGI framework — use fastapi-service
- The migration question is database-generic — use schema-migrations
How to check the result
Tests pass with assertNumQueries guards on touched views, makemigrations --check is clean, sqlmigrate shows no blocking rewrites, and check --deploy reports no warnings.
The common mistake it prevents
Rendering nested DRF serializers without preloading in get_queryset, turning a 50-row page into hundreds of queries.
A healthy Django codebase keeps query counts constant per request, ships migrations that
can run against a live database, puts business rules in models or service functions
rather than views, and passes manage.py check --deploy with production settings.
Workflow
- [ ] 1. Check versions (python -m django --version) and settings layout
- [ ] 2. Read the models and the URLs/views touched by the task
- [ ] 3. Measure queries for the affected views before changing them
- [ ] 4. Make the change: models -> migration -> services -> views/serializers
- [ ] 5. Review the migration SQL and its locking behavior
- [ ] 6. Test with assertNumQueries and the test client
- [ ] 7. Run checks: makemigrations --check, check --deploy, tests
Django 5.2 is the current LTS (supported to April 2028); Django 6.0 shipped December 2025.
Features worth using when available: db_default and GeneratedField (5.0),
CompositePrimaryKey (5.2), async ORM methods (aget, acreate, async for), and the
built-in tasks API in 6.0 (which still needs a real worker backend for production).
3. Measure queries first
from django.db import connection, reset_queries
from django.test.utils import CaptureQueriesContext
with CaptureQueriesContext(connection) as ctx:
response = client.get("/orders/")
print(len(ctx.captured_queries))
for q in ctx.captured_queries[:10]:
print(q["sql"][:200])
Or install django-debug-toolbar in development and read the SQL panel. Repeated
identical queries differing only by ID are N+1.
4. Models and the ORM
class Order(models.Model):
class Status(models.TextChoices):
PENDING = "pending"
PAID = "paid"
CANCELED = "canceled"
customer = models.ForeignKey("customers.Customer", on_delete=models.PROTECT, related_name="orders")
status = models.CharField(max_length=16, choices=Status, default=Status.PENDING, db_index=True)
total = models.DecimalField(max_digits=12, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [models.Index(fields=["customer", "-created_at"])]
constraints = [models.CheckConstraint(condition=models.Q(total__gte=0), name="order_total_gte_0")]
(CheckConstraint(condition=...) replaced check= in Django 5.1.)
| Access pattern | Use |
|---|---|
| Forward FK / one-to-one used per row | select_related("customer") (SQL JOIN) |
| Reverse FK / M2M used per row | prefetch_related("items") (second query) |
| Prefetch with filter/order/nested select | Prefetch("items", queryset=Item.objects.select_related("product")) |
| Counts/sums per row | annotate(item_count=Count("items")) |
| Only a few columns | .only(...), .values(...), .values_list(..., flat=True) |
| Existence check | .exists(), never len(qs) or if qs: on large sets |
| Update without race | F("stock") - 1 in .update() |
| Large loops | .iterator(chunk_size=2000) |
| Many inserts/updates | bulk_create(batch_size=1000), bulk_update(objs, ["field"]) |
bulk_create, update() and delete() on querysets skip save() and signals; do not
rely on signals for invariants.
Transactions
from django.db import transaction
@transaction.atomic
def place_order(customer, cart):
order = Order.objects.create(customer=customer, total=cart.total)
Product.objects.filter(pk__in=cart.ids).select_for_update() # lock rows in this tx
...
transaction.on_commit(lambda: send_confirmation.delay(order.pk))
Enqueue tasks and send emails in on_commit, never inside the transaction: a worker can
otherwise read a row that does not exist yet, or act on a rolled-back order. Keep
ATOMIC_REQUESTS off for APIs with external calls; wrap explicit units instead.
5. Migrations
python manage.py makemigrations --check --dry-run # CI: fail if models drift
python manage.py sqlmigrate orders 0042 # read the SQL before shipping
python manage.py migrate --plan
| Change | Safe approach on a large live table |
|---|---|
| Add nullable column | Safe |
| Add column with default | Use db_default or nullable + backfill; on PostgreSQL 11+ a constant default is metadata-only |
| Add NOT NULL to existing column | Add as nullable, backfill in batches, then add constraint in a later deploy |
| Add index on Postgres | AddIndexConcurrently from django.contrib.postgres.operations in a migration with atomic = False |
| Rename column/model | Expand-contract: add new, dual-write, backfill, switch reads, drop old later |
| Remove field | First deploy code that no longer reads it; then drop in the next release |
| Data migration | RunPython(forwards, backwards) using apps.get_model(), batched, never importing models directly |
Never edit a migration that has run in any shared environment; add a new one. Squash only with team agreement. Dropping columns or tables requires explicit user confirmation.
DRF
class OrderViewSet(viewsets.ModelViewSet):
serializer_class = OrderSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return (Order.objects.filter(customer__user=self.request.user) # object-level scoping
.select_related("customer")
.prefetch_related("items__product"))
- Scope
get_queryset()by user or tenant;queryset = Order.objects.all()with onlyIsAuthenticatedexposes every order to every user. - Nested serializers cause N+1 unless
get_querysetpreloads exactly what they read. - Use explicit
fields = [...], never"__all__"on write serializers (mass assignment). - Set
DEFAULT_PAGINATION_CLASS(cursor pagination for large tables) andPAGE_SIZE. - Put cross-field validation in
validate(), side effects in services called fromperform_create, not in serializers’save()overrides scattered across the code.
Admin
list_select_related = True (or a tuple), autocomplete_fields or raw_id_fields for
FKs to big tables (the default dropdown loads every row), show_full_result_count = False
and list_per_page for huge changelists, readonly_fields for audit columns.
Settings
- One
settings.pyreading environment variables (django-environoros.environ), orbase/dev/prodmodules; secrets never committed. - Production:
DEBUG = False,ALLOWED_HOSTS,CSRF_TRUSTED_ORIGINS,SECURE_PROXY_SSL_HEADERwhen behind a TLS-terminating proxy,SESSION_COOKIE_SECURE,CSRF_COOKIE_SECURE,SECURE_HSTS_SECONDS,CONN_MAX_AGE(or connection pooling viaOPTIONS: {"pool": True}with psycopg 3 on Django 5.1+),STORAGESfor static/media.
DJANGO_SETTINGS_MODULE=config.settings.prod python manage.py check --deploy
Gotchas
default=timezone.now()(called) freezes the value at import; pass the callable.on_delete=CASCADEon a FK to users deletes business records with the user; preferPROTECTorSET_NULLfor financial data.save()overwrites every column; usesave(update_fields=[...])in concurrent code..count()andlen()on the same queryset run separate work; reuse results.- Signals hide control flow and do not fire for bulk operations; prefer explicit calls.
- Async views calling sync ORM methods raise
SynchronousOnlyOperation; use async ORM methods orsync_to_async.
Verification
python manage.py test(orpytest --ds=...) passes; views touched havewith self.assertNumQueries(N):guards so N+1 regressions fail CI.makemigrations --checkis clean andsqlmigrateoutput shows no full-table rewrite or blocking index build on large tables.check --deployreports no warnings for production settings.- Manually request a list endpoint as a second user: it returns only that user’s rows.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.