Skip to content

Case 4: Getting Counts Along with a List (The N+1 Query Problem) ​

The Story ​

  • Table 1: authors, with 10,000 rows. Columns: id, name.
  • Table 2: novels, with 1,000,000 rows. Columns: id, author_id, title.
  • Current State: An index exists on novels(author_id).
  • The Problem: We want to display a list of all authors and the number of novels each author has written.

The Problematic SQL Query ​

A new developer might write this query, which looks very intuitive: "For each author, count their novels."

sql
EXPLAIN ANALYZE
SELECT
    a.id,
    a.name,
    (SELECT COUNT(*) FROM novels n WHERE n.author_id = a.id) AS novel_count
FROM
    authors a;

The Query Plan (BEFORE a fix) ​

                                                      QUERY PLAN
----------------------------------------------------------------------------------------------------------------------
 Seq Scan on authors a  (cost=0.00..345000.00 rows=10000 width=40) (actual time=0.025..1850.750 rows=10000 loops=1)
   SubPlan 1
     ->  Aggregate  (cost=34.45..34.46 rows=1 width=8) (actual time=0.182..0.182 rows=1 loops=10000)
           ->  Index Only Scan using idx_novels_author_id on novels n  (cost=0.43..34.20 rows=100 width=0) (actual time=0.015..0.175 rows=100 loops=10000)
                 Index Cond: (author_id = a.id)
 Planning Time: 0.180 ms
 Execution Time: 1855.432 ms

Why is this a problem? ​

  1. Look at this line: Notice the loops=10000 in the last two lines.

  2. The "N+1 Query" Problem:

    • The "1" Query: Seq Scan on authors a - The system scans the authors table once to get all 10,000 authors.
    • The "N" Queries: For each of those 10,000 authors, the system has to run a separate subquery (SubPlan 1) to count that author's novels. The loops=10000 is the proof.
    • In total, the database had to execute 1 + 10,000 = 10,001 queries!
  3. The Warning Signs:

    • Execution Time: **1855.432 ms**: Taking almost 2 seconds for a simple list is too slow.
    • Even though each subquery (Index Only Scan) is very fast (only 0.182 ms), repeating it 10,000 times adds up to a huge number.
  4. The Root Cause:

    • A Correlated Subquery forces the database to repeat a task over and over. This is a classic anti-pattern in SQL.

The Solution ​

  • Action: Rewrite the query using a JOIN and GROUP BY. This allows the database to process all the data in a single pass.
  • Command:
    sql
    EXPLAIN ANALYZE
    SELECT
        a.id,
        a.name,
        COUNT(n.id) AS novel_count
    FROM
        authors a
    LEFT JOIN novels n ON a.id = n.author_id
    GROUP BY
        a.id, a.name;
    (We use LEFT JOIN to ensure that authors who haven't written any novels still appear in the list with novel_count = 0)

The Query Plan (AFTER the fix) ​

                                                      QUERY PLAN
----------------------------------------------------------------------------------------------------------------------
 HashAggregate  (cost=21850.00..22100.00 rows=10000 width=40) (actual time=215.450..218.123 rows=10000 loops=1)
   Group Key: a.id, a.name
   ->  Hash Join  (cost=315.00..17100.00 rows=1000000 width=36) (actual time=2.875..145.987 rows=1000000 loops=1)
         Hash Cond: (n.author_id = a.id)
         ->  Seq Scan on novels n  (cost=0.00..14500.00 rows=1000000 width=8) (actual time=0.010..50.123 rows=1000000 loops=1)
         ->  Hash  (cost=190.00..190.00 rows=10000 width=36) (actual time=2.850..2.850 rows=10000 loops=1)
               ->  Seq Scan on authors a  (cost=0.00..190.00 rows=10000 width=36) (actual time=0.005..1.250 rows=10000 loops=1)
 Planning Time: 0.350 ms
 Execution Time: 219.850 ms

Analyzing the Result ​

  1. The Big Change: The entire plan has changed. There is no more loops=10000.

    • The database scans both the authors and novels tables only once.
    • It performs a Hash Join to connect them.
    • Finally, it uses a HashAggregate (GROUP BY) to count the novels for each author.
    • The entire process is a single, non-repetitive workflow.
  2. Performance Comparison:

    • Execution Time: Dropped from 1855.432 ms down to just 219.850 ms. That's about 8.5 times faster!

Conclusion ​

Always be wary of correlated subqueries, especially in the SELECT clause. Most of them can and should be rewritten using a JOIN. The JOIN + GROUP BY approach allows the database to optimize the execution across the entire dataset much more effectively.