Lakshya

Software Engineering · Chapter 9 of 31

Databases for application engineers

Indexes, query plans, isolation levels and the N+1. The single largest source of production performance problems, and the area application engineers are least taught.

10 min read2 diagramsAll 31 chapters

Most application performance problems are database problems, and most database problems are one of five things. This chapter is those five things in enough depth that you can diagnose them rather than guess — and it is deliberately about the parts an application engineer actually touches, not about running a database.

1. Indexes, and the rule that catches everyone

An index is a sorted structure — almost always a B-tree — mapping column values to row locations. That single sentence explains all of its behaviour.

INDEX ON (tenant_id, created_at, status) sorted by tenant_id, then created_at, then status t=72026-01-02OPEN t=72026-01-02SHUT t=72026-03-11OPEN t=92026-01-05OPEN THE LEFTMOST-PREFIX RULE — THE MOST COMMON INDEX MISTAKE THERE IS WHERE tenant_id = 7uses the indexWHERE tenant_id = 7 AND created_at > '2026-01-01'uses the indexWHERE tenant_id = 7 AND status = 'OPEN'uses tenant_id only, then filtersWHERE created_at > '2026-01-01'CANNOT use it at all — full scanWHERE status = 'OPEN'CANNOT use it at all — full scan An index is a sorted list. You can start reading a phone book at “Kalambi”; you cannot use it to find everyone whose first name isPranav. And if the index contains every column the query needs, the database never touches the table at all — a covering index, often a 10×difference on its own.
A composite index is one sorted list, so it is usable left to right only. The phone-book analogy at the bottom is the one to keep: you can start reading at a surname, and no amount of cleverness lets you use it to search by first name.
  • The leftmost-prefix rule. An index on (a, b, c) serves queries filtering on a, on a and b, or on all three. It does nothing for a query filtering only on b. Column order in a composite index is a design decision, not a formatting one.
  • Put equality before range. An index on (status, created_at) serves status = 'X' AND created_at > ... beautifully. Reverse the order and the range scan comes first, so the equality can only be applied as a filter over everything the range returned.
  • A covering index avoids the table entirely. If every column the query needs is in the index, the database never dereferences a row. This is frequently a 10× improvement on its own and is the reason INCLUDE columns exist.
  • Any function on the column kills the index. WHERE LOWER(email) = ... or WHERE DATE(created_at) = ... cannot use an ordinary index, because the index stores the raw values. Rewrite as a range, or build an expression index on exactly that expression.
  • Leading wildcards kill it too. LIKE 'abc%' is a range scan; LIKE '%abc' is a full scan, because sorted order gives you no help.
  • Indexes are not free. Every one must be maintained on every write, and they consume memory that would otherwise cache data. An unused index is a pure tax — and every serious database can tell you which of yours are unused.

2. Read the plan, do not guess

EXPLAIN ANALYZE is the single most valuable database skill for an application engineer, and the thing to read is not the total time.

What to look atWhat it means
Estimated vs actual rowsRead this first. An estimate of 10 against an actual of 900,000 means the planner chose its whole strategy on bad information — usually stale statistics or a correlation it cannot model. Fix the estimate, and the plan often fixes itself
Seq Scan on a large tableNot always wrong — a scan beats an index when you are reading most of the table. Wrong when the filter is selective
Nested Loop with a large outerFine for a few rows, catastrophic for many. Usually the consequence of a bad row estimate above
Sort spilling to diskexternal merge in the plan. Either raise work memory or add an index that provides the order
Rows Removed by FilterRows read and then thrown away. A large number here is an index you do not have
Loops = NThis node ran N times. Multiply its time by N — this is where the real cost hides

3. The N+1, which is a design failure rather than a database one

You fetch 100 orders, then in a loop fetch each order's customer. That is 101 queries. Each may take 1 ms in the database and 2 ms of round trip, so 300 ms of latency for work that is a single join.

It is caused by ORM lazy loading and it is invisible in code review, because the loop looks like ordinary object access. The fixes are eager loading, an explicit join, or a batched second query using WHERE id IN (...) — the last being a good pattern because it is two queries regardless of result size.

The durable fix is detection rather than vigilance: count queries per request and alert on the count. A request issuing 340 queries is a bug whether or not anyone has noticed it is slow yet, and a test that fails when a request's query count grows is one of the highest-value tests you can write.

4. Isolation levels, and the anomaly nobody knows

