Internals · Buffer Poolread pathindex scanbuffer poolheap pagetuple id

Follow a Read Through the Engine

SELECT * FROM users WHERE id = 42 is nine stages from text to row: parse, plan, three index pages, the buffer pool decision on each, one heap page, one slot, one tuple. Where each page comes from — pool or storage — decides whether the query takes 4 µs or 400.

▶ InteractiveTry queriesInterview question
Progress

Why this exists

The mechanism as the answer to a problem — read this before the name.

  1. Problem

    The mechanisms are known one at a time — pages, B+ trees, the buffer pool. What actually happens, in order, when a client sends SELECT * FROM users WHERE id = 42, and where do the microseconds go?

  2. Naive solution

    Treat the query as one operation: "look up 42". It returns a row; it takes as long as it takes.

  3. Why it breaks

    The same query takes 4 µs one moment and 400 µs the next, and a plan that says "Index Scan, cost 8.3" gives no hint why. Without the page-level view there is nothing to reason about.

  4. Better idea

    Follow the query as a sequence of page requests. Each request is answered by the buffer pool (hit) or by storage (miss), and the latency is the sum of those answers plus a little CPU.

  5. Internal mechanism

    Parse → plan → index root → internal → leaf → (tuple id) → heap page → slot → tuple → result. Four page requests; each one hashed against the page table; pins taken and released; visibility checked on the tuple, not in the index.

  6. Trade-offs

    The index halves nothing if its pages are cold: four misses are four random reads. Warmth is a property of the running system, not of the schema, and benchmarks that run warm measure the best case.

  7. Real database

    PostgreSQL EXPLAIN (ANALYZE, BUFFERS) prints shared hit=3 read=1 per node — exactly this ledger. InnoDB's clustered index makes the leaf page the data page, so the same lookup is one page shorter.

Choose your depth

The same mechanism at four altitudes. Start where you are; come back deeper.

A query is a list of page requests

A point lookup on a primary key is a walk: from the top of the index down to the row. Every step of the walk is "give me page N" to the buffer pool, and the pool either has it or fetches it from storage. Three or four such steps, plus a little parsing and planning, is the whole query.

The number that matters is how many of those steps missed. All hits: microseconds. All misses: hundreds of microseconds, and on spinning disks tens of milliseconds.

The statement and the plan

The client sends 32 bytes of text. The lexer turns it into tokens, the parser into a tree, and the binder resolves names against the catalog: users is a relation, id is its first column (integer, NOT NULL, primary key), and users_pkey is a unique B+ tree on it. The catalog lookups are themselves reads of catalog pages, which are the hottest pages in the system and never miss in practice. The planner then compares a sequential scan — every page of users, at 1,200 pages roughly 1,200 sequential reads — with an index scan: height of users_pkey (3) plus one heap page, four random reads. For an equality predicate on a unique key the index wins by two orders of magnitude, and the plan is Index Scan using users_pkey on users (cost=0.43..8.45 rows=1).

Notice what the plan does not say: whether those four pages are in memory. The cost model assumes some fraction of pages are cached (effective_cache_size) but the plan is the same either way. The plan describes work; the pool decides its price.

1SELECT * FROM users WHERE id = 42;
2
3-- the plan the engine chose
4-- Index Scan using users_pkey on users (cost=0.43..8.45 rows=1 width=72)
5-- Index Cond: (id = 42)

Descending the index, page by page

The executor asks the buffer manager for page 1 of users_pkey: the root. In it, ~200 separator keys; a binary search over them (about 8 comparisons) picks the child whose range contains 42. That child is an internal page — the same shape one level down — and its binary search picks a leaf. The leaf holds (key → tuple id) pairs in key order; the entry for 42 says (1271, 14): heap page 1,271, slot 14. Three page requests, three binary searches, and the index is done. It has not read the row and it does not know whether the row is visible; it has produced an address.

Each request is the same call: ReadBuffer(users_pkey, N). The buffer manager hashes (relation, block) into the page table; a hit pins the frame and returns it in about a microsecond; a miss picks a victim by clock-sweep, evicts it (writing it if dirty), reads 8 KB from the file, and returns the frame after roughly a hundred microseconds on an SSD. The root and internal pages are shared by every lookup on the table and are essentially never evicted; the leaf is one of ~6,000 for a 1.2-million-row table and is resident only if some recent lookup landed on it.

