django-perf-review について
django-perf-review は、Django プロジェクトのコードレビュー向けスキルです。コードの状況を調べて裏付けた性能問題だけを報告し、該当なしの結果も認めます。
主な機能
- 01クエリとデータフローの追跡
- 02N+1 クエリリスクの検証
- 03無制限 QuerySet とページネーションの確認
- 04インデックスと書き込みループの評価
- 05未検証または影響の小さい問題の除外
活用シーン
- 01Django のビューやシリアライザーの N+1 クエリ確認
- 02リストエンドポイントの無制限なデータ読み込みの確認
- 03頻繁に使われる処理の遅い書き込みの調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ getsentry/ skills --skill django-perf-reviewインストール後、Claude Code で/django-perf-reviewを入力して読み込まれたか確認します。
同梱ファイルLICENSE
詳細
Django パフォーマンスレビュー
Django のコードをレビューし、検証済みのパフォーマンス問題を報告します。報告する前にコードベースを調査して問題を確認してください。証明できる内容だけを報告してください。
レビューの進め方
- まず調査する - データの流れを追い、既存の最適化を確認し、データ量を検証します
- 報告前に検証する - パターンに当てはまるだけでは検証になりません
- 指摘ゼロでも問題ありません - 網羅的に見せるために問題を作り出さないでください
- 深刻度を影響に合わせる - CRITICAL の指摘で「軽微」と書きそうになったら、それは重大ではありません。深刻度を下げるか、指摘を見送ってください。
影響カテゴリ
問題は影響度別に分類されています。実運用で問題を引き起こす CRITICAL と HIGH を重視してください。
| 優先度 | カテゴリ | 影響 |
|---|---|---|
| 1 | N+1 クエリ | CRITICAL - データ量に比例して増加し、タイムアウトを引き起こします |
| 2 | 上限のない QuerySet | CRITICAL - メモリ枯渇や OOM によるプロセス強制終了を引き起こします |
| 3 | インデックスの不足 | HIGH - 大規模テーブルで全件スキャンが発生します |
| 4 | 書き込みループ | HIGH - ロック競合やリクエストの遅延を引き起こします |
| 5 | 非効率なパターン | LOW - 報告する価値があるケースはほとんどありません |
優先度 1: N+1 クエリ(CRITICAL)
影響: N+1 はそれぞれ O(n) のデータベース往復を追加します。100 行では追加クエリが 100 回、10,000 行ではタイムアウトにつながります。
ルール: ループ内でアクセスする関連データは事前に取得する
検証では、View → QuerySet → Template/Serializer → ループ内でのアクセスの順に追跡します。
# 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})
ルール: View だけでなく Serializer でも事前取得する
QuerySet が最適化されていないと、DRF Serializer が関連フィールドにアクセスした際に N+1 が発生します。
# 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)
ルール: クエリを実行する Model プロパティをループ内で使うのは危険です
# 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
N+1 の検証チェックリスト
- View から Template/Serializer までのデータの流れを追跡した
- ループ内で関連フィールドにアクセスしていることを確認した
- コードベースで既存の select_related/prefetch_related を検索した
- テーブルに十分な行数(1000 行以上)があることを確認した
- ホットパスであることを確認した(管理画面ではなく、まれな操作でもない)
優先度 2: 上限のない QuerySet(CRITICAL)
影響: テーブル全体を読み込むとメモリを使い果たします。大規模テーブルでは OOM によるプロセス強制終了やワーカーの再起動が発生します。
ルール: リストエンドポイントには必ずページネーションを適用する
# 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
ルール: 大規模なバッチ処理では iterator() を使う
# 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)
ルール: 上限のない QuerySet に list() を呼び出さない
# PROBLEM: Forces full evaluation into memory
all_users = list(User.objects.all())
# SOLUTION: Keep as queryset, slice if needed
users = User.objects.all()[:100]
上限のない QuerySet の検証チェックリスト
- テーブルが大規模(10k 行以上)、または上限なく増加する
- ページネーションクラス、paginate_by、スライスのいずれも使われていない
- ユーザー向けリクエストで実行される(チャンク処理を伴うバックグラウンドジョブではない)
優先度 3: インデックスの不足(HIGH)
影響: 全件スキャンが発生します。小規模なテーブルでは影響はごくわずかですが、大規模なテーブルでは致命的です。
ルール: 大規模なテーブルで WHERE 句に使うフィールドにインデックスを付ける
# 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)
ルール: 大規模なテーブルで ORDER BY に使うフィールドにインデックスを付ける
# 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')
]
インデックス不足の検証チェックリスト
- テーブルに 10k 行以上ある
- ホットパスでフィールドが filter() または order_by() に使われている
- Model を確認し、db_index=True も Meta.indexes のエントリもない
- 外部キーではない(外部キーには自動的にインデックスが作成されます)
優先度 4: 書き込みループ(HIGH)
影響: 書き込みが 1 回ではなく N 回発生します。ロック競合が起き、リクエストが遅くなります。
ルール: ループ内では create() の代わりに bulk_create を使う
# 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
])
ルール: ループ内では save() の代わりに update() または bulk_update を使う
# 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)
ルール: ループ内で削除するのではなく、QuerySet に delete() を使う
# PROBLEM: N deletes
for obj in queryset:
obj.delete()
# SOLUTION: Single DELETE
queryset.delete()
書き込みループの検証チェックリスト
- ループが 100 件以上(または上限のない件数)の項目を処理する
- 各反復で create()、save()、または delete() を呼び出す
- ユーザー向けリクエストで実行される(単発のマイグレーションスクリプトではない)
優先度 5: 非効率なパターン(LOW)
報告する価値があるケースはほとんどありません。 実際の問題をすでに報告する場合に限り、軽微な注記として含めてください。
パターン: count() と exists() の比較
# Slightly suboptimal
if queryset.count() > 0:
do_thing()
# Marginally better
if queryset.exists():
do_thing()
通常は見送ってください - 多くの場合、差は 1ms 未満です。
パターン: len(queryset) と count() の比較
# Fetches all rows to count
if len(queryset) > 0: # bad if queryset not yet evaluated
# Single COUNT query
if queryset.count() > 0:
指摘するのは、QuerySet が大きく、まだ評価されていない場合に限ります。
パターン:小さなループ内の get()
# N queries, but if N is small (< 20), often fine
for id in ids:
obj = Model.objects.get(id=id)
報告するのは、ループが大きい場合、または非常に頻繁に実行される処理の場合に限ります。
検証要件
問題を報告する前に、必ず次の手順を実施してください。
- データフローを追跡する - クエリセットの作成から使用までを追跡します
- 既存の最適化を検索する - select_related、prefetch_related、ページネーションを grep で検索します
- データ量を確認する - テーブルが実際に大きいかを確認します
- 頻繁に実行される処理か確認する - 呼び出し元を追跡し、頻繁に実行されることを確認します
- 軽減策がないか確認する - キャッシュやレート制限を確認します
すべての手順を検証できない場合は、報告しないでください。
出力形式
## 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
修正:
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))
クエリセットは遅延評価されます。変数に代入しても、何も実行されません。
単一クエリのパターンは N+1 ではありません:
# This is ONE query, not N+1
projects = list(Project.objects.filter(org=org))
N+1 が発生するには、追加のクエリを実行するループが必要です。list() の呼び出しが 1 回だけなら問題ありません。
単一オブジェクトの取得で select_related がない場合も、N+1 ではありません:
# 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 が発生するにはループが必要です。1 つのオブジェクトでクエリが 1 回ではなく 2 回実行される場合、該当すれば LOW として報告できますが、CRITICAL/HIGH として報告してはいけません。
スタイル上の好みはパフォーマンスの問題ではありません: 提案が「この 2 行をまとめる」または「この変数名を変更する」だけの場合、それはスタイルの問題であり、パフォーマンスの問題ではありません。報告しないでください。