Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Django performance code review. Use when asked to "review Django performance", "find N+1 queries", "optimize Django", "check queryset performance", "database performance", "Django ORM issues", or audit Django code for performance problems.
.claude/skills/django-perf-review/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-07 | ✗→✗ | = Same ✗ | — | — |
| case-13 | ✗→✗ | = Same ✗ | — | — |
| case-19 | ✗→✗ | = Same ✗ | — | — |
| case-18 | ✗→✗ | = Same ✗ | — | — |
| case-08 | ✗→✗ | = Same ✗ | — | — |
Review Django code for validated performance issues. Research the codebase to confirm issues before reporting. Report only what you can prove.
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 |
Impact: Each N+1 adds O(n) database round trips. 100 rows = 100 extra queries. 10,000 rows = timeout.
Validate by tracing: View → Queryset → Template/Serializer → Loop access
python# PROBLEM: N+1 - each iteration queries profile def user_list(request): users = User.objects.all() return render(request, 'users.html', {'users': users}) # Template: # {% for user in users %} # {{ user.profile.bio }} ← triggers query per user # {% endfor %} # SOLUTION: Prefetch in view def user_list(request): users = User.objects.select_related('profile') return render(request, 'users.html', {'users': users})
DRF serializers accessing related fields cause N+1 if queryset isn't optimized.
python# PROBLEM: SerializerMethodField queries per object class UserSerializer(serializers.ModelSerializer): order_count = serializers.SerializerMethodField() def get_order_count(self, obj): return obj.orders.count() # ← query per user # SOLUTION: Annotate in viewset, access in serializer 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)
python# PROBLEM: Property triggers query when accessed class User(models.Model): @property def recent_orders(self): return self.orders.filter(created__gte=last_week)[:5] # Used in template loop = N+1 # SOLUTION: Use Prefetch with custom queryset, or annotate
Impact: Loading entire tables exhausts memory. Large tables cause OOM kills and worker restarts.
python# PROBLEM: No pagination - loads all rows class UserListView(ListView): model = User template_name = 'users.html' # SOLUTION: Add pagination class UserListView(ListView): model = User template_name = 'users.html' paginate_by = 25
python# PROBLEM: Loads all objects into memory at once for user in User.objects.all(): process(user) # SOLUTION: Stream with iterator() for user in User.objects.iterator(chunk_size=1000): process(user)
python# PROBLEM: Forces full evaluation into memory all_users = list(User.objects.all()) # SOLUTION: Keep as queryset, slice if needed users = User.objects.all()[:100]
Impact: Full table scans. Negligible on small tables, catastrophic on large ones.
python# PROBLEM: Filtering on unindexed field # User.objects.filter(email=email) # full scan if no index class User(models.Model): email = models.EmailField() # ← no db_index # SOLUTION: Add index class User(models.Model): email = models.EmailField(db_index=True)
python# PROBLEM: Sorting requires full scan without index Order.objects.order_by('-created') # SOLUTION: Index the sort field class Order(models.Model): created = models.DateTimeField(db_index=True)
pythonclass Order(models.Model): user = models.ForeignKey(User) status = models.CharField(max_length=20) created = models.DateTimeField() class Meta: indexes = [ models.Index(fields=['user', 'status']), # for filter(user=x, status=y) models.Index(fields=['status', '-created']), # for filter(status=x).order_by('-created') ]
Impact: N database writes instead of 1. Lock contention. Slow requests.
python# PROBLEM: N inserts, N round trips for item in items: Model.objects.create(name=item['name']) # SOLUTION: Single bulk insert Model.objects.bulk_create([ Model(name=item['name']) for item in items ])
python# PROBLEM: N updates for obj in queryset: obj.status = 'done' obj.save() # SOLUTION A: Single UPDATE statement (same value for all) queryset.update(status='done') # SOLUTION B: bulk_update (different values) for obj in objects: obj.status = compute_status(obj) Model.objects.bulk_update(objects, ['status'], batch_size=500)
python# PROBLEM: N deletes for obj in queryset: obj.delete() # SOLUTION: Single DELETE queryset.delete()
Rarely worth reporting. Include only as minor notes if you're already reporting real issues.
python# Slightly suboptimal if queryset.count() > 0: do_thing() # Marginally better if queryset.exists(): do_thing()
Usually skip - difference is <1ms in most cases.
python# Fetches all rows to count if len(queryset) > 0: # bad if queryset not yet evaluated # Single COUNT query if queryset.count() > 0:
Only flag if queryset is large and not already evaluated.
python# N queries, but if N is small (< 20), often fine for id in ids: obj = Model.objects.get(id=id)
Only flag if loop is large or this is in a very hot path.
Before reporting ANY issue:
If you cannot validate all steps, do not report.
markdown## 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:**
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:
If the answer to any is "no" - remove the finding.
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:
python# This is ONE query, 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:
python# This is 2 queries, not N+1 - report as LOW at most state = AutofixState.objects.filter(pr_id=pr_id).first() project_id = state.request.project_id # second query
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.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-07 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted, and 19 counted toward the lift figure. The other 3 produced results that are not comparable between the two arms, so they are excluded from the headline rather than averaged into it. The headline lift of -100 percentage points is the difference between those two pass rates over the 19 comparable cases. 3 cases got worse with the skill loaded, and they are included in that figure.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.