Skip to main content

Monitor your PostgreSQL databases

The platform runs PostgreSQL through CloudNativePG (CNPG). A CNPG Cluster is a set of PostgreSQL instances — one primary and some number of standbys — managed by an operator that handles failover, backups, and WAL archiving for you.

Every instance exports Prometheus metrics, and the platform scrapes them into the same metrics store as everything else. This page gives you the queries behind the platform's own CloudNativePG dashboard, so you can rebuild any part of it in whatever tooling you already use, and set thresholds that suit your service rather than ours.

What you'll learn

  • Which metrics a CNPG instance exposes, and under which labels
  • How to group instances back into the cluster they belong to, which the labels do not do for you
  • PromQL for availability, replication, restarts, error rate, connections, storage, and backups
  • The three values that read as healthy when they are not
  • Threshold suggestions you can start from

What CNPG exposes

Each instance pod runs an exporter on port 9187. Every metric it emits is prefixed cnpg_. The platform's scrape gives each series these labels:

LabelValue
namespacethe namespace your cluster runs in
podthe instance pod, CLUSTER_NAME-1, CLUSTER_NAME-2, and so on
containerpostgres
jobNAMESPACE/postgres

Replace CLUSTER_NAME with the name of your CNPG cluster and NAMESPACE with its namespace. The examples below use mydb and myapp; substitute your own.

The label that does not mean what it says

cluster is the Kubernetes cluster, not your database cluster. The CNPG exporter does publish its own cluster label holding the database cluster name, but the platform's metrics pipeline overwrites it with the name of the Kubernetes cluster the database runs in. Every CNPG series in your metrics store therefore carries the same cluster value.

This matters because most CloudNativePG examples you will find elsewhere — including the upstream Grafana dashboard — group by cluster and expect the database name. Run those unchanged here and they select nothing, or silently fold every database together.

There is no label carrying the database cluster name. Derive it from the pod name, which CNPG always forms as CLUSTER_NAME-INSTANCE_NUMBER:

label_replace(cnpg_collector_up, "cnpg_cluster", "$1", "pod", "(.+)-[0-9]+")

That gives you a cnpg_cluster label to group by:

count by (namespace, cnpg_cluster) (
label_replace(cnpg_collector_up, "cnpg_cluster", "$1", "pod", "(.+)-[0-9]+") == 1
)

If you only ever query one database, you can skip this and select on namespace and pod directly, as the rest of this page does for readability.

Empty is not zero

An empty result and a result of zero mean different things, and confusing them is the most common way a monitoring setup tells you everything is fine while it is not:

  • Zero means the thing was measured and did not happen. No transactions rolled back.
  • Empty means the thing was not measured. The instance might be gone, the scrape might be broken, or the metrics pipeline might have stopped.

Most dashboard tools draw both as an unremarkable panel. Treat an empty result as Unknown and render it as such. Three metrics on this page break the rule in the other direction, reporting a zero that is not a measurement; each is called out where it appears.

Is the database up?

cnpg_collector_up is 1 when the exporter reached PostgreSQL on that instance and 0 when it could not:

cnpg_collector_up{namespace="myapp"}

An instance that is gone produces no series at all rather than a 0, so check for absence explicitly:

absent(cnpg_collector_up{namespace="myapp"})

Counting ready instances tells you whether the cluster still has the replicas it should:

count(cnpg_collector_up{namespace="myapp"} == 1)

Compare that against the instance count you configured. The metrics do not carry the desired count, so a three-instance cluster reduced to two looks identical to a two-instance cluster that is perfectly healthy. If that distinction matters to you, assert the expected number in your alert rather than deriving it.

Scrape freshness catches a stalled pipeline that has not aged out yet:

time() - timestamp(cnpg_collector_up{namespace="myapp"})

Which instance is the primary?

Exactly one instance should be out of recovery. That one is the primary:

cnpg_pg_replication_in_recovery{namespace="myapp"} == 0

Two results mean a split brain; none means every instance is a standby and no instance is accepting writes. Both are urgent, and neither shows up in cnpg_collector_up, which reads 1 throughout.

The number of standbys streaming from an instance is only meaningful on the primary, because nothing streams from a standby:

cnpg_pg_replication_streaming_replicas{namespace="myapp"}

A 0 on a standby is the healthy, expected value. Read this metric on the primary only.

Replication lag, in seconds:

cnpg_pg_replication_lag{namespace="myapp"}

Restarts

An instance that keeps restarting is failing in a way that availability alone will not show, because each restart is followed by a healthy scrape:

sum by (pod) (
increase(kube_pod_container_status_restarts_total{
namespace="myapp", pod=~"mydb-.*", container="postgres"}[15m])
)

Error rate

PostgreSQL has no single error-rate counter. The closest honest proxy is the share of transactions that roll back:

100 * sum(rate(cnpg_pg_stat_database_xact_rollback{namespace="myapp"}[5m]))
/ clamp_min(
sum(rate(cnpg_pg_stat_database_xact_commit{namespace="myapp"}[5m]))
+ sum(rate(cnpg_pg_stat_database_xact_rollback{namespace="myapp"}[5m])),
1)

clamp_min keeps an idle database from dividing by zero and returning NaN. It also means a completely idle database reports 0, not "unknown" — so read this alongside a throughput signal rather than on its own.

Rollbacks are not automatically failures: an application that uses transactions for optimistic concurrency will roll back routinely. Baseline your own normal before setting a threshold on it.

Deadlocks are unambiguous, and rare enough that any sustained rate deserves attention:

sum(rate(cnpg_pg_stat_database_deadlocks{namespace="myapp"}[5m]))

If the exporter itself fails to collect, it says so, and everything above becomes untrustworthy while this is non-zero:

cnpg_collector_last_collection_error{namespace="myapp"}

Connections

Connection exhaustion presents as application errors with a database that looks fine:

100 * sum by (pod) (cnpg_backends_total{namespace="myapp"})
/ sum by (pod) (cnpg_pg_settings_setting{namespace="myapp", name="max_connections"})

The longest-running transaction catches a session holding locks or blocking vacuum:

max by (pod) (cnpg_backends_max_tx_duration_seconds{namespace="myapp"})

Storage and transaction ID age

Volume usage, as a percentage of each instance's persistent volume:

100 * max by (persistentvolumeclaim) (
1 - kubelet_volume_stats_available_bytes{namespace="myapp"}
/ kubelet_volume_stats_capacity_bytes{namespace="myapp"}
)

A database that fills its volume stops accepting writes, and recovering from that is considerably harder than preventing it.

Transaction ID age is the slow-moving one that nobody watches until it is an emergency. PostgreSQL must freeze old transaction IDs before the counter wraps; if age approaches two billion, the database shuts down to protect itself:

max by (pod) (cnpg_pg_database_xid_age{namespace="myapp"})

Normal values sit in the low millions. Sustained growth means autovacuum is not keeping up.

Backups and WAL archiving

WAL archiving is the part of backup that fails quietly. A failing archiver does not affect a running database at all — it only means you cannot restore:

sum(rate(cnpg_pg_stat_archiver_failed_count{namespace="myapp"}[15m]))
max(cnpg_pg_stat_archiver_seconds_since_last_archival{namespace="myapp"})

The backup age query needs a guard, and without it you will page on every cluster. cnpg_collector_last_available_backup_timestamp reports 0 on an instance with no backup — and on every instance that does not hold the backup, even when the cluster has one. Subtracting that from time() yields the seconds since the Unix epoch, which presents as a backup roughly fifty-six years old:

time() - max by (namespace) (
cnpg_collector_last_available_backup_timestamp{namespace="myapp"} > 0
)

The > 0 filter drops the placeholder zeros. As a result this query returns nothing for a cluster that has never been backed up, and that emptiness is the signal — it is a different condition from "the backup is old", and worth alerting on separately.

Is the operator healthy?

The CNPG operator reconciles your clusters: failover, backups, and configuration changes all flow through it. Your databases keep serving while it is down; what stops is change.

up{job="cnpg-system/manager"}
sum by (controller) (
rate(controller_runtime_reconcile_errors_total{namespace="cnpg-system"}[15m])
)

Suggested thresholds

Starting points, not rules. Every one of them depends on what your service does, and the right value is the one you arrive at after watching your own baseline.

SignalHealthyWatchAct
Instance availabilityall instances at 1any instance at 0 for 1–5 minany instance at 0 for over 5 min, or the series absent
Primary countexactly 10 or more than 1, immediately
Restarts0–1 in 15 min2–3 in 15 min4 or more in 15 min, or a crash loop
Rollback sharebelow your baseline1–5%above 5% for 5 min
Deadlocks0any sustained raterising
Replication lagunder 10s10–60sover 60s
Connections usedunder 70%70–85%over 85%
Volume usedunder 75%75–85%over 85%
Transaction ID ageunder 200M200M–500Mover 500M
WAL archive failures0anysustained
Backup ageunder 24h24–48hover 48h, or no backup at all

Two notes on building alerts from these. First, alert on absence as well as value: a rule that only fires on a bad number will stay silent when the metric disappears, which is the worse condition. Second, the platform does not ship CNPG alert rules, so nothing here fires unless you configure it in your own tooling.

Metric reference

The metrics used on this page. An instance exposes considerably more; these are the ones worth starting from.

MetricTypeNotes
cnpg_collector_upGauge1 when the exporter reached PostgreSQL. Absent, not 0, when the instance is gone.
cnpg_collector_last_collection_errorGaugeNon-zero means every other cnpg_ metric on that instance is suspect.
cnpg_pg_replication_in_recoveryGauge0 on the primary, 1 on a standby.
cnpg_pg_replication_streaming_replicasGaugeStandbys streaming from this instance. Meaningful on the primary only.
cnpg_pg_replication_lagGaugeReplication delay in seconds.
cnpg_pg_stat_database_xact_commitCounterCommitted transactions, by database.
cnpg_pg_stat_database_xact_rollbackCounterRolled-back transactions, by database.
cnpg_pg_stat_database_deadlocksCounterDeadlocks detected, by database.
cnpg_pg_database_xid_ageGaugeTransaction ID age. Watch for sustained growth.
cnpg_pg_database_size_bytesGaugeDatabase size, by database.
cnpg_backends_totalGaugeCurrent backends, by state and user.
cnpg_backends_max_tx_duration_secondsGaugeLongest running transaction.
cnpg_pg_settings_settingGaugePostgreSQL settings as numbers, selected by name.
cnpg_pg_stat_archiver_failed_countCounterFailed WAL archive attempts.
cnpg_pg_stat_archiver_seconds_since_last_archivalGaugeTime since the last WAL segment was archived.
cnpg_collector_last_available_backup_timestampGaugeUnix seconds of the newest backup. 0 where there is none — always filter > 0.
cnpg_collector_postgres_versionGaugePostgreSQL version as a number, such as 16.15.
  • Monitoring — the other platform components you can watch from your own tooling.
  • Observability — instrumenting your own applications with logs, metrics, traces, and health checks.
  • CloudNativePG documentation — the full exporter metric list and how to add custom queries.