| name | django-perf-review |
| description | Reviews Django and DRF code for validated ORM issues: N+1 queries, unbounded querysets, missing indexes, and write loops, reporting only proven hot-path findings. Use when asked to review Django performance, find N+1s, or audit queryset and database problems. Not for general Django feature work, test/admin/migration files, or speculative micro-optimizations. Do not use as a substitute for EXPLAIN ANALYZE on production. |
| version | 1.0.1 |
| allowed-tools | Read, Grep, Glob, Bash, Task |
| license | LICENSE |
| risk | unknown |
| source | community |
Django Performance Review
Review Django code for validated performance issues. Research the codebase to confirm issues before reporting. Report only what you can prove.
When to Use
- You need a Django performance review focused on verified ORM and query issues.
- The code likely has N+1 queries, unbounded querysets, missing indexes, or other database-driven bottlenecks.
- You want only provable performance findings, not speculative optimization advice.
- Trigger keywords: "review Django performance", "find N+1 queries", "optimize Django", "check queryset performance", "database performance", "Django ORM issues", "audit Django code for performance".
Prerequisites
- A Django or Django REST Framework codebase accessible via
Read, Grep, and Glob.
- Sufficient context to trace data flow from view/queryset creation to template/serializer consumption.
- Ability to run
grep/Grep searches across the codebase to validate findings.
Procedure
Step 0: Adopt the Review Mindset
- Research first — Trace data flow, check for existing optimizations, verify data volume.
- Validate before reporting — Pattern matching is not validation.
- Zero findings is acceptable — Don't manufacture issues to appear thorough.
- Severity must match impact — If you catch yourself writing "minor" in a CRITICAL finding, it's not critical. Downgrade or skip it.
Step 1: Triage by Impact Category
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 |
Step 2: Check for 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.
def user_list(request):
users = User.objects.all()
return render(request, 'users.html', {'users': users})
def user_list(request):
users = User.objects.select_related('profile')
return render(request, 'users.html', {'users': users})
Rule: Prefetch in serializers, not just views
DRF serializers accessing related fields cause N+1 if queryset isn't optimized.
class UserSerializer(serializers.ModelSerializer):
order_count = serializers.SerializerMethodField()
def get_order_count(self, obj):
return obj.orders.count()
class UserViewSet(viewsets.ModelViewSet):
def get_queryset(self):
return User.objects.annotate(order_count=Count('orders'))
class UserSerializer(serializers.ModelSerializer):
order_count = serializers.IntegerField(read_only=True)
Rule: Model properties that query are dangerous in loops
class User(models.Model):
@property
def recent_orders(self):
return self.orders.filter(created__gte=last_week)[:5]
Validation Checklist for N+1
Step 3: Check for Unbounded Querysets (CRITICAL)
Impact: Loading entire tables exhausts memory. Large tables cause OOM kills and worker restarts.
Rule: Always paginate list endpoints
class UserListView(ListView):
model = User
template_name = 'users.html'
class UserListView(ListView):
model = User
template_name = 'users.html'
paginate_by = 25
Rule: Use iterator() for large batch processing
for user in User.objects.all():
process(user)
for user in User.objects.iterator(chunk_size=1000):
process(user)
Rule: Never call list() on unbounded querysets
all_users = list(User.objects.all())
users = User.objects.all()[:100]
Validation Checklist for Unbounded Querysets
Step 4: Check for 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
class User(models.Model):
email = models.EmailField()
class User(models.Model):
email = models.EmailField(db_index=True)
Rule: Index fields used in ORDER BY on large tables
Order.objects.order_by('-created')
class Order(models.Model):
created = models.DateTimeField(db_index=True)
Rule: Use composite indexes for common query patterns
class Order(models.Model):
user = models.ForeignKey(User)
status = models.CharField(max_length=20)
created = models.DateTimeField()
class Meta:
indexes = [
models.Index(fields=['user', 'status']),
models.Index(fields=['status', '-created']),
]
Validation Checklist for Missing Indexes
Step 5: Check for Write Loops (HIGH)
Impact: N database writes instead of 1. Lock contention. Slow requests.
Rule: Use bulk_create instead of create() in loops
for item in items:
Model.objects.create(name=item['name'])
Model.objects.bulk_create([
Model(name=item['name']) for item in items
])
Rule: Use update() or bulk_update instead of save() in loops
for obj in queryset:
obj.status = 'done'
obj.save()
queryset.update(status='done')
for obj in objects:
obj.status = compute_status(obj)
Model.objects.bulk_update(objects, ['status'], batch_size=500)
Rule: Use delete() on queryset, not in loops
for obj in queryset:
obj.delete()
queryset.delete()
Validation Checklist for Write Loops
Step 6: Check for Inefficient Patterns (LOW)
Rarely worth reporting. Include only as minor notes if you're already reporting real issues.
Pattern: count() vs exists()
if queryset.count() > 0:
do_thing()
if queryset.exists():
do_thing()
Usually skip — difference is <1ms in most cases.
Pattern: len(queryset) vs count()
if len(queryset) > 0:
if queryset.count() > 0:
Only flag if queryset is large and not already evaluated.
Pattern: get() in small loops
for id in ids:
obj = Model.objects.get(id=id)
Only flag if loop is large or this is in a very hot path.
Step 7: Validate Every Finding Before Reporting
Before reporting ANY issue:
- Trace the data flow — Follow queryset from creation to consumption.
- Search for existing optimizations — Grep for
select_related, prefetch_related, pagination.
- Verify data volume — Check if table is actually large.
- Confirm hot path — Trace call sites, verify this runs frequently.
- Rule out mitigations — Check for caching, rate limiting.
If you cannot validate all steps, do not report.
Step 8: Produce the Output Report
Use this format:
## Django Performance Review: [File/Component Name]
### Summary
Validated issues: X (Y Critical, Z High)
### Findings
#### [PERF-001] N+1 Query in UserListView (CRITICAL)
**Location:** `views.py:45`
**Issue:** Related field `profile` accessed in template loop without prefetch.
**Validation:**
- Traced: UserListView → users queryset → user_list.html → `{{ user.profile.bio }}` in loop
- Searched codebase: no select_related('profile') found
- User table: 50k+ rows (verified in admin)
- Hot path: linked from homepage navigation
**Evidence:**
```python
def get_queryset(self):
return User.objects.filter(active=True) # no select_related
Fix:
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]."`
### Step 9: Final Sanity Check
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.
## Pitfalls
### 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:**
```python
# This is FINE - no performance difference
projects_qs = Project.objects.filter(org=org)
projects = list(projects_qs)
# vs this - identical performance
projects = list(Project.objects.filter(org=org))
Querysets are lazy. Assigning to a variable doesn't execute anything.
Single query patterns are not N+1:
projects = list(Project.objects.filter(org=org))
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:
state = AutofixState.objects.filter(pr_id=pr_id).first()
project_id = state.request.project_id
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.
Over-reporting Risk
The biggest risk in Django performance review is manufacturing findings to appear thorough. Zero validated findings is a valid and professional outcome. Do not inflate severity to make a finding seem important.
Verification
To verify the review was conducted correctly:
- Every reported finding has a Validation section with traced data flow, codebase search results, data volume confirmation, and hot-path verification.
- No finding lacks evidence — if a finding has no code snippet or traced path, it should not be in the report.
- Severity matches impact — CRITICAL findings involve loops multiplying queries or unbounded memory; HIGH involves large-table scans or write loops; LOW is reserved for minor notes.
- Excluded categories are absent — no findings from test files, migrations, admin-only views, management commands, or tables with <1000 rows.
- False positives are absent — no reports of queryset variable assignment, single-query patterns, or single-object multi-fetch as N+1.
- Zero findings is valid — if no validated issues exist, the report states this explicitly with a summary of what was checked.
Quick Grep Commands for Validation
Use these to confirm findings during review:
# Find existing prefetch/select_related optimizations
grep -rn "select_related\|prefetch_related" --include="*.py"
# Find unbounded queryset patterns
grep -rn "\.all()" --include="*.py" | grep -v "test"
# Find pagination configuration
grep -rn "paginate_by\|PaginationClass\|pagination_class" --include="*.py"
# Find bulk operations
grep -rn "bulk_create\|bulk_update\|\.update(" --include="*.py"
# Find write loops
grep -rn "\.save()\|\.create()\|\.delete()" --include="*.py" | grep -v "test"
Limitations
- Use this skill only when the task clearly matches the scope described above.
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.