SQL locking cheat sheet

Postgres first, MySQL differences at the end. Written so the pieces stop arriving one at a time.
back to sd-prep

Start here: most of the time you need none of this

A single SQL statement is already atomic. This needs no lock clause from you, because Postgres takes a row lock internally for the duration of the statement and the guard either matches or it does not:

UPDATE accounts SET balance = balance - 20
 WHERE id = :a AND balance >= 20

Rows affected = 0 is the rejection. That is the whole mechanism behind the fused guard in devex-payouts and the window fence in like-counter, neither of which writes FOR UPDATE anywhere.

You reach for explicit row locking when you must hold a row across more than one statement in a transaction: read it, decide something, then write based on what you read. That read-to-write gap is the thing the lock protects, and it is the only reason these clauses exist.

lock-gap
Click to zoom. Two transactions withdrawing 20 from the same balance of 100.

The four row lock strengths

All four are written SELECT ... FROM t WHERE ... FOR <mode> and all four last until the transaction ends. Weakest to strongest:

clausewhat it stopswhere you meet it
FOR KEY SHAREonly key changes and deletes of that row what a foreign-key check takes on the parent row
FOR SHAREanyone trying to update or delete the row; other readers are fine "this row must still say what it says when I commit"
FOR NO KEY UPDATEupdaters and FOR SHARE, but not FK checks what a plain UPDATE takes when it touches no key column
FOR UPDATEeverything above "I am about to modify this row, hands off until I commit"

The two you will actually type are FOR SHARE and FOR UPDATE. The other two mostly explain why an UPDATE sometimes blocks on a foreign key you had forgotten about.

The conflict matrix

Read it as: someone already holds the row lock in the left column, you now ask for the one across the top.

held \ requestedKEY SHARESHARE NO KEY UPDATEUPDATE
FOR KEY SHAREokok okBLOCKS
FOR SHAREokok BLOCKSBLOCKS
FOR NO KEY UPDATEokBLOCKS BLOCKSBLOCKS
FOR UPDATEBLOCKSBLOCKS BLOCKSBLOCKS
The two rules worth memorising from that grid
The thing that trips people up
A plain SELECT takes none of these locks and is never blocked by any of them. Readers and writers do not block each other under MVCC. "Locked" never means "unreadable", it only means "the next writer waits".

FOR SHARE as a fence

The interesting use of FOR SHARE is not reading, it is asserting that something stays true. In the game-matching claim transaction:

BEGIN;
SELECT 1 FROM leases WHERE partition = :p AND epoch = :my_epoch FOR SHARE;
-- zero rows means we are a zombie: ROLLBACK
...the claim writes...
COMMIT;

A takeover bumps that epoch with an UPDATE, which conflicts with this FOR SHARE, so the stale owner and the new owner cannot both commit. A plain SELECT there would read a snapshot and prove nothing.

The two modifiers

These attach to FOR UPDATE or FOR SHARE and change what happens when the row you want is already locked. The default is to wait indefinitely.

modifieron a locked rowuse it for
(none)waits, forever if need bethe correct default when you genuinely need that row
NOWAITraises error 55P03 immediatelyinteractive paths where failing beats hanging
SKIP LOCKEDsilently skips it and moves onworker queues
lock-skiplocked
Click to zoom. Two workers claiming disjoint batches with no coordination between them.

SKIP LOCKED is what turns a table into a work queue, which is the delayed-payments poller exactly. The cost is the part to say out loud before an interviewer says it: skipping locked rows quietly abandons strict ordering. Nobody cares in a payments queue. It is fatal in a matchmaking queue whose product promise is join order, which is why game-matching names it and declines it.

Deadlocks, and the one rule that prevents them

Two transactions each holding a row the other wants deadlock, and Postgres kills one with error 40P01. Prevention is boring and absolute: acquire locks in a consistent global order everywhere in the codebase. Wallet does this by locking min(A,B) then max(A,B) so a transfer in either direction takes the same path.

Subtle and commonly wrong
ORDER BY combined with FOR UPDATE does not guarantee the order in which locks are acquired. That is planner behaviour, not a contract. If order matters, issue separate single-row statements in the sequence you want. This is also why the fix for a hot-account batch is to lock every touched row in one global sorted sequence before applying anything, rather than locking the hot row first and the receivers as they come.

Advisory locks

Locks on a number you invent, attached to no row. Useful when the thing to serialise is not a row: "only one instance of this job at a time", or "serialise everything touching user 12345" without holding real rows.

functionscopenotes
pg_advisory_xact_lock(k)transaction released automatically at commit or rollback. Default to this one.
pg_advisory_lock(k)session must be unlocked explicitly or the connection closed. In a pooler this leaks a lock that outlives the request.
pg_try_advisory_lock(k)session returns true or false instead of waiting: "if someone else is doing this, skip it".

Distributed locks, and why a CAS usually beats one

For a single row in a single database you do not need a distributed lock. The CAS is strictly better, and reaching for Redis there is cargo cult. Four situations are where a lock genuinely earns its place.

