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 per Occurrence | Example ORM Usage |
|---|---|---|
JOIN (each) |
5 | select_related(), annotate() with relations |
| Subquery (each) | 10 | Subquery(), qs.filter(id__in=other_qs) |
Aggregation (COUNT, SUM, AVG, MIN, MAX) |
3 | .annotate(total=Sum(...)) |
CASE / WHEN (each) |
4 | Case(When(...)) |
DISTINCT |
3 | .distinct() |
UNION / INTERSECT / EXCEPT |
8 | qs1.union(qs2) |
GROUP BY |
3 | .values().annotate() |
HAVING |
4 | .annotate().filter() after group |
ORDER BY on expression |
2 | .order_by(F("field").desc()) |
| Window function | 8 | Window(expression=..., partition_by=...) |
The total score is the sum of all points. The default threshold is 50.
Problem Code¶
# views.py
from django.db.models import (
Case, Count, F, Q, Subquery, OuterRef, Sum, When, Window
)
from django.db.models.functions import Rank
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 = 10
.prefetch_related("categories")
.annotate(
review_count=Count("reviews"), # aggregation = 3
avg_rating=Subquery(subquery), # subquery = 10
revenue=Sum("sales__amount"), # aggregation = 3, JOIN = 5
tier=Case( # CASE = 4
When(revenue__gte=10000, then=Value("gold")),
When(revenue__gte=1000, then=Value("silver")),
default=Value("bronze"),
),
rank=Window( # window = 8
expression=Rank(),
partition_by=F("publisher"),
order_by=F("revenue").desc(),
),
)
.filter(review_count__gte=5) # HAVING = 4
.distinct() # DISTINCT = 3
.order_by("-rank")
)
# Total score: 10 + 3 + 10 + 3 + 5 + 4 + 8 + 4 + 3 = 50 (at threshold)
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¶
[LOW] High Query Complexity (score: 54)
Location: views.py:12
Issue: Query has a complexity score of 54 (threshold: 50).
Breakdown: 2 JOINs (10), 1 subquery (10), 2 aggregations (6),
1 CASE/WHEN (4), 1 window function (8), 1 DISTINCT (3),
1 HAVING (4), expression ORDER BY (2), GROUP BY (3).
Fix: Consider breaking this into multiple simpler queries.
- Extract the subquery into a separate query and join in Python.
- Move the window function to a dedicated annotation query.
- Use .annotate() with JOIN-based aggregation instead of Subquery
where possible.
Configuration¶
| Setting | Default | Description |
|---|---|---|
COMPLEXITY_THRESHOLD |
50 |
Total score above which a query is flagged. Lower values catch more queries but may produce noise. |
COMPLEXITY_IGNORE_TABLES |
[] |
Tables to exclude from analysis. Reporting queries against analytics tables are often intentionally complex. |
# settings.py
QUERY_DOCTOR = {
"COMPLEXITY_THRESHOLD": 40,
"COMPLEXITY_IGNORE_TABLES": ["analytics_report"],
}
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.