Joins, transactions, and the N+1 problem
A performance bug that turns up in almost any codebase that talks to a database.
Topic 53 of 98 · 5 min to read · 50 min to build
After this topic you can
- Spot an N+1 query loop and fix it with a join or one batch query.
- Write a transaction with a deferred rollback that commits only on success.
- Read an
EXPLAIN ANALYZEplan to find a missing index.
N+1: one query, then one per row
You list twenty notes. For each one you fetch its attachment count. That is 1 + 20 = 21 round trips to the database. At two hundred notes it is 201. At a few milliseconds per round trip, the endpoint takes most of a second, and nothing in the Go code looks slow.
n-plus-one.go
// SLOW — a query per notenotes, _ := repo.List(ctx, userID, limit, offset)for i := range notes { notes[i].Attachments, _ = attRepo.CountByNote(ctx, notes[i].ID) // N queries}// FAST — one query`SELECT n.id, n.title, n.body, COUNT(a.id) AS attachments FROM notes n LEFT JOIN attachments a ON a.note_id = n.id WHERE n.user_id = $1 GROUP BY n.id ORDER BY n.created_at DESC LIMIT $2 OFFSET $3`The other fix: fetch in one batch
When a join is awkward, collect the ids and do a second query for all of them at once. Two queries total, no matter how many rows.
batch.go
ids := make([]int64, len(notes))for i, n := range notes { ids[i] = n.ID}rows, err := db.QueryContext(ctx, `SELECT note_id, id, filename FROM attachments WHERE note_id = ANY($1)`, ids) // pgx encodes a Go slice as a Postgres array// then group them into a map[int64][]Attachment and attach in one passNote: Turn on your database’s slow query log during development, or count queries in a test. When you read the Go code, the loop looks harmless. Count the round trips and N+1 is obvious.
Transactions: all of it, or none of it
Some operations must not half-happen. Creating a note and its first attachment row is one unit: if the second insert fails, the first must be undone.
tx.go
func (r *Repository) CreateWithAttachment(ctx context.Context, n *Note, a *Attachment) (err error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { if p := recover(); p != nil { tx.Rollback() panic(p) // roll back, then let the panic continue } if err != nil { tx.Rollback() // named return: this sees the real error } }() if err = tx.QueryRowContext(ctx, insertNote, n.UserID, n.Title, n.Body).Scan(&n.ID); err != nil { return err } a.NoteID = n.ID if _, err = tx.ExecContext(ctx, insertAttachment, a.NoteID, a.Filename); err != nil { return err } return tx.Commit()}Transaction rules
- Keep them short. A transaction holds a pooled connection for its whole life. A few slow ones at once can use up the pool.
- Never make an HTTP call inside one. A slow third party then ties up a database connection that other requests need.
- Take row locks in a consistent order across the codebase, or two transactions grabbing the same two rows in opposite orders will deadlock.
- Do not wrap reads in a transaction out of habit. A single
SELECTis already atomic.
Read your query plan
psql
EXPLAIN ANALYZESELECT id, title FROM notes WHERE user_id = 1 ORDER BY created_at DESC LIMIT 20;-- "Seq Scan on notes" means no index is being used. Add one.-- "Index Scan using notes_user_id_created_at_idx" is what you want.The short version
- A query inside a loop is N+1. Fix it with a JOIN, or one batched
WHERE id = ANY($1). - A transaction is all-or-nothing. Use a named return so the deferred rollback sees the error.
- Transactions hold a connection: keep them short, and never call an external service inside one.
EXPLAIN ANALYZEevery query on a growing table. A sequential scan on a large table usually means a missing index.
Remember: The database is where your latency lives. One join beats twenty round trips, and one index beats a month of Go micro-optimisation.
Build this before moving on
Add an attachment count to your notes list — first with the naive loop, then with a JOIN. Time both with 200 notes. Then run EXPLAIN ANALYZE on the list query and confirm it uses your index.Then try these in your own editor
- 1Build the N+1 version with 200 rows and time it. Then do it with one JOIN and time that.
- 2Run
EXPLAIN ANALYZEon your list query before and after adding the index. - 3Write a two-statement transaction where the second fails, and confirm the first was rolled back.
- 4Open a transaction and sleep for 30 seconds inside it while running other requests. Watch the pool.
Official documentation