Cloud Tech by Victor
DatabasesIntermediate

Database Locking & Deadlocks

How PostgreSQL's row-level lock modes actually differ in strength, why it can't predict which transaction a deadlock will abort, and why acquiring locks in a consistent order is the real fix, not a "nice to have".

Updated 2026-07-243 min read

Overview

PostgreSQL provides row-level lock modes of increasing strength, from FOR KEY SHARE (weakest) through FOR SHARE, FOR NO KEY UPDATE, to FOR UPDATE (strongest, fully exclusive), and choosing the weakest mode that actually satisfies what a transaction needs is what maximizes real concurrency. Deadlocks happen when two or more transactions form a circular wait, each holding a lock the other needs, and PostgreSQL detects this automatically and aborts one of the transactions to break the cycle, but explicitly does not guarantee which one, applications must be ready to retry either side. The durable fix for recurring deadlocks isn't retry logic alone, it's acquiring locks on shared resources in the same order everywhere in the application, which makes the circular-wait pattern structurally impossible rather than merely less likely.

Quick Reference

Lock modeStrengthBlocks
FOR KEY SHAREWeakestOnly DELETE/key-changing UPDATE
FOR SHARESharedUPDATE, DELETE, and stronger locks; permits other FOR SHARE/FOR KEY SHARE
FOR NO KEY UPDATEExclusive-ishMost, but not FOR KEY SHARE
FOR UPDATEStrongestEverything else attempting to lock the same rows

Syntax

sql
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

Examples

sql
-- Deadlock: opposite lock order on the same two rows across
-- two concurrent transactions.
-- Transaction 1:
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE acctnum = 1;
UPDATE accounts SET balance = balance - 100 WHERE acctnum = 2;
COMMIT;

-- Transaction 2 (concurrently):
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE acctnum = 2;
UPDATE accounts SET balance = balance - 100 WHERE acctnum = 1;
COMMIT;
-- One of these two transactions gets aborted by deadlock detection.

You cannot predict which transaction a deadlock aborts

PostgreSQL's own documentation is explicit that the choice is difficult to predict and shouldn't be relied upon. Any code path that can deadlock needs retry logic on both sides, not just the transaction you'd expect to "lose."

Visual Diagram

Common Mistakes

  • Defaulting to FOR UPDATE everywhere out of caution, when FOR SHARE or FOR KEY SHARE would satisfy the actual requirement with far less blocking.
  • Assuming a deadlock will always abort the "same" transaction (the smaller one, the one that started waiting first), and building logic that depends on that assumption.
  • Treating a recurring deadlock as fixed by adding retry logic alone, without addressing the underlying inconsistent lock ordering that keeps causing it.
  • Locking rows in whatever order a query happens to touch them, rather than establishing and following a consistent ordering convention across the whole application.

Performance

  • Weaker lock modes (FOR KEY SHARE, FOR SHARE) allow meaningfully more concurrency than defaulting to FOR UPDATE, which serializes access more than the operation actually requires.
  • Deadlock detection itself has a real, if usually small, cost, and every deadlock forces a full transaction abort and retry, real wasted work, not just a logged warning.

Best Practices

  • Choose the weakest row-lock mode that actually satisfies the transaction's requirement, not FOR UPDATE by default.
  • Build retry logic for both deadlocks and serialization failures into any transactional code path where concurrent writes to the same rows are possible.
  • Establish and enforce a consistent lock-acquisition order for multi-row/multi-table operations across the entire application, the structural fix for recurring deadlocks.
  • Keep transactions short and avoid unrelated work between acquiring a lock and committing, to minimize the window during which a deadlock or contention can occur.

Interview questions

What is the practical difference between SELECT ... FOR UPDATE and SELECT ... FOR SHARE?

FOR UPDATE takes an exclusive row lock, it blocks other transactions from updating, deleting, or taking any competing lock (including another FOR UPDATE or FOR SHARE) on the same rows, appropriate when you're about to modify the row and need to ensure nothing else changes or locks it first. FOR SHARE takes a shared lock, it still blocks updates and deletes, but permits other transactions to also take FOR SHARE or FOR KEY SHARE locks on the same rows concurrently, appropriate when you only need to ensure a row doesn't change or get deleted while you read it, without needing exclusive access.

PostgreSQL detects a deadlock between two transactions. What does it actually do, and can you predict which transaction survives?

PostgreSQL automatically detects the circular wait (a deadlock) and resolves it by aborting one of the involved transactions, letting the other(s) proceed. The documentation is explicit that which transaction gets aborted is difficult to predict and shouldn't be relied upon, there is no guarantee it's the "smaller" transaction, the one that started the wait, or any other predictable rule. Applications need to handle a deadlock-abort the same way they'd handle a serialization failure: catch it and retry the aborted transaction, rather than assuming a specific transaction will always be the one sacrificed.

Two transactions each update two of the same two accounts, but in opposite order, and deadlock. What's the actual fix, not just for this pair of transactions, but for the application generally?

The deadlock happens because Transaction 1 locks account A then waits for account B, while Transaction 2 locks account B then waits for account A, a circular wait. The general fix isn't retry logic alone, retries only paper over deadlocks that keep recurring, it's acquiring locks on multiple objects in the same, consistent order everywhere in the application (for example, always locking accounts in ascending id order), which makes the circular-wait pattern structurally impossible rather than merely less frequent. Retry logic is still worth having as a safety net, but consistent lock ordering is what actually eliminates this class of deadlock.

References

ShareXLinkedInReddit
Was this page helpful?
Suggest an improvement