Skip to main content
RunBook Academy

PostgreSQLXI · Memory and Resource ManagementMemory

shared_buffers and the OS page cache

Intermediate⏱ ~30 minpsql

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

Not yet marked complete on this device.

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

Read-only / Safepg_buffercache after a large sequential scan
$ 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
...
Read-only / Safethe pool as a whole
$ 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 | 16384

Read 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_buffers and the OS page cache. A shared buffer miss is often not a disk read.
  • Hit ratio alone is weakly informative. track_io_timing separates cache misses from storage reads.
  • Measured: one table at 98.2% of the pool with avg_usage 0.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_buffercache showing 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

  1. 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?

  2. Q2. Why is shared_buffers conventionally limited to around a quarter of system memory rather than the majority, as some databases recommend?

  3. Q3. A pg_buffercache usage count distribution shows nearly every buffer at 5. What does that indicate?

  4. Q4. Which situations genuinely justify a shared_buffers larger than the conventional quarter of memory? Select all that apply.

  5. Q5. pg_buffercache is cheap enough to query from a dashboard every ten seconds, since it reads only summary counters.

  6. 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.