all posts

The Postgres metrics worth watching

Ajay Kumar··8 min read

The default database dashboard is CPU, memory, disk and connection count. All four are real, and all four are lagging indicators — by the time CPU is pegged, the thing that caused it has been happening for a while.

These are the metrics that tell you something is going wrong before it becomes an incident, roughly in order of how badly it goes if you ignore them.

Transaction ID age — the one that takes the database offline

Postgres transaction IDs are 32-bit and wrap around. Vacuum freezes old rows to keep the usable range from being exhausted. If vacuum can't keep up — usually because something is blocking it — Postgres shuts down writes entirely to protect your data. Not slow. Refusing writes until you complete an offline vacuum.

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

-- Alert at 50% (about 1 billion). Page someone at 80%.
-- Under 200 million is normal for a healthy database.

It's rare, and it's catastrophic when it happens, and it always has warning: the number climbs steadily for weeks. Almost always the cause is one of three things holding back the vacuum horizon — a long-running transaction, an abandoned replication slot, or an orphaned prepared transaction.

-- The three usual culprits
SELECT pid, now() - xact_start AS duration, query FROM pg_stat_activity
  WHERE xact_start IS NOT NULL ORDER BY xact_start LIMIT 5;

SELECT slot_name, active, pg_size_pretty(
  pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;

SELECT gid, prepared FROM pg_prepared_xacts;   -- usually should be empty
An inactive replication slot is a quiet disaster. It holds WAL forever waiting for a consumer that is never coming back, filling the disk and blocking vacuum simultaneously. Alert on any slot with active = false for more than a few minutes.

Longest idle-in-transaction

A connection that opened a transaction and stopped doing database work holds locks, pins its backend process, and prevents vacuum from cleaning up rows its snapshot can still see. One of these lasting hours is the root cause of a surprising share of database incidents.

SELECT max(extract(epoch from now() - state_change)) AS longest_idle_in_txn
FROM pg_stat_activity WHERE state = 'idle in transaction';

-- Alert above 60 seconds. Nothing legitimate holds a transaction open
-- that long while doing nothing.

The cause is almost always application code doing something slow inside a transaction — calling an external API, processing a file, waiting on a lock. Set `idle_in_transaction_session_timeout` so the database terminates them, then fix the code path that produced them.

Cache hit ratio

SELECT round(100.0 * sum(heap_blks_hit) /
             nullif(sum(heap_blks_hit) + sum(heap_blks_read), 0), 2) AS hit_pct
FROM pg_statio_user_tables;

Above 99% is comfortable, below 95% deserves a look, below 90% means something is wrong. But watch the trend rather than the absolute number — a ratio drifting from 99.5% to 97% over a month is telling you the working set is outgrowing memory, and that's actionable while it's still a graph rather than an outage.

Query time by total, not by average

`pg_stat_statements` is the single most valuable extension for this, and the ordering you choose changes what you find.

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

SELECT calls,
       round(total_exec_time::numeric, 0) AS total_ms,
       round(mean_exec_time::numeric, 2)  AS mean_ms,
       round(100.0 * shared_blks_hit /
             nullif(shared_blks_hit + shared_blks_read, 0), 1) AS hit_pct,
       left(query, 80) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC LIMIT 15;

Order by `total_exec_time`, not `mean_exec_time`. A query averaging 900ms and running twice a day is a curiosity. A query averaging 12ms called four hundred times a second is 5 seconds of database time per second, and it is your actual problem. Sorting by mean surfaces the first and hides the second, which is why so many optimisation efforts start in the wrong place.

Dead tuples and bloat

Postgres's MVCC leaves dead row versions behind on update and delete. Vacuum reclaims them. When vacuum falls behind, tables grow, scans read dead rows, and cache holds data nobody wants.

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
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY dead_pct DESC LIMIT 15;

Above 20% dead on a large table means autovacuum isn't keeping up. Look at `last_autovacuum` — if it's null or very old on a busy table, autovacuum is either being blocked or its thresholds are wrong for that table's write rate. High-churn tables often need per-table settings rather than the global default.

Lock waits and deadlocks

-- Who is blocked, and by whom
SELECT w.pid AS waiting, w.query AS waiting_query,
       b.pid AS blocking, b.query AS blocking_query,
       now() - w.query_start AS waited
FROM pg_stat_activity w
JOIN LATERAL unnest(pg_blocking_pids(w.pid)) AS bp(pid) ON true
JOIN pg_stat_activity b ON b.pid = bp.pid
WHERE w.wait_event_type = 'Lock';

-- Deadlocks are counted per database and should be near zero
SELECT datname, deadlocks, temp_files, temp_bytes FROM pg_stat_database;

Any deadlock at all is worth investigating — it means two code paths acquire the same locks in different orders, and that's a bug that will recur under load. `temp_files` in the same view is a useful side signal: a rising count means queries are spilling sorts to disk, which usually means `work_mem` is too low for the workload.

What to actually alert on

Dashboards are for investigation; alerts are for waking people. Keep the second list short or it gets ignored.

  • Page: transaction ID age above 80% of the limit. Page: disk above 85%, since a full disk stops writes entirely. Page: the database is unreachable.
  • Warn: idle-in-transaction over 60 seconds. Warn: a replication slot inactive for more than a few minutes. Warn: connection count above 80% of the limit.
  • Ticket: cache hit ratio trending down week over week. Ticket: any table above 20% dead tuples. Ticket: a new query appearing in the top five by total time.
  • Do not alert on: CPU spikes without a latency impact, individual slow queries, or absolute connection counts that are within the pool's expected range.

One caveat about suspended databases. If you run instances that pause when idle — increasingly common for per-customer and preview databases — a suspended instance emits no metrics at all. Any alert configured to fire on missing data will page someone about an entirely expected state. Teach your monitoring the difference between 'not reporting' and 'asleep' before you enable auto-suspend, not after the third false page.

The pattern across all of these: the metrics that matter are about work the database is failing to finish — vacuum falling behind, transactions left open, WAL not being consumed. Resource metrics tell you the consequence. These tell you the cause, and they tell you earlier.

Frequently asked questions

What Postgres metric should I monitor above all others?

Transaction ID age. Postgres transaction IDs are 32-bit and wrap around, and vacuum freezes old rows to keep the usable range from being exhausted. If vacuum falls far enough behind, Postgres stops accepting writes entirely to protect your data — not slow, refusing writes until you complete an offline vacuum. It is rare, catastrophic, and always gives weeks of warning as the number climbs. Alert at 50% of the limit and page at 80%. The cause is nearly always a long-running transaction, an inactive replication slot, or an orphaned prepared transaction.

Why is an inactive replication slot dangerous?

Because it holds write-ahead log indefinitely, waiting for a consumer that may never return. That fills the disk and simultaneously blocks vacuum from advancing, which pushes you toward transaction ID wraparound. Two serious failure modes from one forgotten object, and it accumulates silently — nothing looks wrong until the disk is full. Alert on any replication slot showing active = false for more than a few minutes, and treat leftover slots from removed replicas as something to clean up promptly rather than eventually.

Should I sort slow queries by average or total time?

Total time, essentially always. A query averaging 900 milliseconds that runs twice a day is a curiosity; a query averaging 12 milliseconds called four hundred times a second consumes five seconds of database time every second and is your actual bottleneck. Sorting pg_stat_statements by mean_exec_time surfaces the first and completely hides the second, which is why so many optimisation efforts begin in the wrong place. Sort by total_exec_time and fix what is at the top.

How much table bloat is too much?

Above roughly 20% dead tuples on a large table means autovacuum is not keeping up with the write rate. Check last_autovacuum in pg_stat_user_tables alongside the dead tuple count — if it is null or very old on a busy table, autovacuum is either being blocked by a long-running transaction or its thresholds are wrong for that table. High-churn tables frequently need per-table autovacuum settings rather than the global defaults, which are tuned for a typical table rather than your busiest one.

How should monitoring handle databases that suspend when idle?

By distinguishing 'not reporting' from 'asleep'. A suspended database emits no metrics at all, so dashboards show gaps and any alert configured to fire on missing data will page someone about a completely expected state. This is worth configuring before enabling auto-suspend rather than after the third false page, because an alert channel that produces known-false pages quickly becomes one nobody reads — which is much more dangerous than the original gap.

Keep reading

Related posts

  • SLOs and error budgets for a code-execution platform

    The standard SRE examples assume a long-lived request-serving service. A platform that boots machines on demand breaks all of them. Here's the version that survives contact with a sandbox fleet.

  • Rotating database credentials without downtime

    Everyone agrees credentials should be rotated. Almost nobody does it, because the naive version drops every connection in the fleet. Here's the version that doesn't.

  • Firecracker Metrics and Logger FIFOs, Explained

    Firecracker has no /metrics endpoint on purpose — it pushes JSON to a file descriptor your supervisor owns. Which means your collector is now load-bearing infrastructure, and a named pipe nobody is reading can wedge a running microVM on a write(). Here's the design, the footgun, and the collector loop that survives it.

  • How to connect to Postgres with a connection pooler

    Postgres gives every connection its own process. Serverless gives every request its own connection. That arithmetic is why your database is refusing connections at 2pm.

  • Best Firecracker Monitoring & Observability Tools (2026)

    What to actually watch on a Firecracker fleet in 2026 — the built-in JSON metrics stream, host-layer KVM/cgroup/netns signals, snapshot-restore latency, and the per-VM cardinality trap — plus an honest roundup of the tooling (Prometheus, node_exporter/cAdvisor, Grafana, OpenTelemetry, eBPF, ClickHouse/Loki) that turns raw counters into an SLO dashboard you can page on.

More in Ephemeral databases · See Ephemeral Postgres databases on PandaStack

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.