Database Monitoring & Observability

Detect issues before users do: metrics, logs, and alerts.

You Can't Fix What You Can't Measure

Production databases fail in unpredictable ways: queries slow down, connections max out, disk fills up. Monitoring turns invisible problems into visible numbers, so you fix them on your schedule instead of during an outage. This lesson covers the metrics worth collecting, the tools that collect them, and the alerts that turn them into action. Every command and every line of output below was run against the containers in the setup block, on PostgreSQL 16.14, postgres_exporter 0.20.1, Prometheus 3.13.1 and Grafana 13.1.1.

Real-World Impact:
  • GitHub (October 2018): a 43-second network partition was enough for automated failover to promote West Coast databases to primary, degrading service for 24 hours and 11 minutes. No user data was lost, but a few seconds of writes needed manual reconciliation.
  • GitLab (January 2017): an engineer removed the data directory of the primary instead of the replica. Around six hours of writes were lost, roughly 5,000 projects, 5,000 comments and 700 users. The backups that should have covered it had been failing silently: pg_dump was running against the wrong PostgreSQL version, and the cron job's failure emails were being rejected, so nobody was ever told.
  • AWS RDS: ships dozens of CloudWatch metrics plus Performance Insights and Enhanced Monitoring. Amazon charges for the database and gives the monitoring away, which tells you which one prevents support tickets.
Try it locally: PostgreSQL with pg_stat_statements

Start here. This one container backs every example in the lesson, and the Prometheus and Grafana containers added later attach to the same network. Nothing here touches a database you already run.

# pg_stat_statements and auto_explain are shared_preload_libraries: they can
# only be loaded when the server starts, so they are passed on the command
# line here rather than enabled later. logging_collector is also restart-only,
# so it goes in the same place. Everything else in this lesson is changed on a
# running server without a restart.
docker network create monnet

docker run -d --name pgmon-demo --network monnet \
  -e POSTGRES_PASSWORD=demo -e POSTGRES_USER=demo -e POSTGRES_DB=demo \
  -p 5432:5432 postgres:16-alpine \
  -c shared_preload_libraries=pg_stat_statements,auto_explain \
  -c pg_stat_statements.track=all \
  -c logging_collector=on

# Wait for the server rather than guessing with sleep.
until docker exec pgmon-demo pg_isready -U demo -q; do sleep 1; done

# Preloading the library only makes the code available. The view itself is
# created per database, like any other extension.
docker exec pgmon-demo psql -U demo -d demo -c "CREATE EXTENSION pg_stat_statements;"
docker exec pgmon-demo psql -U demo -d demo -c "SELECT version();" -t

# A virtualenv for the Python examples further down.
python3 -m venv .venv && . .venv/bin/activate
pip install "psycopg[binary]>=3.2"
Expected Output:
CREATE EXTENSION
 PostgreSQL 16.14 on x86_64-pc-linux-musl, compiled by gcc (Alpine 15.2.0) 15.2.0, 64-bit

Next, a schema and 220,000 rows. Monitoring an empty database teaches nothing, so the examples need real tables with a real performance flaw in them.

# Two tables and a workload worth measuring. Note there is deliberately NO
# index on orders.user_id: the sequential scan that causes is the slow query
# the rest of the lesson hunts down.
docker exec -i pgmon-demo psql -U demo -d demo -q \
  --pset linestyle=unicode --pset border=2 <<'SQL'
