Databases No. 214

Transactions and Locking skill for Claude Code

Name the race, pick the lock, prove it under load. Claude names the exact anomaly behind a concurrency bug, such as lost update, write skew or double insert, then applies the cheapest mechanism that prevents it: a unique index, a guarded atomic UPDATE, a version check, FOR UPDATE in key order, or Serializable with retries. Every role gets lock and idle-transaction timeouts, and the fix is proven with a real parallel test.

  • nametransactions-locking
  • size237 lines · ≈2,600 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Makes concurrent database writes correct - choosing isolation levels (Read Committed, Repeatable Read, Serializable), preventing lost updates with atomic updates, optimistic version checks, or SELECT FOR UPDATE, job queues with SKIP LOCKED, advisory locks, deadlock diagnosis and prevention, lock and statement timeouts, and retry logic for serialization failures. Use when two requests can update the same rows, balances or inventory go wrong under load, you see deadlock detected or could not serialize errors, requests hang on lock waits, or you are reviewing transaction boundaries in code. Postgres first, with MySQL InnoDB notes.

Try asking

  • Inventory goes negative when two people check out at once - fix the race
  • We keep getting deadlock detected errors in our transfer endpoint
  • Build a Postgres job queue that multiple workers can poll safely

Use it when

  • Two requests can modify the same rows and correctness matters
  • Logs show deadlocks, serialization failures, or lock timeouts
  • Requests hang waiting on locks or idle-in-transaction sessions pile up
  • You are choosing an isolation level or reviewing transaction boundaries

Skip it when

How to check the result

A concurrent test against a real database with dozens of parallel clients holds the invariant exactly, and retried errors never reach callers.

The common mistake it prevents

Using SELECT FOR UPDATE to guard a check-then-insert does nothing when the row does not exist yet; only a unique constraint stops the duplicate.

~/.claude/skills/transactions-locking/SKILL.md

--- name: transactions-locking description: Makes concurrent database writes correct - choosing isolation levels (Read Committed, Repeatable Read, Serializable), preventing lost… license: MIT ---

Concurrent code is correct when you can name the anomaly each transaction is protected against and the mechanism that protects it (constraint, atomic statement, row lock, version check, or serializable isolation), when every lock wait has a timeout, and when the code retries the errors that the chosen mechanism is designed to raise. “It works in testing” means nothing: tests run one request at a time.

Workflow

- [ ] 1. Identify the invariant (e.g. balance >= 0, stock never oversold, one active subscription)
- [ ] 2. Find every code path that reads and writes the data behind it
- [ ] 3. Name the race (lost update, write skew, phantom, double insert)
- [ ] 4. Pick the cheapest correct mechanism (decision table)
- [ ] 5. Set timeouts and retries
- [ ] 6. Prove it with a concurrent test

3. Name the race

AnomalyExamplePrevented at Read Committed (PG default)?
Lost updateTwo requests read stock=5, both write 4No, unless atomic update or lock
Write skewTwo doctors each check “another doctor on call” and both go off callNo; needs Serializable or locking the checked rows
Phantom / double insertTwo requests check “no active subscription” and both insert oneNo; needs a unique (partial) index, or Serializable
Non-repeatable read in reportsReport totals change mid-transactionNo; use Repeatable Read for consistent snapshots

4. Pick the mechanism

SituationMechanism
Uniqueness rule (“one active subscription per user”)Unique (partial) index; handle the unique violation (23505)
Simple counter or balance changeAtomic update with a guard in the WHERE clause
Read, decide in app code, write back; conflicts rareOptimistic concurrency: version column, check affected rows
Read, decide, write; conflicts common or the work is expensivePessimistic: SELECT ... FOR UPDATE
Invariant spans rows/tables that are not easily locked (write skew)SERIALIZABLE + retry, or lock a parent row that represents the invariant
Work queue consumed by many workersFOR UPDATE SKIP LOCKED
Mutual exclusion on something that is not a row (a tenant’s nightly job)Advisory lock
Consistent multi-query reportREPEATABLE READ (read-only)

Atomic update (preferred whenever the logic fits in SQL):

UPDATE inventory SET stock = stock - $2
WHERE sku = $1 AND stock >= $2
RETURNING stock;                 -- 0 rows returned = insufficient stock

Optimistic concurrency:

UPDATE documents SET body = $3, version = version + 1
WHERE id = $1 AND version = $2;  -- affected rows 0 = someone else won; reload and retry or report conflict

