Locks
DraftThe 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).
| Scope | What gets locked | Taken by | Notes |
|---|---|---|---|
| Table (relation) | A whole table, and also indexes, sequences, and views | Every statement, automatically; or LOCK TABLE | Eight modes, from ACCESS SHARE to ACCESS EXCLUSIVE. Visible in pg_locks. |
| Row | One row | UPDATE, DELETE, SELECT ... FOR UPDATE / SHARE, foreign-key checks | Four 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 transaction | Every transaction that writes, on itself | How 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 chooses | Only your code: pg_advisory_xact_lock() and friends | Postgres 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 read | SERIALIZABLE transactions, automatically | Never 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;| relation | mode |
|---|---|
| accounts | AccessShareLock |
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;| relation | mode |
|---|---|
| accounts | RowExclusiveLock |
| transfers | AccessShareLock |
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;| relation | mode |
|---|---|
| accounts | AccessExclusiveLock |
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.
| Mode | Taken by | AS | RS | RE | SUE | S | SRE | E | AE |
|---|---|---|---|---|---|---|---|---|---|
| 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.
BEGIN;
SELECT owner, balance FROM accounts ORDER BY id;| owner | balance |
|---|---|
| alice | 1000 |
| bob | 500 |
Session A only reads, but its transaction keeps the ACCESS SHARE lock on accounts until it ends, not just while the SELECT runs.
UPDATE accounts SET balance = balance + 10 WHERE owner = 'bob'
RETURNING owner, balance;| owner | balance |
|---|---|
| bob | 510 |
An UPDATE needs ROW EXCLUSIVE, which doesn't conflict with ACCESS SHARE, so it goes straight through.
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.
SELECT owner, balance FROM accounts ORDER BY id;| owner | balance |
|---|---|
| alice | 1000 |
| bob | 510 |
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.
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;| session | mode | granted |
|---|---|---|
| Session A | AccessShareLock | true |
| Session B | AccessExclusiveLock | false |
| Session C | AccessShareLock | false |
granted = false is what waiting looks like in pg_locks: Session A holds its lock, Sessions B and C are queued.
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_session | blocked_by | waiting_query |
|---|---|---|
| Session B | Session A | ALTER TABLE accounts ADD COLUMN note TEXT; |
| Session C | Session B | SELECT 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.
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.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE owner = 'alice'
RETURNING owner, balance;| owner | balance |
|---|---|
| alice | 900 |
Session A now holds a row lock on alice's row, until its transaction ends.
SELECT owner, balance FROM accounts ORDER BY id;| owner | balance |
|---|---|
| alice | 1000 |
| bob | 500 |
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.
UPDATE accounts SET balance = balance + 50 WHERE owner = 'bob'
RETURNING owner, balance;| owner | balance |
|---|---|
| bob | 550 |
Same table, different row: no conflict. Row locks are per row.
UPDATE accounts SET balance = balance + 50 WHERE owner = 'alice'
RETURNING owner, balance;| owner | balance |
|---|---|
| alice | 950 |
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.
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;| session | locktype | mode | granted |
|---|---|---|---|
| Session A | transactionid | ExclusiveLock | true |
| Session B | transactionid | ExclusiveLock | true |
| Session B | transactionid | ShareLock | false |
| Session B | tuple | ExclusiveLock | true |
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.
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 lock | Taken by | Conflicts with |
|---|---|---|
| FOR KEY SHARE | Foreign-key checks on the referenced row | FOR UPDATE |
| FOR SHARE | SELECT ... FOR SHARE | FOR NO KEY UPDATE, FOR UPDATE |
| FOR NO KEY UPDATE | UPDATE that doesn't change a key column | FOR SHARE, FOR NO KEY UPDATE, FOR UPDATE |
| FOR UPDATE | DELETE, UPDATE of a key column, SELECT ... FOR UPDATE | All 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.
BEGIN;
SELECT owner, balance FROM accounts WHERE owner = 'alice' FOR UPDATE;| owner | balance |
|---|---|
| alice | 1000 |
INSERT INTO transfers (account_id, amount) VALUES (1, 40)
RETURNING id, account_id, amount;| id | account_id | amount |
|---|---|---|
| 4 | 1 | 40 |
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.
COMMIT;No rows
BEGIN;
SELECT owner, balance FROM accounts WHERE owner = 'alice' FOR NO KEY UPDATE;| owner | balance |
|---|---|
| alice | 1000 |
INSERT INTO transfers (account_id, amount) VALUES (1, 40)
RETURNING id, account_id, amount;| id | account_id | amount |
|---|---|---|
| 5 | 1 | 40 |
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.
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.
BEGIN;
SELECT id, account_id, amount
FROM transfers
WHERE status = 'pending'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;| id | account_id | amount |
|---|---|---|
| 1 | 1 | 100 |
SELECT id, account_id, amount
FROM transfers
WHERE status = 'pending'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;| id | account_id | amount |
|---|---|---|
| 2 | 1 | 250 |
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.
SELECT id, account_id, amount
FROM transfers
WHERE status = 'pending'
ORDER BY id
LIMIT 1
FOR UPDATE;| id | account_id | amount |
|---|---|---|
| 2 | 1 | 250 |
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.
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.
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:
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE owner = 'alice';No rows
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE owner = 'bob';No rows
UPDATE accounts SET balance = balance + 10 WHERE owner = 'bob';deadlock detected
Session A needs bob's row, which Session B holds, so it waits.
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.
ROLLBACK;No rows
An error aborts Session A's whole transaction, so all it can do is roll back, undoing its first update too.
COMMIT;
SELECT owner, balance FROM accounts ORDER BY id;| owner | balance |
|---|---|
| alice | 1010 |
| bob | 490 |
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_timeoutin migrations, and preferCREATE INDEX CONCURRENTLY, which takesSHARE UPDATE EXCLUSIVEinstead ofSHAREand so doesn't block writes. - Use
FOR NO KEY UPDATErather thanFOR UPDATEunless 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_locksandpg_blocking_pids(pid)tell you who is waiting on whom.
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 plainSELECTs. - Row-level locks
FOR KEY SHARE,FOR SHARE,FOR NO KEY UPDATE, andFOR 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.