PostgreSQL Error Guide: 'canceling statement due to statement timeout'
Fix PostgreSQL's statement timeout cancellations: diagnose slow plans, missing indexes, lock waits, bloat and stale statistics with EXPLAIN ANALYZE and pg_stat_statements.
- #postgres
- #troubleshooting
- #errors
- #performance
Overview
statement_timeout is a per-statement guard rail: when a single SQL command runs longer than the configured limit, PostgreSQL cancels it and raises an error. It exists to stop one runaway query from holding connections and locks, but it is also the symptom that surfaces almost any performance problem — a missing index, a bad plan, a lock wait, or a timeout simply set too low for the work.
You will see this in the client and the server log:
ERROR: canceling statement due to statement timeout
LOG: duration: 30001.442 ms statement: UPDATE orders SET status = 'shipped' WHERE batch_id = 4821
ERROR: canceling statement due to statement timeout
It occurs whenever a statement’s wall-clock time exceeds statement_timeout (which may be set at the server, role, database, or session level, or injected by a connection pooler). Importantly, the timer counts lock-wait time too — so a fast query blocked behind another transaction can be cancelled just like a genuinely slow one. The error is the same regardless of cause, so the work is figuring out why the statement ran long.
Symptoms
- A specific query or endpoint intermittently fails with “canceling statement due to statement timeout”, often under load.
- The same query is fast in isolation but times out during peak traffic (a lock-wait or contention signal).
- A migration or batch job fails partway through with the timeout error.
- The server log shows
duration:lines just over the timeout value immediately before the cancel.
SHOW statement_timeout;
statement_timeout
-------------------
30s
SELECT pid, now() - query_start AS runtime, state, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE state = 'active'
ORDER BY runtime DESC
LIMIT 5;
pid | runtime | state | wait_event_type | wait_event | query
-------+----------------+--------+-----------------+------------+-------------------------------
52210 | 00:00:28.91144 | active | Lock | tuple | UPDATE orders SET status...
52114 | 00:00:00.04122 | active | | | SELECT id FROM users WHERE...
(2 rows)
Common Root Causes
1. statement_timeout set too low for legitimately heavy work
A global timeout meant for OLTP queries is enforced against a reporting query or batch update that genuinely needs longer.
SELECT name, setting, source FROM pg_settings WHERE name = 'statement_timeout';
SHOW statement_timeout;
name | setting | source
-------------------+---------+----------
statement_timeout | 30000 | database
statement_timeout
-------------------
30s
A 30s database-level default applied to an analytics query that scans 200M rows will always cancel. The fix is a per-session SET statement_timeout for that workload, not raising it globally.
2. Missing index causing a sequential scan
Without a supporting index, a selective WHERE turns into a full table scan whose cost grows with the table.
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE batch_id = 4821;
Seq Scan on orders (cost=0.00..488221.10 rows=312 width=148)
(actual time=2204.118..31002.554 rows=298 loops=1)
Filter: (batch_id = 4821)
Rows Removed by Filter: 41992210
Buffers: shared hit=1024 read=388110
Planning Time: 0.182 ms
Execution Time: 31002.701 ms
A Seq Scan removing ~42M rows to return 298 is the classic missing-index pattern. CREATE INDEX CONCURRENTLY idx_orders_batch ON orders (batch_id); turns this into a sub-millisecond index scan.
3. Lock waits counted against the timeout
The timer includes time spent blocked on a lock. A trivial UPDATE can be cancelled because another transaction holds a conflicting row lock for 30 seconds.
SELECT blocked.pid AS blocked_pid, blocked.query AS blocked_query,
blocking.pid AS blocking_pid, blocking.query AS blocking_query,
now() - blocking.xact_start AS blocking_runtime
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.wait_event_type = 'Lock';
blocked_pid | blocked_query | blocking_pid | blocking_query | blocking_runtime
-------------+----------------------------+--------------+-----------------------------+------------------
52210 | UPDATE orders SET status.. | 51990 | BEGIN; SELECT ... FOR UPDATE | 00:01:42.10221
The blocked UPDATE is fast on its own; it times out because pid 51990 has held a FOR UPDATE lock for over a minute. Fix the blocker (often a long idle in transaction), not the query.
4. Table or index bloat inflating scan and update cost
Dead tuples from heavy UPDATE/DELETE traffic bloat heaps and indexes, so even indexed access reads far more pages than the live row count justifies.
SELECT relname, n_live_tup, n_dead_tup,
round(n_dead_tup::numeric / nullif(n_live_tup,0) * 100, 1) AS dead_pct,
last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 5;
relname | n_live_tup | n_dead_tup | dead_pct | last_autovacuum
-----------+------------+------------+----------+-------------------------------
orders | 4221004 | 9880221 | 234.1 | 2026-06-19 02:11:04.221+00
sessions | 188210 | 410332 | 218.0 |
n_dead_tup far exceeding n_live_tup (234% dead) means scans wade through dead rows. A VACUUM (ANALYZE) orders; (or addressing why autovacuum can’t keep up) restores performance.
5. Bad plan from stale or missing statistics
After a large data change, the planner’s row estimates go stale and it can pick a nested loop or wrong index, producing a plan that is orders of magnitude slower.
SELECT relname, last_analyze, last_autoanalyze, n_mod_since_analyze
FROM pg_stat_user_tables
WHERE relname = 'orders';
relname | last_analyze | last_autoanalyze | n_mod_since_analyze
---------+--------------+-------------------------------+---------------------
orders | | 2026-06-12 04:00:11.882+00 | 8442110
8.4M modifications since the last analyze means estimates are stale. Look for actual row counts wildly diverging from rows= estimates in EXPLAIN ANALYZE, then ANALYZE orders; and re-check the plan.
6. A long migration or batch run under a global timeout
DDL like CREATE INDEX (without CONCURRENTLY), ALTER TABLE, or a bulk UPDATE is a single statement and is subject to statement_timeout just like a query.
-- Find the most expensive statements overall
SELECT substring(query, 1, 60) AS query,
calls, round(mean_exec_time) AS mean_ms, round(max_exec_time) AS max_ms
FROM pg_stat_statements
ORDER BY max_exec_time DESC
LIMIT 5;
query | calls | mean_ms | max_ms
-----------------------------------------+-------+---------+--------
ALTER TABLE orders ADD COLUMN region... | 1 | 45221 | 45221
UPDATE orders SET status = $1 WHERE ... | 210 | 18044 | 31002
The ALTER TABLE ran 45s and would be cancelled under a 30s timeout. Run migrations with SET statement_timeout = 0; (or a high migration-specific value) for that session.
Diagnostic Workflow
Step 1: Confirm which timeout is in effect and from where
SHOW statement_timeout;
SELECT name, setting, source FROM pg_settings WHERE name = 'statement_timeout';
SELECT rolname, rolconfig FROM pg_roles WHERE rolconfig IS NOT NULL;
source tells you whether it is a server default, per-database, or per-role setting. Pooler-injected SET statements won’t show in pg_settings — check the pooler config too.
Step 2: Catch the slow statement and whether it is waiting
SELECT pid, now() - query_start AS runtime, state,
wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE state = 'active'
ORDER BY runtime DESC LIMIT 5;
A non-null wait_event_type = 'Lock' points at contention (go to Step 4). Otherwise it is the query plan itself (Step 3).
Step 3: Get the real plan with EXPLAIN ANALYZE
EXPLAIN (ANALYZE, BUFFERS) <the slow query>;
Look for Seq Scan on large tables, actual rows far from estimated rows=, high read= buffer counts, and nested loops over big inputs. These reveal missing indexes, stale stats, or bloat.
Step 4: If it is a lock wait, find the blocker
SELECT blocked.pid AS blocked_pid, blocking.pid AS blocking_pid,
now() - blocking.xact_start AS blocking_runtime,
blocking.state, blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.wait_event_type = 'Lock';
Terminate or fix the blocking session (SELECT pg_terminate_backend(<blocking_pid>); for an abandoned idle in transaction).
Step 5: Rank offenders historically and validate the fix
SELECT substring(query,1,60) AS query, calls,
round(mean_exec_time) AS mean_ms, round(max_exec_time) AS max_ms
FROM pg_stat_statements
ORDER BY mean_exec_time DESC LIMIT 10;
After adding an index, running VACUUM ANALYZE, or fixing the blocker, re-run EXPLAIN ANALYZE and confirm the execution time is comfortably under the timeout.
Example Root Cause Analysis
An order-fulfillment endpoint starts failing intermittently with “canceling statement due to statement timeout”, but only during the afternoon batch window. The query — UPDATE orders SET status = 'shipped' WHERE batch_id = $1 — is fast when tested in isolation, which rules out a simple slow plan.
Catching it live during a failure:
SELECT pid, now() - query_start AS runtime, wait_event_type, wait_event, query
FROM pg_stat_activity WHERE state = 'active' ORDER BY runtime DESC LIMIT 3;
pid | runtime | wait_event_type | wait_event | query
-------+----------------+-----------------+------------+--------------------------
52210 | 00:00:29.41122 | Lock | tuple | UPDATE orders SET status...
It is blocked on a tuple lock, not scanning. Finding the blocker:
SELECT blocking.pid, now() - blocking.xact_start AS runtime, blocking.state, blocking.query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.pid = 52210;
pid | runtime | state | query
-------+----------------+---------------------+------------------------------
51990 | 00:03:18.20114 | idle in transaction | SELECT * FROM orders WHERE...
A reporting job opened a transaction with SELECT ... FOR UPDATE, then sat idle in transaction for over three minutes while it post-processed in the application. Every batch UPDATE on those rows queued behind it and tripped the 30s timeout.
Fix: terminate the stuck session and stop the reporting job from holding the lock across application work:
SELECT pg_terminate_backend(51990);
The blocked updates complete immediately. The durable fix is setting idle_in_transaction_session_timeout so a forgotten transaction can’t pin locks, and refactoring the report to not hold FOR UPDATE across round-trips.
Prevention Best Practices
- Set
statement_timeoutper workload, not one global value: a tight limit for OLTP, a generous (or zero) one for migrations and analytics sessions. - Set
idle_in_transaction_session_timeoutandlock_timeoutso a stuck transaction or lock wait fails fast and obviously instead of being counted againststatement_timeout. - Enable
pg_stat_statementsand review the top queries bymean_exec_timeregularly to catch missing indexes before they cause cancellations. - Keep statistics fresh: ensure autovacuum/autoanalyze keep up, and run
ANALYZEafter bulk loads or large data changes. - Watch table bloat (
n_dead_tupvsn_live_tup) and make sure autovacuum keeps pace on high-churn tables. - Build indexes and run heavy migrations with
CREATE INDEX CONCURRENTLYand a raised session timeout so they don’t trip the production limit. - When a timeout fires in production, the free incident assistant can turn the
pg_stat_activitysnapshot and plan into a likely cause and next action.
Quick Command Reference
-- Which timeout is in effect, and from where
SHOW statement_timeout;
SELECT name, setting, source FROM pg_settings WHERE name = 'statement_timeout';
-- Catch the slow/active statement and whether it is waiting
SELECT pid, now() - query_start AS runtime, state, wait_event_type, wait_event, query
FROM pg_stat_activity WHERE state = 'active' ORDER BY runtime DESC LIMIT 5;
-- Real plan for the slow query
-- EXPLAIN (ANALYZE, BUFFERS) <query>;
-- Find the blocker on a lock wait
SELECT blocked.pid AS blocked_pid, blocking.pid AS blocking_pid,
now() - blocking.xact_start AS blocking_runtime, blocking.query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.wait_event_type = 'Lock';
-- Bloat and stale-stats checks
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum, last_autoanalyze, n_mod_since_analyze
FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 10;
-- Worst statements historically
SELECT substring(query,1,60), calls, round(mean_exec_time) AS mean_ms, round(max_exec_time) AS max_ms
FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10;
-- Per-session overrides for heavy work
-- SET statement_timeout = '5min';
-- SET statement_timeout = 0; -- migrations
Conclusion
“Canceling statement due to statement timeout” is a symptom, not a root cause — it just means one statement exceeded its time limit. The usual reasons:
- The timeout is set too low for genuinely heavy reporting or batch work.
- A missing index forces a sequential scan that grows with the table.
- A lock wait is being counted against the timeout (the query itself is fast).
- Table/index bloat inflates the pages every scan and update must read.
- Stale or missing statistics push the planner into a bad plan.
- A migration or bulk statement runs under a global OLTP timeout.
Always check wait_event_type first: a lock wait means fix the blocker, while a slow plan means EXPLAIN ANALYZE and an index, ANALYZE, or VACUUM. For more PostgreSQL troubleshooting, see the Postgres guides.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.