PostgreSQLXI · Memory and Resource ManagementMemory
shared_buffers and the OS page cache
What you'll learn
- Explain the two-level caching PostgreSQL relies on and why it exists
- Read pg_buffercache to see what the pool is actually holding
- Size shared_buffers from evidence rather than from a percentage
- Recognise a scan evicting the working set, and what to do about it
Prerequisites
Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27
PostgreSQL does not manage its own I/O the way some databases do. It
reads through the filesystem, which means every page it reads is cached
twice: once in shared_buffers, and once in the operating system’s page
cache.
That design decision is the reason shared_buffers sizing advice is
different from the “give the database 80% of RAM” rule that applies
elsewhere.
The two levels
query -> shared_buffers -> OS page cache -> storage
A page not in shared_buffers is a read in EXPLAIN (BUFFERS)
output — but that read may be served entirely from the page cache, at
memory speed, without touching storage at all.
This is why buffer “hit ratios” are less informative than they look. A 90% hit ratio on a cluster whose misses are all served by the page cache is fine. A 99% hit ratio on a cluster whose 1% of misses are cold storage reads may not be.
track_io_timing distinguishes them, at the clock cost discussed in
lesson X-02:
ALTER SYSTEM SET track_io_timing = on;
SELECT relname,
heap_blks_read, heap_blks_hit,
round(100.0 * heap_blks_hit
/ nullif(heap_blks_hit + heap_blks_read, 0), 2) AS hit_pct
FROM pg_statio_user_tables
ORDER BY heap_blks_read DESC
LIMIT 10;
What the pool is actually holding
$ psql -U postgres -c "SELECT c.relname, count(*) AS buffers, pg_size_pretty(count(*)*8192::bigint) AS size, round(100.0*count(*)/(SELECT setting::numeric FROM pg_settings WHERE name='shared_buffers'),1) AS pct_of_pool, round(avg(b.usagecount),2) AS avg_usage FROM pg_buffercache b JOIN pg_class c ON c.relfilenode=b.relfilenode GROUP BY c.relname ORDER BY count(*) DESC LIMIT 8" relname | buffers | size | pct_of_pool | avg_usage
--------------------------+---------+--------+-------------+-----------
ordersx | 16093 | 126 MB | 98.2 | 0.23
pg_operator | 11 | 88 kB | 0.1 | 2.91
pg_amop | 7 | 56 kB | 0.0 | 3.86
pg_amproc_fam_proc_index | 6 | 48 kB | 0.0 | 2.67
pg_index | 6 | 48 kB | 0.0 | 4.17
...$ psql -U postgres -c "SELECT count(*) FILTER (WHERE relfilenode IS NOT NULL) AS used, count(*) FILTER (WHERE relfilenode IS NULL) AS free, count(*) FILTER (WHERE isdirty) AS dirty, count(*) AS total FROM pg_buffercache" used | free | dirty | total
16384 | 0 | 31 | 16384Read the avg_usage column against the pct_of_pool column, because
together they tell a story a size figure alone cannot.
ordersx holds 98.2% of the pool at an average usage count of
0.23. A usage count near zero means those pages were read once and are
first in line for eviction. Meanwhile the catalogue entries — a few
kilobytes each — show usage counts of 3 to 4, the maximum. Those are
genuinely hot.
This is a large sequential scan having evicted the working set. The scan’s own pages will themselves be evicted shortly, and everything that was warm is now cold. Nothing is wrong, exactly, but a workload that does this repeatedly gets no benefit from its buffer pool at all.
Sizing
The conventional starting point is 25% of system memory, and it is a starting point rather than an answer.
The reasoning behind not going much higher, on a system that has the memory:
Double buffering. Every page in shared_buffers is probably also in
the page cache. Memory given to shared_buffers is memory taken from
the page cache, and past a point you are simply moving the same pages
between two caches.
Checkpoint cost. A larger pool holds more dirty pages, so each checkpoint writes more at once. Part XII covers the smoothing settings that address this.
The OS is good at this. The page cache uses whatever is free, adapts instantly, and needs no restart to resize.
When a larger pool genuinely helps:
- The working set fits. If the hot data is 40 GB and the server has 128 GB, a 48 GB pool keeps it all resident with no page cache round trip.
- Write-heavy workloads, where a page updated many times before being written benefits from staying in the pool.
- Cloud storage with real latency, where a page cache miss is expensive enough to justify avoiding it.
Measure rather than assume:
-- how much of each table is currently resident
SELECT c.relname,
pg_size_pretty(count(*) * 8192::bigint) AS cached,
pg_size_pretty(pg_relation_size(c.oid)) AS total,
round(100.0 * count(*) * 8192 / nullif(pg_relation_size(c.oid), 0), 1) AS pct_cached
FROM pg_buffercache b
JOIN pg_class c ON c.relfilenode = b.relfilenode
GROUP BY c.oid, c.relname
ORDER BY count(*) DESC
LIMIT 20;
A hot table at 100% cached needs no more pool. One at 30% cached, being read constantly, is the argument for a larger one.
What to take from this
- Two caches:
shared_buffersand the OS page cache. A shared buffer miss is often not a disk read. - Hit ratio alone is weakly informative.
track_io_timingseparates cache misses from storage reads. - Measured: one table at 98.2% of the pool with
avg_usage0.23 — a scan that evicted the working set. - Ring buffers bound large sequential scans and vacuum already.
- 25% of RAM is a starting point.
pg_buffercacheshowing a hot table partly cached is the argument for more. - The usage count distribution is the honest sizing signal. Everything at 5 means the pool is too small.
Cross-course references
- Linux for Production Sysadmins — Part XL (Memory Performance) covers the page cache from the operating system’s side, including why a large cache is not a leak.
- Docker & Containers — Part XV (Resource controls) covers how a memory limit accounts for page cache, which changes the sizing arithmetic in a container.
- Observability for Production Sysadmins — Part LIX (Database observability) covers the cache hit ratio and why it is a weaker signal than it looks.
Quiz
Knowledge check · 6 questions
Q1. pg_buffercache shows one table occupying 98% of the pool with an average usage count of 0.23, while catalogue entries show counts of 3 to 4. What does this describe?
Q2. Why is shared_buffers conventionally limited to around a quarter of system memory rather than the majority, as some databases recommend?
Q3. A pg_buffercache usage count distribution shows nearly every buffer at 5. What does that indicate?
Q4. Which situations genuinely justify a shared_buffers larger than the conventional quarter of memory? Select all that apply.
Q5. pg_buffercache is cheap enough to query from a dashboard every ten seconds, since it reads only summary counters.
Q6. A team reports a buffer cache hit ratio of 92% and wants to raise shared_buffers. What would you check first?
Passing score: 75%. Answers are checked in this browser.