npx skills add ...
npx skills add getsentry/sentry-skills --skill django-perf-review
npx skills add getsentry/sentry-skills --skill django-perf-review
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.
The same skill content is published under more than one repo. The install counts are split across them; any of these commands works.
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
DRF serializers accessing related fields cause N+1 if queryset isn't optimized.
Impact: Loading entire tables exhausts memory. Large tables cause OOM kills and worker restarts.
Impact: Full table scans. Negligible on small tables, catastrophic on large ones.
Impact: N database writes instead of 1. Lock contention. Slow requests.
Rarely worth reporting. Include only as minor notes if you're already reporting real issues.
Usually skip - difference is <1ms in most cases.
Only flag if queryset is large and not already evaluated.
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.
Fix:
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.
# 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)# 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# 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# 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)# PROBLEM: Forces full evaluation into memory
all_users = list(User.objects.all())
# SOLUTION: Keep as queryset, slice if needed
users = User.objects.all()[:100]# 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)# 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)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']), # for filter(user=x, status=y)
models.Index(fields=['status', '-created']), # for filter(status=x).order_by('-created')
]# 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
])# 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)# PROBLEM: N deletes
for obj in queryset:
obj.delete()
# SOLUTION: Single DELETE
queryset.delete()# Slightly suboptimal
if queryset.count() > 0:
do_thing()
# Marginally better
if queryset.exists():
do_thing()# Fetches all rows to count
if len(queryset) > 0: # bad if queryset not yet evaluated
# Single COUNT query
if queryset.count() > 0:# N queries, but if N is small (< 20), often fine
for id in ids:
obj = Model.objects.get(id=id)