WHAT EACH ISOLATION LEVEL ACTUALLY PREVENTS LEVELDIRTY READNON-REPEATABLEPHANTOMWRITE SKEW READ UNCOMMITTED possiblepossiblepossiblepossibleREAD COMMITTED preventedpossiblepossiblepossibleREPEATABLE READ preventedpreventedpossible*possibleSERIALIZABLE preventedpreventedpreventedprevented * PostgreSQL's REPEATABLE READ is snapshot isolation and does prevent phantoms. MySQL InnoDB prevents them with next-key locks. Thestandard does not require it. Almost every database defaults to READ COMMITTED — the second row. So by default your transaction can read two different values forthe same row. Write skew is the one nobody knows. Two transactions each read a set, each check a constraint that still holds, each write adifferent row — and the constraint is now violated. “At least one doctor must be on call”: two doctors each see two on call, each removes themselves, both commit. Snapshot isolationpermits this. Only SERIALIZABLE stops it.
What each level actually prevents. Note the default row — almost every database ships at READ COMMITTED, which permits three of the four anomalies.

Most engineers have heard of dirty reads and stop there. The two that matter in practice are further down.

  • Lost update. Two transactions read a balance of 100, each adds 50, each writes 150. One update is gone. At READ COMMITTED this is entirely possible. The fixes are SELECT ... FOR UPDATE, an atomic UPDATE ... SET balance = balance + 50, or optimistic concurrency with a version column.
  • Write skew. Two transactions read overlapping data, each verifies a constraint that still holds, each writes a different row, and the constraint is now violated. The canonical example: a rule that at least one doctor must remain on call; two doctors each see that two are on call, each removes themselves, both commit, nobody is on call. Snapshot isolation does not prevent this — only true serialisability does, or an explicit lock on the thing being constrained. It is the anomaly that produces the most baffling production data corruption, precisely because every individual transaction was correct.

Deadlocks, and the one-line fix

A deadlock is two transactions each holding a lock the other wants. The database detects it and kills one, which surfaces to your application as a retryable error.

The overwhelmingly common cause is inconsistent lock ordering — one code path updates account A then B, another updates B then A.

The fix is to always acquire locks in a consistent order, typically by sorting the identifiers before you touch anything. It is a one-line change and it eliminates the entire class.

And retry on deadlock with backoff. A deadlock is not a bug in the sense of something to prevent absolutely; at some concurrency they are normal and the correct response is to retry.

5. Connection pools, and the arithmetic

Every connection to a database like PostgreSQL costs a process and memory on the server. A pool keeps a fixed number open and hands them out.

  • More connections is not more throughput. Past the point where the database can genuinely do parallel work — broadly related to core count and disk parallelism — additional connections add context switching and lock contention and make everything slower. Small pools frequently outperform large ones, which is counter-intuitive enough that people rarely test it.
  • Count the pool across your whole fleet. Twenty application instances with a pool of 50 each is a thousand connections. This is how a routine autoscaling event exhausts a database's connection limit and takes everything down.
  • Pool exhaustion presents as application latency, not database latency. The database looks idle and healthy; requests are queuing for a connection that never arrives. Instrument wait time to acquire a connection as a first-class metric — without it this failure is genuinely hard to see.
  • A long transaction holds its connection. An HTTP call inside a transaction is the classic disaster: a slow third party now holds a database connection for its entire timeout, and under load the pool drains. Never make a network call inside a transaction.

A scenario worth rehearsing

Symptom. An endpoint that was 80 ms is now 4 seconds. It began gradually over two weeks. No deploy correlates. Database CPU is 30%.

What most people do. Add a cache.

What to do. Get the actual query — from the ORM's log or the database's slow query log — and run EXPLAIN ANALYZE on it with realistic parameters.

What you will usually find. Estimated rows 200, actual rows 400,000, and a nested loop. The table grew past the point where the planner's estimate mattered, and the plan flipped. Gradual onset with no deploy is the signature of a plan change driven by data growth or stale statistics.

Fixes, in order of preference. Refresh statistics. Add the index the filter needs, ideally covering. Rewrite the query so the selective predicate is applied first. Only then consider caching — because a cache in front of a query with a bad plan hides the problem until the cache misses under exactly the conditions where you can least afford it.

And the prevention: alert on the slow query log, and track p99 query time by statement rather than in aggregate. This class of failure is entirely predictable and almost never monitored.

Check yourself

Why does an index on (a, b) not help a query filtering on b alone?It is one sorted list, ordered by a first. Sorted order gives no help for a predicate on the second column — the same reason a phone book cannot find people by first name.
What is the first thing to read in a query plan?Estimated rows against actual rows. A large discrepancy means the planner chose its whole strategy on bad information, and fixing the estimate often fixes the plan.
What is write skew and which isolation level stops it?Two transactions read overlapping data, each verifies a constraint that still holds, each writes a different row, and the constraint breaks. Snapshot isolation permits it; only serialisable prevents it.
Your pool is exhausted. Where does it show up?In application latency, with the database looking idle. Instrument wait-time-to-acquire, or you will not see it. And check for network calls inside transactions.
← Backend, frontend, full-stack, mobile — and where the demand isDistributed systems: the failures that only happen in production →