Expose the version to clients (ETag / If-Match) when edits happen across HTTP requests; a database lock cannot span a user’s think time.

Pessimistic locking:

BEGIN;
SELECT * FROM accounts WHERE id IN ($1, $2) ORDER BY id FOR UPDATE;   -- fixed order prevents deadlocks
-- checks in application code
UPDATE accounts SET balance = balance - $3 WHERE id = $1;
UPDATE accounts SET balance = balance + $3 WHERE id = $2;
COMMIT;

Lock strength (Postgres):

ClauseUse
FOR UPDATEYou will update/delete the row, including key columns
FOR NO KEY UPDATEYou will update non-key columns; does not block FK inserts referencing the row (less contention)
FOR SHARERow must not change while you read, others may also share-lock
FOR KEY SHARETaken implicitly by FK checks
NOWAITFail immediately if locked (55P03)
SKIP LOCKEDSkip locked rows; for queues only, never for correctness checks

Queue with SKIP LOCKED:

WITH next AS (
  SELECT id FROM jobs
  WHERE status = 'queued' AND run_at <= now()
  ORDER BY run_at
  LIMIT 10
  FOR UPDATE SKIP LOCKED
)
UPDATE jobs SET status = 'running', locked_at = now(), attempts = attempts + 1
FROM next WHERE jobs.id = next.id
RETURNING jobs.*;

Commit immediately after claiming; process outside the transaction; mark done in a new transaction; a reaper resets running jobs whose locked_at is stale.

Advisory locks:

SELECT pg_try_advisory_xact_lock(hashtext('nightly-billing'), $1::int);  -- released at commit/rollback

Prefer the _xact_ variants. Session-level advisory locks leak through connection poolers (PgBouncer transaction mode) and stay held if the app forgets to unlock.

Serializable:

BEGIN ISOLATION LEVEL SERIALIZABLE;
-- reads and writes
COMMIT;   -- may fail with 40001 (serialization_failure): retry the whole transaction

Postgres Serializable (SSI) does not block more; it aborts transactions that could produce a non-serial result. It is correct only if every transaction touching the data uses it and the app retries.

5. Timeouts and retries

SET lock_timeout = '3s';                          -- max wait for any single lock
SET statement_timeout = '30s';                    -- per statement
SET idle_in_transaction_session_timeout = '60s';  -- kill sessions that forgot to commit
-- PG17+: transaction_timeout caps the whole transaction

Set them per role (ALTER ROLE app SET lock_timeout = '3s';) so every connection inherits sane limits.

Retry policy (application side):

SQLSTATEMeaningAction
40001serialization_failureRetry whole transaction, exponential backoff + jitter, 3-5 attempts
40P01deadlock_detectedRetry whole transaction; also fix lock ordering
55P03lock_not_available (lock_timeout / NOWAIT)Retry with backoff or return 409/503
23505unique_violationNot a retry: it is the business answer (“already exists”)

Retry the entire transaction from BEGIN, re-reading data. Never retry only the failed statement. Make side effects (emails, HTTP calls, queue publishes) happen after commit or through an outbox table so retries do not duplicate them.

Deadlocks

Causes, in order of frequency:

  1. Two transactions lock the same rows in different orders (A then B vs. B then A).
  2. Batch updates without a deterministic order (UPDATE ... WHERE id IN (...) touches rows in physical order, which differs between statements).
  3. FK checks taking share locks on parent rows while another transaction updates the parent.
  4. Lock upgrades: two transactions FOR SHARE the same row and then both try to update it.

Prevention:

  • Lock in a global order (by primary key) with an explicit SELECT ... ORDER BY id FOR UPDATE.
  • Keep transactions short; take locks late, commit early.
  • Use FOR NO KEY UPDATE when not changing keys to reduce conflicts with FK checks.
  • Batch jobs: process in key order with small batches.

Diagnose: set log_lock_waits = on (logs waits longer than deadlock_timeout, default 1 s); Postgres logs both statements of each deadlock. Live lock trees, blocker queries, and MySQL equivalents are in references/lock-diagnostics.md.

