Locking and deadlocks

Row locks, gap locks, SELECT FOR UPDATE, lock ordering, optimistic locking with a version column, and reading a deadlock graph.

15 min read🐘 Relational Databases in Depth

Locks are how the database keeps two transactions from corrupting the same row, and deadlocks are what happens when two transactions each hold a lock the other needs. Both are normal; a system with no lock waits is a system with no concurrency. What a senior engineer knows is which lock each statement takes, how long it is held, how to order work so that it cannot deadlock, and how to read the report the engine writes when it does.

What takes a lock, and for how long

Every UPDATE, DELETE and INSERT takes an exclusive lock on the rows it touches, and holds it until the transaction ends. Not until the statement ends: until COMMIT or ROLLBACK. That single fact explains most lock problems. A transaction that updates a row and then calls a payment API for four seconds holds that row for four seconds, and every other transaction that wants it queues behind.

Plain SELECTs take no row locks on either engine, because of MVCC; readers see a version, they do not wait for a writer. The locks that matter are between writers, and between writers and locking reads.

Lock types

PostgreSQLMySQL InnoDB
Exclusive row lockUPDATE, DELETE, SELECT ... FOR UPDATEthe same
Weaker exclusiveFOR NO KEY UPDATE: an update that does not change a key; lets FOR KEY SHARE (foreign-key checks) through
Shared row lockFOR SHARE, FOR KEY SHAREFOR SHARE (LOCK IN SHARE MODE)
Range/gap locknone for rows; serializable uses predicate locksgap locks and next-key locks at repeatable read: a locking read on a range also locks the gaps so no row can be inserted into it
Table lockDDL takes ACCESS EXCLUSIVE; LOCK TABLE explicitlyDDL metadata locks; LOCK TABLES

InnoDB's gap locks are the source of deadlocks people cannot explain. Two transactions each INSERT into a range the other has a gap lock on (from an earlier SELECT ... FOR UPDATE that found no row, or from a DELETE on a range) and they wait on each other. Running at read committed removes gap locking, which is why write-heavy MySQL deployments often choose it.

FOR UPDATE and SKIP LOCKED

Reserve stock: read and lock in one stepsql
BEGIN;
SELECT quantity FROM stock WHERE sku = 'A1' FOR UPDATE;   -- nobody else reads-to-write this row now
-- decide in application code
UPDATE stock SET quantity = quantity - 1 WHERE sku = 'A1';
COMMIT;

FOR UPDATE turns a read into a claim. Two workers doing this serialise on the row; the second waits at the SELECT until the first commits, then sees the committed value. Add NOWAIT to fail immediately instead of waiting, which is right for an interactive request that should not queue.

SKIP LOCKED is the job queue in one clause:

Claim the next unclaimed job, from many workers at oncesql
UPDATE jobs SET status = 'RUNNING', claimed_by = :worker, claimed_at = now()
WHERE id = (
  SELECT id FROM jobs WHERE status = 'PENDING'
  ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED
)
RETURNING id, payload;

Each worker locks the first pending row that nobody else has locked, skipping the ones in flight, with no coordination and no duplicates. Both engines support it (PostgreSQL 9.5, MySQL 8.0). This is exactly how this site's blog worker claims jobs.

Optimistic versus pessimistic

Pessimistic locking (FOR UPDATE) holds the row while you think. It is right when conflicts are common and the think time is short and inside the database: stock, balances, counters.

Optimistic locking holds nothing. It reads a version, does its work, and writes back only if the version has not changed:

sql
UPDATE orders SET status = 'SHIPPED', version = version + 1
WHERE id = :id AND version = :versionRead;
-- 0 rows: someone else changed it; reload, re-decide, retry or tell the user

It is right when conflicts are rare and the think time is long or outside the database: a user editing a form for two minutes, a workflow step that calls three services. JPA's @Version does this and throws OptimisticLockException on the 0-row case; the exception has to be handled somewhere, and "somewhere" is usually a retry for background work and a "this changed while you were editing" for people.

Deadlocks

T1 locks row A and wants row B; T2 locks row B and wants row A. Neither can proceed. Both engines detect this rather than letting it hang: InnoDB checks the wait-for graph immediately and rolls back the transaction with the smaller undo footprint (ERROR 1213: Deadlock found when trying to get lock); PostgreSQL checks after deadlock_timeout (one second by default) and aborts one with ERROR 40P01: deadlock detected. The aborted transaction must be retried by the application. A deadlock is not data corruption, it is a retry.

Reading the report is how you find the cause:

PostgreSQL's log entryplaintext
ERROR:  deadlock detected
DETAIL:  Process 8123 waits for ShareLock on transaction 5501; blocked by process 8140.
         Process 8140 waits for ShareLock on transaction 5498; blocked by process 8123.
         Process 8123: UPDATE accounts SET balance = balance - 30 WHERE id = 2
         Process 8140: UPDATE accounts SET balance = balance + 30 WHERE id = 1
HINT:  See server log for query details.

