System Design
T4

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.

Not startedSaved in this browser only.
  1. 1Four row-lock modes. Plain UPDATE takes NO KEY UPDATE, so foreign key checks do not wait for it.
  2. 2A row lock lives in the row. Waiters queue on the holder's transaction ID.
  3. 3SKIP LOCKED turns a table into a work queue. NOWAIT and lock_timeout fail fast instead of waiting.
  4. 4Lock rows in one order. Postgres breaks a deadlock after 1 s by cancelling one transaction.
T4
    A

    One hot row

    writers take turns
    accounts, id = 1balance = 100xmax = T1 (locked)T1holds FOR UPDATEwrites its IDT2waitsT3waitsT4waitsEach waiter asks for a ShareLockon T1's transaction ID.T1 commits: the next one runs.One hot row runs one writer at a time.
    • 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.

    B

    Which lock, where

    pick the narrowest
    methodwherestatus
    UPDATE with a WHERE that checks the statePostgresApproved
    SELECT ... FOR UPDATE, then UPDATEPostgresApproved
    FOR NO KEY UPDATE when the key does not changePostgresForeign keys
    FOR UPDATE SKIP LOCKED for a work queuePostgresApproved
    FOR UPDATE NOWAITPostgresUser can retry
    Advisory lock on a namePostgresNot one row
    LOCK TABLE in the request pathPostgresNot approved
    A Redis lock around a Postgres writeRedisNot approved
    An application mutexServiceNot 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.

    C

    Try it: row-lock conflicts

    recorded from Postgres; click a cell
    Click a cell. Rows: session 1 holds. Columns: session 2 asks.
    held ↓ · asked →FOR KEY SHAREFOR SHAREFOR NO KEY UPDATEFOR 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
    Session 2 gets the lock at once. The two locks do not conflict.
    tSession 1Session 2
    1BEGINBEGIN
    2SELECT id FROM accounts WHERE id = 1 FOR NO KEY UPDATEid = 1
    3BEGINBEGIN
    4SELECT id FROM accounts WHERE id = 1 FOR KEY SHAREid = 1
    5ROLLBACKROLLBACK
    6ROLLBACKROLLBACK

    FOR UPDATE conflicts with everything. FOR KEY SHARE conflicts only with FOR UPDATE. A plain UPDATE takes FOR NO KEY UPDATE.

    D

    Who takes which row lock

    the lower rows of the matrix
    statementtakes
    UPDATE of a non-key columnFOR NO KEY UPDATE
    UPDATE of a key columnFOR UPDATE
    DELETEFOR UPDATE
    INSERT of a row that references itFOR KEY SHARE
    SELECTno 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.

    E

    Table locks

    recorded from Postgres
    ✕: the second request waits. Rows: held. Columns: asked.
    held ↓ · asked →ASRSRESUESSREEAE
    AS ACCESS SHARE·······✕
    RS ROW SHARE······✕✕
    RE ROW EXCLUSIVE····✕✕✕✕
    SUE SHARE UPDATE EXCLUSIVE···✕✕✕✕✕
    S SHARE··✕✕·✕✕✕
    SRE SHARE ROW EXCLUSIVE··✕✕✕✕✕✕
    E EXCLUSIVE·✕✕✕✕✕✕✕
    AE ACCESS EXCLUSIVE✕✕✕✕✕✕✕✕
    statementtakesblocks readsblocks writes
    SELECTASNoNo
    SELECT ... FOR UPDATERSNoNo
    INSERTRENoNo
    UPDATERENoNo
    DELETERENoNo
    VACUUMSUENoNo
    ANALYZESUENoNo
    CREATE INDEX CONCURRENTLYSUENoNo
    CREATE INDEXSNoYes
    ALTER TABLE ... ADD COLUMNAEYesYes
    TRUNCATEAEYesYes
    DROP TABLEAEYesYes
    • 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.

    F

    Capabilities used

    what Postgres gives you
    toolcapabilitywhat it gives youalso used for
    PostgresRow locks in the row headerNo limit on the number of locked rows. Each lock writes to the row.Batch updates of many rows
    PostgresFour row-lock modesA foreign key check takes FOR KEY SHARE and does not wait for balance updates.Parent rows with busy children
    PostgresWaits on the holder's transaction IDWaiters wake when the holder commits, then read the newest row.Counters, stock
    PostgresFOR UPDATE SKIP LOCKEDEach worker takes a different free row. No one waits.Job queues, outbox relays, batch claims
    PostgresNOWAIT and lock_timeoutFail with 55P03 instead of waiting.Interactive requests, DDL on live tables
    PostgresDeadlock detectorAfter deadlock_timeout, finds a wait cycle and cancels one transaction with 40P01.Any multi-row write
    PostgresAdvisory locks: pg_advisory_xact_lock, pg_try_advisory_xact_lockA lock on a number the application picks. It ends with the transaction.Singleton jobs, migrations, per-tenant work
    PostgresEight table-lock modesDML runs together. DDL waits for, and then blocks, everything else.Online schema changes
    Postgrespg_locksShows 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.

    G

    A job queue with SKIP LOCKED

    pseudo code
    claim a jobpseudo code
    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
    1. 1A partial index on ready jobs keeps this a short index scan, however many jobs are done.
    2. 2A row that another worker has locked is skipped, not waited for.
    3. 3The claim and the state change are one statement, so a job is never claimed twice.
    4. 4No free job. Poll again, or wait for LISTEN/NOTIFY.
    Tested source SQL: claim · Go: claim
    SQL: claimsql
    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;
    Go: claimgo
    
    // 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.

    H

    Try it: lock behaviours

    recorded from Postgres

    Two workers ask for the first ready job. Each gets a different one.

    tSession 1Session 2
    1BEGINBEGIN
    2SELECT id FROM jobs WHERE state = 'ready' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKEDid = 1
    3BEGINBEGIN
    4SELECT 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.
    5UPDATE jobs SET state = 'done' WHERE id = 1UPDATE 1
    6UPDATE jobs SET state = 'done' WHERE id = 2UPDATE 1
    7COMMITCOMMIT
    8COMMITCOMMIT
    statement 8 of 8
    Two workers, two jobs, no wait.

    NOWAIT and lock_timeout fail with 55P03. SKIP LOCKED never waits. A deadlock costs one second and one cancelled transaction.

    I

    Deadlocks and lock order

    recorded from Postgres
    Session 1Session 2accounts id = 1accounts id = 2holdsholdseach waits for the otherAfter deadlock_timeout (1 s): a cycle. One session gets 40P01.
    what session 1 gotrecorded · 40P01
    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.

    J

    Timeouts

    defaults read from the lab server
    settingdefaultwhat it stops
    lock_timeout0A statement that waits too long for a lock. 0 means wait forever.
    statement_timeout0A statement that runs too long, waiting or not.
    deadlock_timeout1sHow long a waiter waits before it checks for a deadlock.
    idle_in_transaction_session_timeout0A session that holds locks and does nothing.
    max_locks_per_transaction64Sizes 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.

    K

    Advisory locks

    pseudo code
    lock a namepseudo code
    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
    1. 1Advisory locks take an integer key. Hash the name; keep one key space per application.
    2. 2The try variant returns false at once instead. The recorded run shows both.
    3. 3A transaction-level lock ends at COMMIT or ROLLBACK, also when the client crashes.
    Tested source Go: with an advisory lock
    Go: with an advisory lockgo
    
    // 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.

    L

    Gap locks: a MySQL contrast

    MySQL InnoDB, not Postgres
    MySQL InnoDB, REPEATABLE READ: an index on cSELECT ... WHERE c BETWEEN 10 AND 20 FOR UPDATE102030next-key lock = record + the gap before itINSERT 15 waitsPostgres has no gap locks. It stops phantoms with snapshots, or with SIREAD locks that never block.
    • 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.

    M

    Failure cases

    what breaks, and what stops it
    eventresultwhat stops itsaved by
    ALTER TABLE waits behind a long reportEvery 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 orderA 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 APIEvery 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 idleIts 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 LOCKEDAll of them queue on the first row.Add SKIP LOCKED.SKIP LOCKED
    A worker crashes holding a claimed jobThe connection drops, and the transaction rolls back.The job is ready again. Make the work idempotent.Rollback
    One counter row takes every writeAll writers queue: about 2,600 a second in our lab.Split the counter into N rows and sum them on read.Split row
    N

    Scale ladder

    start with row locks; climb on a signal
    Each step adds one tool1row locks2+ SKIP LOCKED3+ advisory4+ split hot rows5+ one writermore load →
    Lock-and-update transactions per second, 16 clients1001k10k100kOne hot row: 2,609 transactions or jobs per secondOne hot row2,60916 rows: 14,469 transactions or jobs per second16 rows14,46910,000 rows: 16,348 transactions or jobs per second10,000 rows16,348Queue, no SKIP LOCKED: 4,656 transactions or jobs per secondQueue, no SKIP LOCKED4,656Queue, SKIP LOCKED: 11,128 transactions or jobs per secondQueue, SKIP LOCKED11,128transactions or jobs per second, log scale
    stepaddit handlesmove up when you see
    1Row 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.
    2SKIP 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.
    3Advisory 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.
    4Split 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.
    5One 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.

    O

    Drill

    predict, then reveal

    0 of 8 known

    1. A child row with a foreign key is inserted while another transaction updates the parent balance. Does either wait?

    2. Session 1 runs DELETE on a row. Session 2 asks for FOR KEY SHARE on it. What happens?

    3. Where does Postgres store a row lock, and what does that mean for one million locked rows?

    4. Ten workers poll a jobs table with FOR UPDATE and no SKIP LOCKED. What goes wrong?

    5. Two transfers lock accounts 1 and 2 in opposite order. What does Postgres do, and what do you change?

    6. ALTER TABLE ADD COLUMN waits behind a long report. Why do new reads also stop?

    7. When do you use an advisory lock instead of a row lock?

    8. In MySQL InnoDB at REPEATABLE READ, a range SELECT ... FOR UPDATE runs. Can another session insert into the range?

    P

    Numbers to say

    measured in the lab
    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.