Locking and deadlocks
Table and row lock modes, SELECT FOR UPDATE, SKIP LOCKED queues, how PostgreSQL detects a deadlock, and the lock ordering that prevents one.
After this lesson you can
- Name the table lock each common statement takes and what it blocks
- Choose between FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE and FOR KEY SHARE
- Explain how a deadlock forms, how PostgreSQL resolves it, and how to prevent it
- Use SKIP LOCKED, NOWAIT and lock_timeout appropriately
MVCC removes most conflicts between readers and writers, but writers still have to queue for each other, and schema changes have to exclude everyone. PostgreSQL uses two layers of locks for that.
Table-level locks
Every statement takes a lock on each table it touches, held until the transaction ends. The common ones, weakest first:
ACCESS SHARE— plainSELECT. Conflicts only withACCESS EXCLUSIVE.ROW SHARE—SELECT ... FOR UPDATE / FOR SHARE.ROW EXCLUSIVE—INSERT,UPDATE,DELETE,MERGE.SHARE—CREATE INDEX(withoutCONCURRENTLY): blocks writes, allows reads.ACCESS EXCLUSIVE—DROP TABLE,TRUNCATE,VACUUM FULLand many forms ofALTER TABLE: blocks everything, including plainSELECT.
Lock requests queue in order. A migration's ALTER TABLE waiting behind
one long SELECT makes every later query on that table wait behind the
ALTER. Set lock_timeout in migrations so they give up instead.
Row-level locks
UPDATE and DELETE lock the rows they change; SELECT ... FOR UPDATE
locks rows you intend to change later in the transaction, so nobody else
can modify them in between. There are four strengths:
FOR UPDATE— strongest; taken byDELETEand by anUPDATEthat changes a key column.FOR NO KEY UPDATE— taken by an ordinaryUPDATE; does not block foreign-key checks.FOR SHARE— blocks changes but lets others share.FOR KEY SHARE— taken by foreign-key checks; only blocks deleting the row or changing its key.
Row locks are not kept in the shared lock table; they are written into
the row version itself (its xmax), so locking a million rows costs no
lock memory, though it does write to each locked row's page. A session waiting for a row lock waits on the holder's
transaction id instead.
CREATE TABLE accounts (id int PRIMARY KEY, balance int NOT NULL);INSERT INTO accounts VALUES (1, 100), (2, 50), (3, 75);SELECT id FROM accounts WHERE id IN (1, 3) ORDER BY id FOR UPDATE; SELECT (SELECT string_agg(id || '=' || (xmax = pg_current_xact_id()::xid), ', ' ORDER BY id) FROM accounts) AS row_locked_by_me, (SELECT string_agg(concat_ws(' ', locktype, relation::regclass::text, mode), ', ' ORDER BY locktype, mode) FROM pg_locks WHERE pid = pg_backend_pid() AND (relation = 'accounts'::regclass OR locktype = 'transactionid')) AS lock_table_entries;CREATE TABLE accounts (id int PRIMARY KEY, balance int NOT NULL);INSERT INTO accounts VALUES (1, 100), (2, 50), (3, 75);UPDATE accounts SET balance = 0 WHERE id = 2;LOCK TABLE accounts IN SHARE MODE; SELECT (SELECT string_agg(mode, ', ' ORDER BY mode) FROM pg_locks WHERE pid = pg_backend_pid() AND relation = 'accounts'::regclass) AS locks_on_accounts, current_setting('deadlock_timeout') AS deadlock_timeout, current_setting('lock_timeout') AS lock_timeout;Deadlocks
Transaction A locks account 1 and then wants account 2; transaction B
has locked 2 and wants 1. Each waits for the other forever. When a lock
wait lasts longer than deadlock_timeout (1 second by default),
PostgreSQL checks the wait-for graph; if it finds a cycle it aborts one of
the transactions with SQLSTATE 40P01 (deadlock detected) and the
other proceeds. The aborted transaction must be retried by the
application. All of this is documented behaviour rather than something
the examples show: a deadlock needs two sessions, and the sandbox runs
one.
Prevention is about order, not luck:
- Lock rows in one global order, for example ascending id:
SELECT ... WHERE id IN (1, 2) ORDER BY id FOR UPDATE. Two transfers that both lock the lower id first cannot deadlock on each other. - Keep transactions short; take the locks you need early.
- Treat
40P01and40001as retryable.
Waiting less: NOWAIT, SKIP LOCKED, lock_timeout
FOR UPDATE NOWAITfails immediately (55P03) if a row is locked.FOR UPDATE SKIP LOCKEDsilently skips locked rows, which makes a table into a work queue: each worker runsSELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 10 FOR UPDATE SKIP LOCKEDand gets a different batch.SET lock_timeout = '2s'bounds any lock wait in the session.
Common mistakes
Exercise · Pro
A deadlock-free locking plan for transfers
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade