A query plan tells you what PostgreSQL intended to do. A wait event tells you what it actually did with its time. Most performance work I see in production starts one layer too high, in the EXPLAIN output, when the cheaper answer is already sitting in pg_stat_activity.
This is a refresh of a guide I wrote in late 2024. Two things have changed since then. PostgreSQL 17 added pg_wait_events as a first-class catalog view. PostgreSQL 18 shipped asynchronous I/O and changed how I/O wait should be read in production. Both shifts deserve their own treatment, and I have folded them into this update along with five production cases from work my team did over the last two quarters.
The cheapest diagnostic in your toolkit
Every PostgreSQL backend keeps a running state. When the backend is sitting on something instead of doing useful work, it records what it is sitting on. That string is the wait event. It is the most direct signal of where the workload is spending its time, and it costs you nothing to read.
If you can answer the question “what are my busy backends waiting on, right now,” you can usually answer “where is the bottleneck” within a few minutes. The query is two lines long. The skill is in reading what comes back.
What a wait event tells you, and what falls outside it
A wait event tells you the resource a backend is sitting on. Lock means another transaction holds something this transaction needs. DataFileRead means the backend is waiting on a buffer from disk. ClientRead means the backend is waiting on the application to send the next byte.
A wait event leaves it to you to judge whether the wait is normal or pathological. ClientRead on an idle pooled connection is healthy. ClientRead on an active backend in the middle of a transaction is usually a sign that the application opened a transaction and walked away, which shows up later as autovacuum starvation.
A wait event also captures only the present moment. pg_stat_activity is a snapshot. The current wait event is the one this backend happens to be on at the millisecond you queried the view. For pattern detection across time, you need sampling, which I cover later in this post.
The nine wait event types that cover production cases
PostgreSQL groups wait events into nine types. The full list is in the docs. In practice, four of them are where almost every production diagnosis lives.
Lock. Heavyweight lock waits. A transaction is waiting for a row, page, table, or other lock held by another transaction. The usual suspect when “the database froze for thirty seconds.”
Lwlock. Lightweight lock waits inside PostgreSQL internals: WAL insert locks, buffer mapping locks, shared memory locks. These get interesting at high concurrency. If you see WALInsert or BufferMapping waits dominating, the workload is hitting an internal contention point that query tuning alone will leave in place.
IO. Buffer reads, buffer writes, WAL writes, fsyncs. The most common cluster of waits in OLTP workloads sitting on storage that is operating near its ceiling for the request pattern. DataFileRead is the one to know.
Client. The backend is waiting for the application. ClientRead means waiting for the next command. ClientWrite means waiting for the client to consume the result. Network latency and idle-in-transaction sessions both surface here.
The other five types (Activity, Bufferpin, Extension, Ipc, Timeout) appear in narrower situations: background processes idling, replication coordination, custom extensions, intentional sleeps. Worth knowing they exist. Safe to skip past most of the time.
The diagnostic flow
The flow I run when a customer says “the database is slow” is the same every time.
Step 1: Get the current wait event distribution. A snapshot of pg_stat_activity filtered to active backends, grouped by wait event type and wait event. If something is broken right now, this query usually shows it in the first row.
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE state = 'active'
AND backend_type = 'client backend'
GROUP BY wait_event_type, wait_event
ORDER BY count(*) DESC;
Step 2: Join pg_wait_events to get the description for anything unfamiliar. PostgreSQL 17 added this view. It saves a trip to the docs.
SELECT a.pid, a.wait_event_type, a.wait_event,
w.description, a.state, a.query
FROM pg_stat_activity a
LEFT JOIN pg_wait_events w
ON w.type = a.wait_event_type
AND w.name = a.wait_event
WHERE a.state = 'active'
AND a.backend_type = 'client backend';
Step 3: If Lock waits dominate, use pg_blocking_pids() to find the blocker. That gives you the head of the lock chain, which is the transaction you actually need to act on.
SELECT pid,
pg_blocking_pids(pid) AS blocked_by,
wait_event, state, query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
Step 4: If I/O waits dominate, pivot to pg_stat_io to see which backend type and which I/O context is doing the work. This is the view that tells you whether your storage pressure is coming from client queries, autovacuum, the background writer, or the checkpointer.
Step 5: If Client waits dominate, look at the application side. Connection saturation, idle-in-transaction sessions, network latency between the application and the database. These live outside PostgreSQL.
Five steps. Most production diagnoses end at step 1, 2, or 3.
Five production cases
Each of these is anonymized. The technical detail is real.
Case 1: Locks blocking autovacuum in a multi-bank fintech platform
A North American fintech platform that runs across multiple bank backends hit a transaction ID wraparound warning on a production cluster. The XID age was climbing steadily, with no obvious cause.
The wait event distribution showed one backend stuck on wait_event = transactionid, wait_event_type = Lock for hours. The blocker, found via pg_blocking_pids(), was a long-running transaction that the product was leaving open while a downstream batch ran. The open transaction held locks that kept autovacuum from advancing the freeze horizon, which is what the wraparound warning was tracking.
The interim fix was a scheduled job that recycled the long transaction at safe boundaries. The permanent fix went into the product code: advisory locks in place of the held transaction, so the lock semantics worked with autovacuum instead of against it.
Business outcome: the XID age dropped back to normal in the same week. The wraparound risk, which would eventually have blocked database writes entirely, was removed before any customer environment was affected.
The wait event told the story. Without it, the team would have spent days tuning autovacuum settings for a problem that had nothing to do with autovacuum tuning.
Case 2: I/O waits masked by a query-killing cron job at an on-demand delivery platform
A restaurant delivery provider running Odoo on PostgreSQL had a cron job in place that terminated any query running longer than three minutes. The intent was protective. The effect was diagnostic blindness. Every query that was about to surface a real bottleneck got terminated before the wait event pattern showed up in any meaningful sample.
When we extended the threshold from three minutes to ten and watched the workload, the DataFileRead count in pg_stat_io for backend_type = ‘client backend’, context = ‘normal’ spiked into ranges that matched the symptoms exactly. The storage layer was the constraint. The query timeout was hiding it.
The fix worked in two steps. First, raise work_mem so sort and hash operations stayed in memory rather than spilling to disk. Temporary file generation dropped 31% from 280 GB per day to 193 GB per day. Read IOPS spikes dropped from 10 to 15 per day down to 5 to 7 per day. Second, add the missing foreign key indexes that were forcing sequential scans on transactional tables, which removed a class of I/O waits entirely.
Business outcome: report generation that used to take more than thirty minutes (and often failed to finish) now completes in under two minutes. That is a 15 times improvement and a 93 percent reduction in execution time on the same hardware. The query timeout, originally a workaround, could finally be retired.
Case 3: Bulk DELETE performance on a 1 TB blobs table at a video management vendor
A video management software vendor had a 1 TB blobs table that was being kept tidy with periodic DELETE operations. The DELETEs were generating extreme bloat. Autovacuum had to scan the full 1 TB to recover space, which was slow, expensive, and degraded real-time performance for active workloads.
The wait pattern was a mix of DataFileRead (autovacuum scanning the table) and Lock waits (client backends queued behind autovacuum-held locks during scan phases). The root cause was structural. A 1 TB table whose access pattern was effectively partitioned by task_id should have been a partitioned table from the start.
The recommendation was to partition by task_id. With partitioning in place, DELETE becomes DROP partition for the data that ages out, which removes the bloat problem entirely, and the surface area autovacuum has to scan shrinks to the live partitions only.
Business outcome: the partitioning work prepared the environment for 10 times the current workload on the same architecture. The wait event pattern was the leading indicator. The structural fix removed the entire class of waits.
Case 4: WAL growth from a stuck Debezium replication slot
The same fintech platform from Case 1 ran into a different problem in a different environment. WAL files had grown to 1.8 TB on a volume sized at 2 TB. The cluster was 200 GB from running out of disk.
The cause was a Debezium logical replication slot that had stopped advancing because no events were committing back to Kafka. PostgreSQL has to retain every WAL segment that any replication slot might still need, so the slot’s confirmed_flush_lsn was anchoring WAL retention at a position from days earlier.
The wait pattern was indirect. No active wait event jumped out in pg_stat_activity. The signal lived in pg_replication_slots: active = true, confirmed_flush_lsn far behind pg_current_wal_lsn(). That gap was the real wait, expressed as a backlog rather than a wait event on a single backend.
The fix was a Debezium heartbeat configuration that forced periodic activity through the slot, which let confirmed_flush_lsn advance, which let PostgreSQL recycle WAL. WAL usage returned to normal levels.
Business outcome: a near-outage avoided. The lesson is that wait events cover the per-backend story. For replication slot health you also have to watch pg_replication_slots and pg_stat_replication. The complete diagnostic surface is the union of all three.
Case 5: Connection pool exhaustion surfacing as ClientRead
Recurring batch job failures at the same fintech were traced to application connections that were staying open after their work completed. The pool was running out of slots. New batch jobs were either failing to acquire a connection or sitting on existing ones that had gone idle inside transactions.
Looking at pg_stat_activity, the pattern was a high count of backends in state = ‘idle in transaction’ with wait_event = ClientRead. The wait event itself was benign. The state combined with the count was the problem.
The fix was configuring idle_in_transaction_session_timeout and idle_session_timeout based on actual usage patterns. Backends that had drifted past the threshold were terminated automatically. The pool stabilized. The batch failures stopped.
Business outcome: removing the connection exhaustion eliminated a class of recurring incidents that had been costing the team hours per week. The wait event pattern surfaced the answer. The action lived on application timeouts, not on PostgreSQL tuning.
What changed in PostgreSQL 17 and 18
Three changes are worth knowing.
pg_wait_events (PG 17). A catalog view with three columns: type, name, description. It is the in-database equivalent of the wait event table in the documentation. Join it to pg_stat_activity and you get human-readable descriptions next to every active wait without leaving psql.
pg_stat_io improvements (PG 18). This view existed since PostgreSQL 16. PG 18 added byte-level I/O tracking, expanded WAL metrics into the view, and refined the granularity by backend type and context. For I/O diagnosis it is now the partner view to pg_stat_activity: one tells you what is waiting, the other tells you who is doing the I/O.
Asynchronous I/O (PG 18). This is the change that shifts the wait event reading itself. PostgreSQL 18 introduced an io_method parameter with three values: worker (default), io_uring (Linux only, kernel 5.1 and above, requires –with-liburing at build time), and sync (the PG 17 behavior).
With io_method = worker, dedicated I/O worker processes handle read operations on behalf of client backends. The workers appear in pg_stat_activity as backend_type = ‘io worker’. When idle, they show wait_event_type = Activity, wait_event = IoWorkerMain. When busy, they show wait_event_type = IO, wait_event = DataFileRead. The implication for diagnosis is direct. I/O waits move out of client backends and into the worker pool. If you filter pg_stat_activity by backend_type = ‘client backend’ (a habit I had for years), you will undercount the I/O picture on PG 18. Include I/O workers, or pivot to pg_stat_io for the system-wide view.
The default for effective_io_concurrency also went from 1 in PG 17 to 16 in PG 18, which matches the assumption that storage today can sustain meaningfully more concurrent reads than it could a decade ago.
Sampling versus snapshots
pg_stat_activity is a snapshot. A single query against it captures the state of every backend at exactly one moment. For a workload that is having problems right now, the snapshot is enough. For a workload that has intermittent issues, the snapshot will miss what you need to see.
Two ways to fix this.
Roll your own sampler. A cron job that runs the wait event distribution query every second and inserts the result into a history table. Cheap, simple, gives you minute-by-minute pattern detection. The query overhead is small enough to leave running in production.
Install pg_wait_sampling. A widely used extension that samples wait events at a configurable interval (10 ms by default) and exposes three views: pg_wait_sampling_current, pg_wait_sampling_history, and pg_wait_sampling_profile. The profile view is a histogram of wait events over time, which is what you actually want when investigating “queries got slow last Tuesday afternoon between 2 and 3 PM.”
For anything beyond ad hoc diagnosis, sampling beats snapshots.
Queries to keep in your back pocket
Three queries cover most of what I run on a customer system during a first investigation.
Current wait distribution by type and event:
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE state = 'active'
GROUP BY wait_event_type, wait_event
ORDER BY count(*) DESC;
Blockers and their victims:
SELECT blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocker.pid AS blocker_pid,
blocker.query AS blocker_query,
blocked.wait_event_type,
blocked.wait_event
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocker
ON blocker.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.wait_event_type = 'Lock';
I/O activity by backend type and context (PG 16 and later):
SELECT backend_type, object, context,
reads, writes, extends, hits, evictions
FROM pg_stat_io
WHERE reads > 0 OR writes > 0
ORDER BY reads + writes DESC;
Save these. They will get you 80 percent of the way on most performance investigations.
FAQ
Are wait events expensive to collect? Cheap. The wait event reporting is in-process and lock-free. Reading pg_stat_activity is light. Running a sampler every second is light. The cost concern people sometimes raise applies to extensions with broader instrumentation, separate from wait events themselves.
Why does wait_event sometimes show NULL? The backend is actively running on CPU rather than waiting. NULL on wait_event plus state = ‘active’ means the backend is CPU-bound. That distinction matters: a CPU-bound workload calls for query tuning, indexing, or a faster CPU. A wait-bound workload calls for whatever resource is being waited on.
Should I use pg_wait_sampling or write my own sampler? For most teams, pg_wait_sampling wins on time-to-value. The extension is mature, the views are well designed, and the sample rate is configurable. A custom sampler can plug into an existing metrics pipeline more cleanly if you have one you trust.
What about pg_stat_statements and wait events? pg_stat_statements tells you which queries spent the most time. It leaves the wait event breakdown to you. The two views complement each other: pg_stat_statements for “which query,” pg_stat_activity plus sampling for “what was it waiting on.”
Does this all change on PG 18 with async I/O? The wait event vocabulary stays the same. The distribution shifts. I/O waits move into the io worker backends when io_method = worker. Include them in your queries. Pivot to pg_stat_io for system-wide I/O patterns. The diagnostic flow above still works.
What to take away
Wait events are the closest thing PostgreSQL has to a free diagnostic. The information is there in every running cluster. Reading it well is a learnable skill, and the queries above are a starting point.
The cases I covered came from production environments my team supports day to day. The pattern across them is consistent: the wait event surfaced the answer in minutes, and the fix took longer to coordinate than to identify. That ratio is what makes wait events worth investing in. They turn “the database is slow” from a guessing game into a structured investigation.

