PostgreSQLIntermediate 15 min Lesson 32 of 40

Monitoring PostgreSQL

What to monitor and how: connections, slow queries, locks, disk and table sizes, replication lag, autovacuum and wraparound.

PostgreSQL · Lesson 32 of 40
0/40 done(0%)

What is it? #

Monitoring is how you find out about a problem before your users do.

PostgreSQL exposes almost everything about itself through system views — ordinary tables you can query. pg_stat_activity shows what is running right now. pg_stat_user_tables shows table activity and dead tuples. pg_stat_replication shows standby health.

There are two distinct jobs here. Investigation is querying these views when something is wrong. Monitoring is collecting them continuously so you have history, trends and alerts.

This lesson covers the queries worth knowing and, more importantly, which handful of things actually deserve an alert. Alerting on everything produces noise that gets ignored, which is worse than not alerting at all.

Think of it like this #

The instruments in a car.

The speedometer is something you glance at — useful context, no action required. The fuel gauge matters when it gets low. The oil warning light means stop now.

A dashboard where every instrument flashes constantly teaches you to ignore all of them, including the one that means the engine is about to seize.

Monitoring is deciding which readings are context, which are warnings, and which are the oil light.

Simple example #

Three in the morning, the application is timing out.

Without monitoring, you are guessing, and the first hour goes on finding out what is happening. With it, you see connections at maximum, one query running for forty minutes, and the locks queued behind it — and you know what to do within a minute.

Code #

SQL
-- ---------- WHAT IS HAPPENING RIGHT NOW ----------
-- The first query to run when something is wrong.

SELECT pid,
       usename,
       client_addr,
       state,
       now() - query_start AS running_for,
       wait_event_type,
       wait_event,
       left(query, 80) AS query
FROM pg_stat_activity
WHERE state <> 'idle'
  AND pid <> pg_backend_pid()
ORDER BY query_start;

-- state values and what they mean:
--   active               running a query right now
--   idle                 connected, doing nothing (normal)
--   idle in transaction  IN A TRANSACTION, doing nothing  <- a problem if long
--   waiting              blocked on a lock (see wait_event)
SQL
-- ---------- CONNECTIONS ----------

SELECT count(*) AS total,
       count(*) FILTER (WHERE state = 'active')              AS active,
       count(*) FILTER (WHERE state = 'idle')                AS idle,
       count(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_txn,
       (SELECT setting::int FROM pg_settings WHERE name='max_connections') AS max_conn
FROM pg_stat_activity;

-- ALERT when total exceeds ~80% of max_connections. Hitting the limit
-- means new connections are REFUSED and the application starts failing.

-- Long "idle in transaction" sessions hold locks and block VACUUM
-- database-wide. Worth alerting on by itself:
SELECT pid, usename, now() - state_change AS idle_for, left(query,60)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND state_change < now() - interval '5 minutes';
SQL
-- ---------- SLOW QUERIES (needs pg_stat_statements) ----------

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- also requires:  shared_preload_libraries = 'pg_stat_statements'  + restart

SELECT round(total_exec_time::numeric)      AS total_ms,
       calls,
       round(mean_exec_time::numeric, 2)    AS avg_ms,
       round(max_exec_time::numeric, 2)     AS max_ms,
       rows,
       left(query, 70)                      AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 15;

-- Rank by TOTAL time, not average — a 15ms query run a million times
-- costs far more than a 30-second report that runs twice a day.

-- Reset the counters after making a change, to measure the effect:
SELECT pg_stat_statements_reset();
SQL
-- ---------- LOCKS AND BLOCKING ----------

SELECT blocked.pid              AS blocked_pid,
       left(blocked.query, 50)  AS blocked_query,
       blocking.pid             AS blocking_pid,
       left(blocking.query, 50) AS blocking_query,
       now() - blocked.query_start AS blocked_for
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0
ORDER BY blocked_for DESC;

-- Alert if anything has been blocked for more than about a minute.
SQL
-- ---------- DISK AND SIZES ----------

SELECT pg_size_pretty(pg_database_size(current_database())) AS database_size;

SELECT relname,
       pg_size_pretty(pg_total_relation_size(relid)) AS total,
       pg_size_pretty(pg_table_size(relid))          AS table,
       pg_size_pretty(pg_indexes_size(relid))        AS indexes
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 10;

-- WAL directory size — grows without limit if archiving fails or a
-- replication slot is abandoned. Both stop the database when the disk fills.
SELECT count(*) AS wal_segments,
       pg_size_pretty(sum(size)) AS wal_size
FROM pg_ls_waldir();
SQL
-- ---------- CACHE EFFECTIVENESS ----------

SELECT round(100.0 * sum(blks_hit) /
             NULLIF(sum(blks_hit) + sum(blks_read), 0), 2) AS cache_hit_pct
FROM pg_stat_database
WHERE datname = current_database();

-- Below ~95% on an OLTP workload usually means shared_buffers is too small
-- or the working set has outgrown RAM. Analytics workloads legitimately
-- read more from disk — interpret in context.
SQL
-- ---------- AUTOVACUUM AND BLOAT ----------

SELECT relname,
       n_live_tup, n_dead_tup,
       round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup,0), 1) AS dead_pct,
       last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC
LIMIT 10;

-- Autovacuum running right now:
SELECT pid, now() - xact_start AS duration, left(query, 70)
FROM pg_stat_activity WHERE query LIKE 'autovacuum:%';
SQL
-- ---------- TRANSACTION ID WRAPAROUND — ALERT ON THIS ----------

SELECT datname,
       age(datfrozenxid) AS xid_age,
       round(100.0 * age(datfrozenxid) / 2000000000, 1) AS pct_to_limit
FROM pg_database
ORDER BY age(datfrozenxid) DESC;

-- Under 200 million: healthy.
-- Over ~1 billion: investigate now.
-- At 2 billion: THE DATABASE STOPS ACCEPTING WRITES and recovery
-- requires single-user mode. This is one of very few PostgreSQL
-- problems with a hard stop, and it is entirely preventable.
SQL
-- ---------- REPLICATION LAG ----------

-- On the PRIMARY:
SELECT client_addr, state, sync_state,
       pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) AS replay_lag,
       replay_lag AS replay_lag_time
FROM pg_stat_replication;

-- On the STANDBY — the single most useful number:
SELECT now() - pg_last_xact_replay_timestamp() AS lag;

-- ---------- WAL ARCHIVING HEALTH ----------
SELECT archived_count, last_archived_time,
       failed_count,   last_failed_time,
       now() - last_archived_time AS since_last_archive
FROM pg_stat_archiver;

-- failed_count rising -> archiving is broken -> pg_wal grows -> disk fills
-- -> database stops. Alert on this.
SQL
-- ---------- ERRORS, DEADLOCKS AND ROLLBACKS ----------

SELECT datname,
       xact_commit, xact_rollback,
       round(100.0 * xact_rollback /
             NULLIF(xact_commit + xact_rollback,0), 2) AS rollback_pct,
       deadlocks,
       temp_files,
       pg_size_pretty(temp_bytes) AS temp_written
FROM pg_stat_database
WHERE datname = current_database();

-- A rising rollback_pct suggests application errors or lock contention.
-- Growing temp_files means work_mem is too low for your queries
-- (see the configuration lesson).
TEXT
---------- WHAT TO ACTUALLY ALERT ON ----------

Alert on a small number of things that mean real trouble:

  PAGE SOMEONE (the database will stop or is down)
    * PostgreSQL not responding
    * Disk usage above 85% on the data or WAL filesystem
    * Transaction ID age above 1 billion
    * WAL archiving failing (failed_count rising)
    * Connections above 90% of max_connections

  WARN (look at it this week)
    * Replication lag above your tolerance
    * A query blocked for more than a minute
    * "idle in transaction" older than 10 minutes
    * Cache hit ratio falling below 95%
    * Dead tuple percentage above 20% on a large table
    * A backup that did not run, or a failed restore test

  TREND ONLY (graph it, do not alert)
    * Database and table sizes
    * Query throughput
    * Connection counts over time

Everything else is context for investigation, not an alarm.
BASH
# ---------- Tools that collect this for you ----------

# postgres_exporter + Prometheus + Grafana   the common open-source stack
# pgwatch2                                   PostgreSQL-specific, batteries included
# pganalyze                                  commercial, strong query analysis
# PgHero                                     simple web dashboard, easy to start with

# Whatever you use, make sure it keeps HISTORY. "Is this normal?" is
# unanswerable without knowing what last week looked like.

How it works #

PostgreSQL's statistics collector maintains counters about activity, and the pg_stat_* views expose them as ordinary queryable tables. That is why monitoring PostgreSQL needs no special protocol — it is just SQL.