CREATE TABLE users (
    id         serial PRIMARY KEY,
    username   text NOT NULL,
    email      text NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE orders (
    id         serial PRIMARY KEY,
    user_id    integer NOT NULL REFERENCES users(id),
    total      numeric(10,2) NOT NULL,
    status     text NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now()
);

INSERT INTO users (username, email)
SELECT 'user' || g, 'user' || g || '@example.com'
FROM generate_series(1, 20000) AS g;

INSERT INTO orders (user_id, total, status)
SELECT (random() * 19999)::int + 1,
       (random() * 500)::numeric(10,2),
       (ARRAY['pending','paid','shipped'])[(random() * 2)::int + 1]
FROM generate_series(1, 200000);

ANALYZE users;
ANALYZE orders;

SELECT count(*) AS users FROM users;
SELECT count(*) AS orders FROM orders;
SQL
Expected Output:
┌───────┐
│ users │
├───────┤
│ 20000 │
└───────┘
(1 row)

┌────────┐
│ orders │
├────────┤
│ 200000 │
└────────┘
(1 row)

Finally, traffic. Run this whenever you want fresh statistics: it resets pg_stat_statements first, so the numbers always describe one known run.

"""workload.py - traffic worth measuring, so the statistics views are not empty."""
import random

import psycopg

conn = psycopg.connect("host=localhost port=5432 dbname=demo user=demo password=demo")

# Start from a clean slate so the numbers below describe this run only.
conn.execute("SELECT pg_stat_statements_reset()")
conn.commit()

random.seed(42)

with conn.cursor() as cur:
    # Fast: primary-key lookups.
    for _ in range(2000):
        cur.execute("SELECT id, username, email FROM users WHERE id = %s",
                    (random.randint(1, 20000),))
        cur.fetchone()

    # Slow: orders.user_id has no index, so each of these scans the table.
    for _ in range(200):
        cur.execute("SELECT id, total, status FROM orders WHERE user_id = %s",
                    (random.randint(1, 20000),))
        cur.fetchall()

    # Moderate: an aggregate over all 200,000 rows.
    for _ in range(50):
        cur.execute("SELECT count(*) FROM orders WHERE status = %s", ("paid",))
        cur.fetchone()

    # Writes.
    for _ in range(100):
        cur.execute("UPDATE orders SET status = %s WHERE id = %s",
                    ("shipped", random.randint(1, 200000)))

conn.commit()
conn.close()
print("workload done")
Expected Output:
workload done

Essential Database Metrics

Google's four golden signals, latency, traffic, errors and saturation, map cleanly onto a database: how long queries take, how many arrive, how many fail, and how close the server is to running out of something. The tables below group the specific counters worth collecting under those headings.

Read the thresholds as starting points, not laws. "Under 10ms average" is reasonable for an OLTP service answering primary-key lookups and absurd for an analytics warehouse where every query scans millions of rows. Collect the metric for a week first, see what normal looks like on your system, then set the threshold above your normal. An alert calibrated to somebody else's workload is just noise.
1. Query Performance Metrics
MetricDescriptionTypical healthy range (OLTP)Alert Threshold
Average Query TimeMean execution time across all queries<10ms>50ms (degraded), >100ms (critical)
P95 Query Time95th percentile (slow query threshold)<50ms>200ms (degraded), >500ms (critical)
P99 Query Time99th percentile (worst case latency)<100ms>500ms (degraded), >1000ms (critical)
Queries Per SecondTotal query throughputApplication-specificSudden drop >30% or spike >200%
Slow Query CountQueries exceeding slow query threshold<1% of total>5% (degraded), >10% (critical)
2. Connection Metrics
MetricDescriptionTypical healthy rangeAlert Threshold
Active ConnectionsCurrently executing queries<50% of max>70% (degraded), >90% (critical)
Idle ConnectionsConnected but not executing<20% of max>50% (connection leak suspected)
Connection Wait TimeTime waiting for available connection0ms (no wait)>10ms (pool exhausted)
Failed ConnectionsConnection attempts that failed0 per minute>5/min (degraded), >20/min (critical)
3. Cache & Buffer Metrics
MetricDescriptionTypical healthy rangeAlert Threshold
Cache Hit Ratio% of block reads served from shared buffers>99%<95% (degraded), <90% (critical)
Buffer Pool Hit Rate% of pages found in shared buffers>99%<95% (consider raising shared_buffers)
Index Hit Rate% of index block reads satisfied by cache>99%<95% (missing indexes or cold cache)
A high cache hit ratio is not automatically good news. PostgreSQL counts a block as a hit when it was already in shared_buffers, and a query that scans the same cached table over and over reports close to 100% while being exactly the query you should have indexed. Read it alongside query latency, never on its own. Lesson 27 covers what these buffers actually hold.
4. Lock & Contention Metrics
MetricDescriptionTypical healthy rangeAlert Threshold
DeadlocksTransactions aborted due to deadlock0 per hour>1/hour (degraded), >10/hour (critical)
Lock Wait TimeTime transactions spend waiting for locks<10ms>100ms (contention), >1000ms (critical)
Long-Running QueriesQueries running >60 seconds0>5 queries (likely holding locks)
5. Resource Utilization
MetricDescriptionTypical healthy rangeAlert Threshold
CPU UtilizationProcessor usage by database<70%>80% (degraded), >95% (critical)
Memory UsageRAM consumed by database<80%>90% (degraded), >95% (critical)
Disk I/O Wait% time CPU waiting for disk<10%>30% (slow disk), >50% (critical)
Disk SpaceAvailable storage>30% free<20% (warning), <10% (critical)
Replication LagSeconds a replica is behind the primary<1 second>5s (degraded), >30s (critical)

Those numbers have to travel from the database to a human. The rest of the lesson builds this pipeline one box at a time: PostgreSQL keeps the counters, an exporter translates them, Prometheus stores and evaluates them, and Grafana or Alertmanager delivers the result.

Where a Metric Comes From

PostgreSQLpg_stat_* viewspostgres_exporter:9187/metricsPrometheusstore + rulesGrafanadashboardsAlertmanagerpagingSQLscrape 15sPromQLalerts fire

Figure 1: PostgreSQL counts, the exporter translates, Prometheus remembers and decides, Grafana and Alertmanager tell a human

Query Performance Monitoring

Two views answer two different questions. pg_stat_statements is the history book: which queries have cost the most time since the counters were last reset. pg_stat_activity is the live feed: what is running this instant. You need the first to decide what to optimise and the second to survive an incident.

Enabling pg_stat_statements Outside a Container

The setup block already did this with -c flags. On a server you manage yourself the same settings live in postgresql.conf, and the restart is unavoidable because shared_preload_libraries is read once at startup.

# On a server you administer directly rather than in a container, the same
# two settings go in postgresql.conf:

#   shared_preload_libraries = 'pg_stat_statements, auto_explain'
#   pg_stat_statements.track = all

sudo systemctl restart postgresql          # shared_preload_libraries needs a restart
psql -d myapp -c "CREATE EXTENSION pg_stat_statements;"   # once per database

# Confirm the library is actually loaded before trusting anything downstream:
psql -d myapp -c "SHOW shared_preload_libraries;"
Cost: tracking a query is a lookup and update in a fixed-size shared memory hash table, not a disk write, which is why it is safe to leave on in production. The one setting that carries a documented penalty is pg_stat_statements.track_planning: PostgreSQL's own documentation warns it "may incur a noticeable performance penalty" under high concurrency. It defaults to off and this lesson leaves it off.
Finding the Queries That Actually Cost You

The instinct is to sort by the slowest single query. That is usually the wrong list. Sort by total_exec_time instead, which is what the database really spent on each query pattern.

# pg_stat_statements aggregates every execution of every query, with literals
# replaced by $1, $2, ... so that a million lookups collapse into one row.
# Rank by total_exec_time, not mean: a 2ms query called a million times costs
# far more than a 500ms report run twice a day.
docker exec -i pgmon-demo psql -U demo -d demo -q \
  --pset linestyle=unicode --pset border=2 <<'SQL'
SELECT
    left(query, 44)                    AS query,
    calls,
    round(total_exec_time::numeric, 1) AS total_ms,
    round(mean_exec_time::numeric, 3)  AS mean_ms,
    round(max_exec_time::numeric, 3)   AS max_ms,
    rows
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_stat_statements%'   -- hide the monitoring query itself
ORDER BY total_exec_time DESC
LIMIT 6;
SQL
Expected Output:
┌──────────────────────────────────────────────┬───────┬──────────┬─────────┬────────┬──────┐
│                    query                     │ calls │ total_ms │ mean_ms │ max_ms │ rows │
├──────────────────────────────────────────────┼───────┼──────────┼─────────┼────────┼──────┤
│ SELECT id, total, status FROM orders WHERE u │   200 │   1123.8 │   5.619 │  7.057 │ 1996 │
│ SELECT count(*) FROM orders WHERE status = $ │    50 │    463.5 │   9.270 │ 11.548 │   50 │
│ SELECT id, username, email FROM users WHERE  │  2000 │     15.0 │   0.007 │  0.031 │ 2000 │
│ UPDATE orders SET status = $1 WHERE id = $2  │    84 │      1.2 │   0.015 │  0.080 │   84 │
│ UPDATE orders SET status = $1 WHERE id = $2  │    16 │      0.2 │   0.015 │  0.023 │   16 │
│ COMMIT                                       │     2 │      0.0 │   0.001 │  0.001 │    0 │
└──────────────────────────────────────────────┴───────┴──────────┴─────────┴────────┴──────┘
(6 rows)

The ranking makes the argument better than any explanation. The PK lookup on users ran 2,000 times, ten times more often than anything else, and cost 15.0ms in total: it is not a problem and never will be. The orders lookup ran 200 times at 5.6ms each and burned 1,123.8ms, roughly seventy-five times more, purely because orders.user_id has no index and every call scans 200,000 rows. Sorting by mean_exec_time would have put the 9.3ms aggregate on top and pointed you at the wrong query. Absolute timings vary with hardware, so treat the ordering as the reproducible part, not the milliseconds.

One Query, Two Rows: Reading queryid Honestly

The workload ran exactly 100 UPDATE statements, but the table above shows 84 and 16 on two separate rows. This is not a bug in the view, and it matters: any report that takes the top row at face value understates that query's real cost.

# Look again at those two UPDATE rows. Same text, different queryid, and the
# calls split 84/16 instead of showing 100.
docker exec -i pgmon-demo psql -U demo -d demo -q \
  --pset linestyle=unicode --pset border=2 <<'SQL'
SELECT queryid, calls, query
FROM pg_stat_statements
WHERE query LIKE 'UPDATE orders SET status%'
ORDER BY calls DESC;
SQL
Expected Output:
┌─────────────────────┬───────┬─────────────────────────────────────────────┐
│       queryid       │ calls │                    query                    │
├─────────────────────┼───────┼─────────────────────────────────────────────┤
│ 7281876753114279488 │    84 │ UPDATE orders SET status = $1 WHERE id = $2 │
│ -401862770225660403 │    16 │ UPDATE orders SET status = $1 WHERE id = $2 │
└─────────────────────┴───────┴─────────────────────────────────────────────┘
(2 rows)

Identical text, two queryid values. pg_stat_statements hashes the parsed query tree, not the string, and the types of the bound parameters are part of that tree. psycopg picks the narrowest integer type each value fits into:

"""param_types.py - why one SQL text can occupy two pg_stat_statements rows."""
import psycopg

conn = psycopg.connect("host=localhost port=5432 dbname=demo user=demo password=demo")

# psycopg sends the smallest integer type that fits the Python value, and the
# parameter type is part of the parse tree that pg_stat_statements hashes into
# queryid. The workload drew ids from 1..200000, so roughly 32767/200000 = 16%
# of them fit in a smallint. That is the 16 calls.
for value in (1, 32767, 32768, 200000):
    pg_type = conn.execute("SELECT pg_typeof(%s)", (value,)).fetchone()[0]
    print(f"python int {value:>6}  ->  parameter type {pg_type}")

conn.close()
Expected Output:
python int      1  ->  parameter type smallint
python int  32767  ->  parameter type smallint
python int  32768  ->  parameter type integer
python int 200000  ->  parameter type integer

The workload drew ids from 1 to 200,000, and 32,767 of those fit in a smallint: 16.4% of the range, which is the 16 calls. So one logical query can occupy several rows, and its true total is the sum of them. When you build a "top queries" report, group by the normalised text as well as the id, or accept that you are reading a lower bound. The queryid values themselves are specific to one server, since they encode table OIDs, so never hard-code them.

What Is Running Right Now

During an incident nobody cares about the last week's averages. They care which backend is stuck and what it is waiting on. pg_stat_activity has one row per backend, and four filters turn it from noise into a usable signal: only active backends, only client backends, never the monitoring query itself, and only those past the threshold.

"""monitor_activity.py - what is stuck right now.

pg_stat_statements is history. pg_stat_activity is a live view of backends,
which is the view you want during an incident.
"""
import time

import psycopg

# The threshold is a bound parameter, not string-formatted into the SQL.
LONG_RUNNING = """
    SELECT pid,
           now() - query_start AS running_for,
           state,
           wait_event_type,
           left(query, 60) AS query
    FROM pg_stat_activity
    WHERE state = 'active'                 -- executing, not idle
      AND backend_type = 'client backend'  -- skip autovacuum, walwriter, etc
      AND pid <> pg_backend_pid()          -- skip this monitoring query
      AND query_start IS NOT NULL
      AND now() - query_start > make_interval(secs => %s)
    ORDER BY running_for DESC
"""


def check_once(conn, threshold_seconds):
    """Print any backend running longer than threshold_seconds."""
    rows = conn.execute(LONG_RUNNING, (threshold_seconds,)).fetchall()
    if not rows:
        print(f"[ok] no query running longer than {threshold_seconds}s")
        return 0
    print(f"[warn] {len(rows)} query(s) over {threshold_seconds}s:")
    for pid, running_for, state, wait_event_type, query in rows:
        secs = running_for.total_seconds()
        waiting = wait_event_type or "-"
        print(f"   pid {pid}  {secs:5.1f}s  {state}  wait={waiting}  {query}")
    return len(rows)


def monitor(threshold_seconds=1.0, iterations=4, interval=2):
    # One connection for the whole run, closed on the way out. Opening a fresh
    # connection per check leaks a backend every cycle, which is how a
    # monitoring script ends up causing the outage it was meant to catch.
    with psycopg.connect(
        "host=localhost port=5432 dbname=demo user=demo password=demo"
    ) as conn:
        conn.autocommit = True  # never hold a transaction open while polling
        for _ in range(iterations):
            check_once(conn, threshold_seconds)
            time.sleep(interval)


if __name__ == "__main__":
    monitor()
Expected Output:
# In another terminal, start something slow:
#   docker exec -d pgmon-demo psql -U demo -d demo \
#     -c "SELECT pg_sleep(5), count(*) FROM orders;"

[warn] 1 query(s) over 1.0s:
   pid 55    1.1s  active  wait=Timeout  SELECT pg_sleep(5), count(*) FROM orders;
[warn] 1 query(s) over 1.0s:
   pid 55    3.1s  active  wait=Timeout  SELECT pg_sleep(5), count(*) FROM orders;
[ok] no query running longer than 1.0s
[ok] no query running longer than 1.0s

The wait_event_type column is the part people miss: it is the difference between "this query is slow" and "this query is blocked". A value of Lock means another transaction is holding what it needs and you should go looking for that transaction, while Timeout above is just pg_sleep doing its job. Also note the script keeps one connection open for its whole life. A monitoring loop that reconnects every cycle and never closes is the classic way to exhaust max_connections with the tool that was supposed to warn you about it.

Slow Query Logs & Analysis

pg_stat_statements tells you a query pattern is slow on average. The slow query log tells you about the individual execution that was slow at 03:14 last Tuesday, with its parameters and, if you ask for it, the plan the server actually chose. That is what you need to explain a regression after a deployment.

Turning It On Without a Restart

ALTER SYSTEM writes to postgresql.auto.conf and pg_reload_conf() makes the running server re-read it. Everything here takes effect immediately, which also means you can raise the threshold mid-incident when the log is drowning you.

# None of these need a restart: ALTER SYSTEM writes postgresql.auto.conf and
# pg_reload_conf() makes the server re-read it. (logging_collector DOES need a
# restart, which is why it was set at startup in the setup block above.)
docker exec -i pgmon-demo psql -U demo -d demo -q \
  --pset linestyle=unicode --pset border=2 <<'SQL'
ALTER SYSTEM SET log_destination = 'csvlog';
ALTER SYSTEM SET log_directory = 'log';
ALTER SYSTEM SET log_min_duration_statement = '100ms';   -- log slow statements
ALTER SYSTEM SET auto_explain.log_min_duration = '100ms'; -- and their plans
ALTER SYSTEM SET auto_explain.log_analyze = on;           -- with real row counts
SELECT pg_reload_conf();
SQL

# Check from a SEPARATE session. pg_reload_conf() signals the server and
# returns immediately; the session that called it is still running with the
# old values, so verifying inside the block above reports the defaults and
# makes it look like nothing happened.
docker exec pgmon-demo psql -U demo -d demo -q \
  --pset linestyle=unicode --pset border=2 -c "
SELECT name, setting, unit FROM pg_settings
WHERE name IN ('log_destination', 'log_min_duration_statement',
               'auto_explain.log_min_duration', 'auto_explain.log_analyze',
               'logging_collector')
ORDER BY name;"
Expected Output:
┌────────────────┐
│ pg_reload_conf │
├────────────────┤
│ t              │
└────────────────┘
(1 row)

┌───────────────────────────────┬─────────┬──────┐
│             name              │ setting │ unit │
├───────────────────────────────┼─────────┼──────┤
│ auto_explain.log_analyze      │ on      │      │
│ auto_explain.log_min_duration │ 100     │ ms   │
│ log_destination               │ csvlog  │      │
│ log_min_duration_statement    │ 100     │ ms   │
│ logging_collector             │ on      │      │
└───────────────────────────────┴─────────┴──────┘
(5 rows)

Reading back pg_settings is not ceremony. ALTER SYSTEM succeeds even when a startup-only setting cannot take effect yet, so the confirmation that unit is ms and the values landed is what tells you the log is really armed.

What a Slow Query Actually Looks Like in the Log

Now produce one. The query below joins orders to itself on user_id, a column with duplicates, so instead of filtering rows it multiplies them: a fan-out join, and one of the most common ways a harmless-looking report turns into a table-melting query.

# An accidental fan-out join: orders joined to itself on a non-unique key,
# which multiplies rows instead of filtering them. This is a realistic
# production mistake and it comfortably exceeds the 100ms threshold.
docker exec -i pgmon-demo psql -U demo -d demo -q <<'SQL'
\o /dev/null
SELECT a.status, count(*) FROM orders a JOIN orders b ON a.user_id = b.user_id
GROUP BY a.status;
SELECT count(*) FROM orders a JOIN orders b ON a.user_id = b.user_id
AND a.id < b.id;
SQL

# Show the newest log file and one complete record from it.
LOGF=$(docker exec pgmon-demo sh -c 'ls -t /var/lib/postgresql/data/log/*.csv | head -1')
docker exec pgmon-demo sh -c "head -c 900 $LOGF"
Expected Output:
2026-08-02 19:37:24.502 UTC,"demo","demo",236,"[local]",6a6f9c74.ec,1,"SELECT",
2026-08-02 19:37:24 UTC,3/99,0,LOG,00000,"duration: 205.602 ms  plan:
Query Text: SELECT a.status, count(*) FROM orders a JOIN orders b ON a.user_id = b.user_id
GROUP BY a.status;
Finalize GroupAggregate  (cost=26190.40..26190.79 rows=3 width=14) (actual time=203.481..205.589 rows=3 loops=1)
  Group Key: a.status
  ->  Gather Merge  (cost=26190.40..26190.75 rows=3 width=14) (actual time=203.477..205.583 rows=6 loops=1)
        Workers Planned: 1
        Workers Launched: 1
        ->  Sort  (cost=25190.39..25190.40 rows=3 width=14) (actual time=200.720..200.723 rows=3 loops=2)
              Sort Key: a.status
              Sort Method: quicksort  Memory: 25kB
              Worker 0:  Sort Method: quicksort  Memory: 25kB
              ->  Partial HashAggregate  (cost=25190.34..25190.37 rows=3 width=14) (actual

Two things in that record shape everything downstream. First, auto_explain embedded the whole plan, complete with newlines, inside a single quoted CSV field: this one record spans dozens of physical lines, so any analysis that reads the file line by line will produce garbage. Second, the plan is the diagnosis. Workers Planned: 1 and the Partial HashAggregateshow PostgreSQL parallelising a self-join it should never have been asked to run.

The Double-Logging Trap

There is a catch in the configuration above that quietly corrupts every count you take from this log. log_min_duration_statement and auto_explain.log_min_duration are both set to 100ms, and they are independent features: each writes its own record for the same execution.

"""double_log.py - count the records the two settings actually produced."""
import csv

CSVLOG = "postgresql.csv"   # docker exec pgmon-demo cat <logfile> > postgresql.csv

for row in csv.reader(open(CSVLOG, newline="")):
    message = row[13]
    if not message.startswith("duration:"):
        continue
    first_line = message.split("\n")[0]
    source = "auto_explain" if "plan:" in first_line else "statement log"
    print(f"pid={row[3]:>4} line={row[6]:>2}  {source:<14} {first_line[:46]}")
Expected Output:
pid= 236 line= 1  auto_explain   duration: 205.602 ms  plan:
pid= 236 line= 2  statement log  duration: 207.611 ms  statement: SELECT a.stat
pid= 236 line= 3  auto_explain   duration: 115.823 ms  plan:
pid= 236 line= 4  statement log  duration: 116.290 ms  statement: SELECT count(
pid= 260 line= 1  auto_explain   duration: 197.129 ms  plan:
pid= 260 line= 2  statement log  duration: 199.079 ms  statement: SELECT a.stat
pid= 260 line= 3  auto_explain   duration: 108.016 ms  plan:
pid= 260 line= 4  statement log  duration: 108.392 ms  statement: SELECT count(
pid= 283 line= 1  auto_explain   duration: 197.490 ms  plan:
pid= 283 line= 2  statement log  duration: 199.464 ms  statement: SELECT a.stat
pid= 283 line= 3  auto_explain   duration: 114.998 ms  plan:
pid= 283 line= 4  statement log  duration: 115.374 ms  statement: SELECT count(

Three runs of two queries produced twelve records, not six. auto_explain logs first with the plan, then the statement log records the same execution with a slightly larger duration, because it stops the clock marginally later. Aggregate naively and every call count doubles and every total is inflated. The fix is to pick one record type per execution, which is what the analyzer below does by keeping only the statement: records.

Aggregating the Log

A csvlog has no header row, so csv.DictReader must be handed the field names explicitly or it will silently consume your first log record as the header. PostgreSQL 14 and later write 26 columns, and the last one, query_id, is the same identifier pg_stat_statements uses: grouping on it beats regex-normalising literals by hand.

"""analyze_log.py - aggregate a PostgreSQL csvlog by query, ranked by total time.

Two things make this harder than it looks:
  1. A csvlog file has NO header row, so csv.DictReader must be given
     fieldnames explicitly or it silently eats the first log record.
  2. auto_explain writes the plan into the message field, newlines and all,
     so one record spans many physical lines. Only a real CSV parser gets
     the record boundaries right: readline() and split(",") do not.
"""
import csv
import re
import sys
from collections import defaultdict

# csvlog columns for PostgreSQL 14+ (26 fields), confirmed against the running
# server rather than assumed. query_id is the last one.
CSVLOG_FIELDS = [
    "log_time", "user_name", "database_name", "process_id", "connection_from",
    "session_id", "session_line_num", "command_tag", "session_start_time",
    "virtual_transaction_id", "transaction_id", "error_severity",
    "sql_state_code", "message", "detail", "hint", "internal_query",
    "internal_query_pos", "context", "query", "query_pos", "location",
    "application_name", "backend_type", "leader_pid", "query_id",
]

DURATION_RE = re.compile(r"duration: ([\d.]+) ms")
# log_min_duration_statement writes "duration: N ms  statement: <sql>", or
# "execute <name>: <sql>" for extended-protocol queries.
STATEMENT_RE = re.compile(r"(?:statement: |execute [^:]*: )(.+)", re.S)


def analyze(log_file):
    stats = defaultdict(lambda: {"count": 0, "total": 0.0, "max": 0.0, "sample": ""})

    with open(log_file, newline="", encoding="utf-8") as f:
        for row in csv.DictReader(f, fieldnames=CSVLOG_FIELDS):
            message = row["message"] or ""
            match = DURATION_RE.search(message)
            if not match:
                continue

            # Count each execution ONCE. With log_min_duration_statement and
            # auto_explain.log_min_duration both armed, a slow query produces
            # two records. Keeping only the statement records avoids counting
            # every call twice.
            statement = STATEMENT_RE.search(message)
            if not statement:
                continue

            duration = float(match.group(1))
            sample = " ".join(statement.group(1).split())

            # Group by query_id when the server computed one: it is the same
            # identifier pg_stat_statements uses, so no fragile regex
            # normalisation of literals is needed.
            key = row["query_id"] or sample

            entry = stats[key]
            entry["count"] += 1
            entry["total"] += duration
            entry["max"] = max(entry["max"], duration)
            entry["sample"] = entry["sample"] or sample

    ranked = sorted(stats.values(), key=lambda e: e["total"], reverse=True)

    print(f"{'calls':>5} {'total_ms':>9} {'avg_ms':>8} {'max_ms':>8}  query")
    for entry in ranked[:10]:
        avg = entry["total"] / entry["count"]
        print(f"{entry['count']:>5} {entry['total']:>9.1f} {avg:>8.1f} "
              f"{entry['max']:>8.1f}  {entry['sample'][:44]}")


if __name__ == "__main__":
    analyze(sys.argv[1])
Expected Output:
$ python analyze_log.py postgresql.csv
calls  total_ms   avg_ms   max_ms  query
    3     606.2    202.1    207.6  SELECT a.status, count(*) FROM orders a JOIN
    3     340.1    113.4    116.3  SELECT count(*) FROM orders a JOIN orders b

Three calls per query, matching the three runs exactly, which is the check that the deduplication worked. In production you would point this at a log shipper rather than a local file, but the parsing rules do not change: real CSV parsing, explicit field names, one record per execution.

Database Monitoring Tools

Everything so far required you to be logged in and asking. Tools close that gap: they collect continuously, keep history so you can compare today with last Tuesday, and raise alarms while you sleep.

pgAdmin

Web-based GUI for PostgreSQL administration and monitoring.

Features:
  • Dashboard with server metrics
  • Active query monitoring
  • Lock viewer (see blocking queries)
  • Visual EXPLAIN plans
  • Database object statistics
Best for: Development, ad-hoc investigation, single server
DataGrip

JetBrains IDE for database development with built-in monitoring.

Features:
  • Query console with execution plans
  • Live table data viewer
  • Schema comparison
  • SQL autocomplete and refactoring
  • Local query history
Best for: Development, query optimization, schema design
Prometheus + Grafana

Time-series metrics collection and visualization for production monitoring.

Features:
  • Time-series metrics storage
  • Dashboards and ad-hoc PromQL (Grafana)
  • Alerting rules and notifications
  • Historical trend analysis
  • Multi-database fleet monitoring
Best for: Production, fleet management, alerting (recommended)
Managed Service Tools

Cloud providers offer built-in monitoring for managed databases.

Examples:
  • AWS RDS Performance Insights
  • Google Cloud SQL Monitoring
  • Azure Database Insights
  • Heroku Postgres Metrics
Best for: Managed databases, easy setup, integrated alerts
Standing Up Prometheus and Grafana

Three containers on the network created earlier. The single idea that makes or breaks this setup: inside a container, localhost is that container. Every address below is a container name, which only resolves because they share a user-defined network.

# All four containers must share one user-defined network. Docker's default
# bridge network does NOT resolve container names, so every "connect by name"
# step below would fail with "no such host" without this.
docker network create monnet 2>/dev/null || true   # already made in setup

# 1. postgres_exporter turns PostgreSQL statistics into Prometheus metrics.
#    It reaches the database by container name, never localhost: inside a
#    container, localhost is that container.
docker run -d --name pgexporter --network monnet -p 9187:9187 \
  -e DATA_SOURCE_NAME="postgresql://demo:demo@pgmon-demo:5432/demo?sslmode=disable" \
  quay.io/prometheuscommunity/postgres-exporter

# 2. Prometheus scrapes the exporter. Same rule: target the container name.
#    rule_files is how alert rules are loaded (there is no --rules.file flag).
cat > prometheus.yml <<'YAML'
global:
  scrape_interval: 15s

rule_files:
  - /etc/prometheus/alert_rules.yml

scrape_configs:
  - job_name: 'postgresql'
    static_configs:
      - targets: ['pgexporter:9187']
YAML

# rule_files names a file that must EXIST, even if it has no rules yet. Skip
# this and Docker bind-mounts a directory over the missing path, and
# Prometheus exits at once with "alert_rules.yml: is a directory". The
# alerting section later replaces this placeholder with real rules.
cat > alert_rules.yml <<'YAML'
groups: []
YAML

docker run -d --name prom-demo --network monnet -p 9090:9090 \
  -v $PWD/prometheus.yml:/etc/prometheus/prometheus.yml \
  -v $PWD/alert_rules.yml:/etc/prometheus/alert_rules.yml \
  prom/prometheus

# 3. Grafana. Port 3001 on the host because a dev server usually owns 3000.
docker run -d --name grafana-demo --network monnet -p 3001:3000 grafana/grafana

# 4. Confirm the chain works before opening any UI. A just-started Prometheus
#    has no targets until its first scrape, so wait for one instead of racing
#    it: querying immediately returns an empty list, or no JSON at all if the
#    server is not listening yet.
until curl -s localhost:9090/api/v1/targets | grep -q '"health":"up"'; do sleep 2; done

curl -s localhost:9090/api/v1/targets \
  | python3 -c "import json,sys; [print(' ', t['scrapeUrl'], '->', t['health']) \
      for t in json.load(sys.stdin)['data']['activeTargets']]"

# Grafana on http://localhost:3001 (admin/admin). Add Prometheus as a data
# source with URL http://prom-demo:9090, then import dashboard ID 9628
# ("PostgreSQL Database").
Expected Output:
  http://pgexporter:9187/metrics -> up

The targets check at the end is the whole point of running it that way: a target reporting up proves the exporter is reachable, authenticated against PostgreSQL and returning metrics. If it says down, the error field names the reason, and you have learned it in five seconds instead of staring at an empty Grafana panel wondering which of four components is broken.

Logging Into Grafana

Grafana is the one piece here you use through a browser rather than a terminal. Open http://localhost:3001 and sign in with admin / admin. Grafana immediately asks you to set a new password, and that prompt is the single most common way people lock themselves out of a throwaway container: choose one, forget it, and the documented default no longer works.

# Grafana runs in a container but you log into it through the browser, at
# http://localhost:3001 (port 3001 on the host maps to 3000 in the container).
# The first login is admin / admin, and Grafana then asks you to choose a new
# password. That prompt is where this usually goes wrong: pick a password,
# forget it, and admin/admin no longer works.

# Locked out? Reset it from inside the container. Two things to know: the
# command is "grafana cli", two words, because the old "grafana-cli" binary
# is gone from the current image and gives "executable file not found in
# $PATH"; and it prints Grafana's entire startup log to stdout before the
# one line you care about, hence the grep.
docker exec grafana-demo grafana cli admin reset-admin-password admin \
  | grep -F 'Admin password'

# Prefer not to be asked at all? Set the password when you create the
# container and Grafana skips the change-password prompt entirely:
#   docker run -d --name grafana-demo --network monnet -p 3001:3000 \
#     -e GF_SECURITY_ADMIN_PASSWORD=admin grafana/grafana

# This container has no volume, so nothing is persisted. Deleting and
# recreating it is also a perfectly good reset:
#   docker rm -f grafana-demo && docker run -d --name grafana-demo ...
Expected Output:
Admin password changed successfully ✔

Any of the three works. The reset is quickest when Grafana already holds dashboards you care about, and setting GF_SECURITY_ADMIN_PASSWORD up front is the better habit because it removes the prompt from the path entirely. Since this container is started without a volume, deleting and recreating it also loses nothing but the login. On a real installation none of this applies: give the admin account a real password and put it in a secret store.

Recommendation

Import dashboard ID 9628 ("PostgreSQL Database") rather than building panels from scratch: it already covers connections, cache hit rate, transaction rates and locks. Treat it as a starting point and delete the panels you never look at, since a dashboard nobody reads is worse than no dashboard, it just looks like coverage.

Alerting Strategies

A dashboard needs someone looking at it. An alert goes and finds them. That power is exactly why bad alerts are so damaging: every page that turns out to be nothing teaches the on-call engineer to trust the next one a little less.

Alert Severity Levels

Severity is a routing decision, not a description of how you feel about the metric. The question to ask is "what should happen the moment this fires", and the answer has to be different for each level or the levels are decoration.

Info

FYI notifications, no immediate action needed.

Examples:
- Cache hit rate 95-97%
- Replication lag 5-10s
- Disk space 20-30% free

Action: Monitor, schedule
        optimization

Notification: Email, Slack
Warning

Degraded performance, investigate within hours.

Examples:
- Cache hit rate <95%
- Replication lag >30s
- Connections >70%
- Disk space <20%

Action: Investigate today,
        plan fix

Notification: Slack ticket
Critical

Service impacting, wake someone immediately.

Examples:
- Database unreachable
- Connections >95% (maxed)
- Disk space <10%
- Replication stopped

Action: Fix NOW (wake on-call)

Notification: PagerDuty, phone
Check That Your Expressions Match Anything

Before writing a single rule, learn the habit that prevents the most embarrassing class of monitoring failure. A PromQL expression that returns no series produces no alert, ever, and looks completely healthy from every angle. Guessing metric names is how you get there.

The block below runs in your own shell on the Docker host, not inside a container: it queries the Prometheus HTTP API on the port the stack block published, so it needs only curl and python3 locally. Paste it in one piece, because the queries use the q() helper defined at the top. It counts returned series rather than printing values, since the only thing being asked here is "does this match anything".

# WHERE: your own shell, on the Docker host, not inside any container. It
# calls the Prometheus HTTP API through the port the stack block published
# (-p 9090:9090), so all it needs locally is curl and python3.
# HOW: paste the whole block in one go. The queries call the q() helper
# defined here, so running them in a different shell gives "q: command not
# found".
#
# The single most useful habit in Prometheus: before trusting an expression in
# an alert, ask whether it matches anything at all. An expression that returns
# no series is not an alert that never fires by luck, it is an alert that
# CANNOT fire.
q() { n=$(curl -s --data-urlencode "query=$1" localhost:9090/api/v1/query \
        | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["data"]["result"]))'); \
      printf '%2s series  <-  %s\n' "$n" "$1"; }

q 'pg_stat_database_numbackends / pg_settings_max_connections'
q 'sum(pg_stat_database_numbackends) / on() pg_settings_max_connections'
q 'pg_replication_lag'
q 'pg_replication_lag_seconds'
q 'pg_up'
Expected Output:
 0 series  <-  pg_stat_database_numbackends / pg_settings_max_connections
 1 series  <-  sum(pg_stat_database_numbackends) / on() pg_settings_max_connections
 0 series  <-  pg_replication_lag
 1 series  <-  pg_replication_lag_seconds
 1 series  <-  pg_up

Two of those five are silently dead. pg_replication_lag simply does not exist: postgres_exporter calls it pg_replication_lag_seconds, and a rule built on the wrong name would sit at "inactive" through a total replication failure. The first line fails for a subtler reason: pg_stat_database_numbackends carries a datname label and pg_settings_max_connections carries none, so PromQL finds no label pairs to match on and returns nothing. sum() collapses the labels and on() joins on the empty set, which is why the second line works. Both mistakes look entirely reasonable in a code review.

The same check has a point-and-click equivalent: Prometheus ships an expression browser at http://localhost:9090/graph where you paste an expression and see the matching series immediately, and its autocomplete will not offer you a metric name that does not exist. Use whichever you prefer while exploring. The scripted form above is the one worth keeping, because it can live in CI and fail a build when somebody ships a rule that matches nothing.

Prometheus Alert Rules

With the expressions verified, the rules themselves are straightforward. The for clause is the important field: it requires the condition to hold for that long before firing, which is what keeps one slow scrape or one momentary connection spike from paging anybody.

# alert_rules.yml - loaded through rule_files in prometheus.yml
groups:
  - name: postgresql_alerts
    interval: 30s
    rules:
      # pg_settings_max_connections carries no datname label while
      # pg_stat_database_numbackends carries one, so the two do not match
      # directly. sum() collapses the per-database counts and on() joins on
      # the empty label set.
      - alert: PostgreSQLConnectionsHigh
        expr: sum(pg_stat_database_numbackends) / on() pg_settings_max_connections > 0.8
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "PostgreSQL connections high ({{ $value | humanizePercentage }})"
          description: "Server is using >80% of max_connections"

      # The template databases sit idle, so their rate() is 0/0 = NaN.
      # Excluding them keeps the alert about databases you actually serve.
      - alert: PostgreSQLCacheHitRateLow
        expr: |
          rate(pg_stat_database_blks_hit{datname!~"template.*"}[5m])
          / (rate(pg_stat_database_blks_hit{datname!~"template.*"}[5m])
             + rate(pg_stat_database_blks_read{datname!~"template.*"}[5m]))
          < 0.95
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Cache hit ratio low ({{ $value | humanizePercentage }})"
          description: "Database {{ $labels.datname }} cache hit rate <95%"

      # postgres_exporter names this pg_replication_lag_seconds.
      # There is no metric called pg_replication_lag.
      - alert: PostgreSQLReplicationLag
        expr: pg_replication_lag_seconds > 30
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Replication lag high ({{ $value }}s)"
          description: "Replica {{ $labels.instance }} is {{ $value }}s behind the primary"

      - alert: PostgreSQLDeadlocks
        expr: rate(pg_stat_database_deadlocks{datname!~"template.*"}[5m]) > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Deadlocks detected ({{ $value }}/sec)"
          description: "Database {{ $labels.datname }} is experiencing deadlocks"

      # pg_up is the exporter's own reachability signal: 0 means it could not
      # connect to PostgreSQL at all.
      - alert: PostgreSQLDown
        expr: pg_up == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "PostgreSQL unreachable"
          description: "postgres_exporter cannot connect to {{ $labels.instance }}"
There is no --rules.file flag. Prometheus loads rules through the rule_files key in prometheus.yml, which is why the config in the setup block has one. Passing --rules.file=alert_rules.yml does not silently ignore the rules, it refuses to start: error: unknown long flag '--rules.file'.

Save that as alert_rules.yml, over the placeholder the stack block wrote, and hand it to the running Prometheus. The restart here is not laziness:

# Replace the "groups: []" placeholder written in the stack block with the
# rules above, then RESTART Prometheus rather than sending it a reload signal.
# Rewriting a file usually replaces its inode, and a bind mount of a single
# file follows the inode, not the name: the container keeps reading the old
# one, and a SIGHUP reload cheerfully reports success while loading nothing.
docker restart prom-demo

# Wait for it to come back, then confirm the rules are really loaded.
until curl -s localhost:9090/api/v1/rules >/dev/null 2>&1; do sleep 2; done
curl -s localhost:9090/api/v1/rules \
  | python3 -c "import json,sys; print(' rules loaded:', sum(len(g['rules']) \
      for g in json.load(sys.stdin)['data']['groups']))"
Expected Output:
prom-demo
 rules loaded: 5

Five rules loaded. Getting a count back matters more than it looks, because the failure mode it rules out is invisible: a bind mount of a single file tracks the inode rather than the path, so most editors leave the container reading the file you replaced. Mounting the whole directory instead of the single file avoids this entirely, and is what you would do in anything long-lived.

Why Validation Is Not Verification

promtool belongs in CI and catches real mistakes: malformed YAML, invalid PromQL, bad durations. What it cannot do is tell you whether the metrics you named exist. Here it is against the broken rules, the ones using pg_replication_lag and the unmatched division:

# promtool validates YAML structure and PromQL grammar. Run it in CI.
# But notice what it says about the ORIGINAL rules, the ones using
# pg_replication_lag and the unmatched division:
promtool check rules alert_rules.yml

# Then ask Prometheus which rules it actually loaded and how they evaluate.
curl -s localhost:9090/api/v1/rules | python3 -c "
import json, sys
for group in json.load(sys.stdin)['data']['groups']:
    for rule in group['rules']:
        print(f\"  {rule['name']:<28} health={rule['health']}  state={rule['state']}\")"
Expected Output:
$ promtool check rules alert_rules.yml
Checking alert_rules.yml
  SUCCESS: 4 rules found

$ curl -s localhost:9090/api/v1/rules
  PostgreSQLConnectionsHigh    health=ok  state=inactive
  PostgreSQLCacheHitRateLow    health=ok  state=inactive
  PostgreSQLReplicationLag     health=ok  state=inactive
  PostgreSQLDeadlocks          health=ok  state=inactive
  PostgreSQLDown               health=ok  state=inactive

SUCCESS: 4 rules found, and once loaded Prometheus reports every one of them as health=ok. Both tools are being truthful: the rules are syntactically valid and they evaluate without error. They just evaluate to nothing. "Healthy" means "ran without raising", not "would ever fire".

Break It On Purpose

Which leaves exactly one way to know an alert works: cause the condition and watch it fire. Stopping the database is the safest possible drill on a throwaway container, and it exercises the full path from scrape failure through the for clause to a firing alert.

#!/bin/sh
# verify_alert.sh - an alert you have never seen fire is a guess.
alerts() {
  curl -s localhost:9090/api/v1/alerts | python3 -c '
import json, sys
a = json.load(sys.stdin)["data"]["alerts"]
parts = [x["labels"]["alertname"] + "=" + x["state"] for x in a]
print("  " + (", ".join(parts) or "none active"))'
}
pg_up() {
  curl -s --data-urlencode 'query=pg_up' localhost:9090/api/v1/query | python3 -c '
import json, sys
r = json.load(sys.stdin)["data"]["result"]
print("  pg_up =", r[0]["value"][1] if r else "no series")'
}

echo "before:"; pg_up; alerts

docker stop pgmon-demo >/dev/null
until [ "$(curl -s --data-urlencode 'query=pg_up' localhost:9090/api/v1/query \
  | python3 -c 'import json,sys; r=json.load(sys.stdin)["data"]["result"]; print(r[0]["value"][1] if r else "")')" = "0" ]; do sleep 2; done
echo "database stopped:"; pg_up; alerts

# The "for: 1m" clause keeps the alert pending until the condition has held
# that long. This is what stops a single failed scrape from paging anyone.
until [ "$(curl -s localhost:9090/api/v1/alerts | python3 -c \
  'import json,sys; a=json.load(sys.stdin)["data"]["alerts"]; print(a[0]["state"] if a else "")')" = "firing" ]; do sleep 5; done
echo "after the for: 1m clause:"; alerts

docker start pgmon-demo >/dev/null
until docker exec pgmon-demo pg_isready -U demo -q; do sleep 1; done
echo "database restarted."
Expected Output:
before:
  pg_up = 1
  none active
database stopped:
  pg_up = 0
  none active
after the for: 1m clause:
  PostgreSQLDown=firing
database restarted.

Watch the middle step: pg_up is already 0 and no alert is active yet, because for: 1m is holding it pending. That is the tuning knob for flapping. Too short and a network blip wakes someone; too long and you find out about a real outage from a customer. Run this drill whenever you add or edit a rule: it takes ninety seconds and it is the only evidence that means anything.

Alerting Without Prometheus

Not every team runs a metrics stack, and a cron job hitting a few pg_stat_* views covers a surprising amount of ground. The traps are all in the details: one connection rather than one per check, and a guarded denominator.

"""health_check.py - a dependency-free health check, for teams without Prometheus."""
import os

import psycopg

DSN = "host=localhost port=5432 dbname=demo user=demo password=demo"

# NULLIF guards the denominator: on a freshly reset database blks_hit and
# blks_read are both 0, and 0/0 raises division_by_zero instead of alerting.
CHECKS = {
    "connection_usage": """
        SELECT count(*)::float / current_setting('max_connections')::int
        FROM pg_stat_activity
        WHERE backend_type = 'client backend'
    """,
    "cache_hit_ratio": """
        SELECT sum(blks_hit)::numeric
               / nullif(sum(blks_hit) + sum(blks_read), 0)
        FROM pg_stat_database
        WHERE datname = current_database()
    """,
    "long_running_queries": """
        SELECT count(*)
        FROM pg_stat_activity
        WHERE state = 'active'
          AND backend_type = 'client backend'
          AND pid <> pg_backend_pid()
          AND now() - query_start > interval '60 seconds'
    """,
    "deadlocks": """
        SELECT deadlocks FROM pg_stat_database
        WHERE datname = current_database()
    """,
}


def send_alert(message, severity):
    """Route an alert. Prints unless SLACK_WEBHOOK_URL is set."""
    webhook = os.environ.get("SLACK_WEBHOOK_URL")
    if not webhook:
        print(f"  [{severity.upper():<8}] {message}")
        return
    import requests  # imported here so the script runs without it installed

    color = {"info": "#36a64f", "warning": "#ff9900", "critical": "#ff0000"}
    requests.post(
        webhook,
        json={"attachments": [{"color": color[severity], "text": message}]},
        timeout=5,
    )


def check_database_health():
    # One connection, closed by the context manager even if a check raises.
    with psycopg.connect(DSN) as conn:
        conn.autocommit = True
        results = {
            name: conn.execute(sql).fetchone()[0] for name, sql in CHECKS.items()
        }

    usage = results["connection_usage"]
    if usage > 0.8:
        send_alert(f"Connection usage {usage:.1%} of max_connections", "critical")
    else:
        print(f"  [ok      ] connection usage {usage:.1%}")

    ratio = results["cache_hit_ratio"]
    if ratio is None:
        print("  [ok      ] cache hit ratio n/a (no reads yet)")
    elif ratio < 0.95:
        send_alert(f"Cache hit ratio {ratio:.2%} (<95%)", "warning")
    else:
        print(f"  [ok      ] cache hit ratio {ratio:.2%}")

    long_running = results["long_running_queries"]
    if long_running > 5:
        send_alert(f"{long_running} queries running >60s", "critical")
    else:
        print(f"  [ok      ] {long_running} queries running >60s")

    deadlocks = results["deadlocks"]
    if deadlocks > 0:
        send_alert(f"{deadlocks} deadlocks since stats reset", "warning")
    else:
        print(f"  [ok      ] {deadlocks} deadlocks since stats reset")


if __name__ == "__main__":
    check_database_health()
Expected Output:
  [ok      ] connection usage 2.0%
  [ok      ] cache hit ratio 99.97%
  [ok      ] 0 queries running >60s
  [ok      ] 0 deadlocks since stats reset

The nullif in the cache-hit query is not defensive padding. On a database whose statistics were just reset, blks_hit + blks_read is genuinely 0, and without the guard the script dies with division_by_zero at precisely the moment you most want it reporting. Returning None and saying "no reads yet" is the honest answer. The same instinct applies to the whole script: a health check that crashes is indistinguishable from a health check that is passing, unless something is also watching the health check.

Cleaning up

Everything in this lesson lived in throwaway containers. Remove them when you are finished:

docker rm -f pgmon-demo pgexporter prom-demo grafana-demo
docker network rm monnet

Monitoring Best Practices

Most of these are the general form of a mistake made somewhere above. They are worth more as a review checklist than as a list to read once.

Do This
  • Monitor the four golden signals: latency, traffic, errors, saturation
  • Query every alert expression by hand before shipping it, and confirm it returns series
  • Trigger each alert once on purpose so you have seen it fire and route
  • Baseline before you threshold: collect for a week, then alert above your normal
  • Use percentiles (P95, P99) not averages for latency
  • Rank queries by total time, not by their slowest single run
  • Enable pg_stat_statements in production (the overhead is a hash table update)
  • Log slow queries with plans via auto_explain, and dedupe the double records
  • Alert on backup and replication failure, and check the notification path itself works
Avoid This
  • Don't trust a green promtool run (valid syntax, nonexistent metric, silent rule)
  • Don't guess metric names (read /metrics from your own exporter)
  • Don't alert on everything (alert fatigue causes ignored warnings)
  • Don't use average latency (hides outliers, use P95/P99)
  • Don't reconnect on every check (a polling loop that leaks connections causes the outage)
  • Don't divide without a guard (a freshly reset counter makes it 0/0)
  • Don't parse csvlog line by line (embedded plans span many lines)
  • Don't ignore disk space (a full disk stops the database)
  • Don't monitor only production (an untested alert is an unverified alert)

Production Database Monitoring Checklist

Work through this before a database takes real traffic. The point of doing it early is that every item is cheap now and expensive during an incident.

Before Going to Production
Query Performance
  • Enable pg_stat_statements and confirm with SHOW shared_preload_libraries
  • Configure slow query logging (threshold: 100ms) plus auto_explain
  • Set up a dashboard showing P50/P95/P99 query latency
  • Alert on P99 latency >500ms
Connections
  • Set max_connections appropriately (the default is 100)
  • Configure connection pooling (PgBouncer)
  • Monitor active vs idle connections separately
  • Alert on >80% connection usage
Caching
  • Monitor cache hit ratio (target >99%) alongside latency
  • Alert if cache hit ratio <95%
  • Tune shared_buffers (25% of RAM is the usual starting point)
  • Watch for rising block reads after a deployment
Replication
  • Monitor pg_replication_lag_seconds continuously
  • Alert on lag >5 seconds
  • Critical alert when replication stops entirely
  • Test the failover procedure, and test that its alert fires
Resources
  • Monitor CPU, memory and disk I/O
  • Alert on disk space <20% free
  • Track disk I/O wait times
  • Set up log rotation so logging cannot fill the disk
Locks & Deadlocks
  • Monitor deadlock count (target: 0)
  • Track lock wait times and wait_event_type
  • Alert on long-running queries (>60s)
  • Keep a dashboard showing blocking queries
Recommended stack: Prometheus + Grafana + postgres_exporter + Alertmanager routing to PagerDuty. It is proven, open source, and every piece of it was exercised in this lesson.

Key Takeaways

  • Rank queries by total time - a 5.6ms query called 200 times cost seventy-five times more than the 0.007ms lookup called 2,000 times, and sorting by mean would have hidden it
  • pg_stat_statements hashes the parse tree, not the text - identical SQL split across two queryids because psycopg sent smallint for some values and integer for others, so one query's real cost is the sum of its rows
  • pg_stat_statements is history, pg_stat_activity is now - use the first to choose what to optimise, the second to find what is stuck, filtering out idle backends and the monitoring query itself
  • ALTER SYSTEM plus pg_reload_conf() changes logging live - only shared_preload_libraries and logging_collector need a restart, and reading back pg_settings is how you confirm it took
  • log_min_duration_statement and auto_explain both log the same execution - three runs of two queries wrote twelve records, so deduplicate before counting anything
  • csvlog has no header row and 26 columns - hand csv.DictReader explicit fieldnames, parse with a real CSV reader because embedded plans span many lines, and group by query_id
  • A PromQL expression matching no series can never alert - pg_replication_lag does not exist and the unmatched division returned 0 series, yet both passed promtool and reported health=ok
  • Vector matching needs compatible labels - numbackends has datname, max_connections has none, so the division needs sum() and on()
  • An alert you have not seen fire is a guess - stopping the database moved PostgreSQLDown from inactive to pending to firing, and the for clause is what separates a blip from a page
  • Monitoring code is production code - one connection per loop rather than per check, nullif on every ratio, because a health check that crashes looks exactly like one that passes
Taking This Further
  • Alertmanager - the piece this lesson stopped short of: grouping, silencing during deploys, and routing by severity to Slack or PagerDuty
  • Distributed tracing - OpenTelemetry spans tie a slow query back to the specific API request that issued it, which pg_stat_statements alone cannot do
  • pgBadger - a mature log analyzer that does everything the script here does, with charts, once you outgrow parsing csvlog yourself
  • Error budgets and SLOs - turning these metrics into a stated reliability target, so alert thresholds follow from a promise instead of a guess