Problem Statement
A deploy at 14:00 makes one spatial statement forty percent slower. Nothing errors, no dashboard turns red, and the regression is discovered a week later when someone happens to compare a graph. This page builds the alert that would have caught it within an hour, extending the pg_stat_statements workflow from investigation into monitoring.
Why the Naive Approach Fails
Alerting directly on mean_exec_time from the view does not work, for a reason that is easy to miss: the counters are cumulative.
Production-Ready Implementation
Snapshot the spatial slice on a fixed interval into a table you own:
CREATE TABLE pgss_snapshot (
taken_at timestamptz NOT NULL DEFAULT now(),
queryid bigint NOT NULL,
calls bigint NOT NULL,
total_exec_time double precision NOT NULL,
rows bigint NOT NULL,
query_sample text,
PRIMARY KEY (taken_at, queryid)
);
CREATE INDEX ON pgss_snapshot (queryid, taken_at DESC);import psycopg
SNAPSHOT = """
INSERT INTO pgss_snapshot (queryid, calls, total_exec_time, rows, query_sample)
SELECT queryid, calls, total_exec_time, rows, left(query, 200)
FROM pg_stat_statements
WHERE query ILIKE '%ST\\_%' -- the spatial slice only
AND calls > 0
"""
def take_snapshot(dsn: str) -> int:
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
cur.execute(SNAPSHOT)
n = cur.rowcount
conn.commit()
return nDifferencing turns the cumulative counters into per-window rates:
CREATE OR REPLACE VIEW pgss_windows AS
SELECT
queryid,
taken_at,
calls - lag(calls) OVER w AS window_calls,
total_exec_time - lag(total_exec_time) OVER w AS window_ms,
(total_exec_time - lag(total_exec_time) OVER w)
/ nullif(calls - lag(calls) OVER w, 0) AS window_mean_ms,
query_sample
FROM pgss_snapshot
WINDOW w AS (PARTITION BY queryid ORDER BY taken_at);A negative window_calls means the statistics were reset between snapshots; filtering those out is the one piece of defensive logic this view needs.
The alert compares each window against the same hour on previous weeks:
WITH current AS (
SELECT queryid, window_mean_ms, window_calls, query_sample
FROM pgss_windows
WHERE taken_at > now() - interval '15 minutes'
AND window_calls > 100 -- ignore rare statements
),
baseline AS (
SELECT queryid,
percentile_cont(0.5) WITHIN GROUP (ORDER BY window_mean_ms) AS median_ms
FROM pgss_windows
WHERE taken_at > now() - interval '28 days'
AND extract(dow FROM taken_at) = extract(dow FROM now())
AND extract(hour FROM taken_at) = extract(hour FROM now())
AND window_calls > 100
GROUP BY queryid
)
SELECT c.queryid,
round(c.window_mean_ms::numeric, 2) AS now_ms,
round(b.median_ms::numeric, 2) AS baseline_ms,
round((c.window_mean_ms / b.median_ms)::numeric, 2) AS ratio,
left(c.query_sample, 80) AS query
FROM current c
JOIN baseline b USING (queryid)
WHERE b.median_ms > 0
AND c.window_mean_ms / b.median_ms > 2.0 -- twice as slow as usual
ORDER BY ratio DESC;Configuration and Tuning Knobs
Snapshot interval of five minutes balances resolution against volume. Shorter intervals make each window noisier because fewer calls land in it; longer ones delay detection.
pg_stat_statements.max must be large enough that the statements you care about are not evicted. The default of 5,000 is low for a database with many distinct queries; 10,000 costs a few megabytes of shared memory and removes a class of confusing gaps.
The minimum call count in the alert is the main noise control. A hundred calls per window is a reasonable floor — below that, one slow execution moves the mean enough to fire.
The ratio threshold of 2.0 catches real regressions without firing on ordinary variance. Tightening it to 1.5 finds more and pages more; the right value depends on how stable your workload already is.
Verification Steps
-- does the pipeline produce sensible windows?
SELECT queryid, taken_at, window_calls, round(window_mean_ms::numeric, 2)
FROM pgss_windows
WHERE window_calls > 0
ORDER BY taken_at DESC
LIMIT 20;
-- deliberately regress something and confirm the alert fires
BEGIN;
UPDATE pg_index SET indisvalid = false
WHERE indexrelid = 'idx_parcels_geom'::regclass;
-- run the workload for one interval, check the alert query, then:
ROLLBACK;Testing an alert by causing the condition is the only way to know it works. Hiding the index for one interval is a safe, instantly reversible way to make a spatial query genuinely slower — and if the alert does not fire, better to learn that on a Tuesday afternoon.
Gotchas Checklist
- A statistics reset produces negative windows. Filter them out or the alert fires on the reset itself.
queryidis not stable across PostgreSQL major versions. After an upgrade, the baseline is gone; expect a quiet period while it rebuilds and do not chase the gap.- The same SQL from two applications shares a queryid. If one of them regresses, the mean moves for both; a tagging comment per caller separates them.
- Alert on mean, chart on total. The alert catches regressions; the chart shows which query is costing the most, and they are different questions.
- Do not alert per statement without a floor. A database has thousands of statements, most of them rare, and alerting on all of them is how a monitoring system gets muted.
Choosing Thresholds That Do Not Page at Three in the Morning
The failure mode of query alerting is not missing regressions; it is firing so often that the alert is muted, after which it misses everything. Three choices decide which side of that line an alert lands on.
Alert on a ratio against the query’s own baseline, not on an absolute duration. A spatial join that normally takes 900 milliseconds and now takes 1,400 is a regression worth knowing about; a tile query that normally takes 4 milliseconds and now takes 30 is a much larger regression that a 1-second absolute threshold would never see. Storing a rolling median per queryid and alerting when the current window exceeds two times that median treats both correctly.
Alert on a percentile, not a mean. pg_stat_statements gives you mean_exec_time for free, and it is the wrong number, because a single 40-second query mixed into ten thousand fast ones barely moves it. If the extension is compiled with the option, use the per-statement percentile columns; otherwise sample the running query duration from pg_stat_activity and compute a p95 in the collector. The difference shows up precisely on the workloads that matter — those where a small fraction of requests hit a bad plan.
Require persistence before firing. A single bad window is usually a checkpoint, an autovacuum, or a backup. Two consecutive windows above the threshold cuts the noise by roughly an order of magnitude and delays a genuine alert by exactly one window. For a five-minute window that is a trade worth making every time.
Finally, attach the diagnosis to the alert. An alert that carries the queryid, the normalised query text, the current and baseline timings, and the number of calls in the window lets whoever is woken decide in thirty seconds whether to act. An alert that says “database slow” guarantees a twenty-minute investigation before that decision can even be made.
Related Topics
- pg_stat_statements for Spatial Workloads — parent topic: the view this pipeline reads
- Finding Slow ST_ Function Calls — investigating what the alert surfaces
- Spatial Performance Monitoring & Observability — the alert tiers this fits into
- Query Plan Analysis with EXPLAIN — the next step once a regression is confirmed