Using BUFFERS to Find I/O Hotspots #
Execution time tells you a query was slow. Buffer counts tell you why, and they do it in a unit that does not change between a cold cache and a warm one. EXPLAIN (ANALYZE, BUFFERS) reports, for every node, how many 8 KB pages were found in cache, fetched from the operating system, modified, and written — plus how many went through temporary files.
This page is about reading those numbers well. The broader bottleneck-hunting method is in identifying plan bottlenecks.
What Each Counter Means #
Buffer statistics come in three families:
shared— ordinary table and index pages.hitwas already in PostgreSQL’s buffer cache;readwas requested from the OS;dirtiedwas modified by this statement;writtenwas flushed to disk by this backend.local— the same four counters for temporary tables, which use backend-local buffers rather than the shared pool.temp— pages written to and read from temporary files: sort spills, hash batch spills, and materialized intermediate results. This family has onlyreadandwritten.
Two subtleties matter in practice. First, counts are cumulative up the tree: a node’s figures include everything its children did, so a node’s own contribution is its total minus its children’s. Second, a SELECT can legitimately show dirtied pages, because reading a row for the first time after a write may set hint bits — which is why a read-only report can generate write I/O.
Annotated Evidence #
A plan where timings mislead and buffers do not:
EXPLAIN (ANALYZE, BUFFERS)
SELECT c.name, sum(o.total_cents)
FROM customers c JOIN orders o ON o.customer_id = c.id
WHERE o.created_at > '2026-01-01'
GROUP BY c.name;
HashAggregate (actual time=8412.6..8480.2 rows=118422 loops=1)
Buffers: shared hit=41208 read=182400, temp read=61240 written=61240
-> Hash Join (actual time=4102.1..7204.8 rows=4120334 loops=1)
Buffers: shared hit=41208 read=182400
-> Seq Scan on orders o (actual time=0.02..2841.60 rows=4120334 loops=1)
Filter: (created_at > '2026-01-01')
Buffers: shared hit=1204 read=178402 -- 178k pages from storage
-> Hash (actual time=1204.4..1204.4 rows=200000 loops=1)
Buffers: shared hit=40004 read=3998
Execution Time: 8620.4 ms
Work out each node’s own I/O by subtraction:
Seq Scan on orders: 179,606 pages, almost all read from storage — the hotspot.Hashover customers: 44,002 pages, mostly cached.HashAggregate: no additional shared pages of its own, but 61,240 temp pages each way — a spill worth about 480 MB of temporary traffic.
Now compare with the timings. Hash Join shows the largest actual time span, which invites the conclusion that the join is the problem. The buffers say otherwise: the join inherits its time from the scan beneath it, and the genuinely independent cost is the aggregate’s spill.
Step-by-Step Method #
-
Always ask for buffers. Make
EXPLAIN (ANALYZE, BUFFERS)the default form; the extra output costs nothing. -
Subtract children. A node’s own pages are its total minus the sum of its children’s totals. This single habit prevents most misdiagnoses.
-
Rank nodes by own-pages, not by time. Pages are the currency; time is what your particular cache state charged for them today.
-
Read
hitagainstreadas a cache verdict. A node reading 178,402 pages from storage on every execution is either scanning too much or working on a table that does not fit — both actionable, differently. -
Treat any
tempfigure as a spill to be removed:BEGIN; SET LOCAL work_mem = '256MB'; EXPLAIN (ANALYZE, BUFFERS) SELECT … ; -- expect the temp counters to disappear COMMIT;The mechanics are covered in tuning work_mem for hash aggregates.
-
Watch for
dirtiedon read-only statements. It is usually hint-bit setting after a bulk load, and it settles once the pages have been read a first time. -
Compare page counts before and after any fix. A change that lowers time but not pages probably just warmed the cache.
Before and After #
-- BEFORE
Seq Scan on orders Buffers: shared hit=1204 read=178402
HashAggregate temp read=61240 written=61240
Execution Time: 8620.4 ms
-- AFTER: index on orders(created_at) + work_mem raised for the statement
Index Scan using orders_created_idx Buffers: shared hit=18402 read=6120
HashAggregate (no temp counters)
Execution Time: 1204.8 ms
Pages touched fell from roughly 240,000 to 24,500. That ratio holds regardless of cache warmth, which is exactly why it is the number to quote.
Common Pitfalls #
Reading cumulative counts as per-node. A parent always looks like the biggest consumer. Diagnostic signal: the top node showing the largest figures in every plan you look at. Fix: subtract children.
Comparing timings across cache states. A cold first run and a warm second run differ enormously with an identical plan. Diagnostic signal: a “fix” that only works the second time you run it. Fix: compare pages.
Ignoring temp counters. They are the clearest signal of a spill and are easy to overlook beside the shared figures. Diagnostic signal: temp written on a sort, hash, or CTE scan. Fix: raise the allowance or reduce the working set.
Assuming shared read means physical disk I/O. The OS page cache may have served it. Diagnostic signal: high read counts with low system I/O wait. Fix: use track_io_timing and the I/O Timings output for actual device latency.
Overlooking loops when reading a node’s buffers. On the inner side of a nested loop the reported figures are already totals across all loops, but the per-loop timings are averages — mixing the two leads to conclusions that are off by the loop count. Diagnostic signal: buffer counts that seem impossibly large for the reported per-loop time. Fix: read loops first and decide which columns it applies to.
Treating a high hit ratio as proof of health. A plan can read a hundred thousand pages entirely from cache and still be wasteful; the pages were touched, they evicted something else, and the CPU cost of examining them is real. Diagnostic signal: shared hit in the hundreds of thousands with a small result set. Fix: reduce pages touched, not merely pages read from storage.
Comparing buffers across different data volumes. A plan that reads fewer pages because the table is smaller today has not improved. Diagnostic signal: an improvement measured after a retention job ran. Fix: compare on the same data, or normalise pages against the rows returned.
Frequently Asked Questions #
What is the difference between shared hit and shared read?
A hit was found in PostgreSQL’s own buffer cache and costs a memory access. A read was requested from the operating system, which may still serve it from the OS page cache. Both are counted in 8 KB pages.
Why are buffer counts better than timings? Because they measure what the plan had to touch rather than what your cache happened to hold. The same plan can differ tenfold in time between a cold and a warm run, while its page counts stay essentially constant.
What does dirtied mean?
Pages the statement modified and that must eventually be written back. A SELECT can dirty pages by setting hint bits on rows it is first to read after a write, which is why read-only queries sometimes generate write I/O.
Related #
- Identifying Plan Bottlenecks — parent guide: the systematic bottleneck hunt
- Reading Cost vs Actual Time in EXPLAIN ANALYZE — sibling: interpreting the timing columns
- Reading & Interpreting Query Plans — section overview: the full diagnostic framework
- Sort and Hash Node Analysis — where most temp counters originate