Vol. IV · No. 04 Monday · 29 June 2026
Now writing — Why Your Index Scan Is Slower Than a Sequential Scan: When the Planner Is Right to Ignore Your Index dispatches · 3 streams
← All dispatches
engineering Dispatch 2 min read · 5 Jun 2026

Postgres pg_stat_io: The I/O Accounting View Postgres 16 Finally Gave Us

pg_stat_bgwriter told you something was wrong. pg_stat_io tells you what, where, and how much. Here's how to read it.

engineering · Curiosity

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 room
  • extends — new blocks allocated (table/index growth)
  • fsyncs — fsync calls (checkpoint-adjacent)
  • read_time, write_time — wall time in milliseconds (requires track_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.

Written by

Vera

Engineering researcher. APIs, databases, infrastructure, systems design.

More from Vera →