Topic 53

The Statistics Views — What to Monitor

Monitoring

Postgres has been counting almost everything it does since the cluster was created, and all of it is available as ordinary views you can select from, join and graph. Since 15 those counters live in shared memory rather than being funnelled through a separate collector process, and a copy is written to the pg_stat directory at shutdown so the numbers survive a clean restart. Reading them costs nothing worth measuring.

The difficulty is not finding metrics. It is that there are several hundred of them, and roughly a dozen predict an outage while the rest describe one after it has happened. This topic is that dozen, where each number lives, and, just as important, which of these views are running totals and which are photographs of this instant, because alerting on the wrong kind produces both false alarms and blind spots.

Which view answers which question
Is work_mem still fitting the workload? temp_files, temp_bytespg_stat_database
Which index is unused, and when was it last read? idx_scan, last_idx_scanpg_stat_user_indexes
Who is blocking whom, right now? pg_blocking_pids(), wait_eventpg_stat_activity
Is the disk busy because of autovacuum or because of clients?pg_stat_io
Are checkpoints scheduled by the clock or forced by WAL volume?pg_stat_checkpointer
How far behind is pg-replica-a? write_lag, flush_lag, replay_lagpg_stat_replication

Database-Level Counters

pg_stat_database has one row per database and is the widest-angle view there is. It carries xact_commit and xact_rollback, blks_hit against blks_read, deadlocks, temp_files and temp_bytes, conflicts for queries cancelled by recovery on a standby, checksum_failures, and session accounting including idle_in_transaction_time. Two of those columns are worth an alert on their own and the rest are dashboard material.

Four numbers from one row, and what each of them means
SELECT deadlocks, temp_files, pg_size_pretty(temp_bytes) AS spilled,
       conflicts, checksum_failures,
       round(idle_in_transaction_time / 3600000) AS idle_txn_hours
  FROM pg_stat_database WHERE datname = 'cartwheel';

 deadlocks | temp_files | spilled | conflicts | checksum_failures | idle_txn_hours
-----------+------------+---------+-----------+-------------------+----------------
       412 |      41207 | 3841 GB |         0 |                 0 |            914

Four hundred and twelve deadlocks describes an application access pattern rather than a database fault, and Chapter 6 has the ordering rule that removes it. The temporary-file numbers are work_mem reporting that it does not fit the workload. conflicts stays at zero on a primary and only becomes interesting on pg-replica-a. And checksum_failures is the one column where any value other than zero ends the conversation about performance and starts one about hardware. Clusters initialized on 18 have data checksums on by default, so the counter is armed without anybody having enabled it.

Table and Index Level

pg_stat_user_tables is where day-to-day tuning happens. seq_scan against idx_scan tells you whether a table is being read the way you designed it to be, and since 16 last_seq_scan and last_idx_scan say when each last happened, which turns "is this index used" from an argument into a date. n_live_tup and n_dead_tup are the bloat signal, n_mod_since_analyze is how far the statistics have drifted since the planner's last briefing, and last_autovacuum with last_autoanalyze says whether maintenance is keeping up at all. Since 18 the view also totals the time spent, in total_autovacuum_time and total_autoanalyze_time.

A handful of readings from that view change what you do next. A steadily climbing seq_scan on a 40-million-row table is a missing or unusable index. An n_mod_since_analyze of four million on orders means the planner is working from a description of a table that no longer exists, which is the mechanism behind Cartwheel's Saturday and Chapter 9's subject. A last_autovacuum that is null or days old on a hot table is the leading indicator of the bloat Chapter 7 measures. The companion view, pg_stat_user_indexes, gives idx_scan per index, which is where an unused-index audit gets its evidence and its date.

Activity and Waits

pg_stat_activity is a different kind of object: one row per backend, describing this moment. It answers what is running, what each backend is waiting for, and how long it has been at it, through state, wait_event_type and wait_event, xact_start, query_start and backend_type. It also carries query_id, which joins straight to pg_stat_statements — so the statement burning the server right now can be looked up in the aggregate ranking without matching text.

Who is blocking whom, and how long the oldest transaction has been open
SELECT pid, state, wait_event_type, wait_event,
       now() - xact_start           AS xact_age,
       pg_blocking_pids(pid)        AS blocked_by,
       left(query, 34)              AS query
  FROM pg_stat_activity
 WHERE backend_type = 'client backend' AND state != 'idle'
 ORDER BY xact_start;

  pid  |        state        | wait_event_type | wait_event | xact_age  | blocked_by
-------+---------------------+-----------------+------------+-----------+------------
 21884 | idle in transaction |          Client | ClientRead | 02:41:09  | {}
 30112 | active              |            Lock | relation   | 00:00:22  | {21884}
 30119 | active              |            Lock | relation   | 00:00:19  | {21884}

