Locks inside the database
Two writers to one row must take turns. Postgres makes them take turns with row locks, table locks and advisory locks. Here is what conflicts with what, recorded from two real sessions.
- 1Four row-lock modes. Plain UPDATE takes NO KEY UPDATE, so foreign key checks do not wait for it.
- 2A row lock lives in the row. Waiters queue on the holder's transaction ID.
- 3SKIP LOCKED turns a table into a work queue. NOWAIT and lock_timeout fail fast instead of waiting.
- 4Lock rows in one order. Postgres breaks a deadlock after 1 s by cancelling one transaction.
- Readers never wait for a row lock. They read their snapshot.
- Writers and locking reads on the same row wait until the holder commits or rolls back.
- Locks on different rows never wait for each other.
A row lock lives in the row itself. Other writers queue on the holder's transaction, so one hot row commits one writer at a time.
| method | where | status |
|---|---|---|
| UPDATE with a WHERE that checks the state | Postgres | Approved |
| SELECT ... FOR UPDATE, then UPDATE | Postgres | Approved |
| FOR NO KEY UPDATE when the key does not change | Postgres | Foreign keys |
| FOR UPDATE SKIP LOCKED for a work queue | Postgres | Approved |
| FOR UPDATE NOWAIT | Postgres | User can retry |
| Advisory lock on a name | Postgres | Not one row |
| LOCK TABLE in the request path | Postgres | Not approved |
| A Redis lock around a Postgres write | Redis | Not approved |
| An application mutex | Service | Not approved |
An application mutex covers one process. A Redis lock adds a second store that can lose the lock in a failover. The row lock already sits next to the data.
I lock the narrowest thing: a row before a table, and a row before a lock service.
| held ↓ · asked → | FOR KEY SHARE | FOR SHARE | FOR NO KEY UPDATE | FOR UPDATE |
|---|---|---|---|---|
| FOR KEY SHARE | ||||
| FOR SHARE | ||||
| FOR NO KEY UPDATE | ||||
| FOR UPDATE | ||||
| UPDATE a non-key column | ||||
| UPDATE the key | ||||
| DELETE | ||||
| INSERT a row that references it |
| t | Session 1 | Session 2 |
|---|---|---|
| 1 | BEGINBEGIN | |
| 2 | SELECT id FROM accounts WHERE id = 1 FOR NO KEY UPDATEid = 1 | |
| 3 | BEGINBEGIN | |
| 4 | SELECT id FROM accounts WHERE id = 1 FOR KEY SHAREid = 1 | |
| 5 | ROLLBACKROLLBACK | |
| 6 | ROLLBACKROLLBACK |
FOR UPDATE conflicts with everything. FOR KEY SHARE conflicts only with FOR UPDATE. A plain UPDATE takes FOR NO KEY UPDATE.
| statement | takes |
|---|---|
| UPDATE of a non-key column | FOR NO KEY UPDATE |
| UPDATE of a key column | FOR UPDATE |
| DELETE | FOR UPDATE |
| INSERT of a row that references it | FOR KEY SHARE |
| SELECT | no row lock |
- Each statement's conflicts in the matrix match the mode in this table.
- A key column has a unique index that a foreign key can use.
- FOR SHARE: many readers hold the row, and no one may change it.
Postgres takes row locks for me on every write. I add a locking read only when I decide from a value before I write it.
| held ↓ · asked → | AS | RS | RE | SUE | S | SRE | E | AE |
|---|---|---|---|---|---|---|---|---|
| AS ACCESS SHARE | · | · | · | · | · | · | · | ✕ |
| RS ROW SHARE | · | · | · | · | · | · | ✕ | ✕ |
| RE ROW EXCLUSIVE | · | · | · | · | ✕ | ✕ | ✕ | ✕ |
| SUE SHARE UPDATE EXCLUSIVE | · | · | · | ✕ | ✕ | ✕ | ✕ | ✕ |
| S SHARE | · | · | ✕ | ✕ | · | ✕ | ✕ | ✕ |
| SRE SHARE ROW EXCLUSIVE | · | · | ✕ | ✕ | ✕ | ✕ | ✕ | ✕ |
| E EXCLUSIVE | · | ✕ | ✕ | ✕ | ✕ | ✕ | ✕ | ✕ |
| AE ACCESS EXCLUSIVE | ✕ | ✕ | ✕ | ✕ | ✕ | ✕ | ✕ | ✕ |
| statement | takes | blocks reads | blocks writes |
|---|---|---|---|
| SELECT | AS | No | No |
| SELECT ... FOR UPDATE | RS | No | No |
| INSERT | RE | No | No |
| UPDATE | RE | No | No |
| DELETE | RE | No | No |
| VACUUM | SUE | No | No |
| ANALYZE | SUE | No | No |
| CREATE INDEX CONCURRENTLY | SUE | No | No |
| CREATE INDEX | S | No | Yes |
| ALTER TABLE ... ADD COLUMN | AE | Yes | Yes |
| TRUNCATE | AE | Yes | Yes |
| DROP TABLE | AE | Yes | Yes |
- Use CREATE INDEX CONCURRENTLY on a live table. Plain CREATE INDEX takes SHARE, which blocks every write until it ends.
- A waiting ACCESS EXCLUSIVE request blocks new reads too. We tested it: a SELECT that arrives after a waiting ALTER waits as well.
Every statement takes a table lock. DML takes weak ones that rarely conflict. Most ALTER TABLE forms take ACCESS EXCLUSIVE, which conflicts with every query.
| tool | capability | what it gives you | also used for |
|---|---|---|---|
| Postgres | Row locks in the row header | No limit on the number of locked rows. Each lock writes to the row. | Batch updates of many rows |
| Postgres | Four row-lock modes | A foreign key check takes FOR KEY SHARE and does not wait for balance updates. | Parent rows with busy children |
| Postgres | Waits on the holder's transaction ID | Waiters wake when the holder commits, then read the newest row. | Counters, stock |
| Postgres | FOR UPDATE SKIP LOCKED | Each worker takes a different free row. No one waits. | Job queues, outbox relays, batch claims |
| Postgres | NOWAIT and lock_timeout | Fail with 55P03 instead of waiting. | Interactive requests, DDL on live tables |
| Postgres | Deadlock detector | After deadlock_timeout, finds a wait cycle and cancels one transaction with 40P01. | Any multi-row write |
| Postgres | Advisory locks: pg_advisory_xact_lock, pg_try_advisory_xact_lock | A lock on a number the application picks. It ends with the transaction. | Singleton jobs, migrations, per-tenant work |
| Postgres | Eight table-lock modes | DML runs together. DDL waits for, and then blocks, everything else. | Online schema changes |
| Postgres | pg_locks | Shows who waits, for which lock, held by whom. | Incident debugging |
Postgres gives me row locks with no count limit and four modes. It adds SKIP LOCKED for queues, NOWAIT and timeouts to fail fast, advisory locks, and a deadlock detector.
claim(): // each worker, in a loop
UPDATE jobs SET state = running3
WHERE id = (
first ready job by id1
that no other worker has locked // SKIP LOCKED2
)
RETURN its id, or nothing
worker():
job = claim()
IF nothing: sleep, then try again4
do the work; mark the job done- 1A partial index on ready jobs keeps this a short index scan, however many jobs are done.
- 2A row that another worker has locked is skipped, not waited for.
- 3The claim and the state change are one statement, so a job is never claimed twice.
- 4No free job. Poll again, or wait for LISTEN/NOTIFY.
Tested source SQL: claim · Go: claim
UPDATE jobs SET state = 'running'
WHERE id = (
SELECT id FROM jobs
WHERE state = 'ready'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING id;
// Claim takes the first ready job that no other worker holds, marks it running and returns its
// id. Workers never wait for each other: a locked job is skipped. ok is false when no job is
// free.
func Claim(ctx context.Context, db *pgxpool.Pool) (id int, ok bool, err error) {
err = db.QueryRow(ctx, schema["claim"]).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return 0, false, nil
}
if err != nil {
return 0, false, fmt.Errorf("claim a job: %w", err)
}
return id, true, nil
}
- Tested: 16 workers claimed 500 jobs, and each job went to exactly one worker.
- A worker that crashes rolls back, so its job is ready again.
Workers claim jobs with FOR UPDATE SKIP LOCKED. Each one gets a different job, and no worker waits for another.
Two workers ask for the first ready job. Each gets a different one.
| t | Session 1 | Session 2 |
|---|---|---|
| 1 | BEGINBEGIN | |
| 2 | SELECT id FROM jobs WHERE state = 'ready' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKEDid = 1 | |
| 3 | BEGINBEGIN | |
| 4 | SELECT id FROM jobs WHERE state = 'ready' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKEDid = 2Job 1 is locked, so session 2 gets job 2 with no wait. | |
| 5 | UPDATE jobs SET state = 'done' WHERE id = 1UPDATE 1 | |
| 6 | UPDATE jobs SET state = 'done' WHERE id = 2UPDATE 1 | |
| 7 | COMMITCOMMIT | |
| 8 | COMMITCOMMIT |
NOWAIT and lock_timeout fail with 55P03. SKIP LOCKED never waits. A deadlock costs one second and one cancelled transaction.
ERROR: deadlock detected
DETAIL: Session 1 waits for ShareLock on the transaction of session 2.
Session 2 waits for ShareLock on the transaction of session 1.
HINT: See server log for query details.- In this run Postgres cancelled session 1. The documentation says the choice is hard to predict, so retry either one.
- The other session's statement completes as soon as the cancelled one rolls back.
A deadlock is a wait cycle. Postgres cancels one transaction after deadlock_timeout. I prevent it by locking rows in one order, and I retry 40P01.
| setting | default | what it stops |
|---|---|---|
| lock_timeout | 0 | A statement that waits too long for a lock. 0 means wait forever. |
| statement_timeout | 0 | A statement that runs too long, waiting or not. |
| deadlock_timeout | 1s | How long a waiter waits before it checks for a deadlock. |
| idle_in_transaction_session_timeout | 0 | A session that holds locks and does nothing. |
| max_locks_per_transaction | 64 | Sizes the table-lock memory. Row locks do not count against it. |
- Set lock_timeout with SET LOCAL inside the transaction, so it covers only that work.
- Run DDL with a short lock_timeout and retry it. A waiting ALTER then never blocks the reads that queue behind it.
Postgres waits for a lock forever by default. I set lock_timeout for DDL and for user requests, and statement_timeout as a ceiling.
with_lock(name, work):
BEGIN
take advisory lock hash(name)1 // waits for the holder2
work() // one caller at a time
COMMIT // the lock ends here3- 1Advisory locks take an integer key. Hash the name; keep one key space per application.
- 2The try variant returns false at once instead. The recorded run shows both.
- 3A transaction-level lock ends at COMMIT or ROLLBACK, also when the client crashes.
Tested source Go: with an advisory lock
// WithAdvisoryLock runs fn in a transaction that holds the advisory lock for name. Callers with
// the same name run one at a time; the lock ends with the transaction, even on a crash.
func WithAdvisoryLock(ctx context.Context, db *pgxpool.Pool, name string, fn func(pgx.Tx) error) error {
return pgx.BeginFunc(ctx, db, func(tx pgx.Tx) error {
// hashtext maps the name to the integer key that advisory locks take.
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext($1))`, name); err != nil {
return fmt.Errorf("advisory lock %q: %w", name, err)
}
return fn(tx)
})
}
- Tested: 16 callers read, paused and wrote one value under one name. No increment was lost.
- Session-level advisory locks outlive the transaction. With a connection pool, prefer the transaction-level form.
For work that is not one row, such as a monthly payout run, I take a transaction-level advisory lock on a hash of its name.
- This panel is about MySQL InnoDB. Its default level is REPEATABLE READ.
- A next-key lock is an index-record lock plus a lock on the gap before the record.
- Gap locks only stop inserts into the gap. Two gap locks on one gap do not conflict.
- At READ COMMITTED, InnoDB uses gap locks only for foreign key and duplicate key checks.
Source: the MySQL 8.0 reference manual, InnoDB locking. Not measured in our lab.
MySQL InnoDB locks gaps between index records at REPEATABLE READ. Postgres has no gap locks; it uses snapshots and SIREAD locks.
| event | result | what stops it | saved by |
|---|---|---|---|
| ALTER TABLE waits behind a long report | Every new query on the table queues behind the ALTER. | Run DDL with SET lock_timeout and retry it. | lock_timeout |
| Two transactions lock the same rows in opposite order | A deadlock. One is cancelled after 1 s. | Lock rows in one order, by id. Retry 40P01. | Lock order |
| A worker holds a row lock, then calls a slow API | Every writer of that row waits for the API. | Call the API outside the transaction. Keep transactions short. | Short tx |
| A client opens a transaction and goes idle | Its locks stay until it closes. | idle_in_transaction_session_timeout ends the session. | Idle timeout |
| Workers poll a queue with FOR UPDATE and no SKIP LOCKED | All of them queue on the first row. | Add SKIP LOCKED. | SKIP LOCKED |
| A worker crashes holding a claimed job | The connection drops, and the transaction rolls back. | The job is ready again. Make the work idempotent. | Rollback |
| One counter row takes every write | All writers queue: about 2,600 a second in our lab. | Split the counter into N rows and sum them on read. | Split row |
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | Row locks from plain writes and FOR UPDATE. | 12,000 to 16,000 lock-and-update transactions a second on spread rows in our lab. | A table used as a queue: workers wait on the same first rows. |
| 2 | SKIP LOCKED for queues, NOWAIT and lock_timeout for user requests. | 8,000 to 11,000 job claims a second, against 2,500 to 4,700 without SKIP LOCKED. | Work that must run once but is not one row: a payout run, a rebuild. |
| 3 | Advisory locks on a hashed name. | One holder per name, released at commit, with no extra service. | One row takes most of the writes: waits grow, and it tops out near 2,600 a second. |
| 4 | Split the hot row into N rows. Write to a random one; sum on read. | Writes spread over N rows. In our lab, 16 rows took about 14,000 a second against 2,600 for one. | The value needs one exact order of writes, such as a ledger balance. |
| 5 | One writer per key: a queue consumer applies the key's writes in batches. | No lock waits for the key. Batching cuts commits. | Top of the ladder. |
A lock-and-update transaction is FOR UPDATE on one row, then an UPDATE, then COMMIT. A job claim is the SKIP LOCKED statement in panel G.
Row locks on spread rows run many thousands of transactions a second. I act only when one row gets hot: split it, queue with SKIP LOCKED, or give the key one writer.
0 of 8 known
A child row with a foreign key is inserted while another transaction updates the parent balance. Does either wait?
Session 1 runs DELETE on a row. Session 2 asks for FOR KEY SHARE on it. What happens?
Where does Postgres store a row lock, and what does that mean for one million locked rows?
Ten workers poll a jobs table with FOR UPDATE and no SKIP LOCKED. What goes wrong?
Two transfers lock accounts 1 and 2 in opposite order. What does Postgres do, and what do you change?
ALTER TABLE ADD COLUMN waits behind a long report. Why do new reads also stop?
When do you use an advisory lock instead of a row lock?
In MySQL InnoDB at REPEATABLE READ, a range SELECT ... FOR UPDATE runs. Can another session insert into the range?
- hot row
- 2,300 to 2,600 lock-and-update transactions a second.
- spread
- 12,000 to 16,000 on 16 rows or more.
- queue
- 8,000 to 11,000 job claims a second with SKIP LOCKED; 2,500 to 4,700 without it.
- deadlock
- deadlock_timeout is 1s: a deadlock costs at least that.
- modes
- 4 row-lock modes, 8 table-lock modes.
- errors
- 55P03 lock not available, 40P01 deadlock.
Postgres 16 on an 8-core laptop, 16 clients, 3-second runs, every commit flushed, two runs. The bars show one run. Use these as orders of magnitude.