Slow Query & N+1 Diagnosis

Web & Application Troubleshooting

Chapter 6 · Slow Query & N+1 Diagnosis

Logging & Log Analysis's own capstone found an N+1 pattern by comparing query counts before and after a deployment. This chapter goes deeper: how to recognize an N+1 pattern directly, how to tell it apart from a single genuinely slow query (a different problem with a completely different fix), and how to read just enough of an EXPLAIN plan to spot the single most common cause of a slow query without needing a full database-tuning background.

What N+1 Actually Is

Instead of one query that fetches everything needed at once — a JOIN, or a single query with an IN clause — the code runs one query to get a list of N items, then loops through them and runs N additional individual queries, one per item, to fetch each one's related data. N+1 total queries where 1 or 2 would have done the job. A very common ORM pitfall, usually from a relationship being lazily loaded inside a loop without anyone noticing.

Recognizing N+1 vs. a Single Slow Query

The fix for each is completely different, so telling them apart matters — and the distinguishing evidence is straightforward: count the queries a single request triggers, and look at each one's own duration.

# A single slow query [query] SELECT * FROM order_items WHERE order_id = 9001; -- 812ms Total queries: 1 Total time: 812ms # N+1 [query] SELECT * FROM orders WHERE user_id = 4821; -- 3ms [query] SELECT * FROM order_items WHERE order_id = 9001; -- 15ms [query] SELECT * FROM order_items WHERE order_id = 9002; -- 14ms [query] SELECT * FROM order_items WHERE order_id = 9003; -- 16ms ... 47 more nearly-identical lines ... Total queries: 51 Total time: 780ms

One long query and dozens of individually-fast ones can add up to almost the same total time — but the fix couldn't be more different: optimizing or indexing one query, versus restructuring the code so it stops looping and issuing a query per item.

Reading EXPLAIN for a Genuinely Slow Single Query

A full database-tuning background isn't needed to catch the single most common cause of a slow query — a full table scan on a large table:

EXPLAIN ANALYZE SELECT * FROM order_items WHERE order_id = 9001; Seq Scan on order_items (cost=0.00..48213.00 rows=1 width=64) (actual time=810.442..810.443 rows=1 loops=1) Filter: (order_id = 9001) Rows Removed by Filter: 2499998 Planning Time: 0.112 ms Execution Time: 810.501 ms

Seq Scan means the database read through the entire table — 2.5 million rows, to find one matching row — because no index exists on the column being filtered. Contrast with a healthy result on an indexed column:

Index Scan using idx_order_items_order_id on order_items (cost=0.42..8.44 rows=1 width=64) (actual time=0.015..0.016 rows=1 loops=1)

Same query shape, over 50,000 times faster — the entire difference is having (or not having) the right index. Recognizing Seq Scan on a large table in an EXPLAIN output is, by itself, one of the highest-value diagnostic skills in this chapter.

The N+1 Fix, at a Practical Level

Two standard fix shapes worth being able to describe clearly, even if the actual code change belongs to a developer: batch loading (one additional query using an IN clause to fetch every needed related record at once, instead of one query per item) or a JOIN that retrieves everything in a single query from the start.

"It's fast in testing" is exactly what you'd expect
N+1 patterns scale with N — with a small test dataset (5 items = 6 queries total), the extra overhead is barely noticeable. In production, with realistic data volumes (500 items = 501 queries), the exact same code becomes genuinely slow. This is precisely why these bugs so often pass testing unnoticed and only surface under real load — the same "only happens under load" signal Chapter 1 opened with.

Working Example: The Slow Order History Page

A fresh ticket: the order history page takes 8+ seconds to load for users with a long order history, but loads instantly for new users with few orders. That user-count correlation is itself a strong early hint — a single slow query wouldn't care how many orders a particular user has; an N+1 pattern would scale exactly this way. Query logging confirms it directly: one query fetching the order list, followed by one additional query per order to fetch that order's line items — 51 total queries for a user with 50 orders, each individually fast, together adding up to the full 8 seconds plus per-query round-trip overhead. Replacing the per-order loop with a single batched query (an IN clause covering every order ID from the first query) cuts the page load from 8 seconds to under 200ms — the same total data, retrieved in two queries instead of fifty-one.

Hands-On Exercises

Exercise 1

Explain why a single 812ms query and 51 queries totaling 780ms can both make a page feel equally slow, but need completely different fixes.

📄 View solution
Exercise 2

Explain what a Seq Scan in an EXPLAIN output actually means, and why it's a genuine red flag on a large table specifically.

📄 View solution
Exercise 3

Explain why the order-history page's slowness scaling with a user's own order count was itself a meaningful clue, before any query log was even checked.

📄 View solution

Chapter 6 Quick Reference

  • N+1: one query for a list, then N more — one per item — instead of a single batched fetch
  • Distinguish a single slow query from N+1 by counting queries per request, not just total time
  • Seq Scan on a large table in EXPLAIN ANALYZE = the single most common, easiest-to-spot cause of a slow single query
  • Fixes: batch loading (IN clause) or a JOIN — one query instead of many
  • N+1 bugs routinely pass testing because they scale with N — small test data hides them; production-scale data reveals them
  • Next chapter: Deployment-Related Symptoms: Version Skew & Migration Failures