The Buffer Pool
A B+ tree finds the right page in four reads — but four SSD reads are 400 µs, and the same four pages are wanted ten thousand times a second. The buffer pool keeps recently used pages in RAM frames, maps page ids to frames with a hash table, tracks which frames are dirty or pinned, and evicts under its own rules instead of the operating system's.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- Problem
After B+ Tree Internals: Pages, Splits, Merges, a point lookup is root → internal → leaf → heap: four page reads. On an SSD each is ~100 µs; ten thousand lookups a second is four seconds of I/O per second — the correct page is found, and reading it is still the bottleneck.
↓ - Naive solution
Read every page from storage each time it is needed, and let the operating system's file cache absorb the repetition.
↓ - Why it breaks
The OS cache does not know which pages are index roots, cannot promise a page stays in memory while the executor is using it, and flushes dirty data in whatever order it likes — which breaks the ordering the write-ahead log depends on. And every cached page is copied twice: once in the OS, once in the process.
↓ - Better idea
Give the engine its own cache of pages in RAM, sized and managed by the engine: keep the pages that are used, write back the ones that changed when it suits the engine, and never evict a page someone is reading.
↓ - Internal mechanism
A buffer pool: N fixed-size frames, a page table (hash map page id → frame), per-frame metadata (dirty bit, pin count, replacement state), and a replacement policy that picks a victim when every frame is occupied. Hits cost a hash lookup; misses cost one storage read, plus one write if the victim is dirty.
↓ - Trade-offs
RAM is finite and the pool competes with the OS cache, sort memory and connections. Dirty pages are durable only once flushed, which forces a log to exist. A single bad workload — a sequential scan — can evict everything useful.
↓ - Real database
PostgreSQL
shared_buffers(8 KB pages, clock-sweep, sitting on top of the OS cache); InnoDBinnodb_buffer_pool_size(16 KB pages, LRU with a young/old midpoint, typically 70–80 % of RAM with the OS cache bypassed via O_DIRECT).
Choose your depth
The same mechanism at four altitudes. Start where you are; come back deeper.
Reading a page from RAM costs about a microsecond; reading it from an SSD costs about a hundred. The buffer pool is the block of RAM where the database keeps the pages it has read recently, so that the second and thousandth read of the same page cost the microsecond, not the hundred.
When the pool is full and a new page is needed, an old page has to go. If that old page was changed in memory, it must be written to storage first. Everything else — pinning, dirty bits, hit rates, replacement policies — is bookkeeping around those two sentences.
The problem: the right page, read again and again
Everything before this point made lookups cheap in page reads: a B+ tree of height 3 plus one heap page is four reads whatever the table size. But a read is not free. A page in RAM is reachable in ~100 ns; a random 8 KB read from an NVMe SSD is ~100 µs, from a SATA SSD ~200 µs, from a spinning disk ~10 ms. Four reads per lookup at ten thousand lookups per second is 40,000 page reads per second — 4 seconds of SSD time per wall-clock second. The correct page is being found; fetching it is the bottleneck.
The same pages are wanted over and over. The root page of users_pkey is read by every lookup on users. The internal pages number a few dozen. Even the leaf and heap pages are skewed: in almost every application a small fraction of rows receives most of the traffic. Reading from storage a page that was read a millisecond ago is the waste to eliminate, and the tool is a cache.
L1 cache hit ~1 ns RAM access ~100 ns ← page already in the buffer pool NVMe SSD random 8 KB ~100 µs ← buffer pool miss SATA SSD random 8 KB ~200 µs HDD random 8 KB ~10 ms network round trip ~500 µs (same datacenter)
Frames, the page table and pinning
The buffer pool is a contiguous region of RAM divided into frames, each exactly one page (8 KB in PostgreSQL, 16 KB in InnoDB). A page table — a hash map from (file, page number) to frame index — answers "is this page in memory, and where?" in O(1). Each frame has a small descriptor: the page it holds, a dirty bit, a pin count, and the replacement policy's state.
A hit: the page table finds the frame; the pin count is incremented; the executor gets a pointer into the frame. A miss: the buffer manager chooses a frame to reuse, writes it back if it is dirty, reads the wanted page from storage into it, updates the page table, and pins it. The pin is a promise: while any operation holds a pin, the frame is not a candidate for eviction, because evicting a page out from under a running executor node would hand it garbage. Unpinning happens when the operation is done with the page — a scan that forgets to unpin, or a cursor left open, quietly removes frames from the pool.
page table (hash map) frames (6 × 8 KB)
users_pkey:1 → frame 0 f0 [ users_pkey p1 root pin=0 dirty=0 ]
users_pkey:41 → frame 1 f1 [ users_pkey p41 internal pin=0 dirty=0 ]
users_pkey:388→ frame 3 f2 [ users p2093 heap pin=1 dirty=1 ] ← being modified
users:1271 → frame 4 f3 [ users_pkey p388 leaf pin=0 dirty=0 ]
users:2093 → frame 2 f4 [ users p1271 heap pin=0 dirty=0 ]
f5 [ (free) ]Dirty pages, flushing and eviction
Writes never go to storage directly. An UPDATE changes bytes inside a frame and sets its dirty bit; storage still holds the old page. The dirty page is written back — flushed — later: when its frame is chosen as a victim, when a background writer cleans frames ahead of demand, or when a checkpoint writes every dirty page. A page updated a hundred times between flushes is written once. This is the write-back behaviour that makes the pool a write cache as much as a read cache, and it has a consequence: between the update and the flush, the only durable record of the change is the write-ahead log, which is why Write-Ahead Logging is not optional in a write-back design.
Eviction is what happens on a miss when no frame is free: the replacement policy picks an unpinned frame, the manager writes it if dirty, and the frame is reused. A miss on a clean victim costs one read; on a dirty victim, one write and then one read — twice the I/O, on the critical path of the query that missed. Background writers exist to move that write off the critical path. Which frame to pick is a question large enough for its own lesson: Buffer Replacement: LRU, Clock and Scan Resistance.
The working set and the hit rate
The working set is the set of pages a workload touches in a representative interval. If it fits in the pool, the hit rate climbs toward 100 % after warm-up and storage is barely involved; if it does not, the pool can only hold a fraction of it and the hit rate drops toward pool size ÷ working set, whatever the replacement policy does. A hit rate of 99 % sounds excellent, but at four page requests per lookup it still means one lookup in 25 pays for a storage read; 99.9 % is ten times better in latency, not 0.9 % better.
Sizing is therefore a question about the working set, not about the data. A 500 GB database whose active rows and index upper levels amount to 20 GB is happy with a 32 GB pool; a 50 GB database scanned uniformly is not helped much by a 40 GB pool. The signal to watch is the miss rate under load and, in PostgreSQL, EXPLAIN (ANALYZE, BUFFERS): shared hit versus read per query tells you where each plan node's time went. Cold-cache latency after a restart or a failover is the same plan with every request missing — expect it, and warm the pool deliberately if it matters.
| Pool state | Hits / misses | Latency | What it means |
|---|---|---|---|
| Cold (after restart) | 0 / 4 | ~400 µs | Every page from storage; first query on a table after a failover |
| Typical (upper levels resident) | 2 / 2 | ~200 µs | Root and internal pages shared by all lookups stay; leaf and heap page miss |
| Warm (working set fits) | 4 / 0 | ~4 µs | The benchmark number — real only if production stays warm |
Why the database manages its own cache
The operating system already caches file blocks. Why duplicate it? Four reasons. Double buffering: a page read through the OS cache exists twice in RAM — once in the kernel's page cache, once in the database's pool — halving the effective memory. Control over write ordering: the write-ahead rule says a data page may not reach storage before the log records describing it; the kernel flushes dirty file pages in whatever order suits it, so the database must either hold pages itself and write them explicitly or open files with O_DIRECT to bypass the cache. Pinning: the kernel gives no way to say "keep this page in memory while I hold a pointer into it"; a page cache eviction under a running executor is a crash. Knowledge: the engine knows that this read is a sequential scan of a 40 GB table and should not evict the index root, that this page is a B+ tree root and will be needed again, that a sort's temporary pages will never be read twice. The kernel sees a stream of read() calls.
The cost is that the engine is now responsible for everything the kernel would have done: replacement, write scheduling, read-ahead, and the accounting that tells an operator whether the pool is the right size.
- Double buffering — the same 8 KB in kernel and user memory; avoided with O_DIRECT, at the cost of losing the kernel's read-ahead and its opportunistic use of free RAM.
- Write ordering — the WAL rule (log before page) requires the engine to control when a dirty page hits storage.
- Pinning — a frame in use must not move; the kernel offers no such contract for its page cache.
- Access-pattern knowledge — scans, index roots, temp files: the engine can treat them differently; the kernel cannot.
PostgreSQL: shared_buffers on top of the OS cache
PostgreSQL keeps its pool in shared memory (shared_buffers, default 128 MB, commonly set to ~25 % of RAM) and reads files through the ordinary kernel page cache, so a page that misses in shared_buffers may still be served from RAM by the OS — a two-layer cache with double buffering as the price. effective_cache_size tells the planner how much of both layers to expect, and affects plan choice, not memory allocation. Each buffer has a descriptor with a usage count (0–5) for the clock-sweep policy, a pin count, and a dirty flag; the background writer and the checkpointer flush dirty buffers on their own schedules.
The pg_buffercache extension exposes the pool page by page: which relation, which block, whether it is dirty, and its usage count. Aggregating it shows what the pool actually holds — often a surprise, and the most direct evidence in a "should we buy more RAM" conversation. EXPLAIN (ANALYZE, BUFFERS) shows per plan node how many pages hit in shared_buffers and how many were read (from the OS cache or the disk — PostgreSQL cannot tell which).
1CREATE EXTENSION IF NOT EXISTS pg_buffercache;2 3SELECT c.relname,4 count(*) AS buffers,5 pg_size_pretty(count(*) * 8192) AS in_pool,6 round(100.0 * count(*) / (SELECT setting::int FROM pg_settings WHERE name = 'shared_buffers'), 1) AS pct_of_pool,7 count(*) FILTER (WHERE b.isdirty) AS dirty,8 round(avg(b.usagecount), 2) AS avg_usage9FROM pg_buffercache b10JOIN pg_class c ON c.relfilenode = b.relfilenode11GROUP BY c.relname12ORDER BY buffers DESC13LIMIT 15;InnoDB: one big pool with young and old sublists
InnoDB's buffer pool (innodb_buffer_pool_size, typically 70–80 % of RAM on a dedicated server, split into innodb_buffer_pool_instances to reduce lock contention) holds 16 KB pages and, with innodb_flush_method = O_DIRECT, bypasses the OS cache entirely — one copy of each page, and the engine alone decides what stays. Because InnoDB tables are clustered indexes, the pool holds the B+ tree that *is* the table; there is no separate heap.
The list is an LRU with a midpoint: the young sublist (5/8) holds pages accessed more than once, the old sublist (3/8) receives newly read pages at its head. A page is promoted to young only if it is accessed again after innodb_old_blocks_time milliseconds (default 1000) — so a scan that reads a page once, or reads it twice within a millisecond, cannot push the working set out. SHOW ENGINE INNODB STATUS reports the young/old split, the hit rate and pages made young, which is the direct measure of whether the midpoint is protecting the working set. The change buffer, the adaptive hash index and the doublewrite buffer also live in or beside the pool; they are covered in InnoDB Internals: Clustered Index, Buffer Pool, Redo, Undo, Locks.
Key points
- A page in the pool costs ~1 µs to reach; a page on an SSD costs ~100 µs. The buffer pool exists to make the second read of a page cost the first number.
- Frames hold pages; a hash-table page table maps page ids to frames; each frame carries a dirty bit, a pin count and replacement state.
- The pool is write-back: dirty pages are flushed later, once, which is why the write-ahead log must exist.
- Pinned frames cannot be evicted; forgotten pins and open cursors shrink the pool silently.
- The hit rate is decided by whether the working set fits, not by the size of the data. Size the pool for the working set and read BUFFERS in plans.
- The engine manages its own cache to avoid double buffering, to control write ordering for the WAL, to pin pages, and because it knows the access pattern.
Buffer pool
Three hot pages fit in 6 frames: after 3 compulsory misses everything is a hit. This is the working set fitting in the pool — the normal, healthy case.
When to use — and when not
- Any engine with a page-oriented storage layer: the pool is the layer that makes repeated page access affordable.
- Workloads with locality — hot rows, hot index upper levels — where a pool smaller than the data still yields hit rates above 99 %.
- This design fits poorly when access is uniformly random over data far larger than RAM: the hit rate is bounded by pool ÷ data and the pool becomes overhead; column stores and scan-oriented engines often stream instead.
- It fits poorly for a single pass over data that will not be read again (bulk loads, one-off scans): those should bypass or ring-fence the pool rather than flood it.
Failure modes
- A pool sized for the data, not the working set — or the reverse: 25 % of RAM on a machine where the working set is 60 % of RAM.
- A sequential scan or bulk load evicting the entire working set; latency spikes for minutes afterwards.
- Double buffering: shared_buffers plus the OS cache each holding the same pages, so effective cache is half of what was paid for.
- Dirty pages accumulating with no background writer keeping up, so every miss pays a write before its read.
- Benchmarks run warm and production runs cold after a failover; the "same" query is 100× slower and nobody planned for it.
Where you meet this
Back up to the practical layer, and across to the rest of Engineer Atlas.
- Operating SystemsPage cache / virtual memory → Buffer poolThe OS caches file blocks and pages out memory under pressure; the database does the same for its own pages, with knowledge the kernel does not have.
- DSAHash table → Page table (page id → frame)
- DSALRU cache → Replacement policy