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.
- name
transactions-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
- The lock problem comes from a DDL migration - use schema-migrations
- You need cross-service consistency rather than one database - use event-driven-architecture
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.
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
| Anomaly | Example | Prevented at Read Committed (PG default)? |
|---|---|---|
| Lost update | Two requests read stock=5, both write 4 | No, unless atomic update or lock |
| Write skew | Two doctors each check “another doctor on call” and both go off call | No; needs Serializable or locking the checked rows |
| Phantom / double insert | Two requests check “no active subscription” and both insert one | No; needs a unique (partial) index, or Serializable |
| Non-repeatable read in reports | Report totals change mid-transaction | No; use Repeatable Read for consistent snapshots |
4. Pick the mechanism
| Situation | Mechanism |
|---|---|
| Uniqueness rule (“one active subscription per user”) | Unique (partial) index; handle the unique violation (23505) |
| Simple counter or balance change | Atomic update with a guard in the WHERE clause |
| Read, decide in app code, write back; conflicts rare | Optimistic concurrency: version column, check affected rows |
| Read, decide, write; conflicts common or the work is expensive | Pessimistic: 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 workers | FOR UPDATE SKIP LOCKED |
| Mutual exclusion on something that is not a row (a tenant’s nightly job) | Advisory lock |
| Consistent multi-query report | REPEATABLE 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):
| Clause | Use |
|---|---|
FOR UPDATE | You will update/delete the row, including key columns |
FOR NO KEY UPDATE | You will update non-key columns; does not block FK inserts referencing the row (less contention) |
FOR SHARE | Row must not change while you read, others may also share-lock |
FOR KEY SHARE | Taken implicitly by FK checks |
NOWAIT | Fail immediately if locked (55P03) |
SKIP LOCKED | Skip 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):
| SQLSTATE | Meaning | Action |
|---|---|---|
40001 | serialization_failure | Retry whole transaction, exponential backoff + jitter, 3-5 attempts |
40P01 | deadlock_detected | Retry whole transaction; also fix lock ordering |
55P03 | lock_not_available (lock_timeout / NOWAIT) | Retry with backoff or return 409/503 |
23505 | unique_violation | Not 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:
- Two transactions lock the same rows in different orders (A then B vs. B then A).
- Batch updates without a deterministic order (
UPDATE ... WHERE id IN (...)touches rows in physical order, which differs between statements). - FK checks taking share locks on parent rows while another transaction updates the parent.
- Lock upgrades: two transactions
FOR SHAREthe 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 UPDATEwhen 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@Transactionalproxies 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/NOWAITexist in 8.0+.innodb_lock_wait_timeoutdefault is 50 s; lower it for OLTP sessions.- Inspect with
SHOW ENGINE INNODB STATUS\G(LATEST DETECTED DEADLOCK section) andperformance_schema.data_locks/data_lock_waits. - Deadlock error code 1213, lock wait timeout 1205; retry 1213 the same way as
40P01.
Gotchas
SELECT ... FOR UPDATEon rows that do not exist locks nothing in Postgres. “Check then insert” needs a unique constraint orINSERT ... ON CONFLICT.- Upserts:
INSERT ... ON CONFLICT (key) DO UPDATEis atomic;ON CONFLICT DO NOTHINGplus 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:
SETwithoutLOCAL, session advisory locks, and prepared statements may leak or break across transactions. - Repeatable Read in Postgres raises
40001on concurrent updates of the same row; code using it must retry too. deadlock_timeoutis not a lock wait limit; it is how long Postgres waits before checking for a deadlock. Uselock_timeoutfor 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
# 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.