When the Centralized Gate Pass System started handling real traffic, a dashboard that felt instant in development began crawling. The page wasn't doing anything obviously expensive β€” it just listed passes and the gates they belonged to. The culprit was the oldest performance bug in the ORM book: the N+1 query. ## What an N+1 query actually is You fetch a list of N objects in one query, then touch a related object on each one inside a loop. Django dutifully fires one more query per row. A list of 200 passes that each reads `pass.gate.name` becomes 1 + 200 = 201 queries. Each is fast in isolation; together they bury you in round-trips. ``` # The trap: one query for passes, then one per pass for the gate for p in GatePass.objects.all(): print(p.gate.name) # <-- a fresh SELECT every iteration ``` ## Step 1: make the invisible visible You cannot fix what you cannot measure. I install django-debug-toolbar in development and watch the SQL panel β€” it shows the query count and flags duplicates outright. In tests and CI I assert the count directly: ``` from django.test.utils import CaptureQueriesContext from django.db import connection with CaptureQueriesContext(connection) as ctx: response = client.get("/passes/") assert len(ctx.captured_queries) < 10 ``` That assertion is the real fix β€” it stops the regression from ever coming back. ## Step 2: select_related vs prefetch_related The two tools solve two different shapes of relationship. `select_related` follows forward ForeignKey and OneToOne links with a SQL JOIN, pulling everything in a single query. Use it for "many passes, each with one gate." `prefetch_related` handles reverse and ManyToMany links by running a second query and stitching the results together in Python. Use it for "one gate with many passes." ``` # 201 queries -> 1 passes = GatePass.objects.select_related("gate") # For the reverse side, fetch passes for all gates in 2 queries total gates = Gate.objects.prefetch_related("passes") ``` ## Step 3: stop fetching what you do not render The list view didn't need every column or every relation. `only()` and `values()` trim the payload; `annotate()` pushes counts and sums into the database instead of looping in Python. ``` GatePass.objects.select_related("gate").only("code", "issued_at", "gate__name") Gate.objects.annotate(active=Count("passes", filter=Q(passes__active=True))) ``` ## The result The dashboard dropped from ~210 queries to 3, and median response time fell from roughly 900ms to under 100ms β€” without touching the front-end. The lesson I keep relearning: most "slow Django" isn't slow Python or a slow database. It's a fast database asked the same question 200 times.