Topic 50

Memory — shared_buffers, work_mem, and the OS Cache

Memory Tuning

Postgres spends memory out of three pools that have almost nothing to do with each other, and confusing them is the most expensive configuration error in the catalogue. shared_buffers is one shared cache of 8 KB page slots, sized at startup and fixed until the next restart. work_mem is a grant handed to every sort, hash and bitmap in every query in every backend, independently and repeatedly. Underneath both sits the operating system's page cache, which Postgres neither sizes nor manages — but does describe to the planner.

pg-primary has 64 GB of RAM and is running shared_buffers = 128MB, which is what the packaged default has been for years and what the install left behind. 128 MB is 16,384 page slots, roughly one thousandth of the machine. effective_cache_size is at its 4 GB default too, so the planner is pricing every query as though it were running on a small virtual machine. Both numbers are wrong by more than an order of magnitude, in the same direction.

Three pools that have almost nothing to do with each other
work_mem — a grant, per operation
every sort, hash and bitmap node · in every backend · in every parallel worker · 8 MB here, raised per role
shared_buffers — one shared cache
128 MB today, 16 GB by the end of this topic · 8 KB slots, fixed at startup · where a plan's shared hit counts come from
The operating system page cache — underneath both
the other ~48 GB once the pool is 16 · free, unmanaged by the database, gone after a reboot · Postgres reads through it

shared_buffers, the Database's Own Cache

shared_buffers is a single shared-memory region carved into 8 KB slots when the postmaster starts, and every backend reads and writes pages through it. Postgres manages that region itself with its own clock-sweep replacement policy, holds dirty pages in it until a checkpoint writes them out, and counts every request against it as the shared hit or shared read that a plan reports. It is set in blocks, so 16 GB is 2,097,152 slots, and changing it requires a restart — there is no way to grow the pool on a running server.

The starting point that has survived two decades of argument is 25% of RAM, which on this machine is 16 GB. The reason it is a quarter rather than everything is the second pool: Postgres uses ordinary buffered reads, so a page that is not in shared_buffers is very often still in the kernel's cache one memcpy away. Pushing the pool toward half of RAM buys a diminishing amount of genuinely new cache while making every checkpoint dirtier and the OS cache smaller. Nothing about 25% is enforced by the server; it is where the measurements have tended to land.

The memory block of Cartwheel's postgresql.conf
shared_buffers = 16GB           # 25% of 64 GB = 2,097,152 slots
effective_cache_size = 48GB     # planner arithmetic only, allocates nothing
work_mem = 8MB                  # per node, per backend, per worker
maintenance_work_mem = 2GB      # index builds, VACUUM, ALTER TABLE
huge_pages = try                # 2 MB pages behind the 16 GB region

Only the first line and the last need a restart. The other three take a reload, which is what makes measuring them practical: work_mem and effective_cache_size are the two settings on that list you will change more than once.

The Operating System Cache and effective_cache_size

Because Postgres reads through the kernel, the real cache on pg-primary is not 16 GB. It is 16 GB of buffer pool plus whatever the kernel is holding of the same files, which on a database server with nothing else running is most of the remaining 48. That layering is the whole reason MySQL's advice does not transfer: InnoDB opens its data files with O_DIRECT and bypasses the kernel cache, so its buffer pool has to be 70–80% of RAM to hold the same working set that Postgres holds across two caches.

effective_cache_size is how you tell the planner about the layer it cannot see. It defaults to 4 GB and it allocates nothing at all: the documentation is explicit that it neither sizes shared memory nor reserves kernel cache, and exists purely for estimation. The number feeds the cost of an index scan — a larger assumed cache means repeated index descents are expected to hit memory rather than disk, which makes index paths cheaper relative to a sequential scan. Setting it to 48 GB on this machine costs nothing, takes a reload, and changes plans; what the planner then does with the cheaper index path is Chapter 9's subject.

work_mem Is Per Operation, Not Per Query

The name suggests a per-connection budget. It is not. Each sort, hash join, hash aggregate, bitmap and materialize node in a plan may allocate up to work_mem before it spills to disk, every backend running that plan gets its own set, and each parallel worker gets its own again. A query with four such nodes running in 200 backends can therefore have 800 grants outstanding at once. At the 4 MB default that is 3.2 GB, and the default is 4 MB for exactly that arithmetic.

Do the same arithmetic at 256 MB and it comes to 200 GB against 64 GB of physical memory. Nothing warns you, because the grants are only taken as nodes actually run: the setting looks fine for weeks and then a Saturday spike puts enough concurrent sorts on the machine at once and the kernel's OOM killer picks a backend, which on Postgres means the postmaster restarts every session in the cluster. Hash-based nodes get more than that headline number as well — hash_mem_multiplier has defaulted to 2.0 since 15 (it was 1.0 in 14), so a hash join is allowed twice what a sort is, on the reasoning that a hash that spills degrades harder than a sort that does.

Why a global work_mem is multiplied before it is ever spent
One plan, four grant-taking nodessort · hash join · hash aggregate · bitmap
Every backend gets its own set200 backends
Every parallel worker againits own set on top
800 grants outstanding at once4 MB → 3.2 GB · 256 MB → 200 GB

Raising It Where It Belongs

The workloads that need a large work_mem are never the ones with 200 backends. Cartwheel's checkout path sorts nothing large; the analytics dashboard on pg-replica-a sorts millions of rows out of analytics.daily_orders and spills to disk at 8 MB. So the grant goes where the work is: a role-level default for the analytics role, and a transaction-scoped override for the one report that needs far more than everybody else.

Two ways to grant memory to the queries that need it
-- every session that connects as this role starts with the larger grant
ALTER ROLE cartwheel_analytics SET work_mem = '256MB';
ALTER ROLE cartwheel_app       SET work_mem = '8MB';

-- and for one heavy statement, scoped to the transaction, not the session
BEGIN;
SET LOCAL work_mem = '1GB';
REFRESH MATERIALIZED VIEW CONCURRENTLY analytics.daily_orders;
COMMIT;

A role-level setting is applied when the session starts, so it survives for as long as the connection does. SET LOCAL is scoped to the enclosing transaction and reverts at COMMIT, which makes it the form that is safe behind a transaction pooler; a plain session-level SET is not, for reasons the next topic makes concrete. Between the two, the global value stays at 8 MB and the 200 backends that were never the problem keep it.

maintenance_work_mem and Huge Pages

Maintenance work gets its own pool. maintenance_work_mem defaults to 64 MB and is what an index build, a table rewrite and a vacuum's dead-tuple bookkeeping draw from; because only a handful of those run at once, 1–2 GB is an ordinary production value and it makes CREATE INDEX on a 40-million-row table finish visibly sooner. The trap is autovacuum_work_mem, which defaults to -1, meaning "use maintenance_work_mem", while up to autovacuum_max_workers of them can be running, three by default. A 2 GB setting is therefore a 6 GB exposure unless you set the autovacuum value separately. What vacuum does with that memory is Chapter 7's argument; the sizing decision is here.

Huge pages are the other line worth understanding rather than copying. With 16 GB of shared memory and 4 KB pages, every backend's page tables have to map four million entries, and 200 backends mapping the same region separately is real CPU spent on address translation. Linux huge pages make each entry cover 2 MB instead, cutting the mapping to eight thousand entries. huge_pages defaults to try, which falls back to normal pages when the kernel has none reserved, and logs nothing about having done so.

Asking the server how many pages it needs, then checking it got them
postgres -C shared_memory_size_in_huge_pages -D /var/lib/postgresql/18/main
8264

# reserve them in the kernel (vm.nr_hugepages), restart, then verify:
grep Huge /proc/meminfo
HugePages_Total:    8500
HugePages_Free:      236     # most are in use = Postgres actually took them

The server computes the requirement itself from shared_buffers and the rest of the shared area, so there is no arithmetic to get wrong. Reserve slightly more than it asks for, restart, and read /proc/meminfo back: if HugePages_Free is still close to the total after Postgres starts, the reservation happened and the server did not use it. Once the pages are reserved and proven, huge_pages = on makes the server refuse to start rather than fall back, which is the behaviour you want on a machine that has been sized around them.

Measuring Instead of Guessing

Every number above has a counter behind it, and all of them are cumulative since the last reset. pg_stat_database carries blks_hit against blks_read for the cache picture and temp_files with temp_bytes for how much work has spilled. pg_stat_io, added in 16, splits reads, writes, extends, hits and evictions by backend type and by context, which is how you tell a vacuum's bulk reads from a client backend's. And since 18, buffer counts come with EXPLAIN (ANALYZE) without asking, so a single query's behaviour no longer needs a separate investigation.

The two ratios worth reading before changing anything
SELECT blks_hit, blks_read,
       round(100.0 * blks_hit / nullif(blks_hit + blks_read, 0), 1) AS hit_pct,
       temp_files, pg_size_pretty(temp_bytes) AS spilled
  FROM pg_stat_database WHERE datname = 'cartwheel';

 blks_hit  | blks_read | hit_pct | temp_files | spilled
-----------+-----------+---------+------------+---------
 812449311 |  60118204 |    93.1 |     41,207 | 3841 GB

Read the second half of that row, not the first. A 93.1% hit ratio on a 128 MB buffer pool is unremarkable and would still be unremarkable at 99%; it is a trend to watch, not a target to hit. The number that names a decision is 41,207 temporary files and 3.8 TB written to them since the last reset, which is work_mem saying it is too small for whatever is doing the sorting. Find the role behind those files and the fix is one ALTER ROLE; skip that step and the fix is a global raise multiplied by 200 backends.

shared_buffers vs the OS page cache

shared_buffers — managed by Postgres, with its own replacement policy, its own accounting, and dirty pages held until a checkpoint. It is where a plan's shared hit counts come from, it is fixed at startup, and every page in it is a page the kernel may also be holding.

The OS page cache — free, unmanaged by the database, shared with everything else on the box, and gone after a reboot. Postgres reads through it, which is why a miss in shared_buffers is frequently not a disk read at all.

Why that caps the pool — past roughly a quarter of RAM, most of what you add to shared_buffers is a second copy of what the kernel already had, paid for with heavier checkpoints and a smaller OS cache. The gain is real but small, and it stops being obviously positive well before half of RAM.

Common Mistakes
  • Leaving shared_buffers at the packaged 128 MB on a 64 GB server because the default looked like a decision — it is the value that lets Postgres start on a laptop, and it is identical on every machine.
  • Raising work_mem globally after seeing one disk sort — the grant is per node per backend, so the change is multiplied by concurrency with nothing to warn you, and the bill arrives as an out-of-memory kill during a traffic peak.
  • Leaving effective_cache_size at 4 GB on a large machine, then treating the planner's preference for sequential scans as a planner defect rather than a number you never told it.
  • Pushing shared_buffers past half of RAM on the theory that more cache is strictly better — checkpoints get dirtier, the OS cache is starved, and most of the added space holds a second copy of pages the kernel had.
  • Setting maintenance_work_mem to 2 GB and forgetting autovacuum_work_mem is -1 — three autovacuum workers inherit the same grant and the real exposure is 6 GB.
  • Setting huge_pages = try and assuming huge pages are in use, when no kernel reservation exists and the server fell back to 4 KB pages months ago without saying so.
Best Practices
  • Start at 25% of RAM for shared_buffers and 50–75% for effective_cache_size, then move either one only on the evidence of a measurement you can name.
  • Keep the global work_mem small and grant memory with ALTER ROLE … SET work_mem for the analytic role, or SET LOCAL inside the transaction that needs it.
  • Set autovacuum_work_mem explicitly whenever maintenance_work_mem is large, so the autovacuum workers' total is a number you chose.
  • Reserve huge pages in the kernel using the count from postgres -C shared_memory_size_in_huge_pages, and confirm with /proc/meminfo that the server actually took them.
  • Watch temp_files and temp_bytes in pg_stat_database as the standing signal that work_mem no longer fits the workload.
  • Treat the cache hit ratio as a trend line rather than a target, since a healthy analytical workload legitimately reads pages that were never going to be cached.
Comparable toolsInnoDB innodb_buffer_pool_size at 70–80%, because O_DIRECT skips the OS cacheMySQL sort_buffer_size, the same per-operation trapOracle SGA and PGA, with automatic managementSQL Server max server memory, one pool for everything

Knowledge Check

You raise effective_cache_size from 4 GB to 48 GB on a 64 GB server. What happens?

  • The planner prices index scans more cheaply and no memory is allocated
  • The shared page cache grows by 44 GB and a server restart is required
  • That much kernel page cache is reserved for this cluster's files alone
  • Each backend's grant for sorts and hash joins rises to match the setting

Why is work_mem = 256MB dangerous on a server with 200 connections?

  • Every sort or hash node in every backend may take that much, so it multiplies
  • It is taken out of shared_buffers at startup, shrinking the cache to match
  • Each backend reserves that much when it logs in, whatever it goes on to run
  • Any sort larger than the setting is forced to spill to disk rather than run

Why is 25% of RAM a starting point for shared_buffers rather than "as much as fits"?

  • Postgres reads through the OS cache, so more pool means double-caching and heavier checkpoints
  • The Linux kernel refuses to map a shared memory segment larger than a quarter of the physical RAM
  • A backend can only address a quarter of the pool, so the rest is never used
  • The planner stops choosing index scans once the pool passes a quarter of RAM

maintenance_work_mem is set to 2GB and autovacuum_work_mem is left alone. What is the exposure?

  • Exactly 2 GB, because maintenance operations run one at a time cluster-wide
  • Up to 6 GB, because three autovacuum workers each inherit the 2 GB grant
  • No more than 2 GB, since autovacuum workers stay capped at the 64 MB default
  • Up to 4 GB, since each worker takes one grant for the heap and one for indexes

Which pg_stat_database column is the standing signal that work_mem is too small?

  • blks_read, which counts the pages a sort had to fetch again after eviction
  • temp_bytes, which counts what spilled to disk when a node ran out of memory
  • conflicts, which counts the operations cancelled for want of a memory grant
  • deadlocks, which climbs when two backends contend for the same memory pool

You got correct