Skip to content
← Back to all lessons
Day 045 Databases

lock_timeout and NOWAIT - fail fast instead of waiting forever

Unbounded lock waits vs lock_timeout ceiling vs FOR UPDATE NOWAIT; lab medians 441 / 114 / 13.3 ms on til-postgres.

8 min read

Default PostgreSQL lock waits are unbounded. Session B wants a row Session A holds. B parks until A commits, rolls back, or the server restarts. That is fine for a 5 ms critical section. It is fatal when A is stuck in a slow report, a forgotten BEGIN, or a human transaction left open in psql.

Three ways to not wait forever

ToolScopeWhat happens on conflictUse when
lock_timeoutSession / transaction GUCWait up to N ms, then error 55P03 lock_not_availableAny lock wait (row, table, advisory) should have a ceiling
NOWAITOne SELECT … FOR UPDATE/SHAREIf a target row lock is unavailable → immediate errorThis claim must succeed now or abort
SKIP LOCKED (Day 38)One claim querySkip locked rows; take the next free oneQueue workers — work is fungible

SKIP LOCKED finds other work. NOWAIT refuses to wait for the selected row lock; the statement can still wait while acquiring its required table-level lock. Keep lock_timeout (or explicitly acquire the table lock with LOCK … NOWAIT) when the whole lock path needs a ceiling. Pick by whether another row is acceptable.

Worked race: holder vs waiter

Two sessions, one row. A holds FOR UPDATE for 500 ms. B wants the same row.

B’s modeBehaviorLab median (30 runs)
Default FOR UPDATEBlocks until A ends · 0 lock errors441 ms
SET LOCAL lock_timeout = ‘100ms’Waits ≤ budget · 30/30 errors114 ms
FOR UPDATE NOWAITErrors if locked · 30/30 errors13.3 ms

3.9× default/timeout wall (441 / 114). Product claim is bounded wait, not a micro-optimised “speedup.” NOWAIT is “refuse now,” not “wait a little.”

-- Session A
BEGIN;
SELECT id FROM accounts WHERE id = 1 FOR UPDATE;
-- hold… do not commit yet

-- Session B (default): parks until A commits
SELECT id FROM accounts WHERE id = 1 FOR UPDATE;

-- Session B (ceiling):
BEGIN;
SET LOCAL lock_timeout = '100ms';
SELECT id FROM accounts WHERE id = 1 FOR UPDATE;
-- ERROR: canceling statement due to lock timeout
ROLLBACK;

-- Session B (instant refuse):
SELECT id FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;
-- ERROR: could not obtain lock on row in relation "accounts"

lock_timeout vs statement_timeout vs idle killers

GUCMeasuresStops
lock_timeoutTime spent waiting for a lock onlyParked acquirers (our lab)
statement_timeoutWhole statement wall (CPU + I/O + waits)Runaway queries overall
idle_in_transaction_session_timeoutIdle time after BEGIN with no activityForgotten open transactions holding locks

Default for lock_timeout and statement_timeout is 0 = disabled. Docs note: if both are set and statement_timeoutlock_timeout, the statement timeout usually fires first. For writer APIs, set a tight lock_timeout on the pool role; use statement_timeout as a coarser safety net; use idle-in-transaction timeout so a crashed client cannot pin rows forever after BEGIN.

NOWAIT vs SKIP LOCKED (decision)

QuestionPrefer
Is any free job OK?SKIP LOCKED (Day 38)
Must I lock this row (inventory unit, seat, advisory key)?NOWAIT or short lock_timeout + retry/backoff (Day 37)
Is waiting a few ms OK, but not seconds?lock_timeout (budget), not bare default
Could this be a deadlock risk with multi-lock order?Consistent order (Day 36) and a timeout so a bug fails loud

Diagram: wait budget

Lab (Podman, real numbers)

Dual backends run inside til-postgres; the recorded harness times the waiter only (no podman-exec tax). Holder hold 500 ms. 30 runs, median. The harness and standalone write-up are not included in this repository.

Modemedian msminmaxlock errors / 30
default FOR UPDATE4414395960
lock_timeout=100ms11411211730
FOR UPDATE NOWAIT13.311.315.830

Default sits just under the 500 ms sleep (acquire stagger ~80 ms + commit path). Timeout clusters on the 100 ms budget. NOWAIT is “a few ms of client work,” not zero — still two orders below default wait.

Production shape (pool role)

-- On the app role / pool startup (not only one lucky query)
ALTER ROLE app_pool SET lock_timeout = '2s';
ALTER ROLE app_pool SET statement_timeout = '15s';
ALTER ROLE app_pool SET idle_in_transaction_session_timeout = '10s';

-- Per hot path you can still tighten:
BEGIN;
SET LOCAL lock_timeout = '100ms';
SELECTFOR UPDATE;  -- or NOWAIT / SKIP LOCKED
COMMIT;

Budgets are product choices. The invariant is: never ship unbounded lock waits on a user-facing pool.

Anti-patterns

  • Global lock_timeout in postgresql.conf only — docs warn against blunt cluster defaults; set on the app role / pool.
  • NOWAIT on a multi-row queue head when any job would do — you wanted SKIP LOCKED.
  • Timeout without retry policy — fail-fast still needs Day 37 jittered backoff or a user-visible “busy, try again.”
  • Assuming deadlock_timeout replaces lock_timeout — deadlock detection (Day 36) is a different timer for cycles, not a general wait ceiling.
  • Measuring with cold podman exec per session — exec overhead (~1 s) can hide a 100 ms budget; lab times waiter inside the container.

How it connects

  • Day 24 / 38: row locks and claim patterns (SKIP LOCKED when work is fungible).
  • Day 36: unbounded waits + cycles; timeouts make stuck lockers visible.
  • Day 37: after 55P03, retry with full jitter — not a tight loop.
  • Day 43: advisory locks honor lock_timeout the same way.
  • Day 44: backpressure is the distributed cousin — slow the producer instead of queueing forever.

Interview version (60s)

“Postgres lock waits are infinite by default. I set lock_timeout on writer sessions so a stuck holder cannot pin my API thread for minutes. Lab shape: holder 500 ms → default waiter ~441 ms, lock_timeout=100ms ~114 ms then error (3.9×), NOWAIT ~13 ms. If the business rule is ‘this exact row or fail,’ I use FOR UPDATE NOWAIT. If any free job is fine, I use SKIP LOCKED. Timeouts without a retry/backoff story just turn hangs into error storms.”

Quiz

1. Default lock_timeout is:

2. You need any free job from a queue. Prefer:

3. In the Day 45 lab (holder 500 ms), which is true?

Questions? Ask the agent — next DB slot after living this day is Mon again in the rotation; prep may already hold later concepts.