lock-vs-cas
Click to zoom. Note that no branch ends at "the lock made it safe".

1. The resource cannot compare anything

A CAS needs a WHERE clause to live in. An S3 object, a filesystem, a printer, a third-party payment gateway: there is no state to hang a condition on. That is the case fencing tokens were invented for, and they only work if the resource compares the token, which is resource-side participation all over again. If it genuinely cannot compare anything, no lock saves you and the honest move is idempotency at the far side. That is exactly why delayed-payments keys the gateway call by paymentId instead of trying to fence it.

2. The critical section spans more than one row

A CAS guards one row. If the unit of work is "read 10,000 rows, compute a plan, write to three tables and publish to Kafka", per-row CAS gives you no mutual exclusion over the job. Every individual write can pass its own guard while two workers still duplicate the entire computation and interleave their outputs. A lock is a claim about the whole critical section, which no single-row guard can make.

3. Efficiency, which is a different goal from correctness

This is the distinction most people skip. Locks get used for two unrelated reasons:

purposea duplicate causeswhat you actually need
efficiencywasted work, a bigger bill a sloppy lock is fine; correctness never depended on it
correctnesscorrupted data fencing or CAS at the resource; the lock alone is not enough

With 50 workers and an expensive job, a lock stops 49 of them burning CPU on work that would lose the CAS anyway. That is a throughput argument, not a safety one. CAS is optimistic (everyone works, losers retry and discard) and a lock is pessimistic (one works). When the work before the write is expensive and contention is high, optimism gets expensive.

4. There is no distinguishable "before" value to CAS on

CAS-on-state works when a write is a one-shot transition out of a known state: WHERE status = 'WAITING' works precisely because WAITING happens once and is consumed. When the same owner legitimately writes the same row over and over, there is no stale prior value left to test against, and a zombie's write looks structurally identical to a legitimate one. That is when you add a monotonic column and compare against it instead of against the data.

mechanismrejects based onreads asexample
CAS on statewhat changed"the world must still look like this" status = 'WAITING'
fencing tokenwho is newer"no later owner has spoken yet" last_window < :w

They are the same machinery pointed at different columns. The like-counter fence is exactly this: you cannot CAS on the count, because the count is supposed to keep changing, so it fences on a monotonic window id instead.

Why Redis in particular is the wrong pick for correctness
A Redis lock gives mutual exclusion under normal conditions but issues no monotonic fencing token, and its failover can hand the lock to two holders at once. That makes it fine for reason 3 and unsound for correctness unless the resource fences independently. If you want a lock that also hands out a monotonically increasing number, that is what etcd revisions and ZooKeeper zxids are for.

And the punchline for these designs: if you already have the database, you do not need Redis at all. A lease row with an epoch column gives you mutual exclusion, a monotonic token, and, uniquely, validation in the same transaction as the write. That is what game-matching does, and it is strictly stronger than an external lock plus fencing, because with an external lock the check and the write can never be atomic.

The sentence to never say in an interview
"The lock makes it safe." The lock reduces contention. The resource-side check is what makes it correct.

Table-level locks, and why your migration caused an outage

You rarely write LOCK TABLE yourself. These matter because DDL takes them.

modetaken byblocks
ACCESS EXCLUSIVEDROP, TRUNCATE, VACUUM FULL, REINDEX, most ALTER TABLE everything, including plain reads
SHARECREATE INDEX (non-concurrent) writes; reads are fine
SHARE UPDATE EXCLUSIVEVACUUM, ANALYZE, CREATE INDEX CONCURRENTLYneither reads nor writes

The failure mode to be able to describe: an ACCESS EXCLUSIVE request queues behind whatever long transaction is already running, and every new query then queues behind it. That is how a migration that "takes 2ms" produces a five minute outage. Defences: CREATE INDEX CONCURRENTLY, and set a short lock_timeout before DDL so the migration fails fast instead of forming a queue.

The other axis: isolation levels

Locking is one way to prevent anomalies. Isolation level is the other, and they trade off against each other.

levelwhat you getwhat you owe
READ COMMITTEDa fresh snapshot per statement (Postgres default) explicit locks or WHERE guards wherever correctness needs them
REPEATABLE READone snapshot for the whole transaction handle serialization failures (40001) with a retry
SERIALIZABLEtrue serializability via predicate locking retry on 40001, and accept the abort rate under contention

SERIALIZABLE can replace most explicit locking, but it does not delete the work, it moves it into a retry loop you have to actually write. Worth naming as the alternative you considered and declined.

If the interview is MySQL

Where each of these already lives in your own designs

mechanismdesigndoing what
FOR SHAREgame-matching fencing the lease epoch inside the claim transaction
FOR UPDATE SKIP LOCKEDdelayed-payments the poller claiming a batch of due rows
FOR UPDATE, sortedwallet two single-row locks in min/max order to prevent deadlock
no explicit lockdevex-payouts, like-counter a single guarded statement: fused balance guard, window fence

Ninety second recall