Incident brief
Could not obtain lock on row
A session asked to lock a row with NOWAIT, but another session already had that row locked. Instead of waiting, PostgreSQL said no right away. The same SQLSTATE is also raised when lock_timeout expires while waiting for a row lock.
What lands in your log
ERROR: could not obtain lock on row in relation "orders"
In 10 seconds
- What triggers it
- Create a table and insert one row.
- Fix
- Catch SQLSTATE 55P03 and retry or tell the user to try again in a moment.
- Proof
- Reproduced on PostgreSQL 16.14 → Session B was rejected the instant it asked for the lock, it never waited at all. Session A's own update went through normally once it committed.
Fix
What to do right now
Application-level steps for this error.
- Catch SQLSTATE 55P03 and retry or tell the user to try again in a moment.
- Use NOWAIT when the app should fail fast instead of sitting blocked.
- Prefer lock_timeout for a short, bounded wait when a brief pause is acceptable.
- For job queues, prefer FOR UPDATE SKIP LOCKED so workers skip busy rows without erroring.
-- Fail-fast (NOWAIT): app catches 55P03 and retries / tells the user to wait
SELECT id, status FROM fulfillment.orders WHERE id = 1 FOR UPDATE NOWAIT;
-- Bounded wait (often better than infinite block OR instant fail):
SET lock_timeout = '2s';
SELECT id, status FROM fulfillment.orders WHERE id = 1 FOR UPDATE;
-- both NOWAIT and lock_timeout raise SQLSTATE 55P03 when the lock is not obtained
-- SKIP LOCKED is the other common pattern for worker queues (no error; skips busy rows)For this error
See this error live on the server
Run these against the affected instance to confirm the diagnosis before you act.
55P03 means NOWAIT or lock_timeout refused to wait. Identify the root holder before retrying or choosing SKIP LOCKED.
Row-lock blockers for waiting backends
Show blocked and blocking PIDs, transaction age, and both statements.
SELECT w.pid AS waiting_pid,
b.pid AS blocking_pid,
now() - b.xact_start AS blocker_transaction_age,
left(w.query, 100) AS waiting_query,
left(b.query, 100) AS blocking_query
FROM pg_stat_activity w
JOIN LATERAL unnest(pg_blocking_pids(w.pid)) p(pid) ON true
JOIN pg_stat_activity b ON b.pid = p.pid
WHERE w.wait_event_type = 'Lock';Wait policy currently configured
Distinguish an immediate NOWAIT failure from a bounded lock_timeout policy.
SELECT current_setting('lock_timeout') AS lock_timeout,
current_setting('statement_timeout') AS statement_timeout,
current_setting('deadlock_timeout') AS deadlock_timeout;Why it happens
What PostgreSQL is telling you
The mechanism behind the error, grounded in the official manual, not paraphrased.
PostgreSQL 16 Documentation, SQL Commands, SELECT (The Locking Clause)
To prevent the operation from waiting for other transactions to commit, use either the NOWAIT or SKIP LOCKED option. With NOWAIT, the statement reports an error, rather than waiting, if a selected row cannot be locked immediately.Read the full section on postgresql.org →
Session A (holds the lock)
Session A holds the row lock for the whole 3 seconds it 'thinks', then updates the row and commits, this is ordinary FOR UPDATE behavior, nothing unusual here.Session B (refuses to wait)
Because NOWAIT was used, PostgreSQL didn't make session B wait even a moment for session A's lock to free up. It rejected the request instantly with SQLSTATE 55P03, naming the exact relation involved.Reproduce & verify
A real, two-session PostgreSQL reproduction
A literal transcript of SQL run against a live PostgreSQL instance in an isolated lab. The commands below are exactly what was executed.
- 1Create a table and insert one row.
- 2Session A locks that row with SELECT ... FOR UPDATE and does not commit yet.
- 3Session B asks for the same row with SELECT ... FOR UPDATE NOWAIT.
- 4PostgreSQL rejects session B immediately with SQLSTATE 55P03, instead of making it wait.
- 5Optional: Session B uses SET lock_timeout = '500ms' then FOR UPDATE (no NOWAIT), still 55P03 after the timeout.
Setup runs first, then session A begins, then session B begins while session A is still open.
CREATE SCHEMA IF NOT EXISTS fulfillment;
DROP TABLE IF EXISTS fulfillment.orders;
CREATE TABLE fulfillment.orders (
id integer primary key,
status text not null
);
INSERT INTO fulfillment.orders (id, status) VALUES (1, 'pending');BEGIN;
-- session A locks the row and holds it while it "thinks"
SELECT id, status FROM fulfillment.orders WHERE id = 1 FOR UPDATE;
SELECT pg_sleep(3);
UPDATE fulfillment.orders SET status = 'shipped' WHERE id = 1;
COMMIT;-- session B refuses to wait for session A's lock
SELECT id, status FROM fulfillment.orders WHERE id = 1 FOR UPDATE NOWAIT;What PostgreSQL actually returned
DROP TABLE
CREATE TABLE
INSERT 0 1BEGIN
id | status
----+---------
1 | pending
(1 row)
pg_sleep
----------
(1 row)
UPDATE 1
COMMITERROR: could not obtain lock on row in relation "orders"lock_timeout was tested as a second way to avoid waiting forever: instead of failing instantly like NOWAIT, it lets the statement wait a little while, then fails on its own if the lock still isn't free.
Without this
Above: NOWAIT made session B fail in an instant, with zero wait at all.
With this, tested
Below: lock_timeout let session B wait up to 500ms first, then it failed on its own, a bounded wait instead of an instant refusal or an indefinite hang.
- A second operational test: exact SQL, raw output, measured result, and engineer notes
- Fix that looks safe but wastes resources: exact SQL, output, and verdict
- A manual-grounded production interpretation of the lab result
Card required. Cancel before day 7 and you are not charged.
Runbook to fix this
Runbooks for this incident
Full step-by-step fixes for the condition behind this error: the diagnosis, the exact SQL, and output captured in the lab.
- Capture Sev-1 evidence before recovery erases itEvery postmortem ends at 'high load' because the evidence was gone by morning.Free
- Detect and resolve lock contentionQueries hang with no error and no progress, and you need the wait graph now.Free
- Add a NOT NULL column to a huge table safelySET NOT NULL grabs a strong lock, scans a huge table, and the deploy stalls behind it.Pro
Connected
Everything this error touches
Every page this SQLSTATE connects to: the concept that explains it, the runbooks that fix it, the parameters you tune to prevent it, and the sibling errors it travels with. All real cross-references. Jump straight in, or open the full interactive map.
Understand the concept
Fix it — runbooks
Set sane statement, lock, and idle timeoutsChange a column type without a full table rewriteDetect and resolve lock contentionStop idle-in-transaction sessions from holding locksBuild a safe job queue with SKIP LOCKEDCapture Sev-1 evidence before recovery erases itDetach and attach partitions with minimal lockingKnow which lock mode your statement takesPartition a growing table so the planner can pruneResolve row-level lock contention from SELECT FOR UPDATEUnblock vacuum held back by a long transactionWatch wait events in pg_stat_activityAdd a NOT NULL column to a huge table safelyAssess multixact wraparound riskCoordinate work with advisory locksKeep ALTER TABLE from blocking your appMonitor a CLUSTER or VACUUM FULL rewriteRead pg_locks: relation waits versus tuple waitsReclaim table bloat: plain VACUUM vs VACUUM FULLValidate a CHECK constraint without a long lockTune to prevent it
Related errors
Verification
- Last verified
- 2026-08-15 (Docker lab, PostgreSQL 16.14)
- Verification scope
- Verified against PostgreSQL 16.14 in an isolated lab environment
- Audit status
- reviewed
Went further?
Pro unlocks the second lab proof
Free page stops the bleeding. Pro adds the operational test, SQLSTATE audit, and deeper evidence, same error, more certainty.