Django ORM Performance skill for Codex
Measure the queryset, then fix the actual cause. Codex measures a slow Django view, DRF endpoint or admin page before changing it, sorts the cause into too many queries, one slow query, too much data or too much Python per row, and applies the matching ORM fix. It avoids fan-out sums from stacked annotations, swaps AddIndex for AddIndexConcurrently on big Postgres tables, and leaves an assertNumQueries test behind.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- django-orm-performance Installs to ~/.agents/skills/django-orm-performance/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install django-orm-performance The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project django-orm-performance Installs to .agents/skills/django-orm-performance/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/django-orm-performance.zipunzip -o django-orm-performance.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) django-orm-performance PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $django-orm-performance, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Speeds up Django ORM code by measuring queries first and then applying the right tool - select_related, prefetch_related with Prefetch objects, only, defer, values, annotate with Subquery and OuterRef, exists instead of count, iterator with chunk_size, bulk_create with update_conflicts, F expressions, Meta.indexes with partial and GIN indexes, AddIndexConcurrently, and connection pooling. Also fixes slow Django admin changelists. Use when a Django view, DRF endpoint, admin page, management command or Celery task is slow, makes many queries, uses too much memory, or when adding indexes to Django models.
Use it when
- A Django view, DRF endpoint or admin page is slow
- Annotations or aggregates return wrong totals or run slowly
- A batch job or Celery task loads too many rows into memory
- Adding or reviewing indexes declared in Django models
Use something else when
- The only problem is a repeated per-row query in any ORM: use n-plus-one-queries
- General Django project structure, views or settings work: use django-apps
How to check the result
Query count stays flat between 20 and 200 parent rows, SQL time drops in the before/after record, payloads are unchanged, and an assertNumQueries test guards the path.
The mistake it prevents
Stacking two Sum or Count annotations across different related tables, which multiplies joined rows and returns inflated totals.
Done well means: the slow path has a measured before and after (query count, total SQL time, memory), each fix is the smallest ORM change that addresses the measured cause, and a test pins the query count so the regression cannot return unnoticed.
Workflow
- Read
AGENTS.mdfor the settings module, test runner (pytestwithpytest-djangoormanage.py test) and database (Postgres, MySQL, SQLite behave differently). - Reproduce with realistic volume: at least a few hundred parent rows and children.
- Measure (table below). Record query count, the top 3 slowest statements, and time.
- Classify the cause: too many queries, one slow query, too much data per query, or too much Python work per row.
- Apply the matching fix from the sections below. Change one thing at a time.
- Re-measure, then add an
assertNumQueriestest.
Measuring
| Need | Tool |
|---|---|
| Queries per request in dev | django-debug-toolbar SQL panel |
| Queries in a test | with self.assertNumQueries(4): or pytest-django django_assert_num_queries(4) |
| Ad hoc in a shell | from django.db import connection, reset_queries with DEBUG=True; len(connection.queries) |
| Capture without DEBUG | with connection.execute_wrapper(fn): to log or count every statement |
| Plan of one queryset | print(qs.explain(analyze=True, buffers=True)) on Postgres; qs.query shows SQL |
| Production | pg_stat_statements or APM traces grouped by view |
explain(analyze=True) executes the query. Do not run it on a queryset that ends in an
update or delete, and run it against a replica or local copy when the query is heavy.
Too many queries
- Forward FK and one-to-one used in output:
select_related("customer", "customer__plan"). - Reverse FK and many-to-many:
prefetch_related(...). - Filtered or ordered children:
Prefetch("items", queryset=Item.objects.filter(active=True).only("id", "sku", "order_id"), to_attr="active_items"). Keep the FK column inonly()or Django re-fetches it per row. - Calling
.filter(),.order_by()or.count()on a prefetched relation issues a new query per row. Useto_attrlists orlen(). - DRF: put the
select_related/prefetch_relatedin the viewset’sget_queryset, matched to every nested serializer field. Full N+1 playbook:$n-plus-one-queries.
Push work into SQL
from django.db.models import Count, Sum, Q, F, OuterRef, Subquery, Exists, Value
from django.db.models.functions import Coalesce
latest_payment = Payment.objects.filter(order=OuterRef("pk")).order_by("-created_at")
orders = (
Order.objects.filter(tenant=tenant)
.annotate(
item_count=Count("items", distinct=True),
paid=Exists(Payment.objects.filter(order=OuterRef("pk"), status="settled")),
last_paid_at=Subquery(latest_payment.values("created_at")[:1]),
)
.only("id", "number", "created_at")
)
- Two
Count/Sumannotations across different multi-valued relations multiply rows (join fan-out) and inflate sums. UseSubquerywith a grouped inner query, ordistinct=Truefor counts only (it does not fixSum). Sumover no rows returnsNone: wrap inCoalesce(Sum("amount"), Value(0))with an explicitoutput_fieldwhen types differ.- Updates without loading:
Product.objects.filter(pk=pk).update(stock=F("stock") - 1). This also removes the read-modify-write race. - Existence:
qs.exists(); a single row:qs.first(); a count shown to users:qs.count().len(qs)andif qs:load every row.
Fetch less data
| Situation | Use |
|---|---|
| Need a few fields of model instances | .only("id", "name") or .defer("body", "payload") |
| Read-only dicts or tuples for an API or export | .values(...) / .values_list(..., flat=True) |
| Lookup by many ids | Model.objects.in_bulk(ids) |
| Iterating 100k+ rows in a task | .iterator(chunk_size=2000) (server-side cursor on Postgres) |
| Large text or JSON column not shown in a list | .defer() it |
Accessing a deferred field later costs one query per instance; check templates and serializers before deferring.
Bulk writes
Item.objects.bulk_create(objs, batch_size=1000)
Item.objects.bulk_create(
objs, batch_size=1000,
update_conflicts=True, unique_fields=["tenant", "sku"], update_fields=["price", "updated_at"],
)
Item.objects.bulk_update(objs, ["price"], batch_size=500)
bulk_create and update() skip save(), model signals and auto_now; set timestamps
explicitly and confirm no signal handler is required. Wrap multi-step writes in
transaction.atomic().
Indexes from models
class Order(models.Model):
class Meta:
indexes = [
models.Index(fields=["tenant", "-created_at"], name="order_tenant_created_idx"),
models.Index(fields=["created_at"], name="order_pending_idx",
condition=Q(status="pending")),
]
constraints = [
models.UniqueConstraint(fields=["tenant", "number"], name="order_tenant_number_uq"),
]
ForeignKeycreates an index by default; composite indexes that start with the FK can make that single-column index redundant.- Text search or JSON containment on Postgres:
django.contrib.postgres.indexes.GinIndex(withopclasses=["gin_trgm_ops"]foricontainsafter enablingpg_trgm). - Large Postgres tables: generate the migration, then replace
AddIndexwithAddIndexConcurrentlyfromdjango.contrib.postgres.operationsand setatomic = Falseon the migration class. Usesqlmigrate app 00xxto review the SQL. - Default
Meta.orderingaddsORDER BYto every query, including ones that do not need it. Remove it or call.order_by()to clear it on hot aggregate queries.
Pagination and admin
PaginatorrunsCOUNT(*)on every page. For large tables use keyset pagination (filter(id__lt=last_id).order_by("-id")[:50]) or DRFCursorPagination.- Admin changelist: set
list_select_related,show_full_result_count = False, useautocomplete_fieldsorraw_id_fieldsfor large FK choices, and avoid callables inlist_displaythat query per row (annotate inget_querysetinstead).
Connections
- Django 5.1+ with psycopg 3 supports pooling:
"OPTIONS": {"pool": True}(or a dict of pool options). Do not combine it withCONN_MAX_AGE. - Otherwise use
CONN_MAX_AGE(for example 60) withCONN_HEALTH_CHECKS = True. - Behind PgBouncer in transaction mode, set
DISABLE_SERVER_SIDE_CURSORS = True(.iterator()otherwise breaks).
Gotchas
- A queryset is lazy and cached after evaluation; re-filtering it creates a new query. Evaluate once into a list if you reuse the rows.
prefetch_relatedwith.iterator()needschunk_size(Django 4.1+); without it the prefetch is skipped or errors depending on version.select_related()with no arguments follows every non-null FK; name relations explicitly.distinct()combined withorder_byon a related field returns duplicates or errors on Postgres; check the generated SQL.get_or_createunder concurrency needs a unique constraint to be correct.
Verification
- Query count and total SQL time for the scenario, before and after, at two data sizes (for example 20 and 200 parents): the count must not grow.
EXPLAIN ANALYZEfor the slowest remaining statement shows index use where expected.- Response payload unchanged (snapshot or contract test).
- New test with
assertNumQueries(n)passes and fails if the fix is reverted.
Output
**Path:** <view/endpoint/task>
**Before:** <N queries, X ms SQL, Y ms total> at <data size>
**Cause:** <N+1 on serializer field / fan-out sum / missing index / full-row fetch>
**Changes:** <queryset changes, indexes + migration name>
**After:** <N queries, X ms SQL, Y ms total>
**Guard:** <test name with assertNumQueries> What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Django ORM Performance"
short_description: "Measure the queryset, then fix the actual cause"
default_prompt: "$django-orm-performance The Django admin changelist for orders takes 12 seconds to load, speed it up" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.