Django Perf Review
Review Django code for **validated** performance issues. Research the codebase to confirm issues before reporting.
Content
Review Django code for validated performance issues. Research the codebase to confirm issues before reporting. Report only what you can prove.
Review Approach
1. Research first - Trace data flow, check for existing optimizations, verify data volume
2. Validate before reporting - Pattern matching is not validation
3. Zero findings is acceptable - Don't manufacture issues to appear thorough
4. Severity must match impact - If you catch yourself writing "minor" in a CRITICAL finding, it's not critical. Downgrade or skip it.
Impact Categories
Issues are organized by impact. Focus on CRITICAL and HIGH - these cause real problems at scale.
| Priority | Category | Impact |
|---|---|---|
| 1 | N+1 Queries | **CRITICAL** - Multiplies with data, causes timeouts |
| 2 | Unbounded Querysets | **CRITICAL** - Memory exhaustion, OOM kills |
| 3 | Missing Indexes | **HIGH** - Full table scans on large tables |
| 4 | Write Loops | **HIGH** - Lock contention, slow requests |
| 5 | Inefficient Patterns | **LOW** - Rarely worth reporting |
---
Priority 1: N+1 Queries (CRITICAL)
Impact: Each N+1 adds O(n) database round trips. 100 rows = 100 extra queries. 10,000 rows = timeout.
Rule: Prefetch related data accessed in loops
Validate by tracing: View → Queryset → Template/Serializer → Loop access
Rule: Prefetch in serializers, not just views
DRF serializers accessing related fields cause N+1 if queryset isn't optimized.
Rule: Model properties that query are dangerous in loops
Validation Checklist for N+1
- -[ ] Traced data flow from view to template/serializer
- -[ ] Confirmed related field is accessed inside a loop
- -[ ] Searched codebase for existing select_related/prefetch_related
- -[ ] Verified table has significant row count (1000+)
- -[ ] Confirmed this is a hot path (not admin, not rare action)
---
Priority 2: Unbounded Querysets (CRITICAL)
Impact: Loading entire tables exhausts memory. Large tables cause OOM kills and worker restarts.
Rule: Always paginate list endpoints
Rule: Use iterator() for large batch processing
Rule: Never call list() on unbounded querysets
Validation Checklist for Unbounded Querysets
- -[ ] Table is large (10k+ rows) or will grow unbounded
- -[ ] No pagination class, paginate_by, or slicing
- -[ ] This runs on user-facing request (not background job with chunking)
---
Priority 3: Missing Indexes (HIGH)
Impact: Full table scans. Negligible on small tables, catastrophic on large ones.
Rule: Index fields used in WHERE clauses on large tables
Rule: Index fields used in ORDER BY on large tables
Rule: Use composite indexes for common query patterns
Validation Checklist for Missing Indexes
- -[ ] Table has 10k+ rows
- -[ ] Field is used in filter() or order_by() on hot path
- -[ ] Checked model - no db_index=True or Meta.indexes entry
- -[ ] Not a foreign key (already indexed automatically)
---
Priority 4: Write Loops (HIGH)
Impact: N database writes instead of 1. Lock contention. Slow requests.
Rule: Use bulk_create instead of create() in loops
Rule: Use update() or bulk_update instead of save() in loops
Rule: Use delete() on queryset, not in loops
Validation Checklist for Write Loops
- -[ ] Loop iterates over 100+ items (or unbounded)
- -[ ] Each iteration calls create(), save(), or delete()
- -[ ] This runs on user-facing request (not one-time migration script)
---
Priority 5: Inefficient Patterns (LOW)
Rarely worth reporting. Include only as minor notes if you're already reporting real issues.
Pattern: count() vs exists()
Usually skip - difference is <1ms in most cases.
Pattern: len(queryset) vs count()
Only flag if queryset is large and not already evaluated.
Pattern: get() in small loops
Only flag if loop is large or this is in a very hot path.
---
Validation Requirements
Before reporting ANY issue:
1. Trace the data flow - Follow queryset from creation to consumption
2. Search for existing optimizations - Grep for select_related, prefetch_related, pagination
3. Verify data volume - Check if table is actually large
4. Confirm hot path - Trace call sites, verify this runs frequently
5. Rule out mitigations - Check for caching, rate limiting
If you cannot validate all steps, do not report.
---
Output Format
def get_queryset(self):
return User.objects.filter(active=True) # no select_related
def get_queryset(self):
return User.objects.filter(active=True).select_related('profile')
If no issues found: "No performance issues identified after reviewing [files] and validating [what you checked]."
Before submitting, sanity check each finding:
- -Does the severity match the actual impact? ("Minor inefficiency" ≠ CRITICAL)
- -Is this a real performance issue or just a style preference?
- -Would fixing this measurably improve performance?
If the answer to any is "no" - remove the finding.
---
What NOT to Report
- -Test files
- -Admin-only views
- -Management commands
- -Migration files
- -One-time scripts
- -Code behind disabled feature flags
- -Tables with <1000 rows that won't grow
- -Patterns in cold paths (rarely executed code)
- -Micro-optimizations (exists vs count, only/defer without evidence)
False Positives to Avoid
Queryset variable assignment is not an issue:
Querysets are lazy. Assigning to a variable doesn't execute anything.
Single query patterns are not N+1:
N+1 requires a loop that triggers additional queries. A single list() call is fine.
Missing select_related on single object fetch is not N+1:
N+1 requires a loop. A single object doing 2 queries instead of 1 can be reported as LOW if relevant, but never as CRITICAL/HIGH.
Style preferences are not performance issues:
If your only suggestion is "combine these two lines" or "rename this variable" - that's style, not performance. Don't report it.
FAQ
Discussion
Loading comments...