The disks on your PostgreSQL server are busy and latency is climbing. pg_stat_statements tells you which queries read the most blocks, but not who does the writes: the checkpointer, the background writer, autovacuum, or the client backends that should be serving queries. pg_stat_io, added in PostgreSQL 16, answers that by splitting cluster-wide I/O by backend type, object, and context. PostgreSQL 18 changed the view: op_bytes is gone, read_bytes, write_bytes, and extend_bytes replace it, and WAL gets its own rows. This guide explains the rows and columns, the questions they answer, and how to sample them so the numbers mean something.
pg_stat_iohas one row per backend type, object, and context, with counters since the last reset. It exists in PostgreSQL 16 and later.- Most relation writes in context
normalshould come from the checkpointer and background writer. A large share fromclient backendpoints atshared_buffers, the background writer, or checkpoint settings. - The time columns stay at zero unless
track_io_timingis on. In PostgreSQL 18, WAL rows needtrack_wal_io_timinginstead. pg_stat_iodoes not count temp files from sorts and hashes. Usepg_stat_database.temp_bytesandpg_stat_statementsforwork_memspills.- Compare two snapshots instead of reading lifetime totals, and avoid
pg_stat_reset_shared('io')on servers where other tools read the view.
Scope and review date: November 2026. This post covers pg_stat_io in PostgreSQL 16, 17, and 18, with a note on PostgreSQL 19, which was at release candidate stage with general availability planned for 19 November 2026. Columns, defaults, and functions were checked against the PostgreSQL documentation and release notes on 9 November 2026. The queries were not run in a lab, and all output shown is illustrative.
What does pg_stat_io show, and which versions have it?
The PostgreSQL 16 documentation describes pg_stat_io as one row for each combination of backend type, target I/O object, and I/O context, with cluster-wide totals. Combinations that make no sense are left out: the checkpointer never writes temporary tables, so there is no checkpointer row for temp relation. Operations a row can never perform show as NULL: the background writer does not read, so its reads is NULL.
The three key columns take these values:
| Column | Values | Meaning |
|---|---|---|
backend_type | client backend, checkpointer, background writer, autovacuum worker, walsender, and others | The same names as pg_stat_activity.backend_type |
object | relation, temp relation, and wal from 18 | Permanent tables and indexes, temporary tables, or write-ahead log |
context | normal, vacuum, bulkread, bulkwrite, and init from 18 | Shared buffers, or one of the size-limited rings; init is WAL segment creation |
Two kinds of I/O do not appear: relation I/O that bypasses shared buffers, such as moving a table to another tablespace, and temp files written by sorts and hashes.
-- Every row that has done any relation or WAL I/O since the last reset
SELECT backend_type, object, context,
reads, writes, extends, hits, evictions, reuses, fsyncs
FROM pg_stat_io
WHERE reads > 0 OR writes > 0 OR extends > 0 OR hits > 0
ORDER BY backend_type, object, context;Interpretation: NULL means "this backend type never does this here", and 0 means "it could, but has not". Each process flushes its counters at most once per second, when it goes idle, so a query still running is not yet in the totals.
What do the pg_stat_io columns mean?
Each operation has a count and, in most cases, a matching time in milliseconds. The descriptions below follow the PostgreSQL 18 pg_stat_io reference.
| Column | What it counts |
|---|---|
reads, read_time | Read operations from the kernel into a buffer |
writes, write_time | Write operations from a buffer to the kernel; not proof the data reached the disk |
writebacks, writeback_time | Blocks the process asked the kernel to write out to permanent storage |
extends, extend_time | Operations that grew a relation file with new blocks |
hits | Times a wanted block was already in a shared buffer |
evictions | Times a buffer was taken from its current block for another use |
reuses | Times a buffer already in a bulkread, bulkwrite, or vacuum ring was reused |
fsyncs, fsync_time | fsync calls; only tracked in context normal |
op_bytes (16, 17) or read_bytes, write_bytes, extend_bytes (18) | Size per operation, or total bytes per operation type |
The time columns depend on track_io_timing, which is off by default because it reads the clock around every I/O. Measure that overhead with pg_test_timing before you turn it on.
SHOW track_io_timing;
SHOW track_wal_io_timing; -- PostgreSQL 18: controls the wal rows
-- Turn it on cluster-wide without a restart
ALTER SYSTEM SET track_io_timing = on;
SELECT pg_reload_conf();Interpretation: once it is on, read_time / reads is the average wait per read. A rising average with flat reads points at storage latency, not more work.
If track_io_timing was turned on after the last reset, the counts cover a longer period than the times, and every per-operation average comes out too low. Compare deltas taken after the change.
What changed in pg_stat_io in PostgreSQL 17 and 18?
PostgreSQL 17 kept the pg_stat_io columns unchanged. The 17 release notes remove buffers_backend and buffers_backend_fsync from pg_stat_bgwriter as redundant with pg_stat_io, and move the checkpoint columns into a new pg_stat_checkpointer view. The 18 release notes change the view itself.
pg_stat_io and its neighbours in PostgreSQL 16, 17, and 18
Switch versions to see which columns and views your queries and dashboards can use
PostgreSQL 16
| Item | PostgreSQL 16 |
|---|---|
| I/O size columns | op_bytes, always the block size (8192 by default) |
object values | relation, temp relation |
context values | normal, vacuum, bulkread, bulkwrite |
| WAL write and sync timing | pg_stat_wal, with track_wal_io_timing |
| Per-backend I/O | Not available |
| Writes by client backends elsewhere | pg_stat_bgwriter.buffers_backend |
| Checkpoint buffer writes | pg_stat_bgwriter.buffers_checkpoint |
PostgreSQL 17
| Item | PostgreSQL 17 |
|---|---|
| I/O size columns | op_bytes, always the block size (8192 by default) |
object values | relation, temp relation |
context values | normal, vacuum, bulkread, bulkwrite |
| WAL write and sync timing | pg_stat_wal, with track_wal_io_timing |
| Per-backend I/O | Not available |
| Writes by client backends elsewhere | Removed; use pg_stat_io |
| Checkpoint buffer writes | pg_stat_checkpointer.buffers_written |
PostgreSQL 18
| Item | PostgreSQL 18 |
|---|---|
| I/O size columns | read_bytes, write_bytes, extend_bytes; op_bytes removed |
object values | relation, temp relation, wal |
context values | Adds init for WAL segment creation |
| WAL write and sync timing | pg_stat_io wal rows; pg_stat_wal loses wal_write, wal_sync, and their times |
| Per-backend I/O | pg_stat_get_backend_io(pid) |
| Writes by client backends elsewhere | Removed; use pg_stat_io |
| Checkpoint buffer writes | pg_stat_checkpointer.buffers_written, plus num_done and slru_written |
Dashboards break on 18: any query that multiplies by op_bytes or reads pg_stat_wal.wal_write fails after the upgrade. Rewrite it against the byte columns and the wal rows first.
In PostgreSQL 18, reads counts operations and read_bytes their total size, without assuming one block per operation. On 16 and 17, multiply the counts by op_bytes instead.
-- PostgreSQL 18: byte columns, plus average size per read
SELECT backend_type, context,
pg_size_pretty(read_bytes) AS read_volume,
pg_size_pretty(write_bytes) AS write_volume,
pg_size_pretty(read_bytes / NULLIF(reads, 0)) AS avg_read
FROM pg_stat_io WHERE object = 'relation'
ORDER BY read_bytes DESC NULLS LAST;Interpretation: large bulkread or vacuum read volumes show how much disk traffic is scans and maintenance rather than point lookups.
WAL rows in PostgreSQL 18
With object = 'wal', writes count WAL writes and fsyncs count WAL flushes, attributed to the process that issued them. Context init covers the writes that create new WAL segments.
SELECT backend_type, context, writes,
pg_size_pretty(write_bytes) AS written,
fsyncs, round(fsync_time::numeric, 1) AS fsync_ms
FROM pg_stat_io
WHERE object = 'wal'
ORDER BY write_bytes DESC NULLS LAST;Interpretation: committing client backends often flush WAL themselves, so client backend rows here are normal. A high fsync_ms / fsyncs average means the WAL device is slow at durable writes, and with synchronous_commit on, every commit waits for it.
Per-backend I/O in PostgreSQL 18
pg_stat_get_backend_io(pid) returns the same fields as pg_stat_io for one backend process. It skips the checkpointer, background writer, startup process, and autovacuum launcher, which already have their own rows.
SELECT a.pid, a.application_name, b.context, b.reads, b.writes, b.extends
FROM pg_stat_activity AS a,
LATERAL pg_stat_get_backend_io(a.pid) AS b
WHERE a.backend_type = 'client backend'
AND b.object = 'relation' AND b.reads > 0
ORDER BY b.reads DESC
LIMIT 10;Interpretation: this names the connections behind a read spike. Behind a connection pooler, one server process serves many clients, so the result is blurred.
As of November 2026, the PostgreSQL 19 release notes list no new pg_stat_io columns. The 19 documentation adds that read_time may include waiting for a read started by another backend, so reads can be zero while read_time is not.
Where is a page read or write counted?
I/O is charged to the process that issued it, not to the query that caused it. The same dirty page can be a checkpointer write one day and a client backend write the next.
Who gets charged for a read or a write
Three processes move pages between shared buffers and the kernel; pg_stat_io counts whichever one issues the call
Evictions explain client writes: when a client backend needs a free buffer and the victim is dirty, the backend writes it out first, and that write is counted as a client backend write.
A page already in shared buffers counts as a hit. Otherwise the backend takes a buffer from another page (an eviction), writes it first if it is dirty, and reads the new page. The kernel may serve that read from its page cache, so a read in pg_stat_io is not always a disk read. writebacks are the follow-up requests asking the kernel to start writing pages to disk.
Are client backends or the checkpointer doing the writes?
The documentation says client backends should normally rely on the checkpointer and background writer to write dirty data, and that large numbers of writes or fsyncs by client backends could indicate a misconfiguration of shared buffers or of the checkpointer.
Share of relation writes by backend type
Illustrative output: the numbers are invented to show the shape, not measured
Read the second row: in this example, client backends issue about 30% of relation writes in context normal. Before changing anything, check whether that share holds over a busy hour, using the delta method below.
Then look at the background writer and checkpointer views to see why.
-- PostgreSQL 17 and later
SELECT buffers_clean, maxwritten_clean, buffers_alloc
FROM pg_stat_bgwriter;
SELECT num_timed, num_requested, buffers_written
FROM pg_stat_checkpointer;Interpretation: a climbing maxwritten_clean means the background writer keeps hitting bgwriter_lru_maxpages (100 by default, per bgwriter_delay round of 200 ms). num_requested well above num_timed means WAL volume forces checkpoints early, which usually points at max_wal_size. In PostgreSQL 18 both include skipped checkpoints; num_done counts completed ones.
The documentation also notes that a high evictions count can mean shared_buffers should be larger. A falling client backend hit ratio, hits / (hits + reads) in context normal, plus many client backend writes makes that case stronger; many client backend fsyncs point at the checkpointer instead. Some client backend writes are normal, so watch the trend. Our checkpoint and WAL tuning guide covers the settings.
What do the bulkread, bulkwrite, and vacuum rows mean?
Large one-pass operations cycle through a small ring of buffers so a big scan cannot flush hot data out of shared buffers. The buffer manager README gives the sizes:
- bulkread: sequential scans of large tables use a 256 kB ring.
- bulkwrite:
COPYinto a table andCREATE TABLE ASuse 16 MB, but not more than one eighth ofshared_buffers. - vacuum: the ring size is
vacuum_buffer_usage_limit. The default is 256 kB in PostgreSQL 16 and 2 MB in PostgreSQL 18, capped at one eighth ofshared_buffers.
In these contexts, evictions counts buffers taken from shared buffers to join a ring, and reuses counts a ring buffer used again.
SELECT backend_type, context, reads, writes, evictions, reuses,
round(read_time::numeric) AS read_ms
FROM pg_stat_io
WHERE context IN ('bulkread', 'bulkwrite', 'vacuum')
AND (reads > 0 OR writes > 0)
ORDER BY reads DESC NULLS LAST;Interpretation: a large client backend row in bulkread means big sequential scans, often a missing index or a report running on the primary. A bulkwrite row with high read or write time shows bulk loads competing with normal traffic.
How much I/O is vacuum doing?
Buffers that vacuum dirties stay in its ring: WAL is flushed and the buffer is written so it can be reused. Those writes appear as autovacuum worker writes in context vacuum. A manual VACUUM from a session counts under client backend, still in context vacuum.
-- Manual vacuum with a larger ring for one big table (PostgreSQL 16+)
VACUUM (VERBOSE, BUFFER_USAGE_LIMIT '64MB') orders;Interpretation: a larger ring can make one vacuum finish sooner but evicts more useful pages. If autovacuum worker rows dominate read volume at peak, revisit cost limits and per-table thresholds with our autovacuum tuning guide before resizing the ring.
Does pg_stat_io show temp files and work_mem spills?
No. The temp relation object means temporary tables, which live in each session's local buffers (sized by temp_buffers). Sorts, hashes, and other operations that exceed work_mem spill to temp files, and pg_stat_io does not track those. Use pg_stat_database, which counts every temp file whatever its cause, and pg_stat_statements to find the query.
-- work_mem spills, per database
SELECT datname, temp_files, pg_size_pretty(temp_bytes) AS temp_written
FROM pg_stat_database
WHERE temp_files > 0
ORDER BY temp_bytes DESC;
-- The statements behind them
SELECT queryid, calls, temp_blks_written, left(query, 60) AS query
FROM pg_stat_statements
ORDER BY temp_blks_written DESC
LIMIT 10;Interpretation: raise work_mem for the statements at the top, per role or per session, not cluster-wide; our shared_buffers and work_mem guide explains why. log_temp_files = 0 logs every temp file with its size.
The temp relation rows have a different meaning: many evictions and writes there mean temporary tables are outgrowing temp_buffers. Raise it in the sessions that build them, before their first temporary table.
How do you spot relation extension pressure?
When a table or index needs more space, a backend extends the file. Many sessions inserting into one table can queue on the relation extension lock.
SELECT backend_type, context, extends,
round(extend_time::numeric, 1) AS extend_ms,
round((extend_time / NULLIF(extends, 0))::numeric, 3) AS ms_per_extend
FROM pg_stat_io
WHERE extends > 0
ORDER BY extends DESC;
-- What sessions are waiting on right now
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE wait_event IS NOT NULL
GROUP BY 1, 2 ORDER BY 3 DESC;Interpretation: a rising ms_per_extend for client backends, together with sessions waiting on Lock / extend ("waiting to extend a relation") or IO / DataFileExtend, is extension pressure. Batching inserts, partitioning, or faster storage are the usual responses. Our post on PostgreSQL wait events covers the wait side.
How do you reset pg_stat_io and sample deltas?
Lifetime totals mix last month's bulk load with this morning's incident. You need the change over a known window.
Reset. SELECT pg_stat_reset_shared('io'); zeroes the view for everyone, including monitoring agents that compute rates from it. It is restricted to superusers by default, though EXECUTE can be granted. Per the PostgreSQL 18 documentation, calling pg_stat_reset_shared() with NULL or no argument resets all the shared views at once. pg_stat_reset_backend_stats(pid) resets one backend's counters.
Snapshot and diff. This changes nothing shared, so it is the safer default.
-- 1. Take a baseline
CREATE TABLE io_snap AS
SELECT now() AS taken_at, * FROM pg_stat_io;-- 2. After the busy window, in a new transaction
SELECT c.backend_type, c.object, c.context,
c.reads - s.reads AS reads,
c.writes - s.writes AS writes,
c.extends - s.extends AS extends,
c.hits - s.hits AS hits,
c.evictions - s.evictions AS evictions,
round((c.read_time - s.read_time)::numeric, 1) AS read_ms,
round((c.write_time - s.write_time)::numeric, 1) AS write_ms,
now() - s.taken_at AS sampled_over
FROM pg_stat_io AS c
JOIN io_snap AS s USING (backend_type, object, context)
WHERE coalesce(c.reads - s.reads, 0) + coalesce(c.writes - s.writes, 0)
+ coalesce(c.extends - s.extends, 0) > 0
ORDER BY writes DESC NULLS LAST;Interpretation: every number is now per window, so you can compare windows. A negative delta means someone reset the view in between; check stats_reset and start again.
By default, stats_fetch_consistency caches statistics until the transaction ends, so two reads in one transaction can return the same values.
How do you diagnose an I/O-heavy workload step by step?
The checks above, in order, for a server whose storage is the bottleneck.
Diagnosing an I/O-heavy PostgreSQL workload with pg_stat_io
Seven steps; step 1 changes a setting with a reload, and step 2 creates one small table
-
Turn on I/O timing
Measure clock overhead with pg_test_timing first. Without this, time columns stay at zero.
ALTER SYSTEM SET track_io_timing = on; SELECT pg_reload_conf(); -
Snapshot, wait, then diff
Copy the view into io_snap, wait out an hour of peak traffic, and run the delta query from the sampling section.
-
Find out who writes
A large client backend share in context normal points at shared_buffers, the background writer, or checkpoints.
SELECT backend_type, context, writes, fsyncs FROM pg_stat_io WHERE object = 'relation' ORDER BY writes DESC NULLS LAST; -
Check the bulk and vacuum rings
Big bulkread reads mean large sequential scans; big vacuum reads mean autovacuum is a large share.
SELECT backend_type, context, reads, reuses FROM pg_stat_io WHERE context IN ('bulkread', 'vacuum'); -
Check temp files and extends
Use pg_stat_database for work_mem spills, which pg_stat_io cannot see. Rising extend times point at insert contention.
-
Map the I/O to statements
pg_stat_io says which kind of process does the I/O; pg_stat_statements says which queries cause it.
SELECT queryid, shared_blks_read, temp_blks_written FROM pg_stat_statements ORDER BY shared_blks_read DESC LIMIT 10; -
Change one thing and sample again
Refresh the snapshot, apply one change, and compare a window of the same length and traffic.
Main risk: raising shared_buffers needs a restart and memory headroom. Confirm that client backend writes and evictions stay high over several windows before you plan one.
For step 7, see our pg_stat_statements guide.
Frequently asked questions
Which PostgreSQL version added pg_stat_io?
PostgreSQL 16 added pg_stat_io. PostgreSQL 17 kept its columns but removed buffers_backend from pg_stat_bgwriter. PostgreSQL 18 replaced op_bytes with read_bytes, write_bytes, and extend_bytes, added WAL rows, and added pg_stat_get_backend_io() for per-backend statistics.
Why are read_time and write_time zero in pg_stat_io?
Because track_io_timing is off, which is the default. The time columns only accumulate while it is on. In PostgreSQL 18, the rows for object wal use track_wal_io_timing instead. Both can be changed with ALTER SYSTEM and a reload. If timing was enabled after the last reset, compare deltas, not lifetime totals.
How do I reset pg_stat_io?
Run SELECT pg_stat_reset_shared('io'). It is restricted to superusers by default, but EXECUTE can be granted to other roles. The reset applies to every user and monitoring tool reading the view, so on shared servers it is safer to copy the view into a snapshot table and compare later readings with it.
Does pg_stat_io include temp files from sorts and hashes?
No. Its temp relation rows cover temporary tables in local buffers, not the temp files that sorts and hashes write when they exceed work_mem. Use temp_files and temp_bytes in pg_stat_database for totals, and temp_blks_written in pg_stat_statements to find the statements.
What does a high evictions count in pg_stat_io mean?
In context normal, an eviction means a buffer was taken from one page to hold another. The PostgreSQL documentation says a high count can indicate that shared_buffers should be increased. Check the client backend hit ratio and writes over the same window before resizing.
Key takeaways
- Read
pg_stat_ioas backend type by object by context; NULL means "never happens here". - Most relation writes in context
normalshould come from the checkpointer and background writer; a growing client backend share is the main tuning signal. - Turn on
track_io_timing(andtrack_wal_io_timingon 18) before you rely on any time column. - On PostgreSQL 18, use the byte columns and wal rows, and rewrite queries that use
op_bytesorpg_stat_wal.wal_write. - Diff snapshots over a known window, and use
pg_stat_databaseandpg_stat_statementsfor temp file I/O, whichpg_stat_iodoes not track.
Review your PostgreSQL I/O with JusDB
JusDB's PostgreSQL performance tuning team can review your pg_stat_io, pg_stat_statements, and checkpoint statistics under a read-only role, and help plan changes to shared_buffers, work_mem, and autovacuum settings with before-and-after measurements.
Bring your PostgreSQL version, current settings, and snapshots from a busy period. Your team approves every production change.