Topic 31

Locks: Rows, Tables, and Deadlocks

Locking

MVCC removes most of the locking a database would otherwise need, and what remains is small enough to learn exactly. Writers of the same row queue behind each other. DDL takes a table lock that conflicts with everything, including plain reads. A foreign-key check takes a lock on the parent row that shows up during bulk loads as blocking the loader never asked for.

The payoff for learning the modes is diagnostic speed. When an insert into order_items waits on an update to products, that is not a mystery to be solved by restarting the API; it is two named lock modes that conflict, visible in pg_locks, with a fix that follows from which pair they are.

The Four Row Lock Modes

Row locks come in four strengths, and every write takes one whether or not the statement mentions it. FOR UPDATE is the strongest and blocks every other row lock on that row; a DELETE takes it, and so does an UPDATE that changes a column covered by a unique index of the kind a foreign key can use. FOR NO KEY UPDATE is what an ordinary UPDATE of non-key columns takes. FOR SHARE is the shared read lock. FOR KEY SHARE is the weakest, and it exists so that referential integrity checks stop blocking ordinary updates.

Which row lock modes conflict, and which coexist
held \ requested   KEY SHARE   SHARE   NO KEY UPDATE   UPDATE
KEY SHARE              -         -           -            X
SHARE                  -         -           X            X
NO KEY UPDATE          -         X           X            X
UPDATE                 X         X           X            X

X = the requester waits.  Note the empty top-left corner:
a foreign-key check and a non-key UPDATE do not conflict.

The empty corner is the whole point of the design. An UPDATE that changes no key column takes FOR NO KEY UPDATE, a foreign-key check takes FOR KEY SHARE, and those two do not conflict — so repricing a product no longer blocks orders that reference it. What still conflicts is a genuine key change or a delete of the parent, which is correct: the reference must not be allowed to break.

A bulk load into order_items, and what it does to products
-- session 1: the load, holding FOR KEY SHARE on products(4471)
BEGIN;
INSERT INTO order_items (order_id, product_id, qty, unit_price)
VALUES (918273645, 4471, 1, 3.90),
       (918273646, 4471, 2, 3.90);

-- session 2: proceeds, it changes no key column
UPDATE products SET price = 4.20 WHERE id = 4471;   -- UPDATE 1

-- session 3: waits for session 1 to commit or roll back
DELETE FROM products WHERE id = 4471;

Session 3 is not the victim of a bug. It is being told, correctly, that a transaction in flight is creating rows that reference this product, and that the delete cannot be decided until that transaction's fate is known. The lesson for a loader is scope: hold that transaction open for a two-minute batch and every attempt to delete or re-key those parent rows waits two minutes.

Where a Row Lock Lives

Postgres does not keep a memory structure describing every locked row. The claim is written into the row version itself, in the header field Chapter 5 laid out for expiring a tuple, so the documentation can state flatly that there is no limit on the number of rows locked at one time. A transaction can lock ten million rows without exhausting anything, and the same manual notes the consequence in the next breath: SELECT … FOR UPDATE modifies the rows it locks, so a read-only-looking statement produces disk writes.

When more than one transaction holds a lock on the same row, one field cannot name them all, so the id stored there becomes a multixact, an entry in a side structure that lists the actual lockers. The trade is unlimited row locks, paid for in tuple writes and multixact bookkeeping rather than in a fixed lock table. Multixacts have their own counter, their own wraparound and their own freezing rules, and they turn up again in Chapter 7.

Table Locks and the Queue Behind Them

There are eight table-level modes and three that matter day to day. A SELECT takes ACCESS SHARE. Any INSERT, UPDATE, DELETE or MERGE takes ROW EXCLUSIVE. DDL takes ACCESS EXCLUSIVE: DROP TABLE, TRUNCATE, VACUUM FULL, CLUSTER, most forms of ALTER TABLE, and a bare LOCK TABLE. That is the only mode that conflicts with a plain read. Ordinary vacuum, ANALYZE, CREATE INDEX CONCURRENTLY and REINDEX CONCURRENTLY take SHARE UPDATE EXCLUSIVE, which lets writes continue.

One of those modes behaves unlike the rest, and Chapter 3 spent a topic on the consequence: an ALTER TABLE asking for ACCESS EXCLUSIVE waits behind the readers already holding the table, and everything arriving afterwards queues behind the request instead of overtaking it. What the mode list adds is the scope of that queue. ACCESS EXCLUSIVE conflicts with all eight modes, ACCESS SHARE included, so it is the only lock in the set that can stop a plain SELECT — which is why one slow reader and one DDL statement between them halt reads and writes alike on a 40-million-row table. The defence is the one Chapter 3 named, lock_timeout on the migration session.

Which table lock each statement takes
SELECTACCESS SHARE
INSERT, UPDATE, DELETE, MERGEROW EXCLUSIVE
Ordinary vacuum, ANALYZE, CREATE INDEX CONCURRENTLY, REINDEX CONCURRENTLYSHARE UPDATE EXCLUSIVE
DROP TABLE, TRUNCATE, VACUUM FULL, CLUSTER, most ALTER TABLE, LOCK TABLEACCESS EXCLUSIVE

SKIP LOCKED and NOWAIT

There are two modifiers for what a locking SELECT does when the row it wants is already claimed. NOWAIT raises an error immediately instead of waiting, which is what a job that must fail fast wants. SKIP LOCKED passes over the locked rows without complaint and returns the ones it could claim, which turns a table into a work queue.

Courier dispatch: four workers, four disjoint batches, no queue server
BEGIN;
SELECT id, customer_id
  FROM orders
 WHERE status = 'pending'
 ORDER BY placed_at
 LIMIT 10
   FOR UPDATE SKIP LOCKED;

-- hand the ten claimed orders to a courier, then
UPDATE orders SET status = 'dispatched' WHERE id = ANY($1);
COMMIT;   -- the claim ends here, with the rows already moved on

Each worker claims a different ten rows because the rows the others hold are invisible to its SELECT. The price is an inconsistent view of the data, which makes this a queue-consumption tool rather than a general query modifier. Keep the batch small, since every claimed row is held until commit. And the ordinary table-level ROW SHARE lock is still taken: SKIP LOCKED exempts a worker from waiting on rows, not from the table's own lock rules.

Deadlocks

A deadlock is two transactions each holding what the other needs next, and it needs no explicit locking to arise — two UPDATE statements touching the same two rows in opposite orders is enough. Postgres does not prevent it; it detects it. A backend that has been waiting for deadlock_timeout, one second by default, checks whether the wait is part of a cycle, and if it is, one transaction is aborted with 40P01 so the other can finish.

Two batches, two orders, one cycle
-- session A                          -- session B
BEGIN;                                BEGIN;
UPDATE inventory SET on_hand = ...    UPDATE inventory SET on_hand = ...
 WHERE product_id = 4471;              WHERE product_id = 8802;

UPDATE inventory SET on_hand = ...    UPDATE inventory SET on_hand = ...
 WHERE product_id = 8802;              WHERE product_id = 4471;
-- waits for B                        -- waits for A

-- one second later, one of them:
-- ERROR:  deadlock detected            (SQLSTATE 40P01)
-- DETAIL: Process 24911 waits for ShareLock on transaction 487355047

The fix is ordering, always. Sort every batch by primary key before writing it and take locks in one consistent order across every code path, and the cycle cannot form. Raising deadlock_timeout is the tempting non-fix: the setting controls how long a backend waits before it bothers to run the detection algorithm, so raising it does not reduce deadlocks — it makes each one hold its locks longer before anyone notices. The documentation's own framing is that increasing it saves needless checks at the cost of slower reporting of real deadlocks. If the log needs to show the waits that precede an incident, turn on log_lock_waits, which is off by default and uses the same threshold to decide when a wait is worth recording.

A deadlock is a cycle, and it needs no explicit locking to form
A holds product 4471and wants 8802 next
B holds product 8802and wants 4471 next
Each waits for the otherone second passes
One is abortedSQLSTATE 40P01, the other finishes

Advisory Locks

Advisory locks protect an idea rather than a row. The application picks a number, typically a job id, a tenant id or a hash of a name, and calls pg_advisory_lock() or its non-blocking sibling pg_try_advisory_lock(), and Postgres enforces mutual exclusion on that number without any table being involved. It is the cheapest correct answer to "only one instance of this cron job at a time" in a fleet where every instance can reach the database and nothing else is shared.

The mode to prefer is the transaction-scoped one, pg_advisory_xact_lock(), released at the end of the transaction with no unlock to forget. Session-scoped advisory locks do not honour transaction semantics at all: one taken inside a transaction that later rolls back is still held afterwards. In a pooled connection that means the lock outlives the request, and the next borrower of that connection inherits a session holding a lock it never took.

Row Lock vs Advisory Lock vs Table Lock

Row lock — protects specific rows for the length of a transaction, is recorded in the rows themselves, and scales to millions of them. Reach for it when the thing being protected is data you can name with a WHERE clause.

Advisory lock — protects a number that means whatever the application decides, is unrelated to any row, and is entirely yours to define and release. Reach for it for singleton jobs and per-tenant serialization, and prefer the transaction-scoped form.

Table lock — what DDL takes automatically and what you should almost never take by hand. LOCK TABLE in application code is a decision to serialize every user of that table, and it converts a correctness problem into a throughput one.

Common Mistakes
  • Touching the same rows in different orders from two code paths — the deadlock is arithmetic, and it surfaces as random failures in whichever path happens to lose.
  • Raising deadlock_timeout to make deadlock errors go away — detection is merely delayed, and every real deadlock now holds its locks for the longer interval first.
  • Building a job queue on SELECT … FOR UPDATE without SKIP LOCKED — every worker piles onto the same first row and total throughput collapses to that of one worker.
  • Taking a session-scoped advisory lock in a pooled connection — it survives rollback and connection reuse, so the next borrower silently inherits a session that holds it.
  • Running ALTER TABLE without lock_timeout on a table a long reader is scanning — the DDL waits for ACCESS EXCLUSIVE and every conflicting statement queues behind it.
  • Reading a bulk load's blocking of DELETE FROM products as mysterious — the child rows hold FOR KEY SHARE on the parent, exactly as referential integrity requires.
Best Practices
  • Establish one lock ordering, by primary key, and sort every multi-row batch by it before the statement runs.
  • Use FOR UPDATE SKIP LOCKED with a small LIMIT for every queue-like consumption pattern, and commit each claimed batch promptly.
  • Prefer pg_advisory_xact_lock() over the session-scoped functions, so a pooled connection cannot carry a lock into someone else's request.
  • Set lock_timeout on migration and maintenance sessions so DDL fails fast instead of building a queue in front of a live table.
  • Turn on log_lock_waits, which is off by default, so the waits that precede an incident are in the log when you go looking afterwards.
  • Keep transactions that hold row locks free of network calls, because the lock lasts until commit no matter what the application is doing in between.
Comparable toolsInnoDB row locks plus gap and next-key locks, a different model entirelyOracle row locks in the block header, with no lock escalationSQL Server escalates row locks to table locks, which Postgres never doesMySQL 8 SKIP LOCKED and NOWAIT with the same queue semantics

Knowledge Check

An uncommitted bulk load into order_items is running. Which statement against the referenced products row blocks?

  • A SELECT of that product's name and price for a catalogue page
  • An UPDATE of that product's price, which changes no key column
  • A DELETE of that product row while the child rows are uncommitted
  • An INSERT of a different product into the same products table

Why can a Postgres transaction lock ten million rows without exhausting memory?

  • The lock is recorded in the row version, not in an in-memory lock table
  • Row locks are escalated to a single table lock once a threshold is crossed
  • The shared lock table is sized by max_locks_per_transaction to cover them
  • Older row locks are released early once the statement holding them finishes

What does deadlock_timeout actually control?

  • The longest a transaction may wait for a lock before it is cancelled
  • How long a backend waits before it checks whether a cycle exists
  • How long the chosen victim is given to finish before it is aborted
  • How often a background process scans the lock table for deadlocks

Four dispatch workers run the same FOR UPDATE query without SKIP LOCKED. What is the result?

  • Each worker claims a different batch, since their snapshots differ slightly
  • Three of them wait on the first row and throughput drops to one worker
  • All four process the same rows, so every delivery is dispatched four times
  • Postgres detects a deadlock and aborts three of the four transactions

Why is pg_advisory_xact_lock() safer than pg_advisory_lock() behind a connection pooler?

  • It takes a weaker lock that other sessions can override when they need to
  • It is released with the transaction, so it cannot outlive the request
  • It expires automatically after deadlock_timeout if nothing unlocks it
  • It uses a separate key space that pooled sessions cannot collide within

You got correct