The four pages of the lookup and where they usually come from
users_pkey  page 1     root      ~200 separators   ── hit   (every lookup reads it)
users_pkey  page 41    internal  ~200 separators   ── hit   (one of ~30, all resident)
users_pkey  page 388   leaf      ~200 (key → tid)  ── miss? (one of ~6,000)
users       page 1271  heap      slot 14 → tuple   ── miss? (one of ~1,200, scattered)

4 page requests · 2 likely hits (2 µs) · 2 likely misses (200 µs) · +20 µs CPU ≈ 220 µs

Every page goes through the pool

The buffer pool is not a stage the query passes once; it is the door every page request goes through. The executor code that walks the tree does not know whether page 388 came from RAM or from the SSD — it asked, and it got a pinned frame. This separation is what keeps the executor simple and what makes latency a property of the running system rather than of the plan. It also means the pool's replacement decisions from a minute ago — a scan that flooded it, a checkpoint that cleaned it — are visible in this query's latency.

The interactive above lets you set the pool state: cold (nothing resident), typical (index upper levels resident), warm (everything resident). Same SQL, same plan, same four pages; only the hit/miss column changes, and with it the latency by two orders of magnitude.

The read path: every page request is a buffer-pool decision
tid (1271, 14)misshit, ~1 µsSQL textplan: Index Scanroot pageinternal → leafbuffer pool: hit or miss?storage read, ~100 µsheap page, slot 14tuple → result
UserLLMAgentToolDataDecisionHumanGuardrail

The heap page, the slot and the tuple

The tuple id names heap page 1,271. The buffer manager fetches it — the page least likely to be resident, since heap pages are spread over the whole table and only the recently touched ones are in the pool. The executor reads entry 14 of the page's slot directory, which gives the byte offset of the tuple within the page (see Slotted Pages). At that offset sits the tuple header: transaction ids that created and (possibly) deleted this version, and flag bits. Visibility is decided here — was the creating transaction committed before my snapshot, and is there no committed deletion? — and only then are the columns decoded using the catalog's column layout, honouring alignment padding and the NULL bitmap.

Then the pins are released. The heap page and the leaf page become evictable again; the root stays pinned by nobody yet survives, because its usage count is at the maximum and the clock hand never catches it at zero. The row is sent to the client. Nine stages, four page requests, one row.

Heap page 1271, the part the executor touches
┌ page header (24 B) ──── LSN 5 812 100 · lower/upper free-space pointers ┐
│ slot directory: [1: off 8120] [2: off 8040] … [14: off 6968] … [37: …]   │
│                                                    │                     │
│ free space                                         │                     │
│                                                    ▼                     │
│ … tuple 14 @6968: hdr{xmin 4 117 220, xmax 0, flags} | id=42 | email=… │
└──────────────────────────────────────────────────────────────────────────┘

Where the time goes

Add it up with round numbers: ~20 µs of CPU for parse, plan and executor setup; ~1 µs per pool hit; ~100 µs per SSD miss. Warm cache: 4 hits, ~24 µs. Typical: 2 hits and 2 misses, ~220 µs. Cold: 4 misses, ~420 µs. On a network round trip of 500 µs the warm case is invisible to the client and the cold case doubles the request time; on a spinning disk (10 ms per miss) the cold case is 40 ms and the warm case is still 24 µs — a ratio of 1,600. Every intuition about "the database is fast" is an intuition about the warm case.

The lesson generalises. A join that touches 10,000 rows via an index is 10,000 heap page requests; whether they hit is the difference between a 30 ms query and a 1 s query. The planner estimates this with random_page_cost and effective_cache_size; the plan's BUFFERS output measures it after the fact.

The same lookup, three pool states (simulated: SSD miss 100 µs, hit 1 µs, CPU 20 µs)
Pool staterootinternalleafheapTotal
Coldmissmissmissmiss~420 µs
Typicalhithitmissmiss~220 µs
Warmhithithithit~24 µs

PostgreSQL: reading the ledger in EXPLAIN

PostgreSQL implementation

EXPLAIN (ANALYZE, BUFFERS) prints, for each plan node, Buffers: shared hit=H read=R — H page requests satisfied from shared_buffers, R that had to be read (from the OS cache or the disk; PostgreSQL cannot distinguish, which is the double-buffering blind spot). A point lookup that reports shared hit=3 read=1 is the "typical" row above with only the heap page missing. dirtied and written appear when the query itself had to write pages. Run the same query twice and watch read fall to 0: that is the pool warming, and it is why the second run of any benchmark is faster.

Index-only scans add one more page: the visibility map, which lets the executor skip the heap page when the whole heap page is known to be all-visible. If the table has been updated since the last VACUUM, the bit is clear, the heap page is fetched anyway, and the plan shows Heap Fetches: 1 — an index-only scan that was not.

A typical point lookup with BUFFERS (PostgreSQL)
Index Scan using users_pkey on users  (cost=0.43..8.45 rows=1 width=72)
                                      (actual time=0.184..0.186 rows=1 loops=1)
  Index Cond: (id = 42)
  Buffers: shared hit=3 read=1
Planning Time: 0.061 ms
Execution Time: 0.209 ms

Key points

  • A point lookup is four page requests: index root, internal, leaf, heap. The plan fixes the pages; the pool decides their price.
  • Root and internal pages are shared by every lookup and stay resident; the leaf and the heap page are specific to the key and often miss.
  • Warm ≈ 24 µs, typical ≈ 220 µs, cold ≈ 420 µs for the same SQL on an SSD — bimodal latency graphs are usually hits vs misses.
  • Visibility is checked on the heap tuple, never in the index; the index yields an address, not an answer.
  • EXPLAIN (ANALYZE, BUFFERS) shows the hit/read ledger per node; the second run of a benchmark is faster because the pool warmed.

Follow a read: SELECT … WHERE id = 42

Follow a read: SELECT * FROM users WHERE id = 42
One primary-key lookup, page by page. Every page the executor asks for goes through the buffer pool; toggle how warm it is and watch the latency change by two orders of magnitude.
1SELECT * FROM users WHERE id = 42;
SQL parse · bind
What happens
The text `SELECT * FROM users WHERE id = 42` is tokenised, parsed into an AST and bound against the catalog: `users` is relation 16 421, `id` is column 1 of type integer, and `users_pkey` is a B+ tree on it.
Why
Nothing about pages yet — but the catalog lookup is itself a read of catalog pages, which are always hot.
Algorithm
Recursive-descent parse, then name resolution against a hash map of catalog entries.
DSA concept
Hash table lookup for the catalog; a tree walk for the AST.
Memory
No page request at this stage.
Storage
No I/O.
Pages touched
0
Pool hits
0
Pool misses
0
Est. latency so far
20 µs
Whole query
222 µs
Page ledger · SSD miss ≈ 100 µs · hit ≈ 1 µs · parse + plan ≈ 20 µs
root
hit · 1 µs
internal
hit · 1 µs
leaf
miss · 100 µs
heap
miss · 100 µs
Typical: the root and internal pages are shared by every lookup on the table and stay resident; the leaf and the heap page are specific to this key and usually miss. Two misses, ~220 µs — the number to expect for a point lookup on a large table.
Educational simulation — page sizes, costs and counters are modelled, not measured from a real engine.
1/9 · SQL

Try it in the playground

When to use — and when not

Use it when
  • This page-level reading of a plan fits whenever latency is inconsistent for the same query: count the page requests and ask which ones missed.
  • It fits when deciding whether an index scan over many rows is really cheaper than a sequential scan — the answer is a miss count, not a row count.
Avoid it when
  • Reasoning at page level is overkill when a query is slow for algorithmic reasons — a missing index, a wrong join order — which the plan shows without BUFFERS.
  • It misleads on a freshly restarted or tiny database: everything misses or everything hits, and neither says anything about production.

Failure modes

  • Benchmarking warm and shipping cold: the p99 after a failover is 100× the number in the ticket.
  • Reading cost=8.45 as "fast" when the four pages are on a spinning disk and cold.
  • Index-only scans that fetch the heap anyway because VACUUM has not set the visibility map bits.
  • Treating a bimodal latency histogram as two different queries when it is one query with and without a leaf-page miss.

Where you meet this

Back up to the practical layer, and across to the rest of Engineer Atlas.