Mastering Pessimistic Locking in PostgreSQL: A Production-Ready Guide
As systems scale and concurrency increases, maintaining data integrity without sacrificing performance becomes a core architectural challenge. Pessimistic locking is a foundational concurrency control strategy that guarantees consistency by proactively securing resources. This guide synthesizes deep-dive discussions on its mechanics, PostgreSQL-specific implementations, transactional semantics, lock compatibility, and modern ORM integration, structured for senior engineering practice.
1. Fundamentals of Pessimistic Locking
Core Concept & Mechanism
Pessimistic locking operates on the assumption that data conflicts are highly likely. Instead of hoping for the best and retrying (like optimistic locking), it proactively locks resources before any read or write operation to prevent concurrent access from causing inconsistencies.
Lifecycle:
- A transaction requests a lock before accessing data.
- The lock is granted; other transactions are blocked or forced to wait.
- Once the transaction
COMMITs orROLLBACKs, the lock is automatically released. - Waiting transactions proceed in turn.
Common Lock Types
| Lock Type | Purpose | Behavior |
|---|---|---|
| Shared Lock (Read Lock) | Reading data | Multiple transactions can hold it simultaneously. Blocks exclusive locks. |
| Exclusive Lock (Write Lock) | Modifying data | Only one transaction can hold it. Blocks all other shared & exclusive locks. |
| Update Lock (DB-specific) | Read-then-write workflows | Prevents deadlocks during SELECT → UPDATE patterns by upgrading atomically. |
Pros & Cons
| ✅ Advantages | ❌ Disadvantages |
|---|---|
| Guarantees strong consistency & prevents lost updates, dirty reads, and race conditions | Reduces concurrency & throughput due to blocking |
| Predictable behavior under high contention | Can cause deadlocks (requires timeout or deadlock detection) |
| Simpler application logic (no version checks or retry loops) | Overhead of lock management and context switching |
Typical Use Cases
- Financial transactions (banking, payments)
- Inventory/stock reservation systems
- High-contention rows/tables where conflicts are frequent
- Systems where data integrity outweighs performance scalability
Pessimistic vs Optimistic Locking
| Aspect | Pessimistic | Optimistic |
|---|---|---|
| Assumption | Conflicts are likely | Conflicts are rare |
| Mechanism | Locks upfront | Reads freely, checks version/timestamp at commit |
| Blocking | Yes (waits or fails) | No (retries on conflict) |
| Best for | High contention, strict consistency | Low contention, high read scalability |
💡 Architectural Note: Modern systems often combine both: pessimistic locks for critical write paths, optimistic/version-based control for reads or low-conflict writes, leveraging MVCC (Multi-Version Concurrency Control) under the hood.
2. PostgreSQL's Implementation: Row & Table Locks
PostgreSQL implements pessimistic locking primarily through SELECT ... FOR ... clauses (row-level) and the LOCK TABLE statement (table-level). All locks are held until the end of the transaction.
🔹 Row-Level Locks (SELECT ... FOR ...)
| Clause | Purpose | Behavior |
|---|---|---|
FOR UPDATE | Exclusive row lock | Blocks other transactions from updating, deleting, or acquiring row locks. |
FOR SHARE | Shared row lock | Allows concurrent reads/shared locks, but blocks updates/deletes and exclusive locks. |
FOR NO KEY UPDATE | Optimized exclusive lock | Blocks UPDATE/DELETE but does not block SELECT ... FOR KEY SHARE. Used internally when updating non-PK/unique columns. |
FOR KEY SHARE | Lightweight lock | Used internally for foreign key checks. Blocks DELETE and SELECT ... FOR UPDATE, allows most others. |
🔧 Lock Acquisition Modifiers
NOWAIT: Fails immediately with an error if the lock can't be acquired instead of waiting.SKIP LOCKED: Ignores already-locked rows and returns only unlocked ones. Ideal for job queues or task distribution. (Available since PostgreSQL 9.5)
🗄️ Table-Level Locks (LOCK TABLE)
Explicit table locks are rarely needed in application logic (PostgreSQL auto-acquires appropriate table locks during DML), but useful for maintenance or strict serialization.
Common modes:
| Mode | Blocks | Typical Use |
|---|---|---|
ACCESS SHARE | ACCESS EXCLUSIVE | Normal SELECT (auto-acquired) |
ROW EXCLUSIVE | SHARE, EXCLUSIVE, ACCESS EXCLUSIVE | INSERT, UPDATE, DELETE (auto) |
SHARE | ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE | CREATE INDEX CONCURRENTLY |
EXCLUSIVE | All except ACCESS SHARE & ROW SHARE | Strict DDL or batch processing |
ACCESS EXCLUSIVE | Everything | ALTER TABLE, DROP TABLE, VACUUM FULL |
⚠️ PostgreSQL-Specific Behaviors & Best Practices
- Transaction Scope: All row/table locks are held until
COMMIT/ROLLBACKor session end. - MVCC Doesn't Block Plain
SELECT: UnlockedSELECTreads snapshot data and never blocks writers. Pessimistic locks only affect other locking statements or writes. - Deadlock Detection: PG automatically detects deadlocks (default timeout: 1 sec, configurable via
deadlock_timeout). It aborts one transaction to break the cycle. - Index Impact: Row locks are also acquired on index entries pointing to the locked rows.
- Avoid Long Transactions: Hold locks for the shortest time possible to prevent contention and deadlocks.
- Prefer
SKIP LOCKEDfor Queues: Much safer and more scalable thanNOWAIT+ retry loops for worker pools.
🛠️ Practical Example: End-to-End Concurrency Workflow
To see how these lock types work in tandem, consider a high-traffic e-commerce system handling inventory, order validation, status updates, and background processing.
🔹 Scenario 1: Strict Inventory Deduction (FOR UPDATE)
Prevent overselling by locking the exact product row during checkout.
BEGIN;
-- Exclusively lock the row. Other sessions trying to buy this SKU will wait.
SELECT id, stock FROM products WHERE sku = 'LAPTOP-X' FOR UPDATE;
-- Verify stock, deduct, and commit atomically
UPDATE products SET stock = stock - 1 WHERE sku = 'LAPTOP-X';
COMMIT;
🔹 Scenario 2: Order Validation Before Invoicing (FOR SHARE)
Validate pricing/taxes without blocking other readers, but prevent concurrent price changes during calculation.
BEGIN;
-- Allow concurrent reads, but block any price/total updates during validation
SELECT order_id, total, status FROM orders WHERE order_id = 8842 FOR SHARE;
-- ... run pricing engine, apply tax rules, verify discounts ...
INSERT INTO invoices (order_id, amount) VALUES (8842, ...);
COMMIT;
🔹 Scenario 3: High-Frequency Status Updates (FOR NO KEY UPDATE)
Update shipment_status or tracking_id without bottlenecking foreign key checks on child tables (e.g., order_items).
BEGIN;
-- Locks row data but skips PK/unique index locks, allowing FK validations to proceed
SELECT id FROM orders WHERE order_id = 8842 FOR NO KEY UPDATE;
UPDATE orders SET tracking_id = '1Z999AA1', status = 'shipped' WHERE order_id = 8842;
COMMIT;
🔹 Scenario 4: Distributed Worker Queue (SKIP LOCKED)
Multiple background workers pulling email_notification tasks without deadlocking or duplicating work.
BEGIN;
-- Grabs the next unlocked pending task, skipping rows already locked by other workers
SELECT id, payload FROM notifications
WHERE status = 'pending'
ORDER BY created_at ASC
LIMIT 1 FOR UPDATE SKIP LOCKED;
-- Process notification...
UPDATE notifications SET status = 'sent', processed_at = NOW() WHERE id = ...;
COMMIT;
🔹 Scenario 5: Flash Sale Seat Reservation (NOWAIT)
User clicks "Reserve". If another transaction already holds it, fail instantly rather than hanging the HTTP connection.
BEGIN;
SELECT id FROM tickets WHERE event_id = 101 AND status = 'available'
LIMIT 1 FOR UPDATE NOWAIT;
-- If successful, mark as reserved.
-- If error (SQLSTATE 55P03), catch and return "Sold Out / Try Another" to UI.
UPDATE tickets SET status = 'reserved', reserved_until = NOW() + INTERVAL '15 min' WHERE id = ...;
COMMIT;
3. The Transaction Imperative
Why Pessimistic Locks Require Transactions
In relational databases (PostgreSQL, MySQL, Oracle, SQL Server, etc.), pessimistic locks are strictly tied to transactions. Locks need a defined lifecycle: acquire → do work → release. The transaction boundary provides exactly that. Without a transaction, the database defaults to releasing locks immediately to avoid resource leaks.
⚠️ The Auto-Commit Trap
Most database drivers run in auto-commit mode by default. Each statement is treated as its own implicit transaction.
-- ❌ USELESS in auto-commit mode
SELECT id, balance FROM accounts WHERE id = 1 FOR UPDATE;
-- Lock is acquired... and immediately released when this statement finishes.
-- The next UPDATE runs WITHOUT protection. Race conditions are still possible.
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- ✅ CORRECT: Explicit transaction
BEGIN;
SELECT id, balance FROM accounts WHERE id = 1 FOR UPDATE; -- Lock held
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- Protected
COMMIT; -- Lock released
💡 Rule of thumb: If you use
FOR UPDATE,FOR SHARE, orLOCK TABLE, you must wrap the read-modify-write sequence in an explicit transaction.
Non-Transactional Locks (Exceptions)
Some systems offer lock primitives that don't require DML transactions, but they work differently:
| Lock Type | Transaction Required? | Notes |
|---|---|---|
| RDBMS Row/Table Locks | ✅ Yes | Scope = transaction lifecycle |
PostgreSQL Advisory Locks (pg_advisory_lock()) | ❌ No (session/transaction scoped) | App-level coordination, doesn't block SQL, requires explicit unlock |
| Application-Level Locks (Redis, ZooKeeper, etcd) | ❌ No | External to DB, used for distributed coordination |
File/OS Locks (flock, LockFileEx) | ❌ No | OS-level, not database-aware |
4. Deep Dive: FOR UPDATE vs FOR NO KEY UPDATE
The Core Difference: "Key" = Primary/Unique Indexes & Foreign Keys
Both locks prevent concurrent UPDATE/DELETE on the row data itself. The distinction lies in index entries and foreign key validation:
FOR UPDATElocks everything: row data + all index entries (PK, unique keys). This blocks FK validation on child tables.FOR NO KEY UPDATElocks row data only, skipping PK/unique index entries. Since FK validation only needsFOR KEY SHARE, it doesn't block.
📦 Side-by-Side Production Example
Schema Setup:
CREATE TABLE customers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
status TEXT DEFAULT 'active',
balance NUMERIC(12,2) DEFAULT 0
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_id INT REFERENCES customers(id) ON DELETE RESTRICT,
total NUMERIC(10,2),
created_at TIMESTAMPTZ DEFAULT NOW()
);
INSERT INTO customers (id, name, status, balance) VALUES (100, 'Acme Corp', 'active', 5000.00);
Scenario A: Using FOR UPDATE → Blocks FK Insert
-- Session 1
BEGIN;
SELECT * FROM customers WHERE id = 100 FOR UPDATE; -- Holds strongest lock
-- Session 2
INSERT INTO orders (customer_id, total) VALUES (100, 50.00);
-- ⏳ BLOCKS indefinitely. Waiting for Session 1 to COMMIT/ROLLBACK.
Why? FOR UPDATE ❌ conflicts with FOR KEY SHARE (auto-acquired for FK check).
Scenario B: Using FOR NO KEY UPDATE → Allows FK Insert
-- Session 1
BEGIN;
SELECT * FROM customers WHERE id = 100 FOR NO KEY UPDATE;
-- Session 2
INSERT INTO orders (customer_id, total) VALUES (100, 50.00);
-- ✅ SUCCEEDS IMMEDIATELY. Order created without waiting.
Why? FOR NO KEY UPDATE ✅ is compatible with FOR KEY SHARE. FK validation proceeds.
✅ Corrected Lock Compatibility Matrix (PostgreSQL Official)
| 🔒 Held Lock (Session A) \ 🆕 Requested Lock (Session B) | FOR KEY SHARE | FOR SHARE | FOR NO KEY UPDATE | FOR UPDATE |
|---|---|---|---|---|
FOR KEY SHARE | ✅ | ✅ | ✅ | ❌ |
FOR SHARE | ✅ | ✅ | ❌ | ❌ |
FOR NO KEY UPDATE | ✅ | ❌ | ❌ | ❌ |
FOR UPDATE | ❌ | ❌ | ❌ | ❌ |
How to Read:
- Pick the lock Session A already holds (left column).
- Pick the lock Session B is trying to acquire (top row).
- Intersection shows if Session B proceeds (
✅) or blocks (❌).
🤖 How PostgreSQL Uses Them Automatically
You rarely need to type FOR NO KEY UPDATE manually. PG chooses automatically:
UPDATE users SET status = 'active' WHERE id = 1; -- → FOR NO KEY UPDATE
UPDATE users SET email = 'new@example.com' WHERE id = 1; -- → FOR UPDATE (if email is UNIQUE)
🧭 When to Choose Which
Use FOR UPDATE When... | Use FOR NO KEY UPDATE When... |
|---|---|
| Updating PK/unique columns | Updating non-unique columns only |
| You need absolute isolation | You want higher concurrency with FK-heavy schemas |
| Complex read-then-write logic | Batch processing status flags, counters, timestamps |
| Unsure & want maximum safety | You understand your schema & want to reduce lock contention |
💡
FOR NO KEY UPDATEcan improve throughput by 20-40% in FK-heavy workloads. It's a concurrency optimization, not a safety downgrade.
5. Decoding Database Concurrency: Sessions, Transactions & Queries
A common point of confusion in concurrency documentation is the hierarchy of database execution contexts.
The Hierarchy
🌐 Session (1 Database Connection)
│
├─▶ Transaction 1 (BEGIN → queries → COMMIT/ROLLBACK)
│ ├─ Query 1
│ ├─ Query 2
│ └─ Query 3
│
├─▶ Transaction 2
│ └─ Query 4
│
└─▶ ... (session stays alive until disconnected)
| Concept | Lifespan | Holds Locks? | Can Run Multiple Of... |
|---|---|---|---|
| Session | Connect → Disconnect | Yes (while transaction active) | Transactions, Queries |
| Transaction | BEGIN → COMMIT/ROLLBACK | Yes | Queries |
| Query/Statement | Execution start → finish | No (inherits transaction locks) | - |
How Sessions Relate to Pessimistic Locks
- Locks are held per session, but scoped to the active transaction.
- When
COMMIT/ROLLBACKexecutes, the lock is released, but the session/connection stays open. - In production, connection pools (PgBouncer, HikariCP, SQLAlchemy pool) borrow sessions per request/thread. When the transaction ends, the session returns to the pool and locks are released.
✅ Bottom Line: When docs say "Session A holds a lock, Session B tries to acquire one", they mean two separate connections running in parallel. Locks live on the session but die when the session's transaction ends.
6. Practical Decision Framework & Real-World Scenarios
🎯 Quick Reference Matrix
| Lock Type | Best For | Blocks Others? | Fails Fast? | Use When... |
|---|---|---|---|---|
FOR UPDATE | Money, inventory, bookings | ✅ Yes (writes & locks) | ❌ Waits | You must prevent concurrent modifications |
SKIP LOCKED | Job queues, task workers | ⚠️ Only locks returned rows | ❌ Skips locked | Multiple workers need distinct rows |
NOWAIT | Flash sales, real-time UI | ✅ Yes | ✅ Fails immediately | Waiting is worse than failing |
FOR NO KEY UPDATE | Status flags, frequent updates | ⚠️ Weaker than FOR UPDATE | ❌ Waits | You update rows often but don't touch PK/unique keys |
FOR SHARE | Multi-step validation | ✅ Blocks writes only | ❌ Waits | You need to read + validate without blocking other readers |
FOR KEY SHARE | Parent-child batch ops | ✅ Blocks deletes | ❌ Waits | You process child rows but must prevent parent deletion |
🧭 Decision Flow
Are multiple workers/threads competing for rows?
├─ YES → Use SKIP LOCKED (queues) or NOWAIT (user-facing)
└─ NO → Need to prevent concurrent writes?
├─ YES → Use FOR UPDATE (strict) or FOR NO KEY UPDATE (frequent updates)
└─ NO → Rely on MVCC (plain SELECT). Pessimistic lock not needed.
⚠️ Real-World Gotchas
- Lock escalation: Locking 10,000 rows with
FOR UPDATEcan exhaust lock memory. Batch or paginate. - HTTP timeouts:
NOWAIT+ retry logic > long waits. Never hold locks across network calls. - ORM pitfalls: Django
select_for_update(), SQLAlchemywith_for_update(), HibernatePESSIMISTIC_WRITEall default toFOR UPDATE. Know what your ORM emits. - Deadlocks: Order locks consistently (e.g., always lock
sender_idbeforereceiver_id). PG detects & aborts afterdeadlock_timeout(default 1s). - MVCC vs Locks: Plain
SELECTnever blocks writers. Only use pessimistic locks when application logic requires write exclusion.
7. Implementing in TypeORM (Node.js/TypeScript)
TypeORM fully abstracts PostgreSQL's pessimistic locking into a unified API. Below are production-ready patterns.
📖 TypeORM Lock Mode → PostgreSQL Mapping
TypeORM LockMode | PostgreSQL SQL | When to Use |
|---|---|---|
"pessimistic_write" | FOR UPDATE | Strict read-modify-write (payments, inventory) |
"pessimistic_read" | FOR SHARE | Multi-step validation, block writes but allow readers |
"pessimistic_partial_write" | FOR NO KEY UPDATE | High-throughput non-PK updates (status, timestamps) |
"pessimistic_write_or_fail" | FOR UPDATE NOWAIT | Fail fast instead of waiting |
"pessimistic_partial_write_or_fail" | FOR NO KEY UPDATE NOWAIT | Fail fast for non-PK updates |
⚠️
pessimistic_partial_writefalls back toFOR UPDATEon MySQL/SQLite. It's PostgreSQL-specific.
🛠️ Usage Patterns
Basic Lock (Repository / EntityManager)
// Requires explicit transaction!
await dataSource.transaction(async (manager) => {
const account = await manager.findOne(Account, {
where: { id: 1001 },
lock: { mode: "pessimistic_write" } // → FOR UPDATE
});
if (!account) throw new Error("Not found");
account.balance -= 100;
await manager.save(account);
});
Advanced: NOWAIT & SKIP LOCKED (QueryBuilder only)
Requires TypeORM 0.3.0+
await dataSource.transaction(async (manager) => {
const job = await manager
.createQueryBuilder(Job, "job")
.where("job.status = :status", { status: "pending" })
.orderBy("job.priority", "DESC")
.setLock("pessimistic_write", { skipLocked: true }) // → FOR UPDATE SKIP LOCKED
.getOne();
if (!job) return null;
job.status = "processing";
job.workerId = workerId;
await manager.save(job);
});
// For NOWAIT:
// .setLock("pessimistic_write", { nowait: true })
📦 Real-World Example: Inventory Deduction
import { DataSource } from "typeorm";
async function reserveInventory(dataSource: DataSource, sku: string, qty: number) {
return dataSource.transaction(async (manager) => {
// 1. Lock row FOR NO KEY UPDATE (optimizes FK contention)
const product = await manager
.createQueryBuilder(Product, "p")
.where("p.sku = :sku", { sku })
.setLock("pessimistic_partial_write")
.getOne();
if (!product || product.stock < qty) {
throw new Error("Insufficient stock");
}
// 2. Atomic update
product.stock -= qty;
await manager.save(product);
// 3. Create order (FK check won't block due to NO KEY UPDATE)
const order = manager.create(Order, { productId: product.id, qty });
return manager.save(order);
});
}
⚠️ Critical TypeORM Pitfalls
| Mistake | Why It Fails | Fix |
|---|---|---|
Using lock outside dataSource.transaction() | Auto-commit releases lock instantly | Wrap in transaction() or use EntityManager |
Using repository.findOne() with skipLocked | FindOptions doesn't support it | Use QueryBuilder |
Holding lock across await external API calls | HTTP latency = connection starvation | Lock → compute → update → commit. Keep < 100ms |
Mixing pessimistic_partial_write with PK updates | PG won't error, but FK safety degrades | Use pessimistic_write if modifying unique/PK cols |
| ORM silently falling back | Some drivers ignore unsupported lock modes | Check generated SQL in logs: logging: ["query"] |
✅ Best Practice Wrapper Pattern
async function withPessimisticLock<T>(
dataSource: DataSource,
entityClass: EntityTarget<T>,
id: any,
lockMode: LockMode,
callback: (entity: T, manager: EntityManager) => Promise<void>
) {
return dataSource.transaction(async (manager) => {
const entity = await manager.findOneOrFail(entityClass, {
where: { id },
lock: { mode: lockMode }
});
await callback(entity, manager);
// lock auto-releases on COMMIT
});
}
8. Verification & Monitoring
🔍 Verify Generated SQL
Enable query logging temporarily to confirm TypeORM emits the correct clauses:
const dataSource = new DataSource({ /* ... */, logging: ["query"] });
Expected output:
SELECT "Product"."id", "Product"."sku", "Product"."stock"
FROM "product" "Product"
WHERE "Product"."sku" = $1
FOR NO KEY UPDATE
🛠️ Real-Time Lock Monitoring
Run this in a third session while transactions are active:
SELECT pid, mode, granted, query
FROM pg_locks l
JOIN pg_stat_activity a ON l.pid = a.pid
WHERE relation = 'your_table'::regclass;
| Internal PG Mode | Maps To |
|---|---|
ExclusiveLock | FOR UPDATE |
ShareRowExclusiveLock | FOR NO KEY UPDATE |
ShareLock | FOR SHARE |
RowShareLock | FOR KEY SHARE |
Conclusion
Pessimistic locking is not a silver bullet, but it is an indispensable tool for high-contention, high-integrity workloads. By understanding PostgreSQL's nuanced lock modes (FOR UPDATE vs FOR NO KEY UPDATE), respecting strict transactional boundaries, clarifying session vs transaction scopes, and leveraging modern ORM patterns correctly, you can prevent race conditions without crippling throughput.
Architectural Takeaway: Always align your locking strategy with your access patterns. Use FOR NO KEY UPDATE for high-frequency non-PK mutations, SKIP LOCKED for distributed queues, NOWAIT for user-facing timeouts, and reserve strict FOR UPDATE for critical financial or inventory paths. Monitor pg_locks and pg_stat_activity proactively, keep transactions short, and never hold database locks across external service boundaries.