pg_stat_activity has one row per connection and is the right starting point for any live problem. The state column distinguishes a connection that is working from one that is merely connected, and critically from one sitting idle in transaction — connected, inside an open transaction, doing nothing. That last state holds locks and blocks VACUUM across the whole database, which is why it deserves its own alert rather than being lumped in with idle connections.

pg_stat_statements aggregates execution statistics per normalised query, with parameter values stripped so that the same query shape groups together regardless of arguments. Ranking by total time rather than average is what surfaces the queries genuinely consuming your database's capacity — usually fast queries executed enormously often, not the slow report everyone complains about.

pg_blocking_pids() answers "what is blocking this" directly, which turns a confusing frozen-application incident into a specific process id to investigate.

Most counters are cumulative since the last reset, so a single reading tells you little. The rate of change is the useful signal, which is precisely why a collector storing history is worth more than a set of queries you run manually.

The metrics worth alerting on share a property: they predict the database stopping. Disk filling, transaction id wraparound, archiving failure and connection exhaustion all end with PostgreSQL refusing to work, and all give plenty of warning if anyone is watching. The rest of the metrics describe performance getting worse, which is important but rarely needs waking someone.

Interpreting a reading requires context. A cache hit ratio of 90% is concerning for an OLTP application and entirely normal for an analytics workload scanning large tables. Ten thousand dead tuples is nothing on a hundred-million-row table and significant on one with fifty thousand rows. Baselines, not absolute thresholds, are what make monitoring useful.

Real-world use #

Set up monitoring before you need it. During an incident is the wrong moment to discover there is no history, because the most valuable question — "when did this start, and what changed then?" — is unanswerable without it.

Keep the alert list short. Every alert that fires without requiring action trains people to ignore alerts, and the cost is eventually paid when a real one is missed. The page-worthy list above is deliberately five items long.

pg_stat_statements is the highest-value single thing to enable. It requires a restart to add to shared_preload_libraries, which is worth scheduling, and it turns query optimisation from guesswork into a ranked list.

Alert on your backups as well as your database. A failed backup job, a failed restore test, or archiving that stopped are all more dangerous than most performance metrics, because they are silent until the moment they matter. The automated-backup lesson's dead man's switch belongs in your monitoring, not separate from it.

Graph sizes over time. Disk exhaustion is completely predictable weeks in advance from a growth trend, and it is one of the few failures that can be scheduled away rather than responded to.

When investigating, start broad and narrow down: what is running now, what is blocked, what is consuming the most total time, what has changed. Resist reaching for pg_terminate_backend early — understand what the query is doing and whose it is first, and prefer pg_cancel_backend.

Finally, record what normal looks like. A number is only alarming relative to a baseline, and writing down the typical connection count, cache hit ratio and replication lag for your system makes the next incident much faster to diagnose.

Common mistakes #

  • Alerting on too many metrics, training everyone to ignore alerts including the important ones.
  • Not running pg_stat_statements, leaving query optimisation as guesswork.
  • Monitoring the database but not the backups, archiving or restore tests.
  • Reading cumulative counters as instantaneous values instead of watching their rate of change.
  • Never recording a baseline, so no one can tell whether a reading is actually abnormal.

Practice #

Enable pg_stat_statements on a test server and generate mixed traffic — some fast queries run many times, one slow query run twice. Confirm that ranking by total time surfaces the fast-but-frequent query above the slow one. Then open a transaction and leave it idle, and find it with the idle in transaction query. Create a blocking situation with two sessions and identify it using pg_blocking_pids(). Finally, write down current baseline values for connections, cache hit ratio, database size and transaction id age.

Quick quiz

  1. 1. Why is "idle in transaction" worth its own alert?

  2. 2. How should you rank queries in pg_stat_statements?

  3. 3. Which metric, if ignored, will eventually stop the database accepting writes?

  4. 4. Why does alerting on too many metrics make things worse?

  5. 5. Why is a baseline necessary to interpret monitoring data?

Summary

  • PostgreSQL exposes its own state through pg_stat_* views you query with ordinary SQL.
  • pg_stat_activity for live problems, pg_stat_statements for what consumes the most total time.
  • Page only on things that stop the database: disk, wraparound, archiving, connections, unresponsiveness.
  • Monitor backups, archiving and restore tests alongside database metrics — they fail silently.
  • Counters are cumulative, so keep history and record baselines to know what abnormal looks like.