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.
In 10 seconds
- What
- Could not obtain lock on row
- What triggers it
- Create a table and insert one row.
- The fix
- Catch the error and 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.
The fix
What to do right now
The immediate, application-level response to this error.
- Catch the error and tell the user to try again in a moment.
- Use NOWAIT on purpose when the app should fail fast instead of sitting blocked.
- Or use lock_timeout for a short, bounded wait instead of failing instantly.
-- fails immediately if the row is already locked, instead of waiting:
SELECT id, status FROM fulfillment.orders WHERE id = 1 FOR UPDATE NOWAIT;Diagnose
See it live on the server
Run these against the affected instance to confirm the diagnosis before you act.
Standard triage — not specific to this error
These are canonical PostgreSQL system-catalog queries, shown as SQL to run. No sample output is attached because this is general triage, not a captured lab transcript.This SQLSTATE does not have an error-specific live snapshot yet. These are the canonical system-catalog queries you run against the affected server to see the problem in real time — standard triage, not a reproduced transcript.
What is running right now
Active backends, how long each has been running, and what it is waiting on.
SELECT pid,
state,
wait_event_type,
wait_event,
now() - query_start AS running_for,
left(query, 80) AS query
FROM pg_stat_activity
WHERE state <> 'idle'
AND pid <> pg_backend_pid()
ORDER BY running_for DESC NULLS LAST;Who is blocking whom
Turn raw blocking PIDs into the actual queries on both sides of the wait.
SELECT blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query
FROM pg_stat_activity AS blocked
JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS b(pid) ON true
JOIN pg_stat_activity AS blocking ON blocking.pid = b.pid
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;Locks that are still waiting
Every lock a backend has requested but not yet been granted.
SELECT l.pid,
l.locktype,
l.mode,
l.granted,
COALESCE(c.relname, l.transactionid::text) AS object
FROM pg_locks l
LEFT JOIN pg_class c ON c.oid = l.relation
WHERE NOT l.granted
ORDER BY l.pid;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.
Setup runs first, then session A begins, then session B begins while session A is still open.
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.
Same lock held by session A for 3 seconds. Session B this time uses a plain FOR UPDATE (no NOWAIT) but with lock_timeout set to 500ms, and \timing on to measure exactly how long it actually waited before giving up.
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.
What Pro unlocks here
- The exact prevention SQL — copy-paste ready
- Raw psql output captured from the Docker lab
- A senior-DBA action list to take it further
- Live monitoring queries to catch it in production
- The deeper audit: fix-that-fails counterexample, GUC before/after, server-log evidence
Related & next steps
Follow the thread
Everything this error touches — jump straight to the sibling error, term, runbook, or parameter.
Verification
- Last verified
- 2026-07-15 (Docker lab, PostgreSQL 16.14)
- Reviewed by
- Verified against PostgreSQL 16.14 in an isolated lab environment
- Audit status
- reviewed