Now-Next

SaaS architecture

canceling statement due to lock timeout in PostgreSQL

PostgreSQL's lock timeout error, measured on 17: what it cancels, why the transaction is then aborted, how it differs from statement_timeout, and a retry.

Chris van Eijk, Now-Next · · 7 min read

ERROR: canceling statement due to lock timeout (SQLSTATE 55P03) means a statement waited for a lock — on a row, a table or another object — longer than lock_timeout allows, and PostgreSQL gave up on that statement. Someone else held the lock: a transaction that changed the same row, or any open transaction that touched the table while you tried to alter it. The statement did nothing, but the transaction around it is now aborted, and until you roll back (to a savepoint) every following statement fails.

We measured each case below on PostgreSQL 17.11, with the other session’s lock confirmed in pg_locks before the waiting statement started. For the cases we repeated on PostgreSQL 16.15 it gave the same messages and times.

When do you get “canceling statement due to lock timeout”?

Only when lock_timeout is set: its default is 0, wait forever, on 16 and 17. With set lock_timeout = '2s':

The other session holdsOur statementResult
an UPDATE of row 1, uncommittedupdate t set v = 2 where id = 1lock timeout after 2,000.6 ms, CONTEXT: while updating tuple (0,1) in relation "t"
the sameselect … where id = 1 for update (or for share)lock timeout after 2,000.6 ms, CONTEXT: while locking tuple …
the sameselect … for update nowaitcould not obtain lock on row in relation "t", also 55P03, after 0.5 ms
the sameselect … for update skip lockedno error: rows 2 and 3, row 1 skipped
an open transaction that only ran a SELECT on talter table t add column c intlock timeout after 2,000.3 ms, no CONTEXT line
a FOR UPDATE on the parent rowan insert into a child table with a foreign key to itlock timeout, CONTEXT: while locking tuple (0,1) in relation "t" — the parent table, plus the internal SELECT 1 FROM ONLY "public"."t" … FOR KEY SHARE OF x
lock table t in access exclusive modelock table t nowaitcould not obtain lock on relation "t", 55P03

A CONTEXT line naming a tuple means a row wait; a DELETE says while deleting tuple, and an insert waiting on a duplicate key that is not yet committed says while inserting index tuple … in relation "t_pkey". No CONTEXT does not prove a table lock: a second session queued behind a first waiter for the same row waits on a tuple lock, gets no CONTEXT, and pg_blocking_pids names that first waiter, not the holder. A SELECT or UPDATE that waited for a table lock got a LINE 1: pointer at the table name; ALTER TABLE and LOCK TABLE got nothing. The tuple address is a physical position, not the primary key, and it changed between our runs because each update writes a new row version. In pg_locks and in the server log a row-lock wait does not show up as a wait on the row but as a wait on the other transaction (ShareLock on transaction …), which is the thing that will release it.

Why is a lock timeout during a migration worse than it looks?

Because the waiting statement makes others wait too. A session ran select * from t limit 1 and kept its transaction open: an ACCESS SHARE lock, which only conflicts with ACCESS EXCLUSIVE — the lock most ALTER TABLE forms, DROP and TRUNCATE take. Session B ran alter table t add column c int with a 5-second lock_timeout. A plain select count(*) from t, started a tenth of a second later with no timeout at all, did not run: pg_blocking_pids named B, not the session that held the table, and its lock stayed granted = f. B gave up after 5,000.3 ms, and the select finished 4,907 ms after it started — at the moment B gave up. So while your migration waits, the timeout is also the longest that other queries on the table wait behind it; on a shared multi-tenant table that is every tenant. Once it has the lock, they wait until its transaction ends: a migration that ran alter table and then pg_sleep(3) in the same transaction kept a plain select count(*) waiting 2,838 ms. Keep the DDL in its own short transaction. With numbers from a real migration, that queue is in schema migrations on a shared multi-tenant table.

What happens to the transaction after a lock timeout?

