Database locking controls concurrent access to data: shared for reads, exclusive for writes
Intent locks (IS/IX/SIX) manage multi-granularity locking at table/page/row levels
MVCC eliminates reader-writer contention but not writer-writer conflicts
Most production deadlocks come from inconsistent lock ordering
Lock escalation from row-level to table-level silently kills concurrency
Monitoring pg_locks or SHOW ENGINE INNODB STATUS is the first step in any lock troubleshooting
✦ Definition~90s read
What is Database Locking Mechanisms?
Database locking mechanisms are the core concurrency control systems that prevent data corruption when multiple transactions access the same rows simultaneously. Without them, you'd get lost updates, dirty reads, and inconsistent states. The fundamental trade-off is between correctness and throughput — locks protect data integrity but create contention that kills performance.
★
Imagine a library with one copy of a popular book.
PostgreSQL and MySQL use different implementations (MVCC vs. row-level locking with gap locks), but both rely on a hierarchy of lock types: shared (read) locks allow concurrent reads but block writes, exclusive (write) locks block everything, and intent locks signal future lock requests at higher granularity (table vs. row). The trap most developers hit is running SELECT FOR UPDATE without a matching index — this forces the database to lock every row it scans, which in PostgreSQL means locking the entire table, and in MySQL (InnoDB) means locking all scanned rows plus gap locks, effectively serializing all writes to that table.
MVCC (Multi-Version Concurrency Control) is what lets PostgreSQL and MySQL serve reads without blocking writes — each transaction sees a snapshot of data as of its start time, so readers never wait for writers. But this doesn't eliminate locking; it just moves it to writes.
Deadlocks happen when two transactions each hold a lock the other needs — databases detect these via wait-for graphs and abort one transaction (usually the one that did the least work). You prevent them by always accessing tables in the same order, keeping transactions short, and using index-backed FOR UPDATE queries.
Isolation levels control how aggressively locks are held: READ COMMITTED releases locks after each statement, REPEATABLE READ holds them until commit, and SERIALIZABLE uses predicate locks or SSI (serializable snapshot isolation) that can fail transactions with serialization errors.
In production, lock contention is the silent killer. You monitor it with pg_locks in PostgreSQL (look for wait_start and granted=false), SHOW ENGINE INNODB STATUS in MySQL (the LATEST DETECTED DEADLOCK section), and tools like pg_stat_activity or performance_schema for real-time blocking queries.
Lock escalation — where the database promotes row locks to page or table locks — is rare in PostgreSQL but common in MySQL under memory pressure. The rule: always index columns used in WHERE clauses of FOR UPDATE or UPDATE statements. A missing index doesn't just slow queries; it turns your concurrent system into a single-threaded queue.
Plain-English First
Imagine a library with one copy of a popular book. If you're reading it, others can read over your shoulder (shared), but nobody can take it away to write notes in it. If you need to write notes, you grab the book exclusively — nobody else can even look until you're done. A database lock works exactly the same way: it controls who can read or change a piece of data at any given moment, so two people don't accidentally overwrite each other's work.
Every production database under real traffic is a battlefield of competing transactions — hundreds of reads and writes colliding every second. Without a disciplined locking strategy, two transactions can read the same account balance, both decide to deduct money, and you end up with funds that vanished into thin air. This is not a hypothetical. It's the kind of bug that costs companies millions and surfaces only under high concurrency — exactly the conditions your monitoring never catches in staging.
Database locking mechanisms are the referee in that battlefield. They define the rules for who gets to read data, who gets to modify it, in what order, and what happens when two transactions want mutually incompatible things at the same time. Understanding them at the internals level — not just 'shared lock good, exclusive lock bad' — is what separates engineers who write correct concurrent code from those who ship subtle data-corruption bugs disguised as race conditions.
By the end of this article you'll understand exactly how shared, exclusive, intent, and row-level locks work internally, how MVCC sidesteps many locking problems, how to read a deadlock graph and resolve it, and the specific isolation level choices that make or break performance in high-throughput systems. You'll also see runnable examples that demonstrate real lock contention and how to diagnose it live in PostgreSQL and MySQL.
Why Database Locking Mechanisms Decide Your System's Fate
Database locking mechanisms control concurrent access to rows or tables during transactions. They prevent dirty reads, lost updates, and other anomalies by temporarily restricting which transactions can read or write specific data. The core mechanic is simple: a lock grants exclusive or shared access, and any conflicting lock request must wait until the lock is released.
In practice, locks operate at different granularities — row-level, page-level, or table-level — and with different modes: shared (S) for reads and exclusive (X) for writes. The critical property is that locks are held until the transaction commits or rolls back. This means a long-running transaction holding an exclusive lock on a frequently accessed row can stall dozens of concurrent requests, creating a queue that grows linearly with contention.
You use explicit locking — SELECT FOR UPDATE, LOCK TABLE — when you need to enforce a business invariant that spans multiple rows or tables. Without it, two concurrent transactions could read the same state, make decisions based on that stale view, and then write conflicting results. This is how overselling inventory, duplicate payments, and race-condition account balances happen in production.
Lock Escalation Trap
A row-level lock can escalate to a table lock if the database decides too many rows are locked — often at a threshold as low as 5,000 rows.
Production Insight
A payment service used SELECT FOR UPDATE on an order row without an index on the order_id column. The query scanned the full orders table (2M rows), locking every row it touched. Within seconds, all concurrent payment attempts deadlocked, and the service returned 500 errors for 4 minutes until the transaction timed out.
Symptom: sudden spike in deadlock errors and transaction wait timeouts, with no obvious hot row.
Rule of thumb: Always verify the execution plan of any SELECT FOR UPDATE — if you see a full table scan, you're locking the entire table, not just your target row.
Key Takeaway
Lock granularity is determined by the query plan, not the SQL syntax — a missing index escalates row locks to table locks.
Holding locks across user interaction or external API calls is a design smell that kills throughput.
Explicit locking is a last resort for correctness; first try optimistic concurrency (version columns, conditional updates).
thecodeforge.io
SELECT FOR UPDATE Without Index — Full Table Lock Trap
Database Locking Mechanisms
Lock Types Demystified — Shared, Exclusive, and the Intent Hierarchy
At the lowest level, every database lock falls into one of two fundamental modes: Shared (S) and Exclusive (X). A Shared lock says 'I'm reading this — others can read too, but nobody rewrites it while I'm looking.' An Exclusive lock says 'I'm writing this — nobody reads or writes until I'm done.' These two modes form the compatibility matrix that every lock manager checks before granting a request.
But modern databases don't just lock individual rows — they lock at multiple granularities: table, page, and row. To avoid scanning every row lock to decide whether a table lock is safe, databases use Intent locks. An Intent Shared (IS) lock on a table means 'some rows in here have Shared locks.' An Intent Exclusive (IX) lock means 'some rows in here have Exclusive locks.' A Shared Intent Exclusive (SIX) lock is the brutal one: the whole table is being read, plus some rows are being modified.
This hierarchy is why a full-table ALTER TABLE can block every single concurrent INSERT — it needs an AccessExclusive lock that's incompatible with every other mode, including IS. Knowing this stops you from running schema migrations on a busy table without precautions like lock_timeout and retries.
Understanding the compatibility matrix is also critical for writing efficient queries. For instance, a SELECT FOR UPDATE acquires RowExclusiveLock at the table level and ExclusiveLock on the row — this blocks other FOR UPDATE/SHARE queries on the same row, but plain SELECTs still work thanks to MVCC. The intent lock protocol ensures the lock manager can quickly determine safety without scanning all row locks.
lock_compatibility_demo.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
-- ============================================================-- DEMO: Observe lock acquisition and compatibility in PostgreSQL-- Run each block in a separate psql session as indicated-- ============================================================-- Setup: create a simple accounts tableCREATETABLEaccounts (
account_id SERIALPRIMARYKEY,
holder_name TEXTNOTNULL,
balance NUMERIC(12,2) NOTNULLCHECK (balance >= 0)
);
INSERTINTOaccounts (holder_name, balance) VALUES
('Alice Nguyen', 5000.00),
('Bob Tremblay', 3200.00),
('Carol Okafor', 12000.00);
-- ============================================================-- SESSION 1: Acquire a row-level Exclusive lock via FOR UPDATE-- ============================================================BEGIN;
-- FOR UPDATE places a RowExclusiveLock on the table and an-- ExclusiveLock on the matching row's heap tuple.-- No other transaction can UPDATE or DELETE this row until we COMMIT.SELECT account_id, holder_name, balance
FROM accounts
WHERE account_id = 1FORUPDATE; -- <-- this is the exclusive row lock-- Simulate a slow business operation (do NOT commit yet)-- Leave this transaction open and switch to Session 2-- ============================================================-- SESSION 2 (run while Session 1 is still open): Try to read-- ============================================================-- Plain SELECT — succeeds immediately because PostgreSQL MVCC-- serves this from a consistent snapshot, no lock neededSELECT account_id, holder_name, balance
FROM accounts
WHERE account_id = 1; -- returns immediately with old value-- FOR UPDATE on the same row — this BLOCKS because Session 1-- already holds the exclusive row lockSELECT account_id, holder_name, balance
FROM accounts
WHERE account_id = 1FORUPDATE; -- <-- will hang until Session 1 commits or rolls back-- ============================================================-- SESSION 3: Inspect active locks while Session 2 is blocked-- ============================================================SELECT
pid, -- process ID of the backend
relation::regclass AS locked_object,
mode, -- lock mode requested or held
granted, -- FALSE means it's waiting
wait_event_type,
wait_event,
query
FROM pg_locks
JOIN pg_stat_activity USING (pid)
WHERE relation = 'accounts'::regclass
ORDERBY granted DESC, pid;
-- ============================================================-- SESSION 1: Now commit to release the lock-- ============================================================UPDATE accounts
SET balance = balance - 200.00WHERE account_id = 1; -- deduct from Alice's accountCOMMIT; -- Session 2's blocked FOR UPDATE will now proceed
Output
-- Session 2 plain SELECT (returns immediately):
account_id | holder_name | balance
-----------+--------------+---------
1 | Alice Nguyen | 5000.00
-- Session 3 pg_locks query (Session 2 is blocked):
18432 | accounts | RowExclusiveLock | t | (null) | (null) | SELECT ... FOR UPDATE [Session1]
18433 | accounts | RowExclusiveLock | f | Lock | relation | SELECT ... FOR UPDATE [Session2]
-- After Session 1 COMMITs, Session 2 unblocks and returns:
account_id | holder_name | balance
-----------+--------------+---------
1 | Alice Nguyen | 4800.00 <-- sees the updated value
Pro Tip: Use lock_timeout in Production
Never let a transaction block indefinitely in production. Set SET lock_timeout = '2s'; at the start of any session that uses FOR UPDATE or runs DDL. If the lock isn't granted in 2 seconds, you get a clean error instead of a silent pile-up of blocked queries that eventually exhausts your connection pool.
Production Insight
A production DDL (like ALTER TABLE) requires AccessExclusiveLock — it blocks every concurrent read and write.
Running it without lock_timeout can freeze your entire application for minutes.
Rule: always use SET lock_timeout AND schedule DDL during low traffic.
Key Takeaway
Lock compatibility matrix: S and S are compatible, S and X are not, X and X are not.
Intent locks make multi-granularity locking efficient without scanning every row.
Always verify lock scope with pg_locks before deploying queries that acquire locks.
MVCC — How PostgreSQL and MySQL Serve Reads Without Locking
Multi-Version Concurrency Control (MVCC) is the single most important reason modern databases can handle thousands of concurrent users without readers and writers constantly blocking each other. Instead of one authoritative copy of a row, the database keeps multiple versions — each stamped with the transaction ID that created it and (optionally) the one that deleted it.
When you start a transaction in PostgreSQL under READ COMMITTED, the database hands you a snapshot of the world as it existed at that instant. Your queries read from that snapshot. Meanwhile, writers are creating new row versions in the heap. Your snapshot never sees those new versions — you're reading stale-but-consistent data from the past. This is why a plain SELECT never blocks waiting for a writer to finish.
MySQL InnoDB implements MVCC differently: it stores the old row versions in a separate undo log rather than in the heap alongside new versions. Reads below REPEATABLE READ isolate reconstruct old versions by applying undo records backward. This means long-running read transactions don't just hold a snapshot — they hold a reference to undo log chains that InnoDB cannot purge, causing the infamous 'undo log growing unboundedly' problem under heavy write load.
The key insight: MVCC eliminates most read-write lock conflicts, but it does NOT eliminate write-write conflicts. Two transactions trying to UPDATE the same row still need to serialize — MVCC just handles the read side.
A common production mistake is assuming MVCC makes all concurrency problems go away. It doesn't. You still need to handle serialization errors, deadlocks, and ensure your isolation level matches your consistency requirements. Long-running transactions under REPEATABLE READ can cause table bloat and degraded performance because old row versions cannot be garbage-collected.
mvcc_isolation_levels.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
-- ============================================================-- DEMO: MVCC snapshot behavior across isolation levels-- Demonstrates READ COMMITTED vs REPEATABLE READ in PostgreSQL-- ============================================================-- Setup: a product inventory tableCREATETABLEproduct_inventory (
product_id INTPRIMARYKEY,
product_name TEXTNOTNULL,
stock_count INTNOTNULL,
last_updated TIMESTAMPTZDEFAULTnow()
);
INSERTINTO product_inventory VALUES
(101, 'Wireless Keyboard', 50, now()),
(102, 'USB-C Hub', 30, now()),
(103, 'Laptop Stand', 75, now());
-- ============================================================-- SCENARIO A: READ COMMITTED — snapshot refreshes each statement-- ============================================================-- SESSION 1: Start a READ COMMITTED transaction (PostgreSQL default)BEGINISOLATIONLEVELREADCOMMITTED;
-- First read: sees stock_count = 50 for keyboardSELECT product_id, product_name, stock_count
FROM product_inventory
WHERE product_id = 101;
-- ---- SESSION 2: Commits an update while Session 1 is still open ------ BEGIN;-- UPDATE product_inventory SET stock_count = 48 WHERE product_id = 101;-- COMMIT;-- ---------------------------------------------------------------------- SESSION 1: Second read in SAME transaction — sees the NEW value 48-- because READ COMMITTED re-snaps at each statement boundary.-- This is a non-repeatable read — same query, different result.SELECT product_id, product_name, stock_count
FROM product_inventory
WHERE product_id = 101;
COMMIT;
-- ============================================================-- SCENARIO B: REPEATABLE READ — snapshot frozen at BEGIN-- ============================================================-- SESSION 1: Start a REPEATABLE READ transactionBEGINISOLATIONLEVELREPEATABLEREAD;
-- First read: sees stock_count = 48 (current committed value)SELECT product_id, product_name, stock_count
FROM product_inventory
WHERE product_id = 101;
-- ---- SESSION 2: Commits another update ------ BEGIN;-- UPDATE product_inventory SET stock_count = 45 WHERE product_id = 101;-- COMMIT;-- --------------------------------------------- SESSION 1: Second read — still sees 48, NOT 45-- The snapshot was frozen at transaction start.-- This guarantees repeatable reads within the transaction.SELECT product_id, product_name, stock_count
FROM product_inventory
WHERE product_id = 101;
-- Attempting to UPDATE the row that Session 2 already changed:-- PostgreSQL will detect the write-write conflict and raise:-- ERROR: could not serialize access due to concurrent updateUPDATE product_inventory
SET stock_count = stock_count - 1-- trying to sell one unitWHERE product_id = 101;
ROLLBACK; -- must retry the entire transaction from scratch-- ============================================================-- Check the xmin/xmax system columns to see MVCC versions-- ============================================================SELECT
product_id,
product_name,
stock_count,
xmin, -- transaction ID that INSERTed/last UPDATEd this version
xmax -- transaction ID that DELETEd/superseded this version (0 = live)FROM product_inventory;
Watch Out: Long Transactions Kill MVCC Performance
A transaction open for 30 minutes under REPEATABLE READ or SERIALIZABLE holds a snapshot that prevents PostgreSQL's VACUUM from reclaiming dead row versions. The result is table bloat and index bloat that silently degrades query performance. Always close transactions as quickly as possible, and monitor pg_stat_activity for idle transactions with state = 'idle in transaction'.
Production Insight
In MySQL InnoDB, a long-running read transaction keeps the undo log from being purged,
causing the undo tablespace to grow indefinitely until disk fills up.
Rule: monitor innodb_history_list_length and kill transactions older than your threshold.
Key Takeaway
MVCC gives readers a snapshot — they never block writers, and writers never block readers.
But write-write conflicts still require serialization (deadlocks, serialization errors).
Long-running transactions bloat your database — commit fast, or pay the storage penalty.
Deadlocks — How They Form, How to Detect Them, and How to Prevent Them
A deadlock is two (or more) transactions caught in a circular wait — Transaction A holds a lock that B needs, while B holds a lock that A needs. Neither can proceed. Without intervention, they'd wait forever. Every serious database engine has a deadlock detector that periodically scans the wait-for graph, finds cycles, picks a victim (usually the transaction that's done the least work), rolls it back, and lets the winner proceed.
The most common production deadlock pattern isn't exotic — it's two transactions updating the same two rows in opposite orders. Transaction A locks row 1 then tries to lock row 2. Transaction B locks row 2 then tries to lock row 1. The fix is almost always enforcing a consistent lock ordering: always acquire locks in the same sequence (e.g., always lock the lower account_id first in a money-transfer operation).
A subtler deadlock source is the gap between a SELECT and a subsequent UPDATE. Transaction A reads a row, does application logic for 200ms, then comes back to update it. Transaction B has done the same thing in parallel. When both arrive at the UPDATE, one wins the lock and the other either deadlocks or serializes. Using SELECT ... FOR UPDATE at read time eliminates this gap by acquiring the exclusive lock immediately.
In PostgreSQL, deadlock details land in the server log and in pg_stat_activity. In MySQL InnoDB, SHOW ENGINE INNODB STATUS gives you the full deadlock report with the exact queries and lock wait chains.
Detecting deadlocks in production: Enable deadlock logging. In PostgreSQL, set deadlock_timeout (default 1s) and inspect logs. In MySQL, enable innodb_print_all_deadlocks to capture every deadlock event. Then instrument your application to log the error code and retry count. A high retry rate tells you your lock ordering is wrong even before users complain.
deadlock_prevention.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
-- ============================================================-- DEMO: Classic deadlock scenario and its prevention-- Run on PostgreSQL. Uses two accounts for a funds transfer.-- ============================================================-- SetupCREATETABLEbank_accounts (
account_id INTPRIMARYKEY,
owner_name TEXTNOTNULL,
balance NUMERIC(12,2) NOTNULLCHECK (balance >= 0)
);
INSERTINTO bank_accounts VALUES
(1, 'Alice Nguyen', 10000.00),
(2, 'Bob Tremblay', 5000.00);
-- ============================================================-- THE DEADLOCK-PRONE PATTERN-- Transaction 1: Transfer $100 from Alice (id=1) to Bob (id=2)-- Transaction 2: Transfer $50 from Bob (id=2) to Alice (id=1)-- Both start simultaneously — classic deadlock setup-- ============================================================-- Transaction 1 (Session 1)BEGIN;
UPDATE bank_accounts SET balance = balance - 100WHERE account_id = 1; -- locks row 1-- (now pause — Session 2 runs its first UPDATE below)UPDATE bank_accounts SET balance = balance + 100WHERE account_id = 2; -- BLOCKS: waits for row 2COMMIT;
-- Transaction 2 (Session 2 — run the first line WHILE Session 1 is paused)BEGIN;
UPDATE bank_accounts SET balance = balance - 50WHERE account_id = 2; -- locks row 2UPDATE bank_accounts SET balance = balance + 50WHERE account_id = 1; -- DEADLOCK: waits for row 1COMMIT;
-- PostgreSQL detects the cycle and raises:-- ERROR: deadlock detected-- DETAIL: Process 18433 waits for ShareLock on transaction 50401;-- blocked by process 18432.-- Process 18432 waits for ShareLock on transaction 50402;-- blocked by process 18433.-- HINT: See server log for query details.-- ============================================================-- THE DEADLOCK-SAFE PATTERN-- Always lock rows in ascending account_id order regardless of-- which direction the transfer flows-- ============================================================CREATEORREPLACEFUNCTIONtransfer_funds(
p_from_account_id INT,
p_to_account_id INT,
p_amount NUMERIC
) RETURNSVOIDAS $$
DECLARE
v_low_id INT := LEAST(p_from_account_id, p_to_account_id);
v_high_id INT := GREATEST(p_from_account_id, p_to_account_id);
BEGIN-- Always lock the LOWER id first — consistent ordering prevents deadlock-- regardless of which direction the transfer flowsPERFORM account_id, balance
FROM bank_accounts
WHERE account_id IN (v_low_id, v_high_id)
ORDERBY account_id ASC-- <-- the critical orderingFORUPDATE; -- acquires exclusive locks in a deterministic order-- Validate sufficient fundsIF (SELECT balance FROM bank_accounts WHERE account_id = p_from_account_id) < p_amount THENRAISEEXCEPTION'Insufficient funds in account %', p_from_account_id;
ENDIF;
-- Perform the actual transferUPDATE bank_accounts
SET balance = balance - p_amount
WHERE account_id = p_from_account_id;
UPDATE bank_accounts
SET balance = balance + p_amount
WHERE account_id = p_to_account_id;
END;
$$ LANGUAGE plpgsql;
-- Both transfers now run safely in parallel — no deadlockSELECTtransfer_funds(1, 2, 100); -- Alice -> BobSELECTtransfer_funds(2, 1, 50); -- Bob -> Alice (different session, same time)-- Verify final balancesSELECT account_id, owner_name, balance FROM bank_accounts ORDERBY account_id;
Output
-- Deadlock-prone pattern: PostgreSQL error in Session 2:
ERROR: deadlock detected
DETAIL: Process 18433 waits for ShareLock on transaction 50401; blocked by process 18432.
Process 18432 waits for ShareLock on transaction 50402; blocked by process 18433.
HINT: See server log for query details.
-- Deadlock-safe pattern: both transfers complete cleanly.
-- Final balances after Alice->Bob $100 and Bob->Alice $50:
Interview Gold: How Does a DB Choose the Deadlock Victim?
PostgreSQL picks the transaction that has done the least work (fewest locks held, youngest by transaction ID). MySQL InnoDB uses a cost heuristic based on the number of undo log records — the transaction that has modified fewer rows is rolled back. You can influence InnoDB's choice with INNODB_LOCK_WEIGHT or by structuring transactions to do heavy writes last. Always write application code that expects ERROR: deadlock detected and retries the entire transaction from scratch — not just the last statement.
Production Insight
Deadlock detection is not free — it runs periodically and scans the wait-for graph.
PostgreSQL's deadlock_timeout (default 1s) is the interval between checks; a very low value wastes CPU.
Rule: if you see too many deadlock victims, fix the ordering — don't tune the timeout.
Key Takeaway
Deadlocks are almost always caused by inconsistent lock order across concurrent transactions.
Fix: always acquire locks on multiple resources in a deterministic sequence.
Application code must retry on deadlock — it's not optional, it's mandatory.
Deadlock Prevention Decision Tree
IfMultiple transactions update the same set of rows in different orders
→
UseApply strict lock ordering (e.g., LEAST/GREATEST, alphabetical key ordering). This is the first and most effective fix.
IfThere's a read-then-write gap in your transaction
→
UseUse SELECT FOR UPDATE at the read point to acquire the exclusive lock immediately and eliminate the gap.
IfDeadlocks still occur after ordering and FOR UPDATE
→
UseConsider optimistic locking (version column) or reduce transaction size to minimize lock duration. Ensure retry logic is in place.
Isolation Levels, Lock Escalation, and Choosing the Right Strategy in Production
Isolation levels are your contract with the database about what anomalies you're willing to tolerate in exchange for concurrency. READ UNCOMMITTED (dirty reads allowed) is almost never correct. READ COMMITTED is the PostgreSQL default and the right choice for most OLTP workloads — each statement gets a fresh snapshot, so writes from other committed transactions are visible immediately. REPEATABLE READ prevents non-repeatable reads by freezing the snapshot at transaction start. SERIALIZABLE gives you full correctness at the cost of more aborts under contention.
Lock escalation is a real performance trap in SQL Server and older MySQL configurations. When a single transaction acquires too many row locks (often >5,000 in SQL Server), the engine escalates to a table lock to save memory. This silently destroys concurrency for all other sessions touching that table. The fix is batching large bulk operations into smaller transactions or using table-level hints explicitly.
For high-throughput write-heavy systems, consider Optimistic Locking — don't lock at read time, just check at write time that nobody else modified the row since you read it. Implement this with a version column or a last_modified timestamp. This works brilliantly when conflicts are rare. When conflicts are frequent, optimistic locking creates a storm of retries and is worse than pessimistic locking.
In PostgreSQL, the SERIALIZABLE isolation level uses Serializable Snapshot Isolation (SSI). It works by detecting read-write conflicts through predicate locks — it tracks which ranges of rows a transaction read, not just which rows it wrote. Under heavy contention, SSI aborts many transactions that would have succeeded under REPEATABLE READ. Don't enable it unless you really need it, and be prepared to handle the serialization failures.
optimistic_locking_pattern.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
-- ============================================================-- DEMO: Optimistic Locking with a version column-- No explicit locks acquired at read time.-- Conflict detection happens at UPDATE using the version stamp.-- ============================================================CREATETABLEproduct_catalog (
product_id SERIALPRIMARYKEY,
product_name TEXTNOTNULL,
unit_price NUMERIC(10,2) NOTNULL,
row_version INTNOTNULLDEFAULT1, -- optimistic lock sentinel
last_modified TIMESTAMPTZDEFAULTnow()
);
INSERTINTOproduct_catalog (product_name, unit_price) VALUES
('Mechanical Keyboard', 129.99),
('27-inch Monitor', 349.99),
('Ergonomic Mouse', 59.99);
-- ============================================================-- APPLICATION LAYER PATTERN-- Step 1: Read the row AND capture its row_version-- Step 2: Do business logic (could take several seconds)-- Step 3: UPDATE only if row_version is still the same value-- we read — if not, someone else changed it first-- ============================================================-- Step 1: Read the keyboard price (application caches row_version = 1)SELECT product_id, product_name, unit_price, row_version
FROM product_catalog
WHERE product_id = 1;
-- Returns: product_id=1, unit_price=129.99, row_version=1-- (Imagine a concurrent session updates the price to 119.99 and-- increments row_version to 2 while our app is thinking)-- Step 3: Our application tries to set price to 124.99-- We include WHERE row_version = 1 (the version we read)UPDATE product_catalog
SET
unit_price = 124.99,
row_version = row_version + 1, -- increment the version
last_modified = now()
WHERE product_id = 1AND row_version = 1; -- <-- the optimistic check-- Check how many rows were actually updatedGETDIAGNOSTICS-- (in PL/pgSQL) or check rowcount in application layer-- If rowcount = 0, the optimistic lock failed — someone else-- modified the row between our read and our write.-- The application must re-read, re-apply its logic, and retry.-- ============================================================-- Stored procedure with retry logic for optimistic locking-- ============================================================CREATEORREPLACEFUNCTIONupdate_product_price(
p_product_id INT,
p_new_price NUMERIC,
p_read_version INT-- version the caller observed at read time
) RETURNSTEXTAS $$
DECLARE
rows_affected INT;
BEGINUPDATE product_catalog
SET
unit_price = p_new_price,
row_version = row_version + 1,
last_modified = now()
WHERE product_id = p_product_id
AND row_version = p_read_version; -- optimistic conflict checkGETDIAGNOSTICS rows_affected = ROW_COUNT;
IF rows_affected = 0THEN-- Return a signal the caller can detect and retryRETURN'CONFLICT: row was modified by another transaction — please re-read and retry';
ENDIF;
RETURN'OK: price updated successfully';
END;
$$ LANGUAGE plpgsql;
-- Simulate a clean update (correct version)SELECTupdate_product_price(2, 329.99, 1); -- Monitor price drop, correct version-- Simulate a stale update (wrong version — someone else already updated)SELECTupdate_product_price(2, 319.99, 1); -- version 1 is stale now, it's 2-- Verify final stateSELECT product_id, product_name, unit_price, row_version FROM product_catalog;
PostgreSQL's SSI (Serializable Snapshot Isolation) uses predicate locks to detect read-write conflicts that could produce non-serializable outcomes. Under heavy contention it generates ERROR: could not serialize access due to concurrent update errors that your application MUST handle with retry logic. Don't enable SERIALIZABLE hoping it silently 'fixes' your concurrency bugs — it just surfaces them as errors you now have to handle explicitly.
Production Insight
Lock escalation in SQL Server happens automatically when a transaction holds >5,000 row locks.
It escalates to a table lock, blocking other sessions silently until the transaction ends.
Rule: batch large updates into chunks of 1,000 rows to avoid triggering escalation.
Key Takeaway
Isolation level is a trade-off between consistency and concurrency.
Lock escalation is a silent concurrency killer — batch writes to prevent it.
Optimistic locking works when conflicts are rare; pessimistic works when they're common.
Choosing an Isolation Level
IfMostly reads, some writes, no strict consistency needed
→
UseREAD COMMITTED — best performance, non-repeatable reads acceptable
IfNeed consistent snapshots for reporting within a transaction
→
UseREPEATABLE READ — freeze snapshot at start, risk of serialization errors on write
IfMust prevent all anomalies including phantoms
→
UseSERIALIZABLE — highest correctness, highest abort rate under contention. Use with retry logic.
Monitoring Lock Contention in Production — Tools and Diagnostic Queries
You can't fix what you can't see. Lock contention in production is often invisible until transactions start timing out or the connection pool is exhausted. Proactive monitoring of lock wait events and lock queue depths is essential.
In PostgreSQL, the pg_locks and pg_stat_activity views are your primary diagnostic tools. Query for un-granted locks to see who's waiting. The wait_event and wait_event_type columns tell you exactly what the backend is waiting on: 'Lock/relation' means waiting for a table-level lock, 'Lock/tuple' means waiting for a row-level lock.
MySQL InnoDB provides SHOW ENGINE INNODB STATUS which includes sections for LATEST DETECTED DEADLOCK, TRANSACTIONS, and LOCK WAIT. It's verbose but contains exact transaction IDs, lock modes, and the blocked and blocking queries. Enable innodb_print_all_deadlocks and innodb_status_output_locks for continuous monitoring.
Both databases support lock_timeout (PostgreSQL) and innodb_lock_wait_timeout (MySQL) to abort transactions that wait too long. Set these aggressively in production to prevent cascading wait chains.
For real-time lock monitoring, set up metrics: lock wait count, average wait time, and deadlock rate. Alert when any of these exceed your baseline. Tools like pg_stat_statements and Performance Schema (MySQL) can help identify queries that frequently cause lock waits.
lock_monitoring_queries.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
-- ============================================================-- PostgreSQL: Find blocking and blocked transactions-- ============================================================-- Query 1: Who is waiting and who holds the lock?SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_locks blocked_locks ON blocked.pid = blocked_locks.pid
JOIN pg_locks blocking_locks ON
blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.database = blocked_locks.database
AND blocking_locks.relation = blocked_locks.relation
AND blocking_locks.page = blocked_locks.page
AND blocking_locks.tuple = blocked_locks.tuple
AND blocking_locks.pid != blocked.pid
JOIN pg_stat_activity blocking ON blocking.pid = blocking_locks.pid
WHERENOT blocked_locks.granted;
-- Query 2: Count of waiting processes by wait eventSELECT wait_event, wait_event_type, COUNT(*)
FROM pg_stat_activity
WHERE wait_event ISNOTNULLAND state = 'active'GROUPBY wait_event, wait_event_type
ORDERBYCOUNT(*) DESC;
-- Query 3: Transactions that have been running the longestSELECT pid, state, query, xact_start,
EXTRACT(epoch FROMnow() - xact_start)::int AS seconds_since_start
FROM pg_stat_activity
WHERE state = 'active'ORDERBY xact_start
LIMIT10;
-- ============================================================-- MySQL InnoDB: Lock monitoring queries-- ============================================================-- Query 1: Current lock waits (requires PERFORMANCE_SCHEMA)-- Requires 'performance_schema' enabledSELECT * FROM performance_schema.data_lock_waits;
-- Query 2: Show InnoDB status (deadlock and lock information)SHOWENGINEINNODBSTATUS\G
-- Query 3: Long-running transactionsSELECT * FROM information_schema.innodb_trx
WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 10;
-- ============================================================-- Setting lock timeouts to prevent indefinite waits-- ============================================================-- PostgreSQL: Set per-sessionSET lock_timeout = '2s';
-- MySQL: Set globally (affects new sessions)SETGLOBAL innodb_lock_wait_timeout = 2; -- seconds
Use pg_stat_statements and pg_locks to build a Grafana dashboard showing lock wait count, deadlock rate, and long-running transactions. Alert when a transaction has been waiting for a lock for more than 5 seconds. This is your first line of defense against lock-related production incidents.
Production Insight
The most common lock-related on-call alert is "connection pool exhausted" — but that's a symptom, not a cause.
The real signal is the number of blocked transactions and their wait time.
Rule: instrument lock_wait_count and lock_wait_avg_ms as metrics, not just queries on demand.
Key Takeaway
Proactive lock monitoring is the only way to catch contention before it causes outages.
Use pg_locks and SHOW ENGINE INNODB STATUS to identify blockers.
Set lock_timeout to limit wait duration and prevent cascading failures.
Locking in Distributed Systems — Why Your Optimistic Offline Lock Pattern Is Lying to You
You slapped an updated_at check on your UPDATE and called it optimistic locking. That works fine when your app server and database share a clock and a network switch. Add a message queue consumer, a background job worker, and a Kubernetes pod restart, and your optimistic check becomes a prayer.
Distributed locking is a different animal. The problem isn't just concurrency — it's clock drift, network partitions, and garbage collection pauses that turn milliseconds into seconds. Your 5-second TTL on a SELECT ... FOR UPDATE timeout doesn't protect you when the scheduler steals your thread for 8 seconds.
The production fix: use a database-backed advisory lock for synchronous coordination, but pair it with a fencing token for async workers. In PostgreSQL, pg_advisory_lock() gives you application-level mutual exclusion without holding table-level rows hostage. For background jobs that can't retry, implement a fencing token — a monotonically increasing integer stored alongside the lock record. If the token on the lock doesn't match your worker's token, you're holding a stale lock. Stop processing.
ZooKeeper and etcd solve this natively. Your relational database can fake it with pg_try_advisory_xact_lock() and a version column. But don't pretend your single-MVCC-snapshot is a distributed lock. It's not.
FencedAdvisoryLock.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// io.thecodeforge — database tutorial
-- Use a versioned advisory lock for distributed workers-- Each worker claims a slot and increments the fence_tokenCREATETABLEjob_locks (
job_name TEXTPRIMARYKEY,
fence_token BIGINTNOTNULLDEFAULT0,
locked_by TEXT,
locked_at TIMESTAMPTZ
);
-- Atomic claim using a fencing tokenUPDATE job_locks
SET fence_token = fence_token + 1,
locked_by = 'worker-001',
locked_at = NOW()
WHERE job_name = 'inventory-sync'AND fence_token = ( -- only claim if token matches last knownSELECT fence_token FROM job_locks WHERE job_name = 'inventory-sync'
)
RETURNING fence_token;
-- Now acquire the PostgreSQL advisory lock to prevent concurrent local executionSELECTpg_try_advisory_xact_lock(hashtext('inventory-sync'));
-- Returns true if lock acquired, false if another local session holds it
Output
fence_token
-------------
1
(1 row)
pg_try_advisory_xact_lock
---------------------------
t
(1 row)
Production Trap:
Never use NOW() for distributed lock expiry. Clock skew between a replicated database and your app server can cause two nodes to believe they both hold the valid lock. Always use a monotonic fencing token instead of timestamps.
Key Takeaway
Optimistic locks are local. Distributed locks need fencing tokens. Don't let clock drift make your 'lock' a fiction.
Lock Escalation Hell — When Row Locks Become Table Locks and Your P95 Latency Goes Supernova
You’re proud of your fine-grained row-level locking. Each transaction touches ten rows. You test at 50 concurrent users and everything purrs. Then Black Friday traffic hits 500 concurrent users and your database CPU spikes to 100% — and your carefully crafted row locks just turned into a single table lock that blocks everything.
This is lock escalation. It’s not a bug. It’s a feature of most relational databases, including SQL Server and DB2, and it’s triggered when a single transaction accumulates too many row-level locks. The database decides it’s cheaper to lock the whole table than track 5,000 individual row locks in memory. The problem: it never asks your permission.
SQL Server’s default threshold is 5,000 locks per session. Hit that in a batch UPDATE or a long-running SELECT ... FOR UPDATE, and your rows per second drops to zero while every other writer waits. MySQL’s InnoDB doesn’t escalate row to table locks natively, but it does use gap locks and next-key locks that effectively behave like escalation under certain isolation levels.
The fix isn’t to crank the threshold higher — that just trades memory pressure for concurrency pressure. Instead: batch your updates into chunks of 1,000 rows max. Use a WHERE id BETWEEN loop with a LIMIT to keep lock counts low. For SQL Server, disable lock escalation on the specific table with ALTER TABLE ... SET (LOCK_ESCALATION = DISABLE) — but only after you’ve proven your transaction won’t need more than a few hundred locks.
If you ever see LOCK_ESCALATION in a deadlock graph: you’ve already lost. Stay under 1,000 locks per transaction, or accept that your database will flip the table.
BatchUpdateWithLockLimit.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// io.thecodeforge — database tutorial
-- Bad: single UPDATE that triggers lock escalation after 5,000 row locksUPDATE orders
SET status = 'archived'WHERE created_at < '2023-01-01';
-- If this hits 5,000 rows, SQL Server escalates to a table lock-- Good: batching with a loop, max 500 locks per transactionDO $$
DECLARE
batch_size INT := 500;
affected INT;
BEGINLOOPUPDATE orders
SET status = 'archived'WHERE id IN (
SELECT id
FROM orders
WHERE created_at < '2023-01-01'AND status != 'archived'LIMIT batch_size
FORUPDATESKIPLOCKED-- skip rows already locked by other txns
);
GETDIAGNOSTICS affected = ROW_COUNT;
COMMIT; -- release locks after each batchEXITWHEN affected = 0;
ENDLOOP;
END;
$$;
Output
-- No direct output, but you can monitor lock count per session:
SELECT COUNT(*)
FROM sys.dm_tran_locks
WHERE request_session_id = @@SPID;
-- Should stay below 500
Senior Shortcut:
Always wrap batch UPDATE/DELETE loops in explicit COMMIT statements. Most ORMs auto-commit after each statement, but raw SQL loops won't. Forgetting COMMIT keeps locks held across batches — defeating the whole point.
Key Takeaway
Keep per-transaction row locks under 1,000. Batch your writes. COMMIT often. Lock escalation is a silent performance killer that no monitoring dashboard warns you about until it’s too late.
Row Versioning: How Your Database Ducks Lock Contention Without You Noticing
Most engineers think locking is the only game in town. They're wrong. Row versioning is how PostgreSQL and MySQL's InnoDB serve thousands of concurrent readers without a single lock conflict. The trick: every write creates a new version of the row, not an in-place update. Readers see the version that was committed before their transaction started. Writes don't block reads. Period.
That's the superpower. When you update a row, the old version stays visible to any transaction that began before your commit. No shared locks, no exclusive locks, no queue. The database just points the reader at the correct snapshot. This is why MVCC-based systems crush read-heavy workloads without deadlocks.
Here's the cost: every update grows the version chain. The database has to track old versions until they're no longer visible to any open transaction. That's where autovacuum comes in — it prunes dead row versions. If you hold long-running read transactions, you're actively preventing cleanup. Your page cache fills with ghosts. Your index scans get slower by the hour. Row versioning isn't free — it's a bet that reads outnumber writes. That bet pays off daily until someone leaves a transaction open over lunch.
Version_Chain_Check.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
// io.thecodeforge — database tutorial
-- Check tuple-level version churn in PostgreSQLSELECT relname,
n_tup_ins,
n_tup_upd,
n_tup_hot_upd,
n_live_tup,
n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'orders'ORDERBY n_dead_tup DESC;
More than 10% dead tuples in a heavily updated table means autovacuum is losing the race. Tune vacuum thresholds or throttle your update rate before your index scan cost doubles.
Key Takeaway
Row versioning trades write cost for read concurrency — but dead tuple bloat is the hidden tax you must monitor.
Row Versioning Resource Usage — Why Your Bloat Bill Arrives on Tuesday at 2 PM
Row versioning isolates reads from writes without blocking, but the cost is invisible until Tuesday afternoon. Every UPDATE or DELETE creates a new row version in the version store—typically tempdb on SQL Server or the undo tablespace on Postgres/Oracle. That store grows monotonically until the oldest active transaction commits or rolls back. If your weekly ETL pipeline runs a long transaction on Monday evening, all subsequent row versions remain pinned for the duration. By Tuesday at 2 PM, when your report reads a row updated at 8 AM, the version store has accumulated 30 hours of unpruned data. Disk fills, query plans bloat, and autogrowth events cascade. The real cost isn't the row version itself—it's retention. Set a hard limit on transaction duration. Monitor version store size per database. Alert on transactions older than your SLAs. Versioning is a performance tradeoff: you buy concurrency with storage, but storage is finite and billing is real. Know your longest transaction before you enable snapshot isolation.
Check_Version_Store_Usage.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
-- SQL Server: find oldest active transaction pinning versionsSELECTDATEDIFF(minute, transaction_begin_time, GETDATE()) AS tenure_minutes,
COUNT(*) AS version_count
FROM sys.dm_tran_active_snapshot_database_transactions
GROUPBY transaction_begin_time
ORDERBY tenure_minutes DESC;
-- Postgres: check snapshots and ageSELECT pid, xact_start, backend_start,
age(backend_xmin) AS xmin_age
FROM pg_stat_activity
WHERE backend_xmin ISNOTNULLORDERBY xmin_age DESC;
Output
tenure_minutes | version_count
----------------+--------------
1872 | 142300
32 | 4001
Production Trap:
One forgotten long-running reporting query on a Monday export job can pin millions of versions. Set statement timeout on reporting connections, or use snapshot isolation with a max version retention period in tempdb.
Key Takeaway
Your version store bill is directly proportional to the age of your oldest open transaction—measure it in minutes, not gigabytes.
● Production incidentPOST-MORTEMseverity: high
The Phantom Lock: How a Missing Index Caused a Full-Table Lock and Brought Down Checkout
Symptom
During peak hours, checkout requests would hang for 30+ seconds then timeout. Connection pool exhaustion followed within minutes. CPU was low, but all database connections were active with state 'active' in pg_stat_activity, executing the same SELECT FOR UPDATE query.
Assumption
The team assumed that SELECT FOR UPDATE on a single row would only lock that row, so it was safe to use in a high-traffic checkout path. They had tested with low concurrency and never observed blocking.
Root cause
The WHERE clause in SELECT FOR UPDATE used a non-indexed column. PostgreSQL couldn't do an index scan, so it performed a sequential scan of the entire table and acquired row locks on every row it examined — effectively a table-level lock. Every concurrent checkout was waiting for the previous one to commit.
Fix
Added a composite index on the columns used in the WHERE clause. The query switched to an index-only scan, locking only the matching row. Checkout concurrency increased from 1 to over 100 concurrent transactions without blocking.
Key lesson
Always run EXPLAIN (ANALYZE, BUFFERS) before deploying any SELECT FOR UPDATE query to production.
An unindexed FOR UPDATE is a disaster waiting to happen — the lock scope expands to every row the sequential scan touches.
Monitor pg_locks regularly to detect unexpected lock granularity before users complain.
Production debug guideSymptom → Action: Diagnose and resolve the most common lock-related production issues4 entries
Symptom · 01
Transaction hangs indefinitely with no error
→
Fix
Check pg_locks for waiting processes using: SELECT * FROM pg_locks WHERE NOT granted; Identify the blocking PID and decide to terminate the blocker or wait.
Symptom · 02
Deadlock detected error after a few seconds
→
Fix
Check PostgreSQL logs for deadlock details. Deadlock victim is rolled back. Adjust application code to retry on deadlock and enforce consistent lock ordering.
Symptom · 03
Table appears locked – DDL or DML hangs
→
Fix
Run SHOW ENGINE INNODB STATUS in MySQL or check pg_locks for AccessExclusiveLock. Identify the long-running transaction. Set lock_timeout to 2 seconds to prevent indefinite hangs.
Symptom · 04
Connection pool exhaustion with many idle in transaction connections
→
Fix
Query pg_stat_activity for 'idle in transaction' connections. Terminate them using pg_terminate_backend(pid). Fix application code to close transactions after the last statement, not after waiting for user input.
★ Lock Debugging Cheat SheetQuick commands and actions for diagnosing lock issues in PostgreSQL and MySQL
Transaction waiting on a lock−
Immediate action
Check pg_locks for blocking PIDs
Commands
SELECT pid, wait_event, wait_event_type, query FROM pg_stat_activity WHERE state = 'active' AND wait_event IS NOT NULL;
SELECT pid, relation::regclass, mode, granted FROM pg_locks WHERE NOT granted;
Fix now
If blocking PID is safe to kill: SELECT pg_terminate_backend(pid);
Deadlock error in application logs+
Immediate action
Extract deadlock details from database logs
Commands
PostgreSQL: Check pg_log directory for deadlock reports (look for 'deadlock detected')
MySQL: SHOW ENGINE INNODB STATUS\G | grep -A 30 "LATEST DETECTED DEADLOCK"
Fix now
Implement retry logic in application. For PostgreSQL, catch error code 40P01 and retry the entire transaction.
Query is slower than usual, no obvious wait+
Immediate action
Check if lock escalation to table lock occurred
Commands
PostgreSQL: SELECT relname, n_dead_tup, n_live_tup, n_mod_since_analyze FROM pg_stat_user_tables; (bloat can cause scans)
MySQL: SHOW ENGINE INNODB STATUS\G | grep 'LOCK WAIT' - check for table-level intention locks
Fix now
Add missing indexes on columns used in WHERE clauses of UPDATE/DELETE/FOR UPDATE queries.
Long-running transaction blocking VACUUM+
Immediate action
Identify the old transaction and its age
Commands
SELECT pid, state, query, xact_start FROM pg_stat_activity WHERE state = 'idle in transaction' ORDER BY xact_start;
SELECT datname, age(backend_xmin) FROM pg_stat_database; (watch for wraparound risk)
Fix now
Terminate the idle transaction: SELECT pg_terminate_backend(pid); Set statement_timeout and idle_in_transaction_session_timeout to prevent recurrence.
Locking Strategy Comparison
Aspect
Pessimistic Locking (SELECT FOR UPDATE)
Optimistic Locking (Version Column)
Lock acquired at
Read time — immediately on SELECT
Write time — only on UPDATE
Best for
High-conflict workloads (financial transfers)
Low-conflict workloads (catalog updates, CMS)
Failure mode
Blocking wait / timeout / deadlock
Silent success with 0 rows affected — needs retry
Throughput impact
Serializes concurrent readers of same row
No blocking reads — higher read throughput
Implementation complexity
Low — just add FOR UPDATE to existing SELECT
Moderate — need version column, retry logic in app
Works across app servers
Yes — lock lives in the DB
Yes — version column is DB-side, stateless
Risk of starvation
Yes — long transactions block everyone behind them
Low — no blocking, just retries on conflict
MVCC interaction
Bypasses MVCC for writes, forces serialization
Fully compatible with MVCC — reads are never blocked
Key takeaways
1
MVCC means plain SELECTs almost never block in PostgreSQL or InnoDB
but write-write conflicts still serialize, and long read transactions bloat the undo log or table heap.
2
Deadlocks are almost always caused by inconsistent lock ordering
always acquire locks on multiple rows in a deterministic sequence (e.g., ascending primary key) and they become nearly impossible.
3
Optimistic locking is strictly better for low-conflict scenarios because it never blocks readers
but it requires application-level retry logic on every write path, which most teams forget to implement.
4
Lock escalation from row-level to table-level is a silent performance killer in SQL Server and legacy MySQL configs
batch large bulk operations and verify lock granularity with your DB's lock diagnostics before touching large tables in production.
5
Proactive monitoring of lock contention (pg_locks, SHOW ENGINE INNODB STATUS) is the only way to catch lock issues before they cause outages
set lock_timeout and alert on wait counts.
Common mistakes to avoid
4 patterns
×
Holding a transaction open across network calls or user input
Symptom
Idle connections pile up in pg_stat_activity with state='idle in transaction', connection pool exhausted, application errors under moderate load
Fix
Restructure so all DB reads happen before the transaction opens, or use application-level caching. Begin the transaction only immediately before the writes and commit immediately after. Never wait for an HTTP response or user click inside an open transaction.
×
Using SELECT FOR UPDATE on a large range without an index
Symptom
The query escalates to a near-table-lock because it must examine thousands of rows and acquires row locks on all of them, grinding concurrent writes to a halt
Fix
Ensure the WHERE clause uses an indexed column so the lock scope is minimal. Run EXPLAIN (ANALYZE, BUFFERS) to verify an index scan is used, not a sequential scan, before deploying any FOR UPDATE query to production.
×
Assuming READ COMMITTED prevents phantom reads in MySQL InnoDB
Symptom
A transaction reads a range, another transaction inserts a row into that range and commits, the first transaction re-reads the range and sees the new row (phantom) — leading to incorrect aggregate calculations or double-processing
Fix
Upgrade to REPEATABLE READ (MySQL InnoDB default) which uses gap locks to prevent phantoms, or restructure so range queries are only executed once per transaction. Understand that PostgreSQL's REPEATABLE READ also prevents phantoms via MVCC snapshots but doesn't use gap locks.
×
Not implementing retry logic for deadlock errors
Symptom
Application crashes with ERROR: deadlock detected and loses the transaction, causing data inconsistency or user frustration
Fix
Catch error code 40P01 in PostgreSQL (or deadlock-related SQLSTATE) and retry the entire transaction from scratch. Implement exponential backoff with jitter to avoid thundering herd.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
Explain the difference between a shared lock and an exclusive lock, and ...
Q02SENIOR
Walk me through exactly how a deadlock forms between two transactions do...
Q03SENIOR
A senior engineer tells you 'just use SERIALIZABLE isolation and all you...
Q01 of 03SENIOR
Explain the difference between a shared lock and an exclusive lock, and describe a real scenario where using SELECT FOR UPDATE instead of a plain SELECT was the correct choice and why.
ANSWER
A shared lock (S) allows multiple transactions to read the same data simultaneously, but prevents any writer from modifying it. An exclusive lock (X) prevents any other transaction from reading or writing the locked data. In production, consider an inventory reservation system: when a user puts an item in their cart, you must ensure the stock count doesn't change between reading and decrementing. If you use a plain SELECT, another transaction could decrement the stock before your UPDATE runs, leading to overselling. By using SELECT FOR UPDATE, you acquire an exclusive lock at read time, guaranteeing no other transaction can modify that row until you commit.
Q02 of 03SENIOR
Walk me through exactly how a deadlock forms between two transactions doing money transfers, and what specific change to the code would prevent it from ever happening — not just detecting and retrying it.
ANSWER
Consider two concurrent transfers: Transaction A transfers $100 from account 1 to account 2. Transaction B transfers $50 from account 2 to account 1. If A locks account 1 first, then tries to lock account 2, while B locks account 2 first then tries account 1, they form a circular wait: A holds account 1, waits for account 2; B holds account 2, waits for account 1. The deadlock detector eventually kills one. To prevent this, always acquire locks in a deterministic order, for example by account ID ascending. Use LEAST and GREATEST to decide which account to lock first, regardless of transfer direction. Then both transactions will lock account 1 before account 2, and no cycle can form. The code change is to wrap the FOR UPDATE in a function that locks the lower ID first.
Q03 of 03SENIOR
A senior engineer tells you 'just use SERIALIZABLE isolation and all your concurrency bugs go away.' What's wrong with that statement, and what would you actually need to implement in the application layer to use SERIALIZABLE correctly in production?
ANSWER
The statement is wrong because SERIALIZABLE doesn't magically resolve all concurrency issues; it changes the failure mode from data corruption to serialization errors. Under SERIALIZABLE, PostgreSQL's SSI (Serializable Snapshot Isolation) uses predicate locks to detect any read-write conflicts that could produce non-serializable histories. When it detects such a conflict, it aborts one of the transactions with 'could not serialize access due to concurrent update'. Your application must have robust retry logic that catches this error and retries the entire transaction, including any side effects (like API calls or email sends) which are tricky to retry idempotently. Also, predicate locks add overhead and can reduce concurrency significantly under heavy contention. SERIALIZABLE is not a free lunch — it shifts the burden from data correctness to application retry logic.
01
Explain the difference between a shared lock and an exclusive lock, and describe a real scenario where using SELECT FOR UPDATE instead of a plain SELECT was the correct choice and why.
SENIOR
02
Walk me through exactly how a deadlock forms between two transactions doing money transfers, and what specific change to the code would prevent it from ever happening — not just detecting and retrying it.
SENIOR
03
A senior engineer tells you 'just use SERIALIZABLE isolation and all your concurrency bugs go away.' What's wrong with that statement, and what would you actually need to implement in the application layer to use SERIALIZABLE correctly in production?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
What is the difference between optimistic and pessimistic locking in a database?
Pessimistic locking acquires an exclusive lock at read time (SELECT FOR UPDATE) so no other transaction can modify the row until you commit. Optimistic locking skips locking at read time and instead checks at write time — using a version column or timestamp — that nobody else changed the row since you read it. Pessimistic is safer under high conflict; optimistic has higher throughput when conflicts are rare.
Was this helpful?
02
How does PostgreSQL automatically resolve deadlocks?
PostgreSQL's lock manager runs a cycle-detection algorithm on the wait-for graph. When it finds a cycle, it picks the transaction that has done the least work (fewest locks, youngest transaction ID) and rolls it back with ERROR: deadlock detected. The winning transaction automatically retries and succeeds. Your application must catch this error and retry the entire transaction from the beginning.
Was this helpful?
03
Does MVCC mean I never need locks in PostgreSQL?
No. MVCC eliminates lock contention between readers and writers — a SELECT never blocks waiting for an UPDATE to finish. But write-write conflicts (two transactions trying to UPDATE the same row) still require serialization via row-level locks. You still need explicit locking (SELECT FOR UPDATE, advisory locks, or SERIALIZABLE isolation) anywhere two concurrent writes must not interleave.
Was this helpful?
04
What is lock escalation and why is it dangerous?
Lock escalation occurs when a database engine converts many fine-grained locks (e.g., row locks) into a single table-level lock to reduce memory overhead. It's dangerous because it suddenly blocks all concurrent access to the table, destroying throughput. It's common in SQL Server when a single transaction holds more than 5,000 row locks. The fix is to batch large updates into smaller transactions or use table hints to prevent escalation.
Was this helpful?
05
What's the first query to run when you suspect lock contention in PostgreSQL?
Run SELECT * FROM pg_locks WHERE NOT granted; to see all waiting lock requests. Then join with pg_stat_activity to see the blocking PID and query. The combination of wait_event and wait_event_type columns tells you exactly what kind of lock is being waited on.