Query Complexity Analyzer¶
What It Detects¶
The query complexity analyzer scores each SQL query based on the number and type of expensive operations it contains. Queries that exceed a configurable complexity threshold are flagged with suggestions for simplification. The goal is to catch queries that have grown unwieldy through chained ORM calls and may benefit from being broken apart or restructured.
Scoring Table¶
Each SQL feature contributes points to the total complexity score:
| SQL Feature | Points | Example ORM Usage |
|---|---|---|
JOIN (each) |
2 | select_related(), annotate() with relations |
Subquery (each additional SELECT) |
3 | Subquery(), qs.filter(id__in=other_qs) |
OR (each) |
1 | Q(...) \| Q(...) |
GROUP BY |
2 | .values().annotate() |
HAVING |
2 | .annotate().filter() after group |
DISTINCT |
1 | .distinct() |
ORDER BY |
1 | .order_by(...) |
CASE / WHEN (each WHEN) |
1 | Case(When(...)) |
UNION / INTERSECT / EXCEPT |
3 | qs1.union(qs2) |
LIKE with leading % |
2 | .filter(name__contains=...) |
COUNT(*) combined with JOIN |
2 | .annotate(n=Count(...)) over relations |
The total score is the sum of all points. The default threshold is 8; a query scoring 8 or more is flagged (WARNING severity, CRITICAL at 12 or more).
Problem Code¶
# views.py
from django.db.models import (
Avg, Case, Count, OuterRef, Subquery, Sum, Value, When
)
def complex_report(request):
subquery = (
Review.objects
.filter(book=OuterRef("pk"))
.values("book")
.annotate(avg_rating=Avg("rating"))
.values("avg_rating")
)
books = (
Book.objects
.select_related("author", "publisher") # 2 JOINs = 4
.prefetch_related("categories")
.annotate(
review_count=Count("reviews"), # JOIN = 2, COUNT+JOIN = 2
avg_rating=Subquery(subquery), # subquery = 3
revenue=Sum("sales__amount"), # JOIN = 2
tier=Case( # 2 WHENs = 2
When(revenue__gte=10000, then=Value("gold")),
When(revenue__gte=1000, then=Value("silver")),
default=Value("bronze"),
),
)
.filter(review_count__gte=5) # HAVING = 2
.distinct() # DISTINCT = 1
.order_by("-revenue") # ORDER BY = 1
)
# GROUP BY from the annotations = 2
# Total score: 4 + 2 + 2 + 3 + 2 + 2 + 2 + 1 + 1 + 2 = 21 (over threshold 8)
Fix Code¶
Break the query into smaller, focused queries:
# views.py
def complex_report(request):
# Query 1: Basic book data with author
books = (
Book.objects
.select_related("author", "publisher")
.annotate(review_count=Count("reviews"))
.filter(review_count__gte=5)
)
# Query 2: Revenue data (separate annotation)
revenue_data = dict(
Sales.objects
.values("book_id")
.annotate(revenue=Sum("amount"))
.values_list("book_id", "revenue")
)
# Query 3: Average ratings (separate query)
rating_data = dict(
Review.objects
.values("book_id")
.annotate(avg_rating=Avg("rating"))
.values_list("book_id", "avg_rating")
)
# Combine in Python
for book in books:
book.revenue = revenue_data.get(book.id, 0)
book.avg_rating = rating_data.get(book.id, None)
Alternatively, use .annotate() instead of subqueries when the ORM can
express the operation as a JOIN:
# Instead of a Subquery for average rating:
books = Book.objects.annotate(avg_rating=Avg("reviews__rating"))
Prescription Output¶
Console output for a flagged query:
CRITICAL: Query complexity score 21 exceeds threshold 8
Location: /app/myapp/views.py:12 in complex_report
Fix: Replace subqueries with JOINs or annotate() where possible.
The fix suggestion is contextual: with more than 3 JOINs it recommends
splitting the query and using select_related/prefetch_related; with
subqueries it suggests JOIN-based rewrites; with multiple OR conditions
it points at index-friendly alternatives.
Configuration¶
| Setting | Default | Description |
|---|---|---|
ANALYZERS.complexity.threshold |
8 |
Total score at or above which a query is flagged. Lower values catch more queries but may produce noise. |
ANALYZERS.complexity.enabled |
True |
Set to False to disable this analyzer. |
Common Scenarios¶
Dashboard Views with Multiple Aggregations¶
Admin dashboards frequently build a single "mega-query" with many annotations. Splitting into per-widget queries is often clearer and no slower:
# Before: one query with 6 annotations
stats = (
Order.objects
.annotate(month=TruncMonth("created_at"))
.values("month")
.annotate(
total_orders=Count("id"),
total_revenue=Sum("amount"),
avg_order=Avg("amount"),
max_order=Max("amount"),
refund_count=Count("id", filter=Q(status="refunded")),
unique_customers=Count("customer", distinct=True),
)
)
# After: two focused queries
monthly_revenue = (
Order.objects
.annotate(month=TruncMonth("created_at"))
.values("month")
.annotate(total=Sum("amount"), avg=Avg("amount"), max=Max("amount"))
)
monthly_counts = (
Order.objects
.annotate(month=TruncMonth("created_at"))
.values("month")
.annotate(
orders=Count("id"),
refunds=Count("id", filter=Q(status="refunded")),
customers=Count("customer", distinct=True),
)
)
Subqueries That Can Be JOINs¶
Many subqueries can be rewritten as JOIN-based annotations:
# Before (subquery, 10 points)
latest_review = Review.objects.filter(
book=OuterRef("pk")
).order_by("-created_at").values("rating")[:1]
books = Book.objects.annotate(latest_rating=Subquery(latest_review))
# After (JOIN-based, fewer points)
from django.db.models import Max
books = Book.objects.annotate(latest_rating=Max("reviews__rating"))
Premature Optimization
A high complexity score does not necessarily mean the query is slow. The
database query planner may handle complex queries efficiently, especially
with proper indexes. Always measure actual query time with EXPLAIN
ANALYZE before investing effort in restructuring. The analyzer provides
awareness, not a mandate.
Using EXPLAIN
Django 4.0+ supports queryset.explain() which runs EXPLAIN on the
query. Use queryset.explain(analyze=True) in development to see actual
execution times and whether the planner chooses efficient access paths
despite query complexity.