It is aborted. Inside one transaction we inserted a row and then ran the update that timed out. The next statement got ERROR: current transaction is aborted, commands ignored until end of transaction block (25P02), COMMIT answered ROLLBACK, and the inserted row was gone. Code that catches the error and carries on with the next statement keeps failing until it ends the transaction. What that looks like in psycopg, SQLAlchemy and Django, and why a commit() at that point loses the rows without an exception, is in current transaction is aborted.

With a savepoint around the statement that may wait, the transaction survives:

begin;
insert into t values (11, 11);
set local lock_timeout = '2s';    -- before the savepoint
savepoint before_lock;
update t set v = 2 where id = 1;   -- ERROR: canceling statement due to lock timeout
rollback to savepoint before_lock;
commit;                            -- COMMIT; row 11 is there

Set the timeout before the savepoint: rollback to savepoint also undoes any SET made after it. With the set local inside the savepoint, show lock_timeout read 0 after the rollback, and a retry in the same transaction would have waited without limit.

How is lock_timeout different from statement_timeout?

statement_timeout counts the whole statement, waiting and working; lock_timeout counts only the wait for one lock. With only statement_timeout = '2s' the same blocked update failed after 2,000.2 ms with a different message, canceling statement due to statement timeout (57014). With both set, the lower one fired first: lock_timeout 1 s and statement_timeout 3 s gave the lock timeout after 1.0 s; the other way round gave the statement timeout after 1.0 s. The documentation puts it as “it is rather pointless to set lock_timeout to the same or larger value” than a non-zero statement_timeout.

The less obvious part is that the limit “applies separately to each lock acquisition attempt”. One statement that has to wait twice can wait longer than lock_timeout in total:

One UPDATE … WHERE id IN (1, 2) with lock_timeout = '2s', while two other sessions each hold one of the rows
  1. 0 to 1.5 sThe update waits for row 1, held by the first session.
    Waiting 1.5 s: under the limit
  2. 1.5 sThe first session commits; the update locks row 1 and moves on to row 2, held by the second session.
    The wait starts again from zero
  3. 1.5 to 3.0 sThe update waits for row 2.
    Waiting 1.5 s: under the limit
  4. 3.0 sThe second session commits.
    UPDATE 2 after 3,003 ms

Four runs: 3,003.0 to 3,005.2 ms, all succeeded. In the three repeat runs, sampled during the wait, pg_blocking_pids named the first session at 0.7 s and the second at 2.2 s. The control, with one row held for 3 seconds, failed after 2,000.7 ms. PostgreSQL 17.11.

If you need a hard ceiling on how long a request can hang, that is statement_timeout (its own error, and where its value comes from, is in canceling statement due to statement timeout); lock_timeout is the ceiling on how long you make others queue behind one lock.

Where should you set lock_timeout?

Per session or per role, not in postgresql.conf; the documentation says that setting it there “is not recommended because it would affect all sessions”. We measured where each setting applies:

HowApplies to
set local lock_timeout = '2s' inside a transactionthat transaction; gone after COMMIT
set lock_timeout = '2s'the session (undone if the transaction it ran in rolls back)
alter role migrate set lock_timeout = '5s'new sessions of that role (beats the database setting)
alter database app set lock_timeout = '10s'new sessions in that database; an open session kept its old value
PGOPTIONS='-c lock_timeout=2s', or options=-c lock_timeout=1500 in a JDBC URLthat connection (pgjdbc 42.7.13 reported source = client and the error as PSQLException, SQLState 55P03)

For a multi-tenant SaaS that gives a simple split: a short lock_timeout on the migration role, so a migration that cannot get its lock gives up before the queue behind it hurts anyone, and SET LOCAL in the few request paths that lock rows. Behind PgBouncer 1.26 in transaction mode, a plain SET leaked to the next client on the same server connection; SET LOCAL and ALTER ROLE … SET were safe, and PGOPTIONS was refused (unsupported startup parameter in options: lock_timeout). The same leak, for the tenant setting, is in PgBouncer and row-level security. If you get the error without having set anything, select setting, source from pg_settings where name = 'lock_timeout' shows where the value came from.

How do you find the session that holds the lock?

While a statement waits:

select w.pid                 as waiting_pid,
       now() - w.query_start as waiting_for,
       left(w.query, 40)     as waiting_query,
       b.pid                 as blocking_pid,
       b.state               as blocking_state,
       now() - b.xact_start  as blocking_xact_age,
       left(b.query, 40)     as blocking_last_query
from pg_stat_activity w
cross join lateral unnest(pg_blocking_pids(w.pid)) as bp(pid)
join pg_stat_activity b on b.pid = bp.pid
where w.wait_event_type = 'Lock';

Follow the chain until you reach a blocking_pid that is not itself waiting: with several waiters on one row, a waiter can be blocked by another waiter. In our retry test the blocker showed up as idle in transaction, with its last statement in blocking_last_query: the usual culprit, a transaction the application opened and did not end. What such a session holds besides the lock is measured in idle in transaction in PostgreSQL.

After the fact, the server log is the place to look. Even with default settings it had the error and the statement (STATEMENT: alter table t add column c int;), because log_min_error_statement is ERROR by default. With log_lock_waits = on, a wait longer than deadlock_timeout (1 s by default) was logged as LOG: process 1729 still waiting for ShareLock on transaction 827 after 1000.047 ms with DETAIL: Process holding the lock: 1714. Wait queue: 1729., and when the lock came through, acquired ShareLock on transaction … after 1848.878 ms. For ALTER TABLE it read still waiting for AccessExclusiveLock on relation ….

How do you retry a migration that hit the lock timeout?

Give up quickly and try again, rather than wait long once. A migration with lock_timeout = '1s', run up to five times, against a session that sat idle in transaction holding the table for about three seconds:

[t=1 ms] poging 1
ERROR:  55P03: canceling statement due to lock timeout
[t=1083 ms] poging 1 mislukt (psql exit 1)
[t=1084 ms] poging 2
ERROR:  55P03: canceling statement due to lock timeout
[t=2159 ms] poging 2 mislukt (psql exit 1)
[t=2160 ms] poging 3
ALTER TABLE
[t=2850 ms] poging 3 geslaagd

(Our test log is in Dutch: poging is attempt, mislukt failed, geslaagd succeeded.) Each failed attempt made other queries on the table wait at most about one second: readers started every 200 ms waited at most 993 ms, against 2,665 ms with a single attempt at lock_timeout = '5s'. One migration needs care before you retry it this way. CREATE INDEX CONCURRENTLY waits for every transaction with an older snapshot, also ones that never touched the table; with a lock_timeout it failed with this error and left an invalid index behind (indisvalid = false). The retry then failed with relation "t_v_idx" already exists, and with IF NOT EXISTS it reported success and kept the invalid index. Drop it before retrying.

The loop itself was a shell for around psql -v ON_ERROR_STOP=1 -c "set lock_timeout = '1s'" -c "alter table …", which exits with code 1 on the timeout.

What we did not measure

Lock timeouts behind PgBouncer under load (we measured only where the setting applies). Deadlocks, which have their own message (deadlock detected) and are resolved by PostgreSQL after deadlock_timeout — unless your lock_timeout is shorter: with both sessions at 500 ms, the deadlock surfaced as a lock timeout. Advisory locks we did check: pg_advisory_lock and pg_advisory_xact_lock were cancelled by lock_timeout like any other lock, and pg_try_advisory_lock just returned false, so a migration tool that serialises runs with a blocking advisory lock can hit this error when two deploys overlap.

Sources: PostgreSQL 17 documentation on client connection defaults (lock_timeout, statement_timeout), read on 21 October 2026. All measurements are our own, on PostgreSQL 17.11 and 16.15 in containers, with PgBouncer 1.26.0 and pgjdbc 42.7.13 for the connection settings, on 21 October 2026.

Let's talk

What are we building?

One conversation is enough to know whether we fit. Tell us what you have in mind — we will tell you how fast it can happen, and whether we are the right people for it.

Start the conversation