git clone https://github.com/Intense-Visions/harness-engineering
T=$(mktemp -d) && git clone --depth=1 https://github.com/Intense-Visions/harness-engineering "$T" && mkdir -p ~/.claude/skills && cp -r "$T/agents/skills/claude-code/db-pessimistic-locking" ~/.claude/skills/intense-visions-harness-engineering-db-pessimistic-locking && rm -rf "$T"
agents/skills/claude-code/db-pessimistic-locking/SKILL.mdPessimistic Locking
Pessimistic locking acquires locks before modifying data, guaranteeing exclusive access and preventing conflicts at the cost of reduced concurrency.
When to Use
- High-contention resources where optimistic retries would be excessive (conflict rate > 5-10%)
- Financial transactions requiring guaranteed exclusive access to account rows
- Inventory decrements where overselling is unacceptable
- Queue-like processing patterns using
SKIP LOCKED - Any workflow where two concurrent modifications to the same row would cause data corruption
Instructions
Key Concepts
1. SELECT FOR UPDATE
Acquires a row-level exclusive lock. Other transactions attempting
FOR UPDATE or UPDATE on the same row block until the lock is released at COMMIT or ROLLBACK.
BEGIN; SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- Row is now locked -- other transactions wait here UPDATE accounts SET balance = balance - 100 WHERE id = 1; COMMIT; -- lock released
2. Lock Modes
PostgreSQL offers four row-level lock strengths:
| Mode | Blocks | Use Case |
|---|---|---|
| All other row locks | Full exclusive access |
| Other updates (not KEY SHARE) | Update non-key columns |
| Updates (not other SHARE) | Shared read lock, prevent changes |
| Only FOR UPDATE | Prevent key changes, allow updates |
Use the weakest lock that satisfies your requirements.
FOR NO KEY UPDATE is preferred over FOR UPDATE when you are not modifying primary key or unique constraint columns.
3. NOWAIT and SKIP LOCKED
-- NOWAIT: fail immediately instead of waiting SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT; -- ERROR: could not obtain lock on row (if already locked) -- SKIP LOCKED: skip already-locked rows SELECT * FROM tasks WHERE status = 'pending' ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED; -- Returns the next unlocked row, or nothing if all are locked
4. Lock Duration
Row locks are held until the end of the transaction (
COMMIT or ROLLBACK). There is no way to release a row lock early within a transaction. Keep transactions short to minimize blocking.
Worked Example
Job queue processing with
SKIP LOCKED -- multiple workers dequeue tasks concurrently:
-- Worker 1 -- Worker 2 BEGIN; BEGIN; SELECT id, payload FROM tasks SELECT id, payload FROM tasks WHERE status = 'pending' WHERE status = 'pending' ORDER BY created_at ORDER BY created_at LIMIT 1 LIMIT 1 FOR UPDATE SKIP LOCKED; FOR UPDATE SKIP LOCKED; -- returns task 101 -- returns task 102 (101 is locked) UPDATE tasks UPDATE tasks SET status = 'processing', SET status = 'processing', worker_id = 'w1' worker_id = 'w2' WHERE id = 101; WHERE id = 102; -- ... process task ... -- ... process task ... UPDATE tasks SET status = 'done' UPDATE tasks SET status = 'done' WHERE id = 101; WHERE id = 102; COMMIT; COMMIT;
Both workers operate on different tasks without conflicts. No retries needed.
Anti-Patterns
-
Locking rows during user think-time. Holding
across an HTTP request-response cycle blocks other users for seconds or minutes. Use optimistic locking for user-facing edit workflows instead. SeeFOR UPDATE
.db-optimistic-locking -
Locking more rows than needed.
without a WHERE clause locks every row in the table. Always scope your lock to the minimum set of rows.SELECT * FROM orders FOR UPDATE -
Forgetting
in queue patterns. Without it, all workers serialize on the same row instead of processing in parallel. The second worker blocks until the first commits.SKIP LOCKED -
Mixing
withFOR UPDATE
isolation. Redundant and can cause unexpected serialization failures.SERIALIZABLE
in Read Committed already provides the exclusion you need. Adding Serializable adds SSI overhead with no additional benefit for the locked rows.FOR UPDATE
PostgreSQL Specifics
- Monitoring locks:
SELECT * FROM pg_locks WHERE relation = 'accounts'::regclass; - Lock timeout:
to prevent indefinite waitsSET lock_timeout = '5s'; - Deadlock timeout:
(default 1s) controls when PostgreSQL checks for deadlocksdeadlock_timeout - Advisory locks:
for application-defined locking that does not map to specific rows. Useful for locking abstract resources (e.g., a customer ID across multiple tables).pg_advisory_lock(key) - Who is blocking whom:
SELECT pg_blocking_pids(pid) FROM pg_stat_activity;
Details
Advanced Topics
Advisory locks for distributed coordination:
-- Lock on an application-defined key (e.g., customer ID for cross-table operations) SELECT pg_advisory_lock(hashtext('customer:' || customer_id::text)); -- ... perform operations across multiple tables ... -- Lock released at end of session (or use pg_advisory_xact_lock for transaction scope)
Table-level locks:
LOCK TABLE orders IN EXCLUSIVE MODE; for bulk operations that need to prevent all concurrent modifications. Rarely needed -- prefer row-level locks.
Lock escalation: PostgreSQL does not escalate row locks to table locks (unlike SQL Server). You can lock millions of individual rows without the engine deciding to lock the entire table. This makes PostgreSQL's locking behavior more predictable.
Engine Differences
MySQL InnoDB uses gap locking in addition to row locking under Repeatable Read. A
FOR UPDATE query can lock index gaps between rows, blocking inserts into ranges -- even inserts that would not conflict with the locked rows. This causes unexpected blocking and is a common source of contention in MySQL.
PostgreSQL does not use gap locks because MVCC handles phantom prevention differently. This means PostgreSQL's
FOR UPDATE is more precisely targeted: it locks exactly the rows selected, nothing more.
MySQL's
LOCK IN SHARE MODE is the equivalent of PostgreSQL's FOR SHARE. MySQL 8.0+ also supports FOR SHARE and SKIP LOCKED syntax, matching PostgreSQL's capabilities.
Real-World Case Studies
A ticket booking system for concert events originally used optimistic locking for seat selection. During high-demand on-sale events, the optimistic retry rate reached 40% as thousands of users competed for the same popular seats. Switching to
SELECT ... FOR UPDATE NOWAIT with immediate user feedback ("seat unavailable, please pick another") reduced conflicts to near zero. Users got instant feedback instead of experiencing retry loops, and the system handled 10x the concurrent load without contention issues.
Source
- PostgreSQL Explicit Locking
- Kleppmann, M. "Designing Data-Intensive Applications" (2017)
Process
- Identify rows that require exclusive access during modification and use the weakest lock mode sufficient.
- Add
for queue patterns andSKIP LOCKED
when immediate feedback is preferred over waiting.NOWAIT - Keep transactions holding locks as short as possible and monitor lock waits via
andpg_locks
.log_lock_waits
Harness Integration
- Type: knowledge -- this skill is a reference document, not a procedural workflow.
- No tools or state -- consumed as context by other skills and agents.
- related_skills: db-optimistic-locking, db-deadlock-prevention, db-isolation-levels, db-mvcc, db-acid-properties, prisma-transactions, drizzle-transactions
Success Criteria
- Row locks scoped to the minimum necessary rows with appropriate WHERE clauses
- Transactions holding locks are kept short (no user think-time under lock)
used for queue/worker patternsSKIP LOCKED
used when immediate feedback is preferred over blockingNOWAIT- Lock waits monitored via
andpg_lockslog_lock_waits = on