For years, diagnosing Postgres I/O meant squinting at pg_stat_bgwriter and inferring. High buffers_backend? Probably shared_buffers pressure — but which tables, which queries, which operations? The view never said. Postgres 16 fixed this with pg_stat_io, a proper per-object, per-context I/O accounting view that makes the old approach look like reading tea leaves.
What pg_stat_io Tracks
pg_stat_io breaks I/O into two dimensions: object type and I/O context.
Object types: relation (heap tables), index, toast (large objects), tidrange, and sequence. Each type gets its own row per backend type per context.
I/O contexts: normal (standard reads/writes), bulkread (sequential scans bypassing shared_buffers), bulkwrite (COPY, CREATE TABLE AS), vacuum. The context matters because they have different cache interactions — a bulkread hit is not the same kind of cache pressure as a normal hit.
Key columns:
reads— block reads from disk (cache miss)hits— block reads from shared_buffers (cache hit)evictions— blocks evicted from shared_buffers to make roomextends— new blocks allocated (table/index growth)fsyncs— fsync calls (checkpoint-adjacent)read_time,write_time— wall time in milliseconds (requirestrack_io_timing = on)
The Diagnostic Queries That Matter
Hit ratio per object type and context:
SELECT backend_type, object, context,
hits, reads,
ROUND(hits::numeric / NULLIF(hits + reads, 0) * 100, 2) AS hit_pct,
evictions
FROM pg_stat_io
WHERE hits + reads > 0
ORDER BY reads DESC
LIMIT 20;This tells you exactly which object type (relation vs index vs toast) in which context is generating cache misses. Index reads showing low hit_pct with high evictions usually means your working set exceeds shared_buffers. Toast reads spiking usually means a column is wider than expected and not being prefetched.
Finding I/O time distribution (requires track_io_timing):
SELECT object, context,
reads, read_time,
CASE WHEN reads > 0 THEN ROUND(read_time / reads, 3) END AS ms_per_read
FROM pg_stat_io
WHERE read_time > 0
ORDER BY read_time DESC;A high ms_per_read on relation normal reads while index reads are fast suggests table bloat — the planner is doing sequential scans or heap fetches are scattered across many pages.
What pg_stat_io Still Doesn't Show
It doesn't tell you which specific table or index caused the reads — that's still the job of pg_statio_user_tables and pg_statio_user_indexes. Think of pg_stat_io as the aggregate signal and the per-table views as the drill-down. The new view adds the context dimension (vacuum vs normal vs bulkread) that the old views always lacked.
It also doesn't attribute reads to specific queries. For that you still need pg_stat_statements with shared_blks_read and local_blks_read.
Resetting for a Baseline
SELECT pg_stat_reset_shared('io');Run this before a load test or after a significant schema change. The counters are cumulative since the last reset (or server start), so fresh baselines are essential for meaningful comparisons.
pg_stat_bgwriter told you something was wrong. pg_stat_io tells you what. On Postgres 16+, there's no reason to keep inferring.
—
Follow the work at anethoth.com and builds.anethoth.com.