Transaction boundaries in application code

  • One transaction per unit of business work, opened as late as possible.
  • No network calls, file uploads, or LLM/API requests while a transaction is open: they hold locks and a pooled connection for seconds.
  • ORMs: know whether the framework wraps each request in a transaction (Django ATOMIC_REQUESTS, Rails controller actions do not by default, Spring @Transactional proxies only work on external calls). Nested “transactions” are usually savepoints.
  • Read-modify-write in ORM code (obj.count += 1; save()) is a lost update. Use the ORM’s atomic expression (F('count') + 1, increment, { increment: 1 }).

MySQL InnoDB notes

  • Default isolation is REPEATABLE READ; locking reads and updates take next-key (gap) locks, so range updates block inserts into the gap. Many deadlocks in MySQL come from gap locks on non-unique index ranges.
  • SELECT ... FOR UPDATE SKIP LOCKED / NOWAIT exist in 8.0+.
  • innodb_lock_wait_timeout default is 50 s; lower it for OLTP sessions.
  • Inspect with SHOW ENGINE INNODB STATUS\G (LATEST DETECTED DEADLOCK section) and performance_schema.data_locks / data_lock_waits.
  • Deadlock error code 1213, lock wait timeout 1205; retry 1213 the same way as 40P01.

Gotchas

  • SELECT ... FOR UPDATE on rows that do not exist locks nothing in Postgres. “Check then insert” needs a unique constraint or INSERT ... ON CONFLICT.
  • Upserts: INSERT ... ON CONFLICT (key) DO UPDATE is atomic; ON CONFLICT DO NOTHING plus a follow-up SELECT can miss a row inserted by a concurrent uncommitted transaction (it becomes visible only after commit).
  • Long-running transactions (even read-only, even idle) block vacuum cleanup and cause bloat; they also block DDL lock acquisition.
  • PgBouncer transaction pooling: SET without LOCAL, session advisory locks, and prepared statements may leak or break across transactions.
  • Repeatable Read in Postgres raises 40001 on concurrent updates of the same row; code using it must retry too.
  • deadlock_timeout is not a lock wait limit; it is how long Postgres waits before checking for a deadlock. Use lock_timeout for limits.

Verification

Prove the fix with real concurrency, not reasoning:

# 50 concurrent attempts to buy the last 10 units; expect exactly 10 successes, stock 0, never negative
seq 1 50 | xargs -P 50 -I{} psql "$DATABASE_URL" -qtAc \
  "UPDATE inventory SET stock = stock - 1 WHERE sku = 'A1' AND stock >= 1 RETURNING stock" | wc -l
psql "$DATABASE_URL" -c "SELECT stock FROM inventory WHERE sku = 'A1'"

Or a test that runs the code path from N threads/goroutines/tasks against a real database (not mocks, not SQLite in place of Postgres), then asserts the invariant and that retries surfaced no errors to callers. Run it against a scratch database only.

Output: concurrency review note

**Invariant:** inventory.stock >= 0; one reservation per (user, drop).
**Race found:** checkout reads stock, checks in app, writes stock - 1 (lost update at RC).
**Fix:** atomic guarded UPDATE ... WHERE stock >= $qty RETURNING; unique index on reservations (user_id, drop_id).
**Timeouts:** app role lock_timeout 3s, statement_timeout 15s, idle_in_transaction 60s.
**Retries:** 40001/40P01 retried 3x with jitter in db.withRetry(); 23505 mapped to 409.
**Evidence:** 200 concurrent buyers for 25 units -> 25 successes, stock 0, no 5xx.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/lock-diagnostics.md 144 lines · 5.1 KB
# Lock diagnostics

Read this when requests hang on lock waits, deadlocks appear in logs, or you need to find
who is blocking whom. All queries are read-only. Terminating sessions is destructive to
in-flight work: never run `pg_cancel_backend` / `pg_terminate_backend` (or MySQL `KILL`)
without explicit user approval for the specific PID.

## Postgres: who is blocking whom

```sql
SELECT blocked.pid                    AS blocked_pid,
       now() - blocked.query_start    AS blocked_for,
       left(blocked.query, 80)        AS blocked_query,
       blocking.pid                   AS blocking_pid,
       blocking.state                 AS blocking_state,
       now() - blocking.xact_start    AS blocking_xact_age,
       left(blocking.query, 80)       AS blocking_query
FROM pg_stat_activity blocked
JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS b(pid) ON true
JOIN pg_stat_activity blocking ON blocking.pid = b.pid
ORDER BY blocked_for DESC;
```

A blocker in state `idle in transaction` is an application that opened a transaction
and stopped talking to the database (awaiting an HTTP call, a bug, a leaked connection).

