Skip to main content
RunBook Academy

PostgreSQLI · Architecture and the Process ModelArchitecture

Shared memory and the buffer pool

Intermediate⏱ ~25 minpsql

What you'll learn

  • List what the shared memory segment holds besides the buffer pool
  • Explain why shared_buffers cannot change without a restart
  • Describe how a page travels between disk, the buffer pool and a backend
  • Interpret a cache-hit ratio correctly, including what it cannot tell you

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.

Separate processes cannot share anything by accident. Every fact that more than one PostgreSQL process needs — which pages are cached, who holds which lock, where the write-ahead log has reached, whether transaction 4,812,993 committed — has to live in memory that all of them can reach. That is the shared memory segment, and it is created once, by the postmaster, before the first backend exists.

That ordering is the whole reason a large group of PostgreSQL parameters cannot be changed on a running server. It is not caution. It is that the memory was already allocated and handed to every child.

What is in there

Read-only / Safethe largest shared memory allocations, default cluster
$ psql -U postgres -c 'SELECT name, pg_size_pretty(allocated_size) AS size FROM pg_shmem_allocations ORDER BY allocated_size DESC LIMIT 12'
         name         |  size
----------------------+---------
Buffer Blocks        | 128 MB
<anonymous>          | 4637 kB
XLOG Ctl             | 4110 kB
AioHandleIOV         | 2784 kB
AioHandle            | 1566 kB
AioHandleData        | 1392 kB
Buffer Descriptors   | 1024 kB
transaction          | 517 kB
Checkpointer Data    | 512 kB
Checkpoint BufferIds | 320 kB
RWConflictPool       | 319 kB
(12 rows)

Read that list as five functional groups.

The buffer pool. Buffer Blocks is the cache of 8 KiB table and index pages, and it is exactly shared_buffers. Buffer Descriptors is its index: one small record per buffer holding which relation and block the buffer contains, whether it has been modified since it was read, and how recently it was used. The descriptors are what make a lookup possible without scanning the pool.

Write-ahead log control. XLOG Ctl holds the WAL insertion state and the in-memory WAL buffers. Every backend that modifies data writes a record here before the change is considered durable, so this structure is contended by definition. Part XII covers it properly.

Transaction state. The transaction allocation holds recently consulted commit status, so that deciding whether a given transaction committed usually does not require reading a file. Part VII explains why that question is asked constantly.

Asynchronous I/O handles. AioHandle, AioHandleData and AioHandleIOV track I/O requests in flight. These are new in PostgreSQL 18 and are absent on earlier releases; if you compare pg_shmem_allocations between a 17 and an 18 cluster, this is the difference you will notice first.

Coordination. The checkpointer’s work list, the lock table, the predicate-lock conflict pool, and the statistics that used to live in a separate collector process before PostgreSQL 15 moved them here.

The total, 150 MB, is worth noticing against shared_buffers of 128 MB. The overhead is real but modest, and it does not scale with connections in this segment — the per-connection memory is private and lives outside it entirely.

Why the sizes are fixed until restart

Ask the server which parameters can change and when.

Read-only / Safethe context column is the operational answer
$ psql -U postgres -c "SELECT name, setting, unit, context FROM pg_settings WHERE name IN ('shared_buffers','max_connections','max_locks_per_transaction','wal_buffers','max_wal_senders') ORDER BY name"
           name            | setting | unit |  context
---------------------------+---------+------+------------
max_connections           | 100     |      | postmaster
max_locks_per_transaction | 64      |      | postmaster
max_wal_senders           | 10      |      | postmaster
shared_buffers            | 16384   | 8kB  | postmaster
wal_buffers               | 512     | 8kB  | postmaster
(5 rows)

Every one of those parameters sizes a structure inside the shared segment, and every one is postmaster context. The connection between the two is not a coincidence: max_connections and max_locks_per_transaction together determine how large the lock table must be, so changing either means reallocating shared memory, so both require a restart.

Note also the unit column. shared_buffers reports 16384 with a unit of 8kB, which is 128 MB. Reading the raw number as megabytes is a classic and expensive misreading. Ask for it in human terms instead:

psql -U postgres -c 'SHOW shared_buffers'
psql -U postgres -c "SELECT pg_size_pretty(setting::bigint * 8192) AS shared_buffers
                       FROM pg_settings WHERE name = 'shared_buffers'"

How a page reaches a backend

The buffer pool is not a cache in front of the database. It is the only way data is read or written. No backend reads a table file directly into its own memory.

flowchart LR
    Q["Backend needs\nblock 42 of orders"] --> L["Look up in\nbuffer descriptors"]
    L -- "present" --> H["Hit: pin the buffer\nand read it"]
    L -- "absent" --> V["Choose a victim buffer\nby clock sweep"]
    V --> D{"Victim dirty?"}
    D -- "yes" --> W["Write victim out\nfirst"]
    D -- "no" --> R["Read block 42\nfrom disk"]
    W --> R
    R --> H
    H --> U["Unpin when done"]

Three properties of that path have operational consequences.

A read can cause a write. If the pool is full and the chosen victim buffer has been modified, it must be written before its slot can be reused. A workload that is nominally read-only can therefore generate write I/O, and a backend can find itself waiting on that write. This is one reason the background writer exists — to keep clean buffers available so that backends rarely have to do this themselves.

A modified page is not written when you commit. COMMIT makes the WAL record durable, not the data page. The page stays dirty in the pool and is written later by the checkpointer or the background writer. Part XII is built around this separation, and it is the single most important thing to understand about PostgreSQL durability.

Eviction is approximate. PostgreSQL uses a clock-sweep algorithm with a usage counter rather than strict least-recently-used ordering, because maintaining true LRU across many concurrent processes would require a contended global structure. The effect is that the pool retains frequently used pages well without needing a lock on every access.

Reading the hit ratio, and what it does not tell you

The buffer pool records hits and reads, so a hit ratio is easy to compute. Interpreting it is where people go wrong.

Read-only / Safebuffer pool activity by backend type
$ psql -U postgres -c 'SELECT backend_type, object, context, reads, writes, extends, hits FROM pg_stat_io WHERE reads > 0 OR writes > 0 ORDER BY backend_type'
    backend_type     |  object  | context  | reads | writes | extends | hits
---------------------+----------+----------+-------+--------+---------+-------
autovacuum launcher | relation | normal   |     1 |      0 |         |     0
autovacuum worker   | wal      | normal   |       |     11 |         |
autovacuum worker   | relation | normal   |   137 |      0 |       9 | 11248
autovacuum worker   | relation | vacuum   |    40 |      0 |       0 |   126
checkpointer        | relation | normal   |       |    389 |         |
checkpointer        | wal      | normal   |     0 |      2 |         |
client backend      | relation | normal   |   334 |      0 |       0 | 15381
client backend      | relation | bulkread |   215 |      0 |         |   345
client backend      | wal      | normal   |     0 |     38 |         |
standalone backend  | relation | normal   |   479 |   1038 |     598 | 92643
(10 rows)

That breakdown is far more useful than a single number, and it is worth dwelling on the context column. Client backend reads split into normal and bulkread: PostgreSQL deliberately uses a small ring buffer for large sequential scans so that one big scan cannot evict the entire working set. A low hit ratio in bulkread context is the system working as designed, not a cache that is too small.

The critical limitation is this: a “read” in pg_stat_io means the page was not in shared_buffers. It does not mean the page came from a disk. PostgreSQL reads through the operating system, so a miss in the buffer pool is very often satisfied from the Linux page cache at memory speed. From inside PostgreSQL those two cases are indistinguishable.

Production discipline

  1. Read context before planning any memory change. postmaster means a restart, and a restart means a change window for every database in the cluster.
  2. Convert units before quoting a value. shared_buffers in pg_settings is a count of 8 KiB blocks. SHOW gives the human-readable form.
  3. Batch restart-only changes into one window. They interact, and discovering the second one afterwards costs a second outage.
  4. Break the hit ratio down by backend_type and context before drawing conclusions. A low ratio under bulkread is the ring buffer doing its job.
  5. Never treat a buffer-pool miss as a disk read. The page cache sits underneath, and confusing the two leads directly to moving memory in the wrong direction.

Cross-course references

  • Linux for Production Sysadmins — Part XL (Memory performance) covers the page cache that sits beneath the buffer pool, and Part XXXVII (Resources) covers the shared-memory limits a large shared_buffers can meet.
  • Observability for Production Sysadmins — Part XV (Histograms) covers representing I/O latency distributions properly, which is the right shape for the timings pg_stat_io exposes.
  • Ceph & Distributed Storage — Part LXVIII (OSD latency) covers what a read actually costs when the storage is distributed rather than local.

Quiz

Knowledge check · 5 questions

  1. Q1. pg_stat_io shows a client backend with a high reads count and comparatively few hits. What does the reads figure establish?

  2. Q2. Why does raising max_connections require a restart rather than a reload?

  3. Q3. Which statements about the PostgreSQL buffer pool are correct? Select all that apply.

  4. Q4. pg_settings reports shared_buffers as a count of 8 KiB blocks, so a setting of 16384 means 128 MB rather than 16 GB.

  5. Q5. Evaluate the proposed change and say what evidence would justify or refute it.

    A 64 GB database host runs a cluster with shared_buffers at 8 GB. A dashboard reports the buffer cache hit ratio at 91%, and the team proposes raising shared_buffers to 32 GB at the next maintenance window to push the ratio above 99%. The workload includes a nightly reporting job that scans a 400 GB table, and the same dashboard shows the hit ratio dropping sharply during that window and recovering afterwards. Daytime query latency has not changed in three months.

Passing score: 75%. Answers are checked in this browser.