Locks

Draft

The MVCC lesson ended on a promise: readers never wait on writers, and writers never wait on readers. But two writers changing the same row can't both win, and nobody should drop a table while someone else is reading it. That's what locks are for.

Postgres takes almost all of its locks automatically, so you rarely write a lock statement yourself. What matters is knowing which locks your statements take, and what they'll make other sessions wait for. Most of this lesson is about two scopes: table-level locks, which every statement takes, and row-level locks, which writes take on the rows they change.

What Postgres can lock

Locks come in different scopes. Each one shows up in pg_locks, the system view that lists every lock currently held or awaited, under its own locktype (except row locks, as you'll see).

ScopeWhat gets lockedTaken byNotes
Table (relation)A whole table, and also indexes, sequences, and viewsEvery statement, automatically; or LOCK TABLEEight modes, from ACCESS SHARE to ACCESS EXCLUSIVE. Visible in pg_locks.
RowOne rowUPDATE, DELETE, SELECT ... FOR UPDATE / SHARE, foreign-key checksFour strengths. Stored on the row itself (in xmax), not in pg_locks, so Postgres can lock millions of rows cheaply.
Transaction ID (transactionid)A running transactionEvery transaction that writes, on itselfHow waiting on a row actually works: a session blocked on a row waits for the holder's transaction id lock, released at commit or rollback.
Advisory (advisory)A number your application choosesOnly your code: pg_advisory_xact_lock() and friendsPostgres gives it no meaning. Held until the end of the transaction, or of the session.
Predicate (SIReadLock)Rows, pages, or tables a SERIALIZABLE transaction has readSERIALIZABLE transactions, automaticallyNever blocks anyone: it's bookkeeping for detecting write skew (see the isolation levels lesson).

Almost every lock lasts until its transaction ends; statements can't release them early. The one exception is session-level advisory locks (pg_advisory_lock), which last until you release them or disconnect.

The migration

Accounts, and a queue of transfers that reference them through a foreign key. That foreign key will matter later.

CREATE TABLE accounts (
  id SERIAL PRIMARY KEY,
  owner TEXT NOT NULL,
  balance INTEGER NOT NULL
);

CREATE TABLE transfers (
  id SERIAL PRIMARY KEY,
  account_id INTEGER NOT NULL REFERENCES accounts (id),
  amount INTEGER NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending'
);

The seed

Two accounts and three pending transfers.

INSERT INTO accounts (owner, balance)
VALUES
  ('alice', 1000),
  ('bob', 500);

INSERT INTO transfers (account_id, amount)
VALUES
  (1, 100),
  (1, 250),
  (2, 75);

Every statement takes a table lock

Filtering pg_locks to your own session (pid = pg_backend_pid()) inside an open transaction shows exactly what your statements have taken so far. Even a plain SELECT takes a lock:

BEGIN;

SELECT owner, balance FROM accounts ORDER BY id;

SELECT relation::regclass AS relation, mode
FROM pg_locks
WHERE locktype = 'relation'
  AND pid = pg_backend_pid()
  AND relation IN ('accounts'::regclass, 'transfers'::regclass)
ORDER BY relation::regclass::text, mode;
relationmode
accountsAccessShareLock

Each table gets the lock its statement needs, and the transaction keeps all of them until it commits or rolls back:

BEGIN;

SELECT id, amount FROM transfers WHERE status = 'pending' ORDER BY id;

UPDATE accounts SET balance = balance - 100 WHERE owner = 'alice';

SELECT relation::regclass AS relation, mode
FROM pg_locks
WHERE locktype = 'relation'
  AND pid = pg_backend_pid()
  AND relation IN ('accounts'::regclass, 'transfers'::regclass)
ORDER BY relation::regclass::text, mode;
relationmode
accountsRowExclusiveLock
transfersAccessShareLock

And a schema change takes the strongest one:

BEGIN;

ALTER TABLE accounts ADD COLUMN note TEXT;

SELECT relation::regclass AS relation, mode
FROM pg_locks
WHERE locktype = 'relation'
  AND pid = pg_backend_pid()
  AND relation IN ('accounts'::regclass, 'transfers'::regclass)
ORDER BY relation::regclass::text, mode;
relationmode
accountsAccessExclusiveLock

The eight table lock modes

Every table-level lock is one of eight modes, from weakest to strongest. A lock only makes another session wait if the two modes conflict (✕ below). Most combinations don't: a thousand sessions can read and write the same table at once, because ACCESS SHARE and ROW EXCLUSIVE are compatible.

Don't let the names mislead you: ROW SHARE and ROW EXCLUSIVE are table-level locks too. The names are historical.

ModeTaken byASRSRESUESSREEAE
ACCESS SHARE (AS)SELECT✕
ROW SHARE (RS)SELECT ... FOR UPDATE / FOR SHARE✕✕
ROW EXCLUSIVE (RE)INSERT, UPDATE, DELETE, MERGE✕✕✕✕
SHARE UPDATE EXCLUSIVE (SUE)VACUUM, ANALYZE, CREATE INDEX CONCURRENTLY✕✕✕✕✕
SHARE (S)CREATE INDEX✕✕✕✕✕
SHARE ROW EXCLUSIVE (SRE)CREATE TRIGGER, ADD FOREIGN KEY✕✕✕✕✕✕
EXCLUSIVE (E)REFRESH MATERIALIZED VIEW CONCURRENTLY✕✕✕✕✕✕✕
ACCESS EXCLUSIVE (AE)Most ALTER TABLE, DROP, TRUNCATE, VACUUM FULL, LOCK TABLE✕✕✕✕✕✕✕✕

When table locks collide

Each session below is a real, separate connection to one real Postgres server, like several psql windows open side by side, run at build time the same way as in the MVCC lesson. Session A holds a transaction open while the others use the same table, and Session D looks at what's going on.

Some statements genuinely wait for a lock. Those steps are marked, and show the result they returned once they finally got it, and after which step that happened.

Session AStep 1Open a transaction and read accounts
BEGIN;

SELECT owner, balance FROM accounts ORDER BY id;
ownerbalance
alice1000
bob500

Session A only reads, but its transaction keeps the ACCESS SHARE lock on accounts until it ends, not just while the SELECT runs.

Session BStep 2Update a row in the same table
UPDATE accounts SET balance = balance + 10 WHERE owner = 'bob'
RETURNING owner, balance;
ownerbalance
bob510

An UPDATE needs ROW EXCLUSIVE, which doesn't conflict with ACCESS SHARE, so it goes straight through.

Session BStep 3Run a migration on the same tableWaits for a lock · finished after step 7
ALTER TABLE accounts ADD COLUMN note TEXT;

No rows

ALTER TABLE needs ACCESS EXCLUSIVE, which conflicts with every mode, including Session A's ACCESS SHARE. So Session B waits until Session A's transaction ends.

Session CStep 4Just read the tableWaits for a lock · finished after step 7
SELECT owner, balance FROM accounts ORDER BY id;
ownerbalance
alice1000
bob510

Session C only wants to read, and reading doesn't conflict with Session A. But it does conflict with the ACCESS EXCLUSIVE lock Session B is waiting for, and Postgres grants conflicting lock requests in the order they arrive. So Session C waits behind Session B.

Session DStep 5Look at the table locks
SELECT activity.application_name AS session, lock.mode, lock.granted
FROM pg_locks AS lock
JOIN pg_stat_activity AS activity USING (pid)
WHERE lock.locktype = 'relation'
  AND lock.relation = 'accounts'::regclass
  AND activity.pid <> pg_backend_pid()
ORDER BY session, lock.mode;
sessionmodegranted
Session AAccessShareLocktrue
Session BAccessExclusiveLockfalse
Session CAccessShareLockfalse

granted = false is what waiting looks like in pg_locks: Session A holds its lock, Sessions B and C are queued.

Session DStep 6Who is blocking whom?
SELECT
  waiting.application_name AS waiting_session,
  blocker.application_name AS blocked_by,
  waiting.query AS waiting_query
FROM pg_stat_activity AS waiting
JOIN pg_stat_activity AS blocker
  ON blocker.pid = ANY (pg_blocking_pids(waiting.pid))
ORDER BY waiting_session, blocked_by;
waiting_sessionblocked_bywaiting_query
Session BSession AALTER TABLE accounts ADD COLUMN note TEXT;
Session CSession BSELECT owner, balance FROM accounts ORDER BY id;

pg_blocking_pids() shows the chain: C waits for B, which waits for A. Every new query on accounts would join the back of this queue.

Session AStep 7Commit
COMMIT;

No rows

Session A commits and releases its lock. Session B's ALTER TABLE runs, then Session C's SELECT: both finished right after this step.

This is why schema migrations on busy tables are risky: while an ALTER TABLE holds or even just waits for ACCESS EXCLUSIVE, every new query on that table queues behind it, like Session C did. See the lock_timeout section below for the standard defense.

Row-level locks

Table locks alone can't stop two sessions from updating the same row at once: both only need the compatible ROW EXCLUSIVE. So every UPDATE and DELETE also locks each row it changes, until the transaction ends.

Row locks don't show up in pg_locks. Postgres records them on the row itself, in the same xmax column you saw in the MVCC lesson, so it can lock millions of rows without running out of lock memory.

Session AStep 1Update alice's row, not committed yet
BEGIN;

UPDATE accounts SET balance = balance - 100 WHERE owner = 'alice'
RETURNING owner, balance;
ownerbalance
alice900

Session A now holds a row lock on alice's row, until its transaction ends.

Session BStep 2Read both rows
SELECT owner, balance FROM accounts ORDER BY id;
ownerbalance
alice1000
bob500

Reading is never blocked by a row lock: Session B sees the last committed version of alice's row (1000), exactly as in the MVCC lesson.

Session BStep 3Update bob's row
UPDATE accounts SET balance = balance + 50 WHERE owner = 'bob'
RETURNING owner, balance;
ownerbalance
bob550

Same table, different row: no conflict. Row locks are per row.

Session BStep 4Update alice's rowWaits for a lock · finished after step 6
UPDATE accounts SET balance = balance + 50 WHERE owner = 'alice'
RETURNING owner, balance;
ownerbalance
alice950

Same row: Session B waits for Session A's transaction to end. When it finally runs, it starts from Session A's committed 900, not the 1000 it read in step 2.

Session CStep 5What is Session B waiting for?
SELECT activity.application_name AS session, lock.locktype, lock.mode, lock.granted
FROM pg_locks AS lock
JOIN pg_stat_activity AS activity USING (pid)
WHERE lock.locktype IN ('transactionid', 'tuple')
  AND activity.pid <> pg_backend_pid()
ORDER BY session, lock.locktype, lock.granted DESC, lock.mode;
sessionlocktypemodegranted
Session AtransactionidExclusiveLocktrue
Session BtransactionidExclusiveLocktrue
Session BtransactionidShareLockfalse
Session BtupleExclusiveLocktrue

The row lock itself isn't in pg_locks, but waiting for it is: Session B waits (granted = false) for a ShareLock on Session A's transaction id, which Session A holds until it commits. The tuple lock marks Session B as first in line for alice's row. Session B's own transaction id lock is there because it already updated bob's row.

Session AStep 6Commit
COMMIT;

No rows

The commit releases Session A's transaction id lock, and Session B's update finishes.

Four strengths of row lock

Like table locks, row locks come in modes. You can take them explicitly with SELECT ... FOR ..., and writes take them automatically:

Row lockTaken byConflicts with
FOR KEY SHAREForeign-key checks on the referenced rowFOR UPDATE
FOR SHARESELECT ... FOR SHAREFOR NO KEY UPDATE, FOR UPDATE
FOR NO KEY UPDATEUPDATE that doesn't change a key columnFOR SHARE, FOR NO KEY UPDATE, FOR UPDATE
FOR UPDATEDELETE, UPDATE of a key column, SELECT ... FOR UPDATEAll four

The subtle one is FOR KEY SHARE. Every time you insert a transfer, Postgres checks that its account exists and locks that account row FOR KEY SHARE, so nobody can delete it before the insert commits. That makes FOR UPDATE on a parent row surprisingly expensive: it blocks inserts into every child table.

Session AStep 1Lock alice's row FOR UPDATE
BEGIN;

SELECT owner, balance FROM accounts WHERE owner = 'alice' FOR UPDATE;
ownerbalance
alice1000
Session BStep 2Queue a new transfer for aliceWaits for a lock · finished after step 3
INSERT INTO transfers (account_id, amount) VALUES (1, 40)
RETURNING id, account_id, amount;
idaccount_idamount
4140

Inserting a transfer for alice makes Postgres check the foreign key, which takes FOR KEY SHARE on alice's accounts row. FOR KEY SHARE conflicts with FOR UPDATE, so the insert waits for Session A to commit.

Session AStep 3Commit
COMMIT;

No rows

Session AStep 4Lock alice's row FOR NO KEY UPDATE instead
BEGIN;

SELECT owner, balance FROM accounts WHERE owner = 'alice' FOR NO KEY UPDATE;
ownerbalance
alice1000
Session BStep 5Queue another transfer for alice
INSERT INTO transfers (account_id, amount) VALUES (1, 40)
RETURNING id, account_id, amount;
idaccount_idamount
5140

FOR NO KEY UPDATE promises not to change alice's id, so the foreign-key check's FOR KEY SHARE doesn't conflict with it, and the insert goes straight through.

Session AStep 6Commit
COMMIT;

No rows

A plain UPDATE accounts SET balance = ... takes FOR NO KEY UPDATE automatically, since it doesn't touch the key. So when you lock rows yourself before updating them, prefer FOR NO KEY UPDATE over FOR UPDATE unless you're about to delete the row or change its key. This is the conflict the reservation system lesson mentions when it avoids locking the user row.

Not waiting: NOWAIT, SKIP LOCKED, lock_timeout

Waiting is the default, and it's usually right. But sometimes waiting is the bug. Take a queue of transfers processed by several workers: each worker grabs the oldest pending transfer and locks it while working on it.

Session AStep 1Worker A claims the oldest pending transfer
BEGIN;

SELECT id, account_id, amount
FROM transfers
WHERE status = 'pending'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;
idaccount_idamount
11100
Session BStep 2Worker B, FOR UPDATE SKIP LOCKED
SELECT id, account_id, amount
FROM transfers
WHERE status = 'pending'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;
idaccount_idamount
21250

SKIP LOCKED skips any row someone else has locked and takes the next one. That's how you build a job queue on a plain table.

Session CStep 3Worker C, plain FOR UPDATEWaits for a lock · finished after step 5
SELECT id, account_id, amount
FROM transfers
WHERE status = 'pending'
ORDER BY id
LIMIT 1
FOR UPDATE;
idaccount_idamount
21250

A plain FOR UPDATE waits for the locked row. When Worker A commits, transfer 1 is no longer pending, so Postgres re-checks the WHERE clause and moves on to transfer 2: Worker C waited only to end up with a different row.

Session DStep 4Worker D, FOR UPDATE NOWAIT
SELECT id, account_id, amount
FROM transfers
WHERE status = 'pending'
ORDER BY id
LIMIT 1
FOR UPDATE NOWAIT;
could not obtain lock on row in relation "transfers"

NOWAIT fails immediately instead of waiting.

Session AStep 5Worker A finishes the job and commits
UPDATE transfers SET status = 'done' WHERE id = 1;

COMMIT;

No rows

NOWAIT and SKIP LOCKED only apply to row locks taken with SELECT ... FOR .... For everything else, including table locks, there's lock_timeout: the longest a statement will wait for a lock before failing. The most important place to use it is schema migrations, so a migration stuck behind a long-running transaction, like Session B's ALTER TABLE earlier, gives up instead of making every other query on the table queue behind it:

BEGIN;

-- Give up quickly instead of queueing behind a long-running query.
-- While this ALTER waits for its ACCESS EXCLUSIVE lock, every new query
-- on accounts queues behind it, so waiting a long time is the dangerous part.
SET LOCAL lock_timeout = '2s';

ALTER TABLE accounts ADD COLUMN note TEXT;

COMMIT;

Deadlocks

Two transfers run at the same time, alice to bob and bob to alice. Each locks its first row, then waits for the row the other one already holds. Neither can ever continue on its own:

Session AStep 1Transfer alice → bob: take 10 from alice
BEGIN;

UPDATE accounts SET balance = balance - 10 WHERE owner = 'alice';

No rows

Session BStep 2Transfer bob → alice: take 10 from bob
BEGIN;

UPDATE accounts SET balance = balance - 10 WHERE owner = 'bob';

No rows

Session AStep 3Give 10 to bobWaits for a lock · finished after step 4
UPDATE accounts SET balance = balance + 10 WHERE owner = 'bob';
deadlock detected

Session A needs bob's row, which Session B holds, so it waits.

Session BStep 4Give 10 to aliceWaits for a lock · finished after step 4
UPDATE accounts SET balance = balance + 10 WHERE owner = 'alice';

No rows

Now Session B needs alice's row, which Session A holds: each waits for the other. After deadlock_timeout (1 second by default), Postgres finds the cycle and cancels Session A's statement with "deadlock detected". That releases alice's row, and Session B's update goes through.

Session AStep 5Session A's transaction is aborted
ROLLBACK;

No rows

An error aborts Session A's whole transaction, so all it can do is roll back, undoing its first update too.

Session BStep 6Session B's transfer goes through
COMMIT;

SELECT owner, balance FROM accounts ORDER BY id;
ownerbalance
alice1010
bob490

Only Session B's transfer happened.

A deadlock isn't a bug in Postgres, and it doesn't corrupt anything: one transaction is cancelled, the other proceeds. Your application should catch deadlock detected (SQLSTATE 40P01) and retry the cancelled transaction.

The real fix is to make the cycle impossible: have every transaction lock the rows it needs in the same order. Then the second transfer waits for the first instead of deadlocking with it:

-- Both transfers lock their rows in the same order (lowest id first),
-- so the second one simply waits for the first instead of deadlocking.
BEGIN;

SELECT id FROM accounts
WHERE owner IN ('alice', 'bob')
ORDER BY id
FOR NO KEY UPDATE;

UPDATE accounts SET balance = balance - 10 WHERE owner = 'bob';
UPDATE accounts SET balance = balance + 10 WHERE owner = 'alice';

COMMIT;

Who is blocking whom?

When queries pile up in production, this is the first query to run. It's the one Session D ran earlier: for every session waiting on a lock, it shows which sessions are in its way, using pg_blocking_pids().

SELECT
  waiting.application_name AS waiting_session,
  blocker.application_name AS blocked_by,
  waiting.query AS waiting_query
FROM pg_stat_activity AS waiting
JOIN pg_stat_activity AS blocker
  ON blocker.pid = ANY (pg_blocking_pids(waiting.pid))
ORDER BY waiting_session, blocked_by;

Follow the chain to the session at its head, and check that session's state in pg_stat_activity. The classic culprit is idle in transaction: an application that opened a transaction, took some locks, and never committed. pg_terminate_backend(pid) ends it, and idle_in_transaction_session_timeout stops it from happening again.

Advisory locks

Every lock so far protects a table or a row. Sometimes you need to protect an idea instead, like "only one request at a time for this user and this event", where there's no single row to lock. Advisory locks lock an arbitrary number that your application chooses the meaning of. The reservation system lesson uses one to close a real race condition.

Rules of thumb

  • Keep transactions short. Every lock is held until the transaction ends, so a transaction left open while your app calls another service holds its locks the whole time.
  • Set lock_timeout in migrations, and prefer CREATE INDEX CONCURRENTLY, which takes SHARE UPDATE EXCLUSIVE instead of SHARE and so doesn't block writes.
  • Use FOR NO KEY UPDATE rather than FOR UPDATE unless you're deleting the row or changing its key.
  • Lock rows in a consistent order to avoid deadlocks, and retry when one happens anyway.
  • For work queues, use FOR UPDATE SKIP LOCKED. The job queue lesson builds a complete one.
  • When something is stuck, pg_locks and pg_blocking_pids(pid) tell you who is waiting on whom.

Try Yourself

Loaded Database:postgres-locks.database-init

The database for this lesson is already loaded. Write any query you want and run it directly in your browser.

Exercises

0 OF 2 DONE
1.Open a transaction, delete bob's pending transfer (account_id = 2), then list relation and mode of the table-level locks your session holds on accounts and transfers, ordered by relation and mode.
2.Claim the oldest pending transfer the way a queue worker would: mark it 'processing' using a subquery that picks it with FOR UPDATE SKIP LOCKED, and return its id and status.

What We Learned

  • Table-level locksEvery statement locks the tables it touches in one of eight modes, held until the transaction ends. Two sessions conflict only if their modes do.
  • ACCESS EXCLUSIVEThe lock most ALTER TABLEs take. It conflicts with everything, including plain SELECTs.
  • Row-level locksFOR KEY SHARE, FOR SHARE, FOR NO KEY UPDATE, and FOR UPDATE. They block other writers and lockers of the same row, never plain readers.
  • pg_locksA system view listing every lock currently held or awaited, by which session (pid), and whether it was granted.
  • NOWAIT / SKIP LOCKEDInstead of waiting for a locked row, fail immediately (NOWAIT) or skip it (SKIP LOCKED).
  • lock_timeoutThe longest a statement will wait for a lock before failing. Essential for schema migrations on busy tables.
  • DeadlockTwo transactions each waiting for a lock the other holds. Postgres detects it and cancels one; locking rows in a consistent order prevents it.