The Buffer Pool
// the problem
Postgres never reads or writes your table's file directly. Every 8 KB
page it touches goes through a fixed-size slab of shared memory called
shared_buffers — the buffer pool. If the page you need is already there,
it's a hit (fast, no I/O). If not, it's a miss: read it from
disk, and if the pool is full, kick something else out. Which page gets evicted,
and how the hot ones survive, is one of the quietly clever parts of Postgres.
Request pages below. Watch hits vs misses, the usage count each page builds up, and the clock hand that chooses victims when the pool fills.
hit = page already cached · miss = read from disk (evict via clock-sweep if full) · dots = usage count, protects hot pages from eviction
Try this: request A, B, C, D to fill a 4-slot pool (all misses), then
request A a few times (hits — its usage count climbs). Now request E, F,
G: misses force evictions, but A — with its high usage count — keeps
surviving while the cold pages get thrown out.
The read path
Every page access follows the same steps:
- Compute the page's address — a
(relation, block number)pair — from actid, an index pointer, or a scan position (everything from the earlier lessons). - Look it up. Postgres keeps a hash table mapping
(relation, block)→ buffer slot, so this is O(1). Found → pin the buffer (bump its pin count so nobody can evict it while you're reading) and use it. That's a hit. - Not found → a miss: grab a free buffer, or evict one to make room, then read the 8 KB page from disk into it — and unpin when done.
A hit costs nothing but a memory access; a miss costs a real disk read. The ratio of hits to total accesses — the cache hit ratio — is the single most important number for read performance, and a healthy OLTP database runs at 99%+. You can read it straight off the server's cumulative counters:
Every page access is tallied as a hit (blks_hit) or a read (blks_read); the
percentage is how well your working set fits in memory. Sliding below ~99% on an
OLTP workload usually means shared_buffers is too small, or the working set has
simply outgrown RAM.
(A couple of exceptions to “everything goes through the shared pool”:
temporary tables use per-session local buffers, and large sequential scans,
VACUUM, and COPY deliberately use a small ring buffer so a one-off bulk
operation can't evict your whole working set.)
Clock-sweep: choosing a victim
When the pool is full, Postgres needs to evict a page — ideally the least useful one. Tracking true LRU for hundreds of thousands of buffers would be too expensive, so it uses clock-sweep, an LRU approximation:
- Each buffer has a small usage count (0–5). A hit bumps it up (capped).
- A hand sweeps the buffers in a circle. At each buffer it either evicts it (if usage is 0) or decrements the count and moves on.
So a page must go several full sweeps without being touched before it's evicted — which means frequently-used (hot) pages naturally stay cached while one-off pages age out quickly. That's exactly the survival you watched above.
Why approximate LRU instead of doing it exactly? True LRU has to move the just-touched buffer to the front of a list on every single access — a global lock that hundreds of CPU cores would fight over constantly, throttling the whole server. Clock-sweep only bumps a per-buffer counter on a hit (no global lock) and lets the hand do the sorting lazily. (Genuinely free buffers — never used, or just evicted — sit on a small free list that's checked before the hand sweeps at all.)
Dirty pages and write-back
When a query modifies a page, its buffer is marked dirty — changed in memory but not yet on disk. A dirty page can't just be discarded on eviction; its contents must be written back first. (Thanks to the WAL lesson, the WAL record for that change is already durable, so the data-page write can happen lazily.) Two background processes handle those writes so query backends usually don't have to:
- The checkpointer flushes all dirty pages at each checkpoint — that's what bounds crash recovery (WAL lesson).
- The background writer continuously trickles out dirty buffers that are near the clock hand — i.e. about to be evicted — so that a backend needing a free buffer usually finds an already-clean one.
When both fall behind, the cost lands on your query: a backend that needs a free buffer must evict a dirty one and write it out synchronously itself (a “backend write”) before it can even start its read — a latency spike that shows up as slow queries under write-heavy load.
// why it matters · sizing shared_buffers
A common starting point is ~25% of RAM for shared_buffers. Not more,
because Postgres relies on the operating system's page cache too — data
is effectively double-buffered, and an oversized shared_buffers steals RAM the
OS cache could use (and makes checkpoints heavier). The OS cache turns many
“disk reads” into fast memory reads even when they miss Postgres's
own pool.
Seeing it in a real plan
That's what Buffers: in EXPLAIN (ANALYZE, BUFFERS) reports — shared hit = pages served from the buffer pool, read = pages that had to come
from disk (or the OS cache). Fewer reads is the goal.
// note · inspect the pool directly
The pg_buffercache extension exposes exactly which pages are cached right now
(SELECT * FROM pg_buffercache) — invaluable for understanding a real
workload's working set. (It's a contrib module, not bundled into this
in-browser Postgres.)
Your turn
Show the size of the shared buffer pool.
Run a scan and show its buffer usage — prove the pages came from the shared buffer pool (a cache hit).
// what you now understand
- 01Postgres reaches the heap only through the buffer pool (shared_buffers). Access goes via a (relation, block) → buffer hash table; a found page is pinned and used (a hit), else a miss reads it from disk into an evicted/free buffer.
- 02Cache hit ratio = blks_hit / (blks_hit + blks_read) from pg_stat_database — the key read metric; healthy OLTP is 99%+.
- 03Clock-sweep approximates LRU without a global lock: a hit bumps a per-buffer usage count (0–5); a hand sweeps decrementing counts and evicts the first that reaches 0, so hot pages survive and one-off pages age out.
- 04Modified pages are dirty and must be written back before eviction. The checkpointer flushes all dirty pages at checkpoints; the bgwriter trickles out soon-to-be-evicted ones; if both lag, a backend writes a dirty page synchronously before its read — a latency spike.
- 05shared_buffers is usually ~25% of RAM because Postgres also leans on the OS page cache (double buffering); oversizing it steals OS-cache RAM and heavies checkpoints.
- 06EXPLAIN (ANALYZE, BUFFERS) reports 'shared hit' (from the pool) vs 'read' (from disk/OS) — fewer reads is the goal.
// self-test
A 4-buffer pool holds pages A (used often), B, C, D. A miss needs to load page E. Which is most likely evicted?
// self-test
Why is setting shared_buffers to 90% of RAM usually a bad idea?
// go deeper
- Resource Consumption — shared_buffers — sizing the buffer pool and background writer
- pg_buffercache — inspect exactly which pages are cached
- buf_internals.h / freelist.c — the clock-sweep victim selection in the source
- Using EXPLAIN — BUFFERS — reading shared hit vs read