Two transfers, 1→2 and 2→1, each updated its source account first and then its destination. MySQL's SHOW ENGINE INNODB STATUS prints a LATEST DETECTED DEADLOCK section with the same shape: both transactions, the locks each holds and waits for, and which one it rolled back.

Lock ordering: the fix that removes deadlocks

Deadlocks need a cycle, and a cycle needs two transactions to take the same locks in different orders. Take them in the same order everywhere and no cycle can form:

Always lock the lower id firstsql
-- transfer between a and b, whichever direction
SELECT * FROM accounts WHERE id IN (:a, :b) ORDER BY id FOR UPDATE;
UPDATE accounts SET balance = balance - :amt WHERE id = :from;
UPDATE accounts SET balance = balance + :amt WHERE id = :to;

The same rule for multi-row updates: UPDATE ... WHERE id IN (...) locks rows in whatever order the plan visits them, which can differ between two statements with different predicates. Lock the set explicitly with an ordered SELECT ... FOR UPDATE first. And keep parent-then-child order consistent across the codebase: always the order, then its lines, never the reverse in one place.

Locks you did not ask for

  • Foreign keys. Inserting a child row takes a share lock on the parent row (FOR KEY SHARE in PostgreSQL). Updating a parent's primary key, or deleting it, waits for those. A hot parent row (one "default" tenant everybody references) is a serialisation point.
  • DDL. ALTER TABLE in PostgreSQL takes ACCESS EXCLUSIVE, which waits for every running transaction on the table and, while it waits, blocks every new one, including reads. A migration that waits behind one long report takes the whole application down with it. Set lock_timeout before DDL so it gives up instead of queueing everyone, and use CREATE INDEX CONCURRENTLY and ADD COLUMN without a default expression (instant since PostgreSQL 11) to avoid long holds.
  • Long transactions. Every lock is held to commit. An open transaction that is idle (idle in transaction in pg_stat_activity) holds its locks and blocks vacuum. idle_in_transaction_session_timeout kills them.

Under the hood: where a row lock lives, and how a deadlock is found

Neither engine keeps a lock table entry per locked row; there would be millions. PostgreSQL writes the locking transaction's id into the row header (xmax, the same field that marks deletion, with a flag bit saying "locked, not deleted"). A second transaction wanting the row reads that id, sees it is still in progress, and waits on a transaction-level lock the first transaction holds on its own id for its whole life: waits for ShareLock on transaction 5501 in the deadlock report is exactly that. The shared lock table (pg_locks) holds those transaction locks and the table-level locks, not the rows. When a transaction commits, its id lock is released and every waiter wakes, re-reads the row version, and finds either that it can proceed or that the row changed and it must re-check its WHERE (read committed) or abort (repeatable read).

InnoDB keeps a real lock hash in the buffer pool, keyed by (space, page) with a bitmap of locked records per page, which costs memory per locked row and is why a DELETE of ten million rows in one transaction can exhaust the lock table. Its lock modes are richer because of the clustered index: a locking read on a secondary index locks the secondary entry and the clustered record, and at repeatable read also the gap before each record it scanned, so the set of locks a single SELECT ... FOR UPDATE takes depends on which index the planner chose. Change the index, change the locks, and a deadlock appears where none was.

Deadlock detection is a wait-for graph: nodes are transactions, edges are "waits on". InnoDB checks for a cycle every time a transaction starts to wait, walking the graph immediately (with a depth limit that, when exceeded, treats the wait as a deadlock anyway). PostgreSQL waits deadlock_timeout (1 s) before checking, on the theory that most waits resolve sooner and the check is not free; then it walks the graph and, if a cycle exists, aborts the transaction that ran the check. That one-second delay is why a PostgreSQL deadlock costs a second of latency for every participant, and why lowering deadlock_timeout on a system with many short lock waits is a trade, not a free win. Lock waits that are not deadlocks are simply not detected at all; lock_timeout is the only bound on them.

the cycle T1holds acct 1 T2holds acct 2 wants 2wants 1 wait-for graph T1 → T2 → T1: a cycle the fix: same order everywhere T1locks 1, then 2 T2waits for 1 T2 holds nothing while it waits: no cycle InnoDB checks the graph at once; PostgreSQL after deadlock_timeout (1 s) a lock wait that is not a cycle is never detected; only lock_timeout bounds it
Detection finds cycles; ordering prevents them. A transaction that holds nothing while waiting cannot be part of one.

Walkthrough: the migration that stopped the site for eleven minutes

A routine deploy added a column with a default to a busy PostgreSQL table.

V42__add_channel.sqlsql
ALTER TABLE orders ADD COLUMN channel TEXT NOT NULL DEFAULT 'web';
  1. On PostgreSQL 11+ this is instant: the default is stored in the catalog and no rows are rewritten. The DDL still needs ACCESS EXCLUSIVE on orders, for a few milliseconds.
  2. A reporting query had been running against orders for nine minutes, holding ACCESS SHARE. The ALTER queued behind it. That is expected and harmless on its own.
  3. PostgreSQL's lock queue is fair: every new request for a lock on orders queued behind the waiting ACCESS EXCLUSIVE, including plain SELECTs from every request thread. Within seconds the connection pool was full of sessions waiting on orders, Tomcat's workers were full of requests waiting on the pool, and the health check timed out.
  4. The report finished after eleven minutes, the ALTER ran in 4 ms, and everything drained. The migration tool reported success; the incident was logged as "database slow".
  5. The fix is one line before any DDL: SET lock_timeout = '3s'. The ALTER then fails after three seconds instead of queueing the world, the migration retries off-peak or after cancelling the report, and the tool (Flyway with a beforeMigrate callback, or the statement in each script) enforces it. The team also set idle_in_transaction_session_timeout so a forgotten open transaction could not hold ACCESS SHARE indefinitely, and statement_timeout on the reporting role.

The DDL was correct and fast. The lock it needed was queued behind one reader, and the queue's fairness turned one slow report into an outage.

Try it yourself

Deadlock or not?

T1: UPDATE t SET v = 1 WHERE id = 5 then UPDATE t SET v = 1 WHERE id = 6. T2: UPDATE t SET v = 2 WHERE id = 6 then UPDATE t SET v = 2 WHERE id = 5. T3: SELECT * FROM t WHERE id IN (5, 6). Which pairs can deadlock, and what does T3 experience while T1 and T2 are stuck?

Answer

T1 and T2 deadlock if T1 takes 5 and T2 takes 6 before either takes its second row: a cycle. T3 is unaffected: a plain SELECT takes no row locks under MVCC and reads the committed versions immediately. If T3 had been FOR UPDATE, it would join the queue on whichever row it reached first, and could become a third node in the graph. Fix T1/T2 by updating in id order in both, or by locking both rows up front with SELECT ... WHERE id IN (5,6) ORDER BY id FOR UPDATE.

The gap lock

MySQL, repeatable read. slots(day, hour) with a unique key. T1 runs SELECT * FROM slots WHERE day = '2026-09-10' AND hour = 9 FOR UPDATE (no row exists), then T2 runs the same for hour = 10 (no row), then T1 inserts hour 9 and T2 inserts hour 10. What happens, and what changes at read committed?

Answer

Both locking reads found no row and took a gap lock on the same gap (the empty range for that day). Gap locks are compatible with each other but block inserts into the gap by other transactions: T1's insert waits for T2's gap lock, T2's insert waits for T1's, and InnoDB reports a deadlock, rolling one back. At read committed there are no gap locks; both inserts proceed and the unique key arbitrates any true conflict. This is the classic "deadlock on insert with no shared rows", and either read committed or an INSERT ... ON DUPLICATE KEY first is the fix.

Read the status

SHOW ENGINE INNODB STATUS shows: transaction A lock_mode X locks rec but not gap on index PRIMARY of orders, waiting; transaction B holds that lock and is waiting on index idx_orders_customer of orders, lock_mode X locks gap before rec, held by A. Explain the shape and name a fix that does not change isolation level.

Answer

A locked a gap in the secondary index (from a range scan or a locking read that found nothing), then wants the clustered record B holds; B updated the row (holding the primary record) and now needs to insert or update through the secondary index into A's gap. Different indexes, different lock orders, a cycle. Fixes without changing the level: make both paths lock the row by primary key first (SELECT ... WHERE id = ? FOR UPDATE) before touching secondary-index ranges, so both acquire the same lock in the same order; or make the secondary-index read an equality on a unique key, which takes a record lock instead of a gap lock.

Misconceptions

  • "The database keeps a list of locked rows." PostgreSQL marks the row header and locks transaction ids; InnoDB keeps per-page bitmaps. Both make waits a matter of "wait for that transaction", which is what the reports say.
  • "Deadlocks are detected instantly." InnoDB checks on each wait; PostgreSQL checks after deadlock_timeout. A plain lock wait is never detected, only bounded by lock_timeout.
  • "SELECT never locks anything." Plain reads do not. FOR UPDATE/FOR SHARE do, and at InnoDB repeatable read they lock gaps too, including on rows that do not exist.
  • "A fast DDL cannot cause an outage." It needs an exclusive lock; while it waits, every new request on the table queues behind it. lock_timeout first.
  • "Optimistic locking avoids all of this." It avoids holding locks during think time; the final UPDATE still takes a row lock, and the retry on conflict is still your code.

Going deeper

  • PostgreSQL manual, "Explicit Locking" (the lock mode conflict table) and the pg_locks view; deadlock_timeout, lock_timeout, idle_in_transaction_session_timeout.
  • MySQL reference, "InnoDB Locking" (record, gap, next-key and insert-intention locks) and "Deadlock Detection".
  • PostgreSQL wiki, "Lock Monitoring", with the query that shows who blocks whom.
  • Braintree's "Safe Operations for High Volume PostgreSQL" and the strong_migrations checklist, for DDL that does not queue the world.
  • Egor Rogov, PostgreSQL 14 Internals, part II on locks.
Progress is saved on this device and to your account when signed in.