The Buffer Cache: How shared_buffers and Clock-Sweep Work
shared_buffers is a fixed-size array of 8KB page frames allocated in shared memory at startup. Every backend reads and writes data through this cache; a page on disk must be loaded into a buffer before it can be examined or modified. The machinery lives in src/backend/storage/buffer/, principally bufmgr.c and freelist.c.
Grounded in the source
Where this comes from in postgres/postgres
Each citation was checked against the REL_17_STABLE branch: real file path, symbol present, exact line number. Follow any link to read the code.
Primary symbol: BufferDesc · line 245
The one thing to understand first
shared_buffers is a fixed-size array of 8KB page frames allocated in shared memory at startup. Every backend reads and writes data through this cache; a page on disk must be loaded into a buffer before it can be examined or modified. The machinery lives in src/backend/storage/buffer/, principally bufmgr.c and freelist.c.
Nothing in PostgreSQL touches a row on disk directly — it touches a copy in a buffer, and a clock hand decides which copies survive. Grasp that the cache is a fixed array managed by an approximate-LRU sweep, and eviction behaviour, the post-checkpoint write storm, and why huge shared_buffers can backfire all make sense.
Three parallel structures
The cache is described by three arrays sized to NBuffers:
- Buffer blocks — the actual 8KB page data.
- BufferDescriptors — one
BufferDescper frame (src/include/storage/buf_internals.h) holding the page's identity (BufferTag: relfilenode + fork + block number), a pin count, a usage count, and state flags packed into an atomic word. - The buffer mapping table — a shared hash table mapping
BufferTag → buffer id, so a backend can find whether a page is already cached.
Reading a page
ReadBuffer() → BufferAlloc() computes the BufferTag, hashes it, and looks it up in the mapping table under a partition lock. On a hit, the buffer is pinned and returned. On a miss, a victim frame must be chosen, the old page evicted (written first if dirty), the mapping table updated, and the new page read from disk.
Pinning vs locking
Two independent concepts protect a buffer:
- A pin (reference count) means "this buffer is in use, do not evict it." Holding a pin does not block other readers.
- A content lock (an LWLock, shared or exclusive) protects the page contents during read or modification.
A backend pins a buffer for as long as it holds a tuple from that page, which is why cursors and long scans keep pages resident.
Clock-sweep: choosing a victim
PostgreSQL does not use strict LRU. Instead StrategyGetBuffer() in freelist.c implements a clock-sweep approximation of LRU-2:
- Each buffer has a
usage_count(0–5). It is incremented on access, capped atBM_MAX_USAGE_COUNT. - A shared "clock hand" sweeps circularly through the buffers.
- At each buffer the hand decrements
usage_count. If it reaches 0 and the buffer is unpinned, that buffer is the victim.
This makes frequently used pages survive many sweeps (they keep getting re-warmed) while cold pages drain to zero and get evicted — all without maintaining an ordered list, which would be a contention nightmare under concurrency.
Ring buffers for big scans
A large sequential scan or VACUUM would otherwise evict the entire working set ("cache trashing"). To prevent this, those operations use a small buffer access strategy ring (GetAccessStrategy()), reusing a handful of buffers instead of polluting the whole cache. That is why scanning a 100GB table does not flush your hot OLTP pages.
Dirty pages and write-back
When a page is modified, its buffer is marked dirty (BM_DIRTY). Dirty buffers are written back by three actors: the background writer (smooths I/O ahead of demand), the checkpointer (flushes everything at a checkpoint), and as a last resort a backend that needs a clean victim. Every write obeys the WAL rule: flush WAL up to the page's LSN first.
Layer 3 — Watch it happen on your own database
CREATE EXTENSION IF NOT EXISTS pg_buffercache;
SELECT c.relname, count(*) AS buffers,
pg_size_pretty(count(*) * 8192) AS cached
FROM pg_buffercache b
JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)
GROUP BY c.relname ORDER BY buffers DESC LIMIT 10;This shows exactly which relations are resident right now. Run a large sequential scan of a cold table and watch its buffer count rise; run it again and the second pass is far faster because the pages are already framed. That is the clock sweep keeping hot pages and evicting cold ones in action.
Layer 4 — The levers this hands you
- Bigger is not always better. Beyond a point, the OS page cache and double-buffering make very large
shared_bufferscounter-productive; 25% of RAM is a common starting point. - Measure hit ratio from
pg_stat_database, but remember the OS cache catches many "misses," so a 99% Postgres hit ratio is not the whole truth. - Smooth the checkpoint write storm with
checkpoint_completion_targetandbgwriter_*settings so dirty-buffer flushes are spread out rather than spiking. pg_buffercacheandpg_prewarmlet you inspect and pre-load the cache after a restart.
Layer 5 — What an Oracle DBA should expect vs what they get
This is Oracle's buffer cache in the SGA, but smaller in ambition and reliant on the OS:
- No "SGA owns all of RAM." Oracle typically sizes the buffer cache to hold most of the working set and bypasses the filesystem cache. PostgreSQL deliberately keeps
shared_buffersmodest and leans on the OS page cache as a second tier — which is why double-buffering, not raw cache size, is the tuning concern. - Clock sweep instead of touch-count LRU lists. Oracle uses LRU/touch-count chains; PostgreSQL uses an approximate-LRU clock sweep with a
usage_countthat decrements on each pass. The effect is similar (hot pages stay) but there are no separate hot/cold LRU regions to tune. - No DBWR army. Background writing is done by a single
bgwriterplus the checkpointer, not a configurable pool of DBWn processes. You tune write smoothing through checkpoint and bgwriter GUCs rather than DBWR counts. - No KEEP/RECYCLE pools. There is one buffer pool. To pin a hot table you pre-warm it with
pg_prewarmrather than assigning it to a KEEP pool.
Key takeaway
The buffer cache is a fixed array of 8KB frames, and an approximate-LRU clock sweep with per-buffer usage_count decides what stays. Dirty buffers are flushed by the checkpointer and bgwriter, not at commit — WAL already guarantees durability. Size shared_buffers for the working set but leave room for the OS cache, and treat the post-checkpoint write spike as the cost of deferring page writes, not a bug to eliminate.
Interview lens
How this shows up in interviews
What an interviewer is actually testing, the same mechanism seen from three roles, and the gap between a shallow answer and one that shows real depth.
What the interviewer is testing
Whether you understand shared_buffers as a fixed frame array with a specific (clock-sweep, not LRU) eviction policy, and its double-buffering with the OS cache.
Questions you should be able to answer
- ▸What is shared_buffers physically, and how does Postgres pick a victim page to evict?
- ▸Why isn't shared_buffers usually set to most of RAM?
- ▸How does a dirty page eventually reach disk?
Database engineer
You size shared_buffers accounting for the OS page cache (double buffering) and read pg_buffercache usagecount to see what's actually hot.
Software engineer
You know the first run warms the cache, so benchmarks must separate cold-buffer from warm-buffer numbers.
Feature builder
You keep hot working sets small enough to stay resident rather than assuming all reads hit RAM or all hit disk.
Shallow answer
'shared_buffers is Postgres's cache; bigger is faster.' Ignores clock-sweep and OS double-buffering.
Answer that shows depth
shared_buffers is a fixed array of 8KB frames allocated in shared memory at startup (bufmgr.c/freelist.c); every backend reads and writes through it. Eviction uses clock-sweep: each buffer has a usage count decremented as the sweep hand passes, and the first unpinned buffer at count 0 becomes the victim, so frequently-touched pages survive. The OS also caches the same file pages (double buffering), which is why a very large shared_buffers can waste RAM. Dirty buffers are flushed by the checkpointer and bgwriter, and even a plain SELECT can dirty a page by setting hint bits.
Pro lab notebook
The captured run behind every query
Every SQL query above is real and the source citations are verified. Pro unlocks the captured psql output — each query re-run against a real PostgreSQL 17.10 lab, with its verbatim result.
1 real lab transcript for this lesson
The explanation and the SQL above are free — so are the verified source citations. What Pro unlocks is the proof: every query in The Buffer Cache: How shared_buffers and Clock-Sweep Work re-run against a real PostgreSQL 17.10 instance, with its verbatim psql output captured line for line.
What Pro unlocks here
- Every query in this lesson re-run against a real PostgreSQL 17.10 lab
- The verbatim psql output — page headers, row versions, plan nodes, catalog rows
- The corrected form of any query the source lesson got wrong, re-captured
- A copy-ready notebook you can replay yourself to confirm every claim
How this was verified
This explanation is anchored to PostgreSQL's source tree — the files linked above, on the REL_17_STABLE branch — and cross-checked against the official documentation for versions 15 through 18. Every SQL query shown was executed and its output captured in a throwaway PostgreSQL 17.10 lab; where a query needed correcting to run, that correction is noted inline.
Official documentation: §20.4 Resource Consumption (Memory)