Postgres Timeouts: statement_timeout, lock_timeout and Friends
Postgres ships with every timeout disabled, so a stuck query or forgotten transaction can hold locks forever. What statement_timeout, lock_timeout, idle_in_transaction_session_timeout, transaction_timeout and idle_session_timeout do, the lock-queue outage they prevent, safe values, and where to set them.
Out of the box, PostgreSQL will wait forever. A query can run for six hours, a transaction can sit idle holding locks all weekend, and an ALTER TABLE can wait indefinitely for a lock — blocking everything behind it while it waits.
Timeouts are how you put limits on that. They're all off (0) by default, which is the right default for a general-purpose database and the wrong one for most web apps.
The five timeouts
| Setting | Cancels / terminates | Since |
|---|---|---|
statement_timeout |
Any single statement running longer than this | Forever |
lock_timeout |
A statement waiting longer than this to acquire a lock | 9.3 |
idle_in_transaction_session_timeout |
A session that has an open transaction but is doing nothing | 9.6 |
idle_session_timeout |
A session idle outside a transaction | 14 |
transaction_timeout |
A whole transaction (or single-statement implicit transaction) longer than this | 17 |
statement_timeout and lock_timeout cancel the statement (error 57014, "canceling statement due to statement timeout" / 55P03 "lock timeout"); the transaction is aborted but the connection survives. The idle timeouts and transaction_timeout terminate the session.
The outage they prevent: the lock queue
Here's the scenario that takes down more production Postgres databases than almost anything else:
- A long-running analytics query (or an idle-in-transaction session) holds an
ACCESS SHARElock onorders. - A deploy runs
ALTER TABLE orders ADD COLUMN note text— a fast operation, but it needs anACCESS EXCLUSIVElock. It waits behind step 1. - Every new
SELECTonordersnow queues behind the ALTER, because Postgres grants locks in order and they conflict with the pending exclusive lock. - Connections pile up, the pool is exhausted, and the app is down — because of a migration that would have taken 5 milliseconds.
lock_timeout breaks the chain: the ALTER gives up after a few seconds, the queue drains, and you retry later. (Postgres migrations on large tables)
SET lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN note text;
Wrap migrations in a retry loop with backoff. Most migration tools let you set this per migration; if yours doesn't, put the SET at the top of the migration file.
Finding what's blocking
SELECT pid,
pg_blocking_pids(pid) AS blocked_by,
state,
now() - xact_start AS xact_age,
wait_event_type, wait_event,
left(query, 80) AS query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY xact_start NULLS LAST;
Look for sessions with state = 'idle in transaction' and a large xact_age — that's usually the root. pg_cancel_backend(pid) cancels its current query; pg_terminate_backend(pid) kills the session. (Postgres deadlocks)
statement_timeout: bound every query
A runaway query — a missing WHERE, a bad plan, an N+1 that turned into a cross join — burns CPU and I/O and holds a snapshot that blocks vacuum cleanup. For web requests, nothing should run longer than your HTTP timeout anyway; the client gave up long ago. (504 Gateway Timeout)
Set it per role so different workloads get different limits:
ALTER ROLE web_app SET statement_timeout = '15s';
ALTER ROLE background SET statement_timeout = '5min';
ALTER ROLE analytics SET statement_timeout = '30min';
ALTER ROLE migrator SET statement_timeout = 0; -- but always set lock_timeout
Override for a single known-slow operation:
BEGIN;
SET LOCAL statement_timeout = '2min';
-- the slow report
COMMIT;
SET LOCAL reverts at transaction end, which is what you want with connection pools — a plain SET would leak to whatever request gets that connection next. (Postgres connection pooling)
Avoid setting statement_timeout globally in postgresql.conf: it applies to maintenance, pg_dump and replication-adjacent tooling too. Some tools set their own, but don't rely on it.
idle_in_transaction_session_timeout: the forgotten transaction
An idle-in-transaction session is a connection that ran BEGIN (or an ORM did) and then… stopped. Typical causes:
- Code that does HTTP calls or other slow work between queries inside a transaction.
- An exception path that skips
COMMIT/ROLLBACKand returns the connection to a pool. - A human in
psqlwho typedBEGIN;and went to lunch.
It's quietly destructive: it holds its locks, and it holds back the xmin horizon, so vacuum can't remove dead rows anywhere in the database. Tables bloat, HOT updates stop working, and in extreme cases you approach transaction ID wraparound. (Postgres vacuum and bloat, Postgres HOT updates)
ALTER ROLE web_app SET idle_in_transaction_session_timeout = '60s';
The session is terminated and the client sees an error on its next use. That's intended — the bug was already there; now it's visible.
transaction_timeout (Postgres 17+)
statement_timeout limits each statement, but a transaction of a thousand 1-second statements still holds locks for 17 minutes. transaction_timeout caps the whole thing:
ALTER ROLE web_app SET transaction_timeout = '2min';
Set it longer than statement_timeout and idle_in_transaction_session_timeout for the same role. The docs are explicit: if transaction_timeout is shorter than or equal to either of them, the longer one is simply ignored — so you lose the more specific error that tells you whether a query was slow or a transaction was left idle. Like the idle timeouts, it terminates the session rather than just cancelling the statement.
idle_session_timeout: use carefully
It terminates connections that are idle outside a transaction. That's fine for humans connected directly, and harmful for connection pools, which keep idle connections open on purpose — the pool hands out a dead connection and the next query fails. If you use it, set it on human/admin roles, not on the app's role, or make sure the pool's idle lifetime is shorter.
Client-side timeouts are different
Driver timeouts (connectionTimeoutMillis, query_timeout in node-postgres, socket_timeout) stop the client waiting; they don't necessarily stop the server working. Unless the driver sends a cancel request, the query keeps running in Postgres. Prefer server-side statement_timeout as the real limit, with client timeouts slightly longer as a backstop.
A sensible starting point for a web app
ALTER ROLE web_app SET statement_timeout = '15s';
ALTER ROLE web_app SET lock_timeout = '5s';
ALTER ROLE web_app SET idle_in_transaction_session_timeout = '60s';
ALTER ROLE web_app SET transaction_timeout = '2min'; -- Postgres 17+; keep it above the two above
ALTER ROLE migrator SET lock_timeout = '3s';
Role settings apply to new sessions — restart the app or recycle the pool afterwards. Then watch logs for timeout errors: each one is a slow query or a transaction bug you didn't know about. (pg_stat_statements)
EasySpawn runs Postgres on your own server, so you can set per-role timeouts like these — and Claude Code can help you trace an idle-in-transaction session back to the code that left it open. See how it works or join the waitlist.
Related: Postgres Migrations on Large Tables · Postgres Deadlocks · Postgres Connection Pooling · Postgres Vacuum and Bloat
Keep reading
Postgres Planner Statistics: Why the Query Plan Is Wrong
Postgres picks query plans from statistics about your data. When they're stale or too coarse, row estimates are off by orders of magnitude and the planner chooses a terrible plan. How ANALYZE samples tables, reading pg_stats, default_statistics_target, correlated columns and CREATE STATISTICS, and fixing misestimates.
Reading Postgres EXPLAIN ANALYZE: A Practical Guide to Query Plans
How to read a Postgres query plan: EXPLAIN vs EXPLAIN ANALYZE, BUFFERS, costs vs actual times, loops, scan and join types, spotting bad row estimates, sorts spilling to disk — plus pg_stat_statements and auto_explain for finding the queries worth fixing.