Coreframe Labs

Automated performance audit

Performance audits for Django ORM

The Django ORM's lazy evaluation is the feature that makes it pleasant and the feature that makes it slow. Attribute access triggers queries, and attribute access is invisible in a template. Nearly every Django performance problem we see traces back to that one property.

Check a file now — free

Paste one file. The free tier runs a single security pass and returns up to three findings, each with the line of code that evidences it. Nothing is stored, nothing is used for training, and detected secrets are redacted before analysis.

Paste one file0 / 400 lines

Not stored. Not trained on. Secrets redacted before analysis.

What we find most often in Django ORM

Each of these is a real pattern, with the code that causes it and the change that fixes it.

1. N+1 from a lazily-evaluated foreign key

Symptom
A list view that was instant with 20 rows takes 8 seconds at 500. Django Debug Toolbar shows 501 queries.

The pattern

# views.py
def order_list(request):
    orders = Order.objects.all()[:500]
    return render(request, "orders.html", {"orders": orders})

# orders.html
# {% for order in orders %}{{ order.customer.name }}{% endfor %}

Why it bites
`order.customer` is a lazy descriptor: the first access per object issues its own SELECT. The template gives no syntactic hint that a database call is happening, so the cost is invisible in review and scales linearly with row count.

The fix

# One JOIN instead of 500 round trips.
orders = Order.objects.select_related("customer").all()[:500]

# For many-to-many or reverse FKs, prefetch_related does it in 2 queries:
orders = (
    Order.objects
    .select_related("customer")
    .prefetch_related("line_items__product")[:500]
)

2. Aggregates computed per row instead of in the database

Symptom
The dashboard issues one COUNT per row. Each is fast; the page is not.

The pattern

# template
# {% for author in authors %}{{ author.book_set.count }}{% endfor %}

Why it bites
`.count()` on a related manager is a fresh `SELECT COUNT(*)` every time it is evaluated, and it is evaluated once per iteration. The database does the same table scan repeatedly for data it could have aggregated in a single grouped query.

The fix

from django.db.models import Count

authors = Author.objects.annotate(book_count=Count("book"))
# template: {{ author.book_count }} — no query, value came back with the row

3. Unbounded querysets loaded into memory

Symptom
A management command works for a year, then OOM-kills the worker after the table crosses a few million rows.

The pattern

for event in Event.objects.all():
    process(event)

Why it bites
`.all()` with no slice materializes every row into Python objects before the loop body runs. Memory grows with table size, so the failure arrives suddenly at a threshold nobody predicted rather than degrading gradually.

The fix

# Streams in chunks; constant memory.
for event in Event.objects.all().iterator(chunk_size=2000):
    process(event)

# Better still, push the work into the database where possible:
Event.objects.filter(status="pending").update(status="queued")

4. Filtering in Python instead of SQL

Symptom
The query is indexed, but the view still reads the whole table.

The pattern

recent = [e for e in Event.objects.all() if e.created_at > cutoff]

Why it bites
The list comprehension forces evaluation of the entire queryset, then discards most of it. The index on created_at is never used, because the WHERE clause never reaches the database.

The fix

recent = Event.objects.filter(created_at__gt=cutoff)
# Lazy, indexed, and the database returns only matching rows.

An automated pass reads one file. An audit reads the system.

The tool above is deliberately narrow — one file, one dimension, one pass. The issues that actually take systems down live between files: in the trust boundary nobody documented, the query pattern that only emerges at scale, the cache that was correct until the second tenant arrived. That is what the Coreframe Technical Audit is for — five business days, a written report, a live findings call, and a remediation proposal you can execute with or without us.

£1,500–£2,500 solo · £3,000–£5,000 funded startup · £3,000–£6,000 SME

See what the full audit covers →

Related audits

All stack audits →