## Postgres: lock tree (root blockers first)

```sql
WITH RECURSIVE tree AS (
  SELECT pid, pid AS root, 0 AS depth
  FROM pg_stat_activity
  WHERE cardinality(pg_blocking_pids(pid)) = 0
    AND pid IN (SELECT unnest(pg_blocking_pids(a.pid)) FROM pg_stat_activity a)
  UNION ALL
  SELECT a.pid, t.root, t.depth + 1
  FROM pg_stat_activity a
  JOIN tree t ON t.pid = ANY (pg_blocking_pids(a.pid))
  WHERE t.depth < 20
)
SELECT t.root, t.depth, a.pid, a.state, a.wait_event_type, a.wait_event,
       now() - a.xact_start AS xact_age, left(a.query, 70) AS query
FROM tree t JOIN pg_stat_activity a USING (pid)
ORDER BY t.root, t.depth;
```

## Postgres: locks held and requested on a table

```sql
SELECT l.pid, l.locktype, l.mode, l.granted, a.state,
       now() - a.xact_start AS xact_age, left(a.query, 70) AS query
FROM pg_locks l
JOIN pg_stat_activity a USING (pid)
WHERE l.relation = 'orders'::regclass
ORDER BY l.granted, a.xact_start;
```

## Postgres: long and idle transactions

```sql
SELECT pid, usename, application_name, state,
       now() - xact_start  AS xact_age,
       now() - state_change AS in_state_for,
       left(query, 80) AS last_query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 20;
```

## Postgres: row lock contention hot spots

```sql
-- Sessions currently waiting, grouped by what they wait on
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE wait_event IS NOT NULL AND state = 'active'
GROUP BY 1, 2 ORDER BY 3 DESC;
```

`Lock / transactionid` waits mean row-level conflicts (waiting for another transaction to
finish); `Lock / relation` means table-level DDL or explicit LOCK TABLE conflicts;
`Lock / advisory` means advisory locks; `LWLock` waits are internal contention, not row locks.

## Postgres: deadlock history

```sql
SELECT datname, deadlocks, conflicts, xact_commit, xact_rollback
FROM pg_stat_database WHERE datname = current_database();
```

Enable logging so each deadlock and long wait is recorded with both statements:

```sql
ALTER SYSTEM SET log_lock_waits = on;     -- logs waits > deadlock_timeout (default 1s)
SELECT pg_reload_conf();                  -- requires superuser; confirm with the user first
```

Reading a deadlock log entry:

```
ERROR:  deadlock detected
DETAIL:  Process 4123 waits for ShareLock on transaction 99812; blocked by process 4188.
         Process 4188 waits for ShareLock on transaction 99807; blocked by process 4123.
         Process 4123: UPDATE accounts SET balance = balance - 10 WHERE id = 2
         Process 4188: UPDATE accounts SET balance = balance + 10 WHERE id = 1
```

Map each statement back to its code path. The fix is almost always a consistent lock
order (lock both rows up front, sorted by id) or a shorter transaction.

## MySQL InnoDB

```sql
SHOW ENGINE INNODB STATUS\G          -- see "LATEST DETECTED DEADLOCK" and "TRANSACTIONS"

-- Current lock waits (8.0+)
SELECT w.requesting_engine_transaction_id AS waiting_trx,
       w.blocking_engine_transaction_id   AS blocking_trx,
       rl.object_name, rl.index_name, rl.lock_type, rl.lock_mode, rl.lock_data
FROM performance_schema.data_lock_waits w
JOIN performance_schema.data_locks rl
  ON rl.engine_lock_id = w.requesting_engine_lock_id;

-- Readable summary
SELECT * FROM sys.innodb_lock_waits\G

-- Long-running transactions
SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_s,
       trx_mysql_thread_id, LEFT(trx_query, 80) AS query
FROM information_schema.innodb_trx
ORDER BY trx_started;
```

Persist every deadlock to the error log, not only the latest:

```sql
SET GLOBAL innodb_print_all_deadlocks = ON;   -- confirm with the user; affects the server
```

`lock_mode X,GAP` or `X locks gap before rec` in the output indicates gap locks from a
range scan on a non-unique index under REPEATABLE READ. Options: index the predicate so
the range is narrower, use a unique lookup, or run that transaction at READ COMMITTED
(which disables most gap locking) if the business logic allows.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc