Skip to content
Software Atlas

N+1 Queries & ORMs

The most common performance bug, and how to see it: why one page quietly makes 51 database round trips, how ORMs hide them, and the JOIN, batch, eager-loading and DataLoader fixes, measured on a real Postgres.

⏱ ~40 min read14 sections9 interactive widgetsGo · JavaScript · SQL
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceThe N+1 query problem: your code fetches a list with 1 query, then loops over it and runs one more query per item. N items, N+1 queries, and each one waits for the network.

Here's the classic case. A blog's home page shows the 50 newest posts, each with its author's name. The code does this:

  1. One query: "give me the 50 newest posts". Each post knows its author's id, but not the name.
  2. Then, for each of the 50 posts: "give me the author with id 920", "…id 839", "…id 758", … 50 more queries.

That's 51 queries for one page. Each query has to travel to the Database and back. That's a round trip (Round-trip time), roughly 1 ms inside a data center (approximate; it varies with your network). The trips happen one after another, so the page waits about 51 × 1 ms ≈ 50 ms before it can even start rendering. The same data could arrive in 2 queries (all posts, then all their authors at once) or 1 (both together). That's 2 ms instead of 50.

10 posts, three ways to fetch them
N+1
list
#1
#2
#3
#4
#5
#6
#7
#8
#9
#10
batch
list
10 authors
JOIN
both
Each coloured block is one round trip to the database, and they run one after another. The N+1 version does the same work in 11 trips that the others do in 2 or 1.

It's called "the most common performance bug" for a reason: the code looks perfectly innocent (a for loop and what looks like reading a field), each query is individually fast, and it works fine on your laptop with 5 posts. It only hurts when N grows, or the database is a few milliseconds away instead of on the same machine.

Play with it. Push N to 200 and the round trip to 5 ms, then compare the strategies:

What does one page cost?
Show a list of N blog posts with their author's name. Pick a strategy, then drag the sliders.
51
queries
51
round trips, one after another
54 ms
page waits on the DB
1
connections busy
Each dot is one query · a column runs at the same timetime →
Model: each query costs one round trip plus ≈0.05 ms of real database work (what our Postgres measured for a lookup by key). Approximate — but notice that the round trips, not the database work, decide the total.

What you'll learn

  • Why it happens: how an ORM hides queries behind innocent-looking code (§03).
  • How to see it: query logs, query counters and tracing "staircases" (§04).
  • How to fix it: JOINs, batched IN queries, ORM eager loading, nested JSON from Postgres, and DataLoader (§05–§08).
  • What doesn't fix it: running the queries in parallel (§09), and the related traps nearby (§10).
  • How to keep it fixed: tests that count queries (§11), and the staff-level view: when N+1 is fine, and the same bug between microservices (§12).
Foundations§02

Primer: tables, SQL and round trips

In one sentenceA database keeps data in tables. You ask it questions in SQL. Every question is a message over the network, and your code waits for the answer.

Tables, rows and ids

A relational Database (one that stores data in tables that point at each other) such as PostgreSQL stores data in tables, which work like spreadsheets: one row per thing, one column per field. Our blog has two:

authors

idname
920Ada
839Lin

posts

idtitleauthor_idcreated_at
1Hello SQL920today 10:00
2Why N+1839today 09:59
3Indexes 101920today 09:58

Each row has an id that no other row in that table shares: its Primary key. A post doesn't store its author's name. It stores the author's id in author_id, a Foreign key that points at a row in authors. That's like a library card that says "see shelf 920" instead of copying the whole book. Storing the name once means renaming an author is one change, not thousands. But it also means showing a post with its author's name needs data from two tables, and that's where N+1 comes from.

SQL in five lines

SQL is the language you use to ask. These are the only forms this page needs:

SELECT id, title, author_id FROM posts ORDER BY created_at DESC LIMIT 50;

SELECT name FROM authors WHERE id = 920;

SELECT id, name FROM authors WHERE id IN (920, 839, 758);

SELECT p.title, a.name
FROM posts p
JOIN authors a ON a.id = p.author_id;

The result of a SELECT is always a flat table of rows, even after a JOIN. Keep that in mind; it matters in §05.

Every query is a round trip

The database is a separate program, usually on another machine. Your code sends the SQL text over a network connection, the database does the work, and the rows come back. Until they do, your code waits.

One query = one round trip
Your appwaiting…Databasework ≈ 0.05 msSELECT name FROM authors WHERE id = 7rows: [ "Ada" ]
The query travels to the database, the database does its (usually tiny) piece of work, and the answer travels back. Your code waits the whole time. Inside one data center a round trip is roughly 0.5–1 ms; finding one row by its id takes the database a few hundredths of a millisecond.

Here's the number that explains this whole topic. On our test Postgres, finding one author by id took the database 0.03–0.06 ms. A round trip inside a data center costs around 0.5–1 ms. So for a simple lookup, more than 90% of the time is spent travelling, not working. Ten lookups cost ten trips. One query that asks for ten rows costs one trip.

Foundations§03

ORMs, lazy loading and the innocent loop

In one sentenceAn ORM lets you treat rows as objects. The price: a query can hide behind anything that looks like a function call or a field, so a harmless-looking loop can talk to the database 50 times.

What an ORM is

Writing SQL by hand and copying every column into a struct or object gets tedious. An ORM (object-relational mapper) does that for you. You describe your tables as classes or structs, including how they relate ("a post belongs to an author, an author has many posts"), and the ORM writes the SQL. Popular ones: Prisma, TypeORM and Sequelize in JavaScript; GORM and ent in Go (where many teams use plain SQL instead, via database/sql or code generators like sqlc).

That's genuinely useful. But it moves the SQL out of sight, and SQL you can't see is SQL you don't count.

The innocent loop

Here's the blog page with two real ORMs. Neither line inside the loop looks like database access, but each one is a query:

posts.go (GORM v1.31)
type Author struct {
    ID   int64
    Name string
}
type Post struct {
    ID       int64
    Title    string
    AuthorID int64
    Author   Author // filled in only if you ask
}

var posts []Post
// (errors ignored for brevity; check .Error in real code)
db.Order("created_at DESC").Limit(50).Find(&posts) // 1 query

for i := range posts {
    db.First(&posts[i].Author, posts[i].AuthorID) // ← 1 query per post
}

With GORM's logger switched on, our test database printed exactly what you'd fear (trimmed to 3 posts to save space):

GORM log (LogMode(logger.Info))
[1.592ms] [rows:3] SELECT * FROM "posts" ORDER BY created_at DESC LIMIT 3
[0.618ms] [rows:1] SELECT * FROM "authors" WHERE "authors"."id" = 920 ORDER BY "authors"."id" LIMIT 1
[0.162ms] [rows:1] SELECT * FROM "authors" WHERE "authors"."id" = 839 ORDER BY "authors"."id" LIMIT 1
[0.142ms] [rows:1] SELECT * FROM "authors" WHERE "authors"."id" = 758 ORDER BY "authors"."id" LIMIT 1

With 50 posts it's 51 lines. Notice the timings: every query is well under a millisecond, so none of them looks slow. The problem is how many there are.

Lazy loading: the query that looks like a field

In those two examples you can at least see a method call in the loop. Many ORMs go further with Lazy loading: post.author itself runs the query, the first time you read it. Rails (ActiveRecord), Django, Hibernate and TypeORM's "lazy relations" all work this way (details vary by library and version). It isn't magic. Here's the whole trick in a few lines of plain JavaScript:

lazy.js — how a lazy relation works
class Post {
  constructor(row, db) {
    Object.assign(this, row); // copy id, title, author_id onto the object
    this.db = db;
  }
  get author() {              // a getter: runs code when you READ post.author
    return this.db
      .query("SELECT * FROM authors WHERE id = $1", [this.author_id])
      .then((res) => res.rows[0]); // ← a query hiding behind a "field"
  }
}

for (const post of posts) {
  const author = await post.author; // looks like a field. It's a query.
}

Step through it and watch the SQL log on the right:

The innocent loop
An ORM with lazy loading. Watch the SQL log on the right as the loop runs.
Code
1const posts = await Post.findAll({ limit: 50 });
2
3for (const post of posts) {
4 const author = await post.author; // a field?
5 html += `<li>${post.title} by ${author.name}</li>`;
6}
What your code sees
posts = [50 Post objects]
SQL the ORM sends
SELECT * FROM posts LIMIT 50
Query counter
1 query
One query fetches the list of 50 posts. Each post object holds the post's own columns, including author_id — but not the author.
1 / 6

Go has the same bug, just without the magic

Go ORMs like GORM don't lazy-load: a relation stays empty unless you ask for it. But Go code hides queries in a different place: helper functions. A function like authorName(ctx, id) that runs a SELECT is perfectly reasonable on its own. Call it from a loop, or from a function that's called from a loop three files away, and you've written an N+1 without ever seeing the loop and the query together.

Practitioner§04

Seeing it: logs, counters and staircases

In one sentenceYou can't fix what you can't see. N+1 hides from the usual "find the slow query" tools, because no single query is slow. Count queries instead, or look at their shape on a timeline.

1. Ask the database to log every statement

Postgres can write every statement it receives to its log file. The setting is log_statement = 'all'. Turn it on for a local or staging database (in production it's far too noisy), load the page once, and read the log. To switch it on, run ALTER DATABASE blog SET log_statement = 'all'; as a superuser (new connections pick it up), or start a Docker Postgres with postgres -c log_statement=all. This is real output from our test database while a plain database/sql version of the §03 loop (SELECT name FROM authors WHERE id = $1 per post) loaded 4 posts:

postgres log (log_statement = 'all')
LOG:  execute stmtcache_d2bf…: SELECT id, title, author_id FROM posts ORDER BY created_at DESC LIMIT $1
DETAIL:  parameters: $1 = '4'
LOG:  execute stmtcache_14e3…: SELECT name FROM authors WHERE id = $1
DETAIL:  parameters: $1 = '920'
LOG:  execute stmtcache_14e3…: SELECT name FROM authors WHERE id = $1
DETAIL:  parameters: $1 = '839'
LOG:  execute stmtcache_14e3…: SELECT name FROM authors WHERE id = $1
DETAIL:  parameters: $1 = '758'
LOG:  execute stmtcache_14e3…: SELECT name FROM authors WHERE id = $1
DETAIL:  parameters: $1 = '677'

That's the fingerprint: the same statement, over and over, with only the parameter changing. (The stmtcache_… name is the Go driver, pgx, reusing a Prepared statement: the database parsed the SQL once and now gets only the new $1 value each time. That makes each query a bit cheaper, but it's still one round trip each.)

Your ORM can print the same thing from your side: GORM with Logger: logger.Default.LogMode(logger.Info) (output shown in §03), Prisma with new PrismaClient({ log: ["query"] }). For a whole database there's also pg_stat_statements, a Postgres extension that keeps a running count of calls per distinct query. An authors-by-id query with a call count 50× higher than the posts query that feeds it is a strong hint.

2. Count queries per request

Even simpler: count. Wrap whatever sends queries so it increments a counter, and log the total at the end of each request, e.g. GET /posts → 51 queries, 54 ms. A page that should need 2 queries and runs 51 stands out at once. Better still, put the count in a test so it can never creep back (§11 shows the code in Go and JS).

3. Look at a trace: the staircase

In production you usually see N+1 through an APM or tracing tool. It records each request as a set of timed spans, one per database query, and draws them as a waterfall. N+1 has an unmistakable shape: a staircase of short, identical bars, each starting when the previous one ends.

What a tracing tool shows
One request for a page of 12 posts, drawn the way tracing tools draw it: one bar per database query, time going right. Each query ≈ 1 ms.
GET /posts
SELECT … FROM posts
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
0 ms2 ms4 ms6 ms8 ms10 ms12 ms
13 queries · page ready at 13.6 ms
The staircase: many short, identical queries, each starting only when the one before it has finished. When you see this shape in a trace, you've found an N+1.

Switch between the modes. The staircase is the loop: N queries, each waiting for the previous one. The batch and JOIN versions collapse the stairs into one or two bars. (The parallel mode looks better than the loop, but it's not a fix; §09 explains why.)

Practitioner§05

Fix 1: let the database JOIN

In one sentenceA JOIN moves the loop into the database, where each lookup costs microseconds instead of a network round trip. The catch: the answer is a flat table, so "one post with many things" turns into many rows.

The fix

Ask for posts and authors together. The database pairs them up and sends back finished rows:

posts.go (database/sql + pgx)
rows, err := db.QueryContext(ctx, `
    SELECT p.id, p.title, a.name
    FROM posts p
    JOIN authors a ON a.id = p.author_id
    ORDER BY p.created_at DESC
    LIMIT 50`)
if err != nil {
    return nil, err
}
defer rows.Close()

var posts []Post // Post here has an AuthorName string field
for rows.Next() { // one row = one post WITH its author's name
    var p Post
    if err := rows.Scan(&p.ID, &p.Title, &p.AuthorName); err != nil {
        return nil, err
    }
    posts = append(posts, p)
}
return posts, rows.Err()

What the database does with it

Prefix any query with EXPLAIN ANALYZE and Postgres runs it and shows its plan: the steps it took, how many rows each produced, and the time. (Reading plans properly is taught in Indexes; here we only need one line.) This is the plan for the query above, from our test database with 100,000 posts and 1,000 authors:

EXPLAIN ANALYZE (Postgres 16)
Limit  (actual time=0.028..0.122 rows=50 loops=1)
  ->  Nested Loop  (actual time=0.027..0.118 rows=50 loops=1)
        ->  Index Scan using posts_created_at_idx on posts p  (… rows=50 loops=1)
        ->  Memoize  (actual time=0.002..0.002 rows=1 loops=50)
              ->  Index Scan using authors_pkey on authors a  (… rows=1 loops=50)
                    Index Cond: (id = p.author_id)
Execution Time: 0.230 ms

Read it from the inside out. Index Scan means "find rows using an index"; Nested Loop means "for each post, look up its author"; Memoize means "remember authors already found, in case they come up again". Now look at loops=50 on the last line. The database still looks up 50 authors one by one. It's the very same loop as the N+1. The difference is that each lookup happens inside the database, in about 0.002 ms, instead of across the network at ~1 ms. The whole thing took 0.23 ms.

Measured from Go through a connection with a 1 ms round trip (median of 30 runs):

StrategyQueriesTime
N+1 loop5155 ms
JOIN11.5 ms

The catch: one row per child

A post has exactly one author, so a posts-JOIN-authors result has one row per post. That's a belongs-to relation, and it's where JOIN shines.

Real blogs have more. Our test database has three more tables:

comments

idpost_idbody
111Nice!
121Thanks
132+1

tags

idname
1go
2sql
3perf

post_tags

post_idtag_id
11
12
23

A comment belongs to one post (comments.post_id), so a post has many comments. Tags are different: a post can have many tags and a tag can be on many posts (many-to-many). One tag_id column on posts can't hold that, so a small Join table stores one row per (post, tag) pair.

Now JOIN a has-many relation, posts with their comments. The result can only be flat, so you get one row per comment, and the post's title and body are repeated on every one of them.

Add a second has-many (tags) and it gets much worse: the database returns every combination of comment and tag. This is the Cartesian explosion. Here's post 1 from our test data, which has 4 comments and 3 tags:

SELECT p.id, p.title, c.id AS comment_id, t.name AS tag
FROM (SELECT * FROM posts ORDER BY created_at DESC LIMIT 1) p
LEFT JOIN comments  c  ON c.post_id  = p.id
LEFT JOIN post_tags pt ON pt.post_id = p.id
LEFT JOIN tags      t  ON t.id       = pt.tag_id;
result: 4 comments × 3 tags = 12 rows
 id | title  | comment_id |  tag
----+--------+------------+-------
  1 | Post 1 |     120506 | tag16
  1 | Post 1 |     120506 | tag2
  1 | Post 1 |     120506 | tag9
  1 | Post 1 |     220506 | tag16
  1 | Post 1 |     220506 | tag2
  … 7 more rows: every comment repeated once per tag …

For the full 50-post page, the database returned 452 rows instead of 50. Each post's 2.4 KB body was copied into every row: 1.06 MB of bodies instead of about 120 KB. Your code then has to walk the rows and throw the duplicates away. Try it on one post, including what an inner JOIN does when a count drops to 0:

One post, two has-many JOINs
Each square is one result row: one comment paired with one tag. Every row carries a copy of the post's 2.4 KB body.
rows: 4 comments ↓ × 3 tags →12 rows
12
rows sent
28.8 KB
of body copies
8
rows if fetched separately
Practitioner§06

Fix 2: batch with IN / ANY, stitch in memory

In one sentenceInstead of asking for one author 50 times, collect the 50 ids and ask once. Then use a map to hand each post its author. That's 2 queries for any N, and no duplicated rows.

This is the most useful fix to know by heart. It works for every kind of relation, even across two different databases, and it's exactly what most ORMs do under the hood when you ask them to eager-load (§07).

The recipe: collect, fetch, index, stitch

First watch it on 6 posts, then read the full code:

Collect, fetch, index, stitch
The batch recipe on 6 posts.
Code
1const ids = [...new Set(posts.map((p) => p.author_id))];
2
3const { rows: authors } = await pool.query(
4 "SELECT id, name FROM authors WHERE id = ANY($1)", [ids]);
5
6const byId = new Map(authors.map((a) => [a.id, a]));
7
8for (const post of posts) {
9 post.authorName = byId.get(post.author_id)?.name;
10}
posts
P1 · author_id 920
P2 · author_id 839
P3 · author_id 920
P4 · author_id 758
P5 · author_id 839
P6 · author_id 920
Set / Map in memory
nothing yet
From the database
nothing yet
We have 6 posts from the list query. Each knows only its author's id, and some ids repeat (Ada wrote three of them).
1 / 5
authors.go (database/sql + pgx)
func attachAuthors(ctx context.Context, db *sql.DB, posts []Post) error {
    // 1. Collect each author id once.
    seen := make(map[int64]bool)
    var ids []int64
    for _, p := range posts {
        if !seen[p.AuthorID] {
            seen[p.AuthorID] = true
            ids = append(ids, p.AuthorID)
        }
    }

    // 2. One query for all of them.
    rows, err := db.QueryContext(ctx,
        `SELECT id, name FROM authors WHERE id = ANY($1)`, ids)
    if err != nil {
        return err
    }
    defer rows.Close()

    // 3. Index the answers by id…
    names := make(map[int64]string, len(ids))
    for rows.Next() {
        var id int64
        var name string
        if err := rows.Scan(&id, &name); err != nil {
            return err
        }
        names[id] = name
    }
    if err := rows.Err(); err != nil {
        return err
    }

    // 4. …and stitch them onto the posts, in memory.
    for i := range posts {
        posts[i].AuthorName = names[posts[i].AuthorID]
    }
    return nil
}

posts here comes from the plain list query (SELECT id, title, author_id FROM posts …), so the whole page is 2 queries. Our test Postgres used the primary-key index for the batch, exactly as it would for one id:

EXPLAIN ANALYZE
Index Scan using authors_pkey on authors  (actual time=0.039..0.045 rows=10 loops=1)
  Index Cond: (id = ANY ('{1,2,3,4,5,6,7,8,9,10}'::bigint[]))
Execution Time: 0.073 ms

Ten authors in 0.073 ms of database time, versus 0.059 ms for one. The batch costs almost nothing extra; the round trips you saved were the whole cost. Measured over a 1 ms connection, the 50-post page went from 55 ms (51 queries) to 2.7 ms (2 queries).

Has-many: group instead of index

For comments, each post has a list. Same recipe, but the map goes from post id to a slice/array:

comments.go
rows, err := db.QueryContext(ctx,
    `SELECT post_id, id, body FROM comments WHERE post_id = ANY($1) ORDER BY id`, postIDs)
// … if err != nil, defer rows.Close() as before …

byPost := make(map[int64][]Comment) // post id → its comments
for rows.Next() {
    var postID int64
    var c Comment
    if err := rows.Scan(&postID, &c.ID, &c.Body); err != nil {
        return err
    }
    byPost[postID] = append(byPost[postID], c) // append works on a missing (nil) entry
}
// … check rows.Err() …
for i := range posts {
    posts[i].Comments = byPost[posts[i].ID]
}

Do the same for tags and the full page (posts + authors + comments + tags) is 4 queries, no duplicated rows, whatever N is. On our test setup at 1 ms per round trip, the N+1 version of that page ran 151 queries in 176 ms. The batched version took 5.2 ms.

Limits worth knowing

  • Very long lists. IN ($1, $2, …) with one placeholder per id hits protocol limits: Postgres allows at most 65,535 parameters per statement. One array parameter with ANY($1) avoids that, but a list of 100,000 ids is still a huge query. If your N can be that big, process the ids in chunks of a few thousand, or rethink the page (nobody reads 100,000 posts).
  • Order isn't guaranteed. WHERE id = ANY($1) returns rows in whatever order the database likes, not the order of your ids. That's why we stitch through a map instead of assuming row 1 belongs to post 1.
  • Missing rows. If an author was deleted, the map just has no entry. Decide what the page shows ("unknown author"?) instead of crashing on undefined.
Practitioner§07

Fix 3: eager loading, and nested JSON from Postgres

In one sentenceEvery ORM has a way to say "I'll need the authors too", called eager loading. Most of them implement it as the batch query from §06. Postgres can go one step further and return the whole nested page as JSON in one query.

Eager loading: tell the ORM up front

With Eager loading, you list the relations you'll need when you fetch the list, and the ORM loads them for all rows at once. These are the real statements our test database received (3 posts, so the lists are short):

GORM v1.31
// Preload: one extra query per relation
db.Preload("Author").Order("created_at DESC").Limit(3).Find(&posts)
// SELECT * FROM "posts" ORDER BY created_at DESC LIMIT 3
// SELECT * FROM "authors" WHERE "authors"."id" IN (920,839,758)

// Joins: a single LEFT JOIN (belongs-to / has-one only)
db.Joins("Author").Order("posts.created_at DESC").Limit(3).Find(&posts)
// SELECT "posts"."id", …, "Author"."name" AS "Author__name"
// FROM "posts" LEFT JOIN "authors" "Author" ON "posts"."author_id" = "Author"."id"
// ORDER BY posts.created_at DESC LIMIT 3

(Prisma always adds OFFSET to its queries, here with the value 0, which skips nothing; ignore it.) So Preload and include are §06's recipe, automated: one query for the list, then one IN (…) query per relation, stitched together in memory. That's 3 queries whatever N is, and because each table comes back separately there's no Cartesian explosion. GORM's Joins is the §05 JOIN instead; GORM only allows it for to-one relations, where JOIN is the right tool.

Nested JSON straight from Postgres

The page doesn't want flat rows at all. It wants posts, each holding a list of comments and a list of tags: a tree. Postgres can build that tree itself. json_agg collects rows into one JSON array, and a small SELECT inside the main one (a Correlated subquery) does it once per post:

SELECT p.id, p.title, a.name AS author,
  (SELECT coalesce(json_agg(json_build_object('id', c.id, 'body', c.body) ORDER BY c.id), '[]')
     FROM comments c WHERE c.post_id = p.id) AS comments,
  (SELECT coalesce(json_agg(t.name ORDER BY t.name), '[]')
     FROM post_tags pt JOIN tags t ON t.id = pt.tag_id
    WHERE pt.post_id = p.id) AS tags
FROM posts p
JOIN authors a ON a.id = p.author_id
ORDER BY p.created_at DESC
LIMIT 50;

One row per post, children already nested. This is real output (two posts shown, comments trimmed to fit):

 id | title  |   author   |                       comments                        |          tags
----+--------+------------+-------------------------------------------------------+-------------------------
  1 | Post 1 | Author 920 | [{"id" : 120506, "body" : "Comment 100000"}, … 3 more] | ["tag16", "tag2", "tag9"]
  2 | Post 2 | Author 839 | [{"id" : 115875, "body" : "Comment 95369"}, … 3 more]  | ["tag10", "tag3"]

Reading it back is easy in both languages. node-postgres turns json columns into JS objects automatically. In Go, wrap the whole query in one more json_agg so the page arrives as a single JSON array, then decode it into structs:

page.go
type CommentView struct {
    ID   int64  `json:"id"`
    Body string `json:"body"`
}
type PostView struct {
    ID       int64         `json:"id"`
    Title    string        `json:"title"`
    Author   string        `json:"author"`
    Comments []CommentView `json:"comments"`
    Tags     []string      `json:"tags"`
}

// pageQuery = `SELECT coalesce(json_agg(x ORDER BY x.created_at DESC), '[]')
//              FROM ( …query above, plus p.created_at… ) x`
// (the ORDER BY inside json_agg keeps the posts in page order)
var raw []byte // the whole page, as one JSON array
if err := db.QueryRowContext(ctx, pageQuery).Scan(&raw); err != nil {
    return nil, err
}
var page []PostView
if err := json.Unmarshal(raw, &page); err != nil {
    return nil, err
}

Which shape when?

Flip through the four result shapes for the same tiny data set:

Same data, four shapes
Two posts. Post 1 has 2 comments and 2 tags; post 2 has 1 of each. Red cells repeat a value the app already received.
SELECT p.id, p.title, a.name, c.id, c.body
FROM posts p JOIN authors a ON a.id = p.author_id
LEFT JOIN comments c ON c.post_id = p.id
posttitleauthorc.idcomment
1Hello SQLAda11Nice!
1Hello SQLAda12Thanks
2Why N+1Lin13+1
3 rows · 15 cells · 3 repeated
One row per comment. The post's columns repeat on every row of that post. Fine when the repeated columns are small.

Here are all strategies on our test database, for the full page (50 posts with author, comments and tags), measured from Go over a connection with a 1 ms round trip (median of 20 runs):

StrategyQueriesBytes receivedTime
N+1 (one query per post per relation)151126 KB176 ms
One big JOIN11,108 KB4.6 ms
Batch with ANY (§06 / ORM eager loading)4129 KB5.2 ms
json_agg1134 KB4.2 ms

Over a 1 ms round trip, the three fixes are all within a millisecond of each other, and all about 35–40× faster than the N+1. On the same machine (almost no round trip), the batch version was the fastest (0.9 ms vs 3.9 ms for json_agg), because building JSON is real work for the database.

Practitioner§08

Fix 4: DataLoader, batching you don't have to plan

In one sentenceWhen the code that needs each author can't see the others, let a DataLoader do the collecting: every load(id) made at about the same moment joins one batch, and one query answers them all.

Where §06 doesn't fit: GraphQL resolvers

The batch recipe needs one place that sees the whole list. Sometimes there isn't one. The classic case is GraphQL. The client sends a query describing the shape it wants:

a GraphQL query
{
  posts(first: 50) {
    title
    author { name }
  }
}

The server answers it by calling a resolver function for each field of each object. Query.posts runs once and returns 50 posts. Then Post.author runs once per post, and each call only knows about its own post:

resolvers.js (the N+1 version)
const resolvers = {
  Query: {
    posts: async () => (await db.query("SELECT * FROM posts ORDER BY created_at DESC LIMIT 50")).rows,
  },
  Post: {
    // called 50 times, once per post; each call can only see its own post
    author: async (post) =>
      (await db.query("SELECT * FROM authors WHERE id = $1", [post.author_id])).rows[0],
  },
};

That's an N+1 built into the architecture, and nested fields make it worse (each post's comments, each comment's author…). The same happens in any code split into small independent functions: React Server Components, serializers, "fetch what you need" helpers.

The idea: collect calls made in the same tick

DataLoader (the npm package dataloader, originally from Facebook's GraphQL team) works like a waiter taking orders for a table. Each person orders separately, but the waiter doesn't run to the kitchen after every order. They collect everyone's order and go once.

You give it one batch function: "given these ids, return their values". Resolvers call loader.load(id) instead of querying:

loaders.js (dataloader 2.x)
import DataLoader from "dataloader";

function makeLoaders(db) {
  return {
    author: new DataLoader(async (ids) => {
      const { rows } = await db.query(
        "SELECT id, name FROM authors WHERE id = ANY($1)", [ids]);
      const byId = new Map(rows.map((a) => [a.id, a]));
      // MUST return one result per id, in the same order as `ids`
      return ids.map((id) => byId.get(id) ?? new Error(`no author ${id}`));
    }),
  };
}

// The resolver no longer queries; it asks the loader:
const resolvers = {
  Post: { author: (post, _args, ctx) => ctx.loaders.author.load(post.author_id) },
};
// …and every request gets FRESH loaders, e.g. in your GraphQL context function:
// context: () => ({ loaders: makeLoaders(db) })

How does it know which calls belong together? JavaScript runs one piece of code at a time. When load is called, the loader adds the id to the current batch and schedules the dispatch to run right after the currently running code and its queued promise callbacks have finished (built on the Microtask queue, see the event loop). The GraphQL server calls all 50 Post.author resolvers in one go, so all 50 load calls land in the same batch. Step through it:

DataLoader, step by step
Three load() calls in one tick become one query.
Code
1const authorLoader = new DataLoader(async (ids) => {
2 const { rows } = await db.query(
3 "SELECT id, name FROM authors WHERE id = ANY($1)", [ids]);
4 const byId = new Map(rows.map((a) => [a.id, a]));
5 return ids.map((id) => byId.get(id) ?? new Error(id));
6});
7
8// three resolvers run during the same tick:
9const a = authorLoader.load("920"); // post 1
10const b = authorLoader.load("839"); // post 2
11const c = authorLoader.load("920"); // post 3
12await Promise.all([a, b, c]);
13
14await authorLoader.load("839"); // later
Promises handed out
none yet
Batch / cache
empty
SQL sent
nothing yet
We create a loader and give it a batch function: given a list of ids, fetch them all in one query and return results in the same order as the ids. Nothing runs yet.
1 / 7

We ran this against our test database: 6 resolvers asking for authors produced one query, WHERE id = ANY('{920,839,758,677,596,515}'). Asking again for an id already loaded produced no query at all.

The three rules of DataLoader

1

Same length, same order

The batch function must return exactly one result per id, in the order of the ids. WHERE id = ANY returns rows in any order, which is why we go through a Map. For a missing id, put an Error in its slot: that one load rejects, the others still succeed.
2

One loader per request

The cache never expires, and everyone using that loader shares it. A global loader would serve stale data forever and could leak one user's data to another. Create loaders when the request starts, and drop them when it ends.
3

Don't await inside a loop

for (const id of ids) await loader.load(id) waits for each result before asking for the next, so every batch holds one id: we measured 6 batches for 6 ids. Start all the loads first: await Promise.all(ids.map((id) => loader.load(id))).

Go: a time window instead of a tick

Go has no "tick". Goroutines run truly at the same time, so there's no moment when "everything currently running has finished". A Go loader uses a short time window instead: the first Load opens a batch and starts a timer (say 1 ms), every Load that arrives before it fires joins, then one query runs.

A Go batcher with a 1 ms window
goroutine 1
Load(920)
waiting for the batch
gets Author 920
goroutine 2
Load(839)
waiting
gets Author 839
goroutine 3
Load(758)
wait
gets Author 758
timer
1 ms window
1 query
Each goroutine calls Load and waits (striped). The first call starts a 1 ms timer; every call that arrives before it fires joins the same batch. Then one query runs and all waiting goroutines wake up together.

Here's a minimal version, complete except for fetchAuthors, which is §06's WHERE id = ANY($1) query returning a map[int64]Author. (We ran it with Go's race detector: 50 goroutines, 1 query. When goroutines start slowly, a batch can split in two because some calls arrive after the window closed. That's the trade-off of a window.)

loader.go
// AuthorLoader collects Load calls for a short window, then runs ONE query.
type AuthorLoader struct {
    db    *sql.DB
    wait  time.Duration // how long a batch collects ids, e.g. time.Millisecond
    mu    sync.Mutex
    batch *authorBatch // the batch currently collecting; nil = none
}

type authorBatch struct {
    ids  []int64
    done chan struct{} // closed when res/err are ready
    res  map[int64]Author
    err  error
}

func (l *AuthorLoader) Load(ctx context.Context, id int64) (Author, error) {
    l.mu.Lock()
    b := l.batch
    if b == nil { // first caller opens a new batch and starts its timer
        b = &authorBatch{done: make(chan struct{})}
        l.batch = b
        time.AfterFunc(l.wait, func() { l.fire(b) })
    }
    b.ids = append(b.ids, id)
    l.mu.Unlock()

    select { // wait for the batch's single query (or give up)
    case <-b.done:
    case <-ctx.Done():
        return Author{}, ctx.Err()
    }
    if b.err != nil {
        return Author{}, b.err
    }
    a, ok := b.res[id]
    if !ok {
        return Author{}, fmt.Errorf("author %d not found", id)
    }
    return a, nil
}

func (l *AuthorLoader) fire(b *authorBatch) {
    l.mu.Lock()
    l.batch = nil // later Loads start a fresh batch; b.ids is now final
    l.mu.Unlock()
    b.res, b.err = fetchAuthors(context.Background(), l.db, b.ids) // WHERE id = ANY($1)
    close(b.done) // wakes every waiting Load at once
}

This toy has no cache and no maximum batch size; production libraries add both. In Go, look at graph-gophers/dataloader (v7 uses generics) or vikstrous/dataloadgen, which the gqlgen GraphQL docs point to. Whatever you use, the window is a trade-off: every request waits up to that long before its first batch runs, in exchange for far fewer queries.

Practitioner§09

Running the queries in parallel is not a fix

In one sentenceFiring the 50 author queries at the same time hides the staircase, but the database still does 50 queries' worth of work, and each page now grabs a handful of connections at once. It's the same bug, moved somewhere harder to see.

The tempting "fix"

Once you know the loop waits for each query in turn, the obvious idea is to stop waiting:

parallel.go
var wg sync.WaitGroup
for i := range posts {
    wg.Add(1)
    go func() {
        defer wg.Done()
        // each goroutine fills in a different post, so they don't clash
        err := db.QueryRowContext(ctx, `SELECT name FROM authors WHERE id = $1`,
            posts[i].AuthorID).Scan(&posts[i].AuthorName)
        _ = err // (real code must collect errors, e.g. with errgroup)
    }()
}
wg.Wait() // still 50 queries, just not one after another

And it does look faster. Measured from Node with a Connection pool of 10 connections and a 1 ms round trip (median of 20 runs):

StrategyQueriesTime for one page
Loop, one await at a time5171 ms
Promise.all5112 ms
Batch with ANY (§06)23 ms

(These are Node numbers; node-postgres adds a little overhead per query, which is why its loop is slower than the 55 ms Go loop in §05.)

Watch the timeline. With only 4 connections, the queries that can't get one wait in line (striped):

What a tracing tool shows
One request for a page of 12 posts, drawn the way tracing tools draw it: one bar per database query, time going right. Each query ≈ 1 ms.
GET /posts
SELECT … FROM posts
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
SELECT … authors WHERE id = $1
0 ms2 ms4 ms6 ms8 ms10 ms12 ms
13 queries · page ready at 4.6 ms
Same 13 queries, now fired at once. Only 4 connections exist here (a small pool keeps the picture readable; §09's numbers use 10), so the rest wait in line for a connection (striped). Faster, but each request now uses 4 connections — see §09.

Why it falls over under load

That 12 ms is what you get with one user. Here's the step people miss: running queries in parallel doesn't reduce how long connections are busy, it just squeezes the same busy time into a shorter period.

Do the arithmetic for a pool of 10 connections. Each query keeps a connection busy for about 1 ms, round trip included.

  • N+1, loop or parallel: 51 queries × 1 ms = 51 connection-milliseconds per page. The pool has 10 connections × 1,000 ms = 10,000 connection-ms per second, so it can serve at most about 200 pages per second. Beyond that, requests queue for a connection and everyone gets slower.
  • Batched: 2 queries × 1 ms = 2 connection-ms per page, or about 5,000 pages per second from the same pool.

Parallelism only changes who waits. When traffic is low, one page uses 10 connections and finishes sooner. When traffic is high, each page's 50 queries compete with every other page's queries for the same 10 connections, and the pool's waiting line grows. That's exactly the "start everything at once" failure from Worker Pools. Push the traffic past 200 pages per second and watch:

One pool, many users
A pool of 10 connections; every query holds one for ~1 ms. N+1 pages fire their 50 author queries in parallel, the best case for N+1. Drag the traffic up.
N+1 (loop or parallel)51 conn-ms / page
pool busy51%
7.3 ms
median page
25 ms
p99 page
page time for each request during the simulated second (top = 60 ms)
Batched (2 queries)2 conn-ms / page
pool busy2%
2.0 ms
median page
2.0 ms
p99 page
page time for each request during the simulated second (top = 60 ms)
A small simulation (random arrivals for one second), not a benchmark. Past ~200 pages/s the N+1 side needs more connection time than the pool has, so the line climbs: every new request waits behind all the earlier ones. The batched side doesn't notice.
Practitioner§10

Pitfalls gallery: N+1's relatives

In one sentenceThe N+1 is one member of a family. They all come from asking the database for things one at a time, or for more than you need, and they all hide behind code that looks fine.
01

N+1 on writes: inserting in a loop

Saving 1,000 rows with 1,000 INSERT statements is the same bug in the other direction. It's even worse, because outside a Database transaction each insert is its own commit, and every commit waits for the database to fsync its log to disk (that's what makes a commit durable).

Measured on our test Postgres, inserting 1,000 rows:

StrategySame machine1 ms round trip
1,000 INSERTs, each its own commit301 ms1,475 ms
1,000 INSERTs inside one transaction68 ms1,190 ms
One INSERT … SELECT FROM unnest(…)2.4 ms5.4 ms
insert.go
// postIDs []int64 and viewers []string: one entry per row
_, err := db.ExecContext(ctx, `
    INSERT INTO views (post_id, viewer)
    SELECT * FROM unnest($1::bigint[], $2::text[])`,
    postIDs, viewers) // ← two arrays, one round trip

unnest(a, b) turns two arrays into rows: element 1 of each becomes row 1, and so on. $1::bigint[] tells Postgres the parameter is an array of bigints. ORMs have the same thing built in: GORM's db.Create(&views) with a slice sent one INSERT … VALUES (1,'a'),(2,'b'),(3,'c'), and Prisma's createMany sent one INSERT … VALUES ($1,$2), ($3,$4). For very large loads (hundreds of thousands of rows), Postgres's COPY is faster still (pgx has CopyFrom; in Node, the pg-copy-streams package).

02

Lazy loads in templates and serializers

The handler loads posts correctly, then hands them to an HTML template or a JSON Serializer, which reads post.author.name for each post. With a lazy-loading ORM, that line runs the query, in the template, far away from the code that loaded the data. Nobody reviewing the handler sees it.

Fix: load everything the response needs in the handler (eager loading, §07), and give the template plain data. Some frameworks can make accidental lazy loads fail loudly instead: Rails has strict_loading, and Django has third-party packages that raise or warn on them.

03

Counting per row

"Show each post with its number of comments" tempts you to call count() per post, which is an N+1 of aggregates (quiz Q5). One grouped query answers it for the whole page:

SELECT post_id, count(*) AS comments
FROM comments
WHERE post_id = ANY($1)
GROUP BY post_id;   -- one row per post: (post_id, how many comments)

GROUP BY post_id makes one result row per distinct post_id, and count(*) counts the rows in each group. Posts with no comments simply don't appear, so treat a missing entry in your map as 0.

04

SELECT * when the page shows the title

Both ORMs above select every column by default (SELECT * FROM "posts", or Prisma's full column list). Our posts have a 2.4 KB body; a list page that shows only titles still downloads about 120 KB of bodies for 50 posts. That's Over-fetching. It can also stop the database from answering from the index alone, without reading the table rows (an index-only scan; see Indexes).

Ask for what you show: GORM db.Select("id", "title", "author_id"), Prisma select: { id: true, title: true }.

05

Deep pages with OFFSET

LIMIT 50 OFFSET 90000 means "find 90,050 rows in order, throw away the first 90,000". The database can't skip them; it has to walk past them. On our 100,000 posts:

QueryExecution time
ORDER BY created_at DESC LIMIT 50 OFFSET 9000012.4 ms
WHERE created_at < $1 ORDER BY created_at DESC LIMIT 500.06 ms

The second one is Keyset pagination: remember the created_at of the last post on the page and ask for older ones. Every page costs the same. (It needs an index on the sort column and a tie-breaker such as id when two rows share a timestamp; Indexes covers the details.)

06

A transaction held open around lazy loads

Some frameworks open a transaction at the start of each request and commit at the end. Combine that with lazy loads during rendering, or a slow call to another service in the middle, and the transaction stays open for the whole request. All that time it holds a pool connection, and any rows it updated stay locked, so other requests that want them wait (see Race Conditions and Transactions).

Fix: load what you need, write what you must, commit, and only then render or call other services. Keep transactions short and free of network calls.

Practitioner§11

Keeping it fixed: tests that count queries

In one sentenceN+1s come back. Someone adds a field to the page, the template reads one more relation, and nobody notices until production. A test that counts queries catches it on the pull request instead.

The trick is the same in every language: route all queries through something that counts them, run the code, and assert on the count. Run it against a real test database (an Integration test), since a fake database wouldn't run the real queries.

Go: count with a pgx tracer

pgx lets you attach a tracer to a connection config: an object that pgx calls at the start and end of every query. Ours just adds one to a counter:

posts_test.go (imports: context, os, sync/atomic, testing, pgx/v5, pgx/v5/stdlib)
// queryCounter counts every SQL statement pgx sends.
type queryCounter struct{ n atomic.Int64 }

func (c *queryCounter) TraceQueryStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryStartData) context.Context {
    c.n.Add(1)
    return ctx
}
func (c *queryCounter) TraceQueryEnd(context.Context, *pgx.Conn, pgx.TraceQueryEndData) {}

func TestListPostsQueryCount(t *testing.T) {
    cfg, err := pgx.ParseConfig(os.Getenv("TEST_DATABASE_URL"))
    if err != nil {
        t.Fatal(err)
    }
    counter := &queryCounter{}
    cfg.Tracer = counter // ← every query now passes through the counter
    db := stdlib.OpenDB(*cfg) // a normal *sql.DB on top of pgx
    defer db.Close()

    before := counter.n.Load()
    if _, err := ListPosts(context.Background(), db, 50); err != nil {
        t.Fatal(err)
    }
    if got := counter.n.Load() - before; got > 2 {
        t.Fatalf("ListPosts ran %d queries for 50 posts, want at most 2 (N+1?)", got)
    }
}

Pointed at an N+1 ListPosts (a plain database/sql version of the §03 loop: one SELECT name FROM authors WHERE id = $1 per post), the test fails with exactly the message you want to see in CI:

go test (real output)
--- FAIL: TestListPostsQueryCount (0.04s)
    posts_test.go:37: ListPosts ran 51 queries for 50 posts, want at most 2 (N+1?)
FAIL

JavaScript: wrap the pool, or listen to Prisma

posts.test.js (node:test + pg)
import { test } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
import { listPosts } from "./posts.js";

// Wrap a pool so it counts every query that goes through it.
function countingPool(pool) {
  const counter = { n: 0 };
  const db = {
    query: (...args) => { counter.n++; return pool.query(...args); },
  };
  return { db, counter };
}

test("listPosts does not run one query per post", async (t) => {
  const pool = new pg.Pool({ connectionString: process.env.TEST_DATABASE_URL });
  t.after(() => pool.end());
  const { db, counter } = countingPool(pool);

  await listPosts(db, 50);

  assert.ok(counter.n <= 2, `listPosts ran ${counter.n} queries for 50 posts (N+1?)`);
});

Run against the N+1 version, node's test runner reports error: 'listPosts ran 51 queries for 50 posts (N+1?)'. With Prisma, the loop version logged 6 queries for 5 posts and the include version 2.

Make the test about growth, not a magic number

"At most 2" breaks the day someone legitimately adds a third query. A sturdier test checks that the count doesn't depend on N: load a page with 2 posts, then with 20, and assert both ran the same number of queries. An N+1 fails that test whatever the exact numbers are.

Staff-level§12

Staff-level: when it's fine, caches, APIs and services

In one sentenceN+1 isn't a database bug; it's a "one round trip per item" bug. It shows up wherever there's a round trip: to a cache, from a phone to your API, between services. The further away the other side, the more it costs.

When N+1 is fine

The cost is N round trips, so the question is always: how big can N get, and how far away is the other side?

  • N is small and bounded by your design, not your data. A settings page with 3 sections, a dashboard with 4 fixed widgets. Three extra 1 ms queries won't show up anywhere, and the simple code is worth more.
  • Each "query" isn't a round trip. Lookups served from an in-memory cache in your own process cost microseconds. (A cache on another machine, like Redis, doesn't count; see below.)
  • The page isn't hot. An internal admin report run twice a day can afford 200 ms.

The dangerous kind is the N that grows with your data: posts per page, items per order, projects per customer.

Caches are a band-aid

Putting each author lookup behind a Cache (say, Redis) feels like a fix. But if Redis is on another machine, you've swapped 50 database round trips for 50 cache round trips, each still around 0.2–1 ms. Faster, still a staircase. And now there's a second copy of the data that can go stale. If you do cache, batch the cache reads too: Redis MGET fetches many keys in one round trip, exactly like WHERE id = ANY. Fix the access pattern first; cache second. (More in Caching.)

N+1 over HTTP: the chatty API

The same shape between a client and your API costs far more per trip. Picture a mobile app showing 20 posts: GET /posts, then GET /authors/920, GET /authors/839… On a phone network a round trip is easily 50–100 ms (approximate; it varies widely).

A mobile app, 20 posts, ~80 ms per request
N+1 API
/posts
…
1 endpoint
/feed
One after another, 21 requests × ~80 ms ≈ 1.7 s before the screen is complete. Sending the 20 author requests in parallel helps, but the server still handles 21 requests per screen and the phone still pays at least two full round trips. One well-shaped endpoint costs one trip.

Ways to give the client the whole screen in one trip:

  • Embed related data: GET /posts?include=author returns each post with its author inside. The server does the batching (§06) close to the database, where round trips are cheap.
  • Batch endpoints: GET /authors?ids=920,839,758 so a client that must fetch separately can at least fetch in one call.
  • A backend for frontend: a thin server per client app that assembles each screen's data from your services and returns it in one response.
  • GraphQL with DataLoaders (§08): the client describes the whole tree; the server batches each level.

These are API design choices, so they're much cheaper to make before clients depend on the chatty version; see API Design and HTTP for the request costs involved.

N+1 between microservices

Split a system into microservices and the database JOIN that used to take 0.2 ms becomes a network call to another service. An orders page that calls the users service once per order is an N+1 over HTTP: each call costs a few milliseconds (connection, JSON encoding and decoding, the other service's own database query), so 50 orders add 100–250 ms. Worse, it multiplies load: one page view becomes 50 requests to the users service, and if those calls retry on timeouts, a slow users service gets hit even harder.

The fixes are the same ideas one level up: batch endpoints (POST /users:batchGet with a list of ids; the colon is a Google-API naming style for "a custom method", and POST /users/batch works just as well), a DataLoader-style batcher in the calling service, or keeping a local, read-only copy of the few user fields the orders service needs.

Practitioner§13

Check yourself

Predict the answer before you click. Q1 is covered in §03, Q2 in §09, Q3 in §08, Q4 in §05, Q5 in §07 and §10, Q6 in §12.

QUESTION 1 / 6
Count the queries
// 20 orders, each with 3 line items; every item has a product
const orders = await Order.findAll({ limit: 20 });       // lazy-loading ORM
for (const order of orders) {
  for (const item of await order.items) {
    console.log((await item.product).name);
  }
}
QUESTION 2 / 6
Which fix is NOT a fix?

A page loads 50 posts, then each post's author one query at a time. Which of these changes still leaves the database running 50 author queries?

QUESTION 3 / 6
Predict the batches
const loader = new DataLoader(async (ids) => {
  console.log("batch", ids);
  return ids.map((id) => ({ id }));
});
for (const id of ["a", "b", "c"]) {
  await loader.load(id);
}
QUESTION 4 / 6
The JOIN that got slower

A page loads posts with LEFT JOIN comments and LEFT JOIN post_tags in a single query. With test data (1 comment and 1 tag per post) it was instant. In production, where posts have ~20 comments and ~10 tags, it got 10× slower. Why?

QUESTION 5 / 6
Where's the N+1?
// GORM
var posts []Post
db.Preload("Author").Limit(50).Find(&posts)
for _, p := range posts {
    var n int64
    db.Model(&Comment{}).Where("post_id = ?", p.ID).Count(&n)
    fmt.Println(p.Title, p.Author.Name, n)
}
QUESTION 6 / 6
Fine or a bug?

A settings page loops over its 3 fixed sections (profile, billing, notifications) and runs one small query per section. It is opened a few times a day. Should you rewrite it to batch the queries?

Answered 0 / 6
Foundations§14

Cheat sheet

🛒
Count round trips, not workA lookup by id costs the database ~0.05 ms; the trip costs ~1 ms. 51 small queries ≈ 51 trips. One query for 50 rows ≈ one trip.
🔍
Suspect every loopIf anything inside a loop over rows talks to the database (a method, a lazy field, a helper), it's probably an N+1.
🪜
Look for the staircaseSame statement repeated with different ids in the log; a staircase of short bars in a trace. The slow query log won't show it.
🔗
JOIN for belongs-toOne query, one row per post. Don't JOIN two has-many relations together: rows multiply.
📦
Batch for has-manyCollect ids → WHERE id = ANY($1) → map by id → stitch. One query per relation, any N.
🧺
Eager load in the ORMinclude / Preload = the batch recipe automated. Name every relation the page reads.
⏱
DataLoader for resolversCalls made in the same tick (JS) or time window (Go) become one query. One loader per request.
🚫
Parallel is not a fixPromise.all / goroutines: same N queries, more connections held. Batch instead.
SituationDo this
List + its "parent" (post → author)JOIN, or GORM Joins
List + a has-many (post → comments)LEFT JOIN if the parent is small, or a second query with = ANY($1) and a map, or include / Preload
Several has-many on one pageOne ANY query per relation, or json_agg subqueries
Counts per rowSELECT post_id, count(*) … WHERE post_id = ANY($1) GROUP BY post_id
GraphQL / independent resolversDataLoader (JS), batcher with a short window (Go)
Inserting many rowsINSERT … SELECT FROM unnest($1::bigint[], …), createMany, db.Create(&slice); COPY for huge loads
Deep paginationKeyset: WHERE created_at < $1 ORDER BY created_at DESC LIMIT n
Keep it fixedCount queries in tests; assert the count doesn't grow with N
Client or service calls per itemEmbed related data, batch endpoints, a BFF
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in DatabasesTransactions & Isolation
Read →