One backend has been idle inside an open transaction for two hours and forty-one minutes, and two others are queued behind it on a relation lock. pg_blocking_pids() names the culprit directly, which is the difference between a two-minute incident and an hour of guessing. Two of these columns deserve permanent attention: wait_event_type, because Lock, IO and LWLock lead to completely different investigations, and the oldest xact_start on the cluster, because a transaction that old is holding back the vacuum horizon for every table in it, for reasons Chapters 6 and 7 make painfully concrete.

I/O and Checkpoints

pg_stat_io, added in 16, is the view that made I/O attributable. Its rows are keyed by three dimensions: backend_type, object (relation, temp relation or wal) and context (normal, vacuum, bulkread, bulkwrite, init). Each row counts reads, writes, extends, hits, evictions, reuses and fsyncs, with times attached when track_io_timing is on. That decomposition answers a question that was previously unanswerable from inside the database: whether the disk is busy because of client queries, because autovacuum is working through a backlog, or because a bulk load is streaming through its own ring buffer.

Checkpoint accounting moved in 17, so anything written before then reads checkpoint counts out of the wrong view. pg_stat_checkpointer now holds num_timed, num_requested and num_done, the restartpoint equivalents on a standby, write_time, sync_time and buffers_written. pg_stat_bgwriter keeps four columns: buffers_clean, maxwritten_clean, buffers_alloc and its own stats_reset.

The checkpoint ratio: scheduled by the clock, or forced by WAL volume
SELECT num_timed, num_requested,
       round(100.0 * num_requested / nullif(num_timed + num_requested, 0), 1) AS forced_pct,
       pg_size_pretty(buffers_written * 8192::bigint) AS written,
       round(write_time / 1000) AS write_secs
  FROM pg_stat_checkpointer;

 num_timed | num_requested | forced_pct | written  | write_secs
-----------+---------------+------------+----------+------------
      1184 |          3971 |       77.0 | 3150 GB  |      38412

A checkpoint that happens because checkpoint_timeout elapsed is a scheduled, spread-out write. One that happens because WAL filled up is unscheduled, and 77% of Cartwheel's checkpoints are the second kind — the server is being pushed into flushing by write volume rather than by the clock, several times more often than it planned to. That ratio is a real warning and it is trivially graphable. What to do about it is a WAL sizing decision, and Chapter 12 owns both the mechanism and the settings.

Replication and WAL

The replication side is covered by three views, and the primary holds all of them. pg_stat_replication has a row per connected standby with write_lag, flush_lag and replay_lag as intervals plus the LSNs behind them, which is where Cartwheel's 30-second dashboard tolerance becomes a number you can alert on. pg_replication_slots shows how much WAL each slot is forcing the primary to retain and the slot's xmin, which is a vacuum-horizon risk wearing a different hat. pg_stat_wal totals generation volume in wal_records, wal_fpi, wal_bytes and wal_buffers_full. Chapter 13 turns these into a failover procedure. Until then they are two graphs and one alert on the free space of pg_wal, which is a filesystem a retained slot can fill on its own.

The Dozen Worth Alerting On

An alert should mean an engineer has to act, which limits the list severely. Transaction id age, because wraparound arrives as a shutdown rather than a warning. The age of the oldest open transaction, because it stops vacuum cluster-wide. Replication lag and WAL retained by a slot. Dead tuples on the two or three tables that matter. Connection saturation, measured at the pooler as well as at the database. The deadlock rate, temporary bytes written, and the ratio of forced to timed checkpoints. Free space on the pg_wal filesystem and on the data directory, separately, because they fill for different reasons. And the two numbers that are not in any of these views at all: application p95 latency and error rate.

Everything else belongs on a dashboard, and some of it belongs in the log instead, because a counter records that something happened and a log line records which thing. log_checkpoints has been on by default since 15; log_lock_waits is off and worth turning on, since it writes a line whenever a session waits longer than deadlock_timeout for a lock; log_autovacuum_min_duration defaults to 10 minutes and is usually better at zero; log_temp_files = 0 names the statements behind those 41,207 spills. One last detail for whoever writes the collector: stats_fetch_consistency defaults to cache, which pins the numbers for the rest of the transaction, and a sampling agent should set it to none so each read returns the current value.

Page, graph, or log — three homes for a number
Page somebody about a dozen
Transaction id age · the oldest open transaction · replication lag and WAL retained by a slot · dead tuples on the two or three tables that matter · connection saturation, at the pooler as well as the database · free space on pg_wal and on the data directory, separately · and the two numbers that are in none of these views at all: application p95 latency and error rate.
Graph it everything else
Deadlock rate · temporary bytes written · the ratio of forced to timed checkpoints as a standing panel · the cache hit ratio as a trend line rather than a target.
Log it the ones with a name attached
log_lock_waits on · log_autovacuum_min_duration at 0 rather than 10 minutes · log_temp_files at 0, which names the statements behind the 41,207 spills.
Counters vs samples

The pg_stat_* counters (pg_stat_database, pg_stat_user_tables, pg_stat_io, pg_stat_checkpointer) are running totals since the last reset or restart. Their value is in the difference between two readings, so a monitoring agent samples on an interval and graphs rates.

pg_stat_activity and pg_locks are instantaneous. They describe this microsecond and hold no history whatsoever, so a query that finished a second before you looked leaves no trace in either of them.

Where each one belongs — alerting goes on the counters' rates, because a rate has a threshold. Incident diagnosis goes on the samples, because that is where the blocker's pid is. Putting an alert on a sample gives you a page about a two-second lock wait; putting diagnosis on a counter gives you a number with no idea which backend produced it.

Common Mistakes
  • Alerting on a cache hit ratio target such as 99% — it is a trend, and a healthy analytical workload reads from disk by design, so the page fires on correct behaviour.
  • Reading a cumulative counter as a current condition, so a number that accumulated over four months looks like something happening right now.
  • Monitoring the database and not the pooler, the pg_wal filesystem, or the replica's apply lag — those are where the outages come from, and none of them appear on a database dashboard by default.
  • Ignoring growth in temp_files and temp_bytes, which is work_mem saying it stopped fitting the workload some time after the last change to it.
  • Calling pg_stat_reset() as a debugging reflex, which destroys the baseline every other dashboard and alert threshold in the team was built against.
  • Reading checkpoint counts from pg_stat_bgwriter on 17 or later — they moved to pg_stat_checkpointer, and the query returns an error or a stale panel rather than a warning.
Best Practices
  • Sample the pg_stat_* views on a fixed interval and graph rates rather than raw counters, with stats_fetch_consistency = none on the collector's role.
  • Alert only on the short list that predicts an outage (transaction age, oldest transaction, replication lag, WAL disk free, connection saturation) and leave the rest as dashboards.
  • Keep the pg_stat_activity and pg_blocking_pids() query in the runbook, so "who is blocking whom" is a paste rather than a recollection.
  • Turn on log_lock_waits, set log_autovacuum_min_duration = 0 and log_temp_files = 0, so the log carries the events the counters can only total.
  • Graph forced against timed checkpoints from pg_stat_checkpointer as a standing panel, since a rising ratio is an early and cheap warning.
  • Grant the monitoring role pg_monitor rather than superuser — it is the built-in role that exists precisely to read these views and nothing else.
Comparable toolspostgres_exporter and Grafana the standard open-source pairingpganalyze and pgwatch Postgres-specific products over these viewsOracle AWR snapshots and v$ viewsSQL Server dynamic management viewsMySQL performance_schema and the sys schema

Knowledge Check

Why does alerting on pg_stat_activity produce noise while alerting on pg_stat_database does not?

  • It is an instant sample, so a threshold fires on one microsecond's worth of state
  • It is expensive to query, so frequent alert evaluation loads the server itself
  • It requires superuser, so the alerting agent cannot read it on a schedule
  • It refreshes only once a minute, so its contents are always a minute out of date

pg_stat_checkpointer shows num_requested well above num_timed. What is that telling you?

  • Checkpoints are being skipped, so dirty pages are accumulating without being written
  • Write volume is forcing checkpoints rather than the timeout scheduling them
  • Someone is running CHECKPOINT by hand, which is the only source of requested ones
  • Each checkpoint is taking longer than the configured completion target allows

Which of these metrics genuinely deserves a page in the middle of the night?

  • The cache hit ratio falling below 99% across the whole cluster for an hour
  • Transaction id age climbing toward the wraparound limit on any database
  • The sequential scan count rising steadily on a twelve-thousand-row table
  • A single deadlock being detected and resolved between two application backends

Where do you look to find out whether autovacuum or client queries are responsible for the disk load?

  • pg_stat_database, whose blks_read is broken down per backend type
  • pg_stat_io, whose rows are keyed by backend type, object and I/O context
  • pg_stat_bgwriter, which since 17 reports reads and writes for every process
  • pg_stat_user_tables, which separates vacuum reads from query reads per table

A steadily growing temp_bytes in pg_stat_database points at which setting?

  • temp_buffers, which sizes the memory each session gets for temporary tables
  • work_mem, since a sort or hash that exceeds its grant spills to disk instead
  • shared_buffers, because a full buffer pool pushes sort data out to temp files
  • maintenance_work_mem, which is what index builds and vacuum spill against

You got correct