Row locks
MVCC means readers and writers never block each other — so who does block? Writers competing for the same rows. Every UPDATE and DELETE takes a row lock, and you can take one explicitly with SELECT ... FOR UPDATE and friends. This page shows what those locks do, which ones coexist, and the row locks you're taking without knowing it.
FOR UPDATE blocks writers — and only writers
A> BEGIN;
BEGIN
A> SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
id | owner | balance
----+-------+---------
1 | alice | 100
(1 row)Reading the locked row costs B nothing — MVCC readers don't take row locks.
B> SELECT balance FROM accounts WHERE id = 1;
balance
---------
100
(1 row)Writing to it is another story: B's UPDATE must wait for A.
B> UPDATE accounts SET balance = balance - 10 WHERE id = 1;
⏳ B is waiting for a lock…
A> UPDATE accounts SET balance = 150 WHERE id = 1;
UPDATE 1
A> COMMIT; -- releases the row lock
COMMIT
⏵ B resumes:
UPDATE 1
B> SELECT balance FROM accounts WHERE id = 1; -- B's -10 applied on top of A's committed 150
balance
---------
140
(1 row)Verified against PostgreSQL 18.4 · Run it yourself · Scenario source
The four modes
Row locks come in four strengths (the manual's Table 13.3 is the same conflict matrix):
| you hold ↓ / they want → | FOR KEY SHARE | FOR SHARE | FOR NO KEY UPDATE | FOR UPDATE |
|---|---|---|---|---|
| FOR KEY SHARE | ✅ | ✅ | ✅ | ⛔ |
| FOR SHARE | ✅ | ✅ | ⛔ | ⛔ |
| FOR NO KEY UPDATE | ✅ | ⛔ | ⛔ | ⛔ |
| FOR UPDATE | ⛔ | ⛔ | ⛔ | ⛔ |
You rarely type the middle two by hand, yet PostgreSQL reaches for them constantly (Row-Level Locks). An UPDATE that doesn't touch key columns takes FOR NO KEY UPDATE — the manual: "This lock mode is also acquired by any UPDATE that does not acquire a FOR UPDATE lock." An UPDATE that changes a key, and every DELETE, takes the full FOR UPDATE. A foreign-key check takes the weakest mode, FOR KEY SHARE, on the referenced row; the manual never quite says so, but the scenario below proves it — the conflict signature you'll see, a non-key UPDATE passing while a DELETE blocks, matches FOR KEY SHARE and nothing else in the matrix.
Two FOR SHARE locks on the same row coexist happily…
A> BEGIN;
BEGIN
A> SELECT id FROM accounts WHERE id = 1 FOR SHARE;
id
----
1
(1 row)
B> BEGIN;
BEGIN
B> SELECT id FROM accounts WHERE id = 1 FOR SHARE;
id
----
1
(1 row)
B> COMMIT;
COMMIT…but FOR SHARE still blocks a plain UPDATE (which takes FOR NO KEY UPDATE).
B> UPDATE accounts SET balance = 200 WHERE id = 1;
⏳ B is waiting for a lock…
A> COMMIT;
COMMIT
⏵ B resumes:
UPDATE 1The weakest lock, FOR KEY SHARE, even coexists with a running UPDATE…
A> BEGIN;
BEGIN
A> UPDATE accounts SET balance = 300 WHERE id = 1;
UPDATE 1
B> SELECT id FROM accounts WHERE id = 1 FOR KEY SHARE;
id
----
1
(1 row)…while the strongest, FOR UPDATE, has to wait for it.
B> SELECT id FROM accounts WHERE id = 1 FOR UPDATE;
⏳ B is waiting for a lock…
A> COMMIT;
COMMIT
⏵ B resumes:
id
----
1
(1 row)Verified against PostgreSQL 18.4 · Run it yourself · Scenario source
The row locks you didn't know you were taking
Insert a child row, and PostgreSQL locks the parent row for you — with FOR KEY SHARE, so the parent can still be updated, but not deleted or re-keyed. That's not a courtesy: without the lock, the parent could vanish between the FK check and the commit.
A> BEGIN;
BEGIN
A> INSERT INTO orders VALUES (1, 1); -- FK check locks customers row 1 FOR KEY SHARE
INSERT 0 1FOR KEY SHARE doesn't mind non-key updates — B can change the balance.
B> UPDATE customers SET balance = 50 WHERE id = 1;
UPDATE 1But DELETE needs the strongest row lock (FOR UPDATE) — B has to wait.
B> DELETE FROM customers WHERE id = 1;
⏳ B is waiting for a lock…
A> COMMIT;
COMMITA's order is now committed, so B's DELETE resumes — straight into the FK.
⏵ B's blocked statement fails:
ERROR: 23503: update or delete on table "customers" violates foreign key constraint "orders_customer_id_fkey" on table "orders"
B> SELECT balance FROM customers WHERE id = 1; -- the parent survived, with B's earlier non-key update intact
balance
---------
50
(1 row)Verified against PostgreSQL 18.4 · Run it yourself · Scenario source
Once you know the implicit locks, most "why is this blocked?" mysteries explain themselves. A plain SELECT takes no row locks, ever — row locks are a writers-only affair. UPDATE takes FOR NO KEY UPDATE, DELETE (or a key-changing UPDATE) takes FOR UPDATE, and an FK insert takes FOR KEY SHARE on the parent. Two share-mode locks coexist, which is what keeps hot parent rows survivable under foreign-key churn.
Two properties of row locks carry over into the next chapters. They live until the transaction ends — there's no unlock statement, so a long transaction means long-held locks. And they're written into the row on disk: SELECT FOR UPDATE causes writes, which is why there's no limit on how many rows you can lock and why they're invisible in pg_locks. The monitoring lesson shows what you see instead, and the MVCC chapter shows the xmax stamp that does the work.