Skip to content

Locking and deadlocks

MVCC removes the need for read locks, but writes still have to be coordinated. When two transactions want to change the same row, one must wait for the other to finish. Most of the time PostgreSQL takes the necessary row locks for you during an UPDATE or DELETE. Sometimes, though, you want to lock a row at the moment you read it, so that nobody else can change it before you do.

SELECT ... FOR UPDATE reads a row and locks it as if you were about to update it. Any other transaction that tries to update, delete, or also lock that row must wait until you commit or roll back. This is the standard pattern for “read a balance, decide, then write it back” without a lost update.

BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- the row is now locked for this transaction
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

The companion lock is FOR SHARE: it lets others read and also take FOR SHARE, but blocks anyone wanting FOR UPDATE or a write. Use it when you must guarantee a row will not change underneath you, yet you do not intend to write it yourself.

When a transaction asks for a lock another holds, it simply waits. The waiting query appears to hang; it resumes the instant the holder commits or rolls back. A short hold is invisible to users; a long hold turns into a queue.

-- session 2 issues SELECT ... FOR UPDATE on a row session 1 already locked
-- session 2 prints nothing and blocks here until session 1 ends its transaction

A deadlock happens when two transactions each hold a lock the other needs. Transaction A locks row 1 and then wants row 2; transaction B locked row 2 and now wants row 1. Neither can proceed, and neither will ever release first.

flowchart LR
  A["Txn A holds lock on row 1"] -->|waits for row 2| B["Txn B holds lock on row 2"]
  B -->|waits for row 1| A
A deadlock cycle: each transaction waits on a lock the other holds

PostgreSQL runs a deadlock detector. After a short timeout it notices the cycle, picks one transaction as the victim, and aborts it with an error so the other can continue. The victim sees SQLSTATE 40P01 and should retry its whole transaction.

ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 678; blocked by process 12346.
HINT: See server log for query details.

Choosing not to wait: NOWAIT and SKIP LOCKED

Section titled “Choosing not to wait: NOWAIT and SKIP LOCKED”

Sometimes waiting is the wrong behaviour. Two modifiers change it. NOWAIT makes the statement fail immediately instead of queuing if the row is locked. SKIP LOCKED quietly steps over locked rows and returns only the ones it could lock, which is the backbone of work-queue patterns where many workers each grab the next free job.

-- fail at once rather than wait:
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;
-- a worker grabbing the next available job, ignoring locked ones:
SELECT id FROM jobs WHERE status = 'pending'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1;

In pgAdmin: to observe a live lock wait, run a BEGIN; SELECT ... FOR UPDATE in one Query Tool tab without committing, then run the same in a second tab and watch it hang. The pg_locks view shows who holds what while you experiment.

  • Lock rows in a consistent order everywhere in your code. If every transaction touches row 1 before row 2, the cycle that causes a deadlock can never form.
  • Treat deadlock errors (40P01) and serialization failures (40001) the same way: roll back and retry the whole transaction. Both are expected under concurrency.
  • Keep the work between taking a lock and committing as short as possible. The longer you hold a lock, the longer others queue behind you.
  • SKIP LOCKED is ideal for queues but wrong for accounting; it deliberately ignores rows, so never use it where you must see every matching row.
What does SELECT ... FOR UPDATE do?
How does PostgreSQL resolve a deadlock between two transactions?
Which practice most reliably prevents deadlocks?