| name | django-perf-review |
| description | ALWAYS use this when the user mentions Django Perf Review, asks to build, debug, review, document, automate, test, configure, migrate, or make decisions in this domain, or the task clearly depends on Django Perf Review; scope: Django performance code review. Apply the bundled workflow, references, scripts, Senior Master standard, and Codex strict review gate before final output. |
Django Performance Review
Selective Reading Rule
Start with:
references/senior-master-standard.md
references/usage-routing.md
references/quality-checklist.md
Then load only the inherited docs, scripts, assets, or examples that match the user's actual task.
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.
Review Approach
- 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.
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
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
Priority 2: 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
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
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
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
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
Priority 5: 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.
Validation Requirements
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.
Output 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]."
**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:**
```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.
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.