PostgreSQLIX · Locks, Blocking and DeadlocksLocks
Row locks versus table locks
What you'll learn
- Choose the correct row lock mode for a read-modify-write pattern
- Explain why foreign keys need FOR KEY SHARE and what it prevents
- Predict which row lock pairs conflict from the measured matrix
- Use SKIP LOCKED and NOWAIT where waiting is the wrong behaviour
Prerequisites
Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27
Table locks arbitrate between statements. Row locks arbitrate between writers touching the same data, and PostgreSQL has four of them for a reason that becomes obvious once you see the foreign key case.
The four modes
Weakest to strongest.
| Mode | Taken by | Intent |
|---|---|---|
FOR KEY SHARE | Foreign key checks | “Do not change this row’s key” |
FOR SHARE | SELECT … FOR SHARE | “Do not change this row at all” |
FOR NO KEY UPDATE | UPDATE of non-key columns | “I am changing this row, but not its key” |
FOR UPDATE | SELECT … FOR UPDATE, DELETE, key UPDATE | “I am changing or removing this row” |
The matrix, measured
$ psql -U postgres -c "SET lock_timeout='2s'; BEGIN; SELECT id FROM lockdemo WHERE id=1 FOR <mode>; COMMIT" # against a held row lock FOR KEY SHARE vs FOR KEY SHARE -> COMPATIBLE
FOR KEY SHARE vs FOR UPDATE -> BLOCKS
FOR SHARE vs FOR NO KEY UPDATE -> BLOCKS
FOR NO KEY UPDATE vs FOR NO KEY UPDATE -> BLOCKS
FOR NO KEY UPDATE vs FOR KEY SHARE -> COMPATIBLE
FOR UPDATE vs FOR KEY SHARE -> BLOCKSThe two COMPATIBLE rows are the interesting ones. FOR KEY SHARE and
FOR NO KEY UPDATE coexist: one session may be updating a row’s
non-key columns while another holds it against key changes. Everything
else in this matrix conflicts.
Why that pair exists
A foreign key check is a read that must remain true until the transaction commits. Inserting a child row requires the parent row to exist, and it must go on existing.
The naive implementation locks the parent row against all changes, which
means every insert into a busy child table blocks every update of the
parent. FOR KEY SHARE is the refinement: it forbids only changes to
the key, which is the only thing the check depends on.
$ psql -U postgres -c "SELECT locktype, mode, granted, relation::regclass FROM pg_locks WHERE relation IN ('parent'::regclass,'child'::regclass) ORDER BY relation::regclass::text, mode" locktype | mode | granted | relation
----------+------------------+---------+----------
relation | RowExclusiveLock | t | child
relation | RowShareLock | t | parent
(2 rows)Now the two updates of that parent row, with the child insert still uncommitted.
$ psql -U postgres -c "SET lock_timeout='3s'; UPDATE parent SET label='changed' WHERE id=1"UPDATE 1
Time: 4.931 ms$ psql -U postgres -c "SET lock_timeout='3s'; UPDATE parent SET id=99 WHERE id=2"ERROR: canceling statement due to lock timeout
Time: 3001.345 ms (00:03.001)4.9 ms against a timeout. Same table, same row, same open transaction on the other side — the only difference is whether the key changed.
Locking clauses in practice
-- read-modify-write: lock what you read
BEGIN;
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
UPDATE accounts SET balance = balance - 30 WHERE id = 42;
COMMIT;
Note, from lesson VII-04, that this particular example is better written
without the lock, as UPDATE accounts SET balance = balance - 30. Use
FOR UPDATE when the application genuinely must decide something
between the read and the write.
FOR NO KEY UPDATE is the weaker version, when you will change the
row but not its key. It permits concurrent foreign key checks, which
matters on a parent row that children are constantly being added to.
FOR SHARE prevents anyone from changing the row while you hold it,
without claiming it for yourself. Multiple sessions can hold it at once.
FOR KEY SHARE is what you want when the invariant is “this row
must still exist with this identity”, which is what a foreign key check
needs and occasionally what application logic needs too.
What to take from this
- Four row lock modes exist so a foreign key check does not block ordinary updates of the referenced row.
- Measured: non-key update of a referenced row, 4.9 ms. Key update of the same row, timeout.
- “Foreign keys cause locking problems” is pre-9.3 advice. The real defect is usually a missing index on the child’s referencing column.
SKIP LOCKEDis what makes a PostgreSQL work queue scale. It trades consistency for concurrency, deliberately.- Concurrent row locks allocate multixacts, which have their own wraparound and their own disk.
Cross-course references
- Observability for Production Sysadmins — Part LIX (Database observability) covers exporting lock waits, and Part XX (Alert quality) covers why an alert on lock count is noise while an alert on wait duration is not.
- Linux for Production Sysadmins — Part XXXVII (Resource management) covers the shared-memory limits that a relation-lock explosion eventually reaches.
Quiz
Knowledge check · 6 questions
Q1. With an uncommitted INSERT into a child table open, updating the referenced parent row's label column succeeded in under 5 ms while updating its id timed out. What explains the difference?
Q2. A work queue where each of twelve workers runs SELECT ... ORDER BY created_at LIMIT 1 FOR UPDATE achieves no more throughput than one worker. What is missing?
Q3. Deleting rows from a parent table has become extremely slow, and each delete appears to scan a large child table. What is the fix?
Q4. Which statements about multixacts are correct? Select all that apply.
Q5. FOR KEY SHARE and FOR NO KEY UPDATE can be held on the same row at the same time by different transactions.
Q6. A team wants to drop foreign key constraints from a busy schema to reduce lock contention. What would you tell them?
Passing score: 75%. Answers are checked in this browser.