Concurrency bugs are almost never about wrong SQL — they are about wrong locking assumptions. The complete PostgreSQL locking matrix.
Module 13 — Locking Internals: Row Locks, Table Locks, and Advisory Locks
What this module covers: Concurrency bugs in production are almost never about wrong SQL — they are about wrong locking assumptions. This module covers the complete PostgreSQL locking matrix from first principles: what every lock mode protects, how row-level locks differ from table-level locks, the precise semantics of
FOR UPDATEvsFOR SHAREvsFOR NO KEY UPDATE, howALTER TABLEcan silently queue behind a single open transaction and take down your application, and how advisory locks let you build distributed coordination primitives directly inside Postgres.
Why Locking Is Hard to Reason About
Postgres has multiple, overlapping lock systems that operate at different granularities simultaneously:
- Table-level locks — protect the table structure and relation-level operations
- Row-level locks — protect individual tuple modifications
- Page-level locks — internal, mostly transparent (buffer pins)
- Advisory locks — application-defined locks with no automatic semantics
Every DML statement acquires locks at multiple levels simultaneously. An UPDATE acquires a RowExclusiveLock on the table and a row-level exclusive lock on each modified tuple. The two systems interact: some table-level operations must wait for all row-level locks to be released before they can proceed.
Understanding which operations conflict — and which do not — is the difference between a schema migration that completes in 30 seconds and one that causes a 20-minute outage.
Table-Level Lock Modes
Postgres defines 8 table-level lock modes, ordered from weakest to strongest. Each mode conflicts with some modes and is compatible with others.
The Full Lock Matrix
| Lock Mode | Acquired By | Conflicts With |
|---|---|---|
ACCESS SHARE | SELECT | ACCESS EXCLUSIVE only |
ROW SHARE | SELECT FOR UPDATE/SHARE | EXCLUSIVE, ACCESS EXCLUSIVE |
ROW EXCLUSIVE | INSERT, UPDATE, DELETE | SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
SHARE UPDATE EXCLUSIVE | VACUUM, ANALYZE, CREATE INDEX CONCURRENTLY, ALTER TABLE ... VALIDATE | SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
SHARE | CREATE INDEX (non-concurrent) | ROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
SHARE ROW EXCLUSIVE | CREATE TRIGGER, some ALTER TABLE | ROW EXCLUSIVE and above |
EXCLUSIVE | Rare — some replication operations | Everything except ACCESS SHARE |
ACCESS EXCLUSIVE | ALTER TABLE, DROP TABLE, TRUNCATE, VACUUM FULL, LOCK TABLE | Everything including SELECT |
The critical insight: ACCESS EXCLUSIVE conflicts with every other lock mode, including plain SELECT. This is why ALTER TABLE on a busy table causes an outage.
The ALTER TABLE Outage Pattern
This is one of the most common causes of production incidents. Here is the exact mechanism:
The ALTER TABLE does not hold a lock — it is waiting for one. But its waiting position in the lock queue blocks all subsequent requests, even ones that would normally be compatible with each other.
The Correct ALTER TABLE Pattern
The window between killing queries and running the ALTER must be short — new long-running queries can start in the gap.
Lock Modes in Practice
Row-Level Locks
Table-level locks protect the schema. Row-level locks protect individual tuples from concurrent modification. They are separate systems.
The Four Row Lock Modes
FOR UPDATE
- Strongest row lock. Marks the row as being updated.
- Blocks: other
FOR UPDATE,FOR NO KEY UPDATE,FOR SHARE,FOR KEY SHARE - Use when: you intend to update or delete the row, or you need to prevent any concurrent modification
FOR NO KEY UPDATE
- Like
FOR UPDATEbut does not blockFOR KEY SHARE - Use when: updating non-key columns (foreign keys to this row remain unblocked)
- Postgres uses this internally for
UPDATEstatements that don't modify key columns
FOR SHARE
- Prevents other transactions from updating or deleting the row, but allows other shared locks
- Blocks:
FOR UPDATE,FOR NO KEY UPDATE - Does not block: other
FOR SHARE,FOR KEY SHARE - Use when: reading a row that must not change for the duration of your transaction (e.g., verifying a foreign key reference)
FOR KEY SHARE
- Weakest row lock. Prevents deletion or key-column updates only.
- Blocks:
FOR UPDATE,FOR NO KEY UPDATE - Does not block:
FOR SHARE, otherFOR KEY SHARE - Postgres uses this automatically for foreign key checks
FOR UPDATE — The Correct Pattern
The FOR UPDATE lock prevents any other transaction from modifying this row between your SELECT and your UPDATE. Without it, two concurrent debits can both read the same balance and both proceed.
But note: with PG18's RETURNING OLD/NEW (Module 9), this two-step pattern is often unnecessary. Prefer atomic single-statement operations where possible.
SKIP LOCKED: Non-Blocking Queue Processing
SKIP LOCKED skips rows that are currently locked by another transaction. This enables efficient work queue patterns:
Without SKIP LOCKED, multiple workers queue up on the same row. With SKIP LOCKED, each worker instantly claims a different row. This is the correct pattern for implementing a job queue in Postgres.
NOWAIT: Fail Fast on Lock Contention
NOWAIT is preferable to waiting when the application can handle the retry — it keeps connection hold time short and prevents lock queue buildup.
Deadlocks
A deadlock occurs when two transactions each hold a lock the other needs.
Postgres detects deadlocks automatically (every deadlock_timeout milliseconds, default 1s) and terminates one transaction:
Preventing deadlocks: always acquire locks in the same order across all code paths. If every transaction locks row IDs in ascending order, deadlocks are impossible.
Advisory Locks: Application-Level Distributed Locks
Advisory locks are cooperative locks with no automatic semantic — Postgres acquires and releases them only when you explicitly call the advisory lock functions. They are:
- Lightweight (stored in shared memory, not in system tables)
- Scoped to either a transaction or a session
- Identified by a 64-bit integer (or two 32-bit integers)
- Not tied to any table or row
Transaction-Level Advisory Locks
Session-Level Advisory Locks
Session-level locks survive transaction boundaries. Use them for long-running operations that span multiple transactions.
Production Pattern: Distributed Singleton Job
The most common use case: ensuring only one instance of a background job runs at a time across multiple application servers.
Viewing Active Advisory Locks
Advisory Locks vs Application-Level Locks (Redis SETNX)
| Advisory Locks | Redis SETNX | |
|---|---|---|
| Atomicity with DB transaction | Yes — lock and data modification in one TX | No — separate systems |
| Automatic cleanup on crash | Yes — session ends, lock released | Requires TTL |
| No additional infrastructure | Yes | No — requires Redis |
| Lock acquisition overhead | ~microseconds | ~1ms network RTT |
| Works without Postgres connection | No | Yes |
For application-level coordination that also modifies database state, advisory locks win on atomicity and simplicity. For coordination between processes that do not share a Postgres connection (microservices), Redis or a distributed lock service is appropriate.
The Production Incident: ALTER TABLE Queue Cascade
Context: A team ran ALTER TABLE transactions ADD COLUMN region TEXT NOT NULL DEFAULT 'us-east' during a low-traffic window — but did not set lock_timeout.
What happened:
A background analytics query had been running for 8 minutes (no statement_timeout was set). It held ACCESS SHARE on the transactions table. The ALTER TABLE queued waiting for ACCESS EXCLUSIVE.
Over the next 30 seconds, 400 application connections tried to query transactions. Each one queued behind the ALTER TABLE. PgBouncer's connection pool filled. New application requests began failing with connection pool full. The analytics query ran for another 4 minutes.
Root cause: one 12-minute analytics query + no lock_timeout on the ALTER TABLE = 430 blocked connections.
The fix and prevention:
Summary
| Concept | Key Takeaway |
|---|---|
ACCESS EXCLUSIVE | Acquired by ALTER TABLE, TRUNCATE, DROP. Blocks everything including SELECT. Always set lock_timeout. |
| Lock queue cascading | A waiting ACCESS EXCLUSIVE blocks all subsequent lock requests, even compatible ones. |
FOR UPDATE | Exclusive row lock. Prevents concurrent modification. Released on COMMIT/ROLLBACK. |
FOR NO KEY UPDATE | Like FOR UPDATE but doesn't block foreign key checks. Prefer when not updating key columns. |
FOR SHARE | Shared row lock. Prevents updates but allows concurrent reads. |
SKIP LOCKED | Skip locked rows. Correct pattern for work queues. |
NOWAIT | Fail immediately on lock contention. Prevents queue buildup. |
| Deadlock prevention | Always acquire locks in consistent order across all code paths. |
| Advisory locks | Application-defined cooperative locks. Ideal for distributed job deduplication. |
lock_timeout | Set it on every DDL statement on a live table. |
A Senior DBA executes ALTER TABLE users ADD COLUMN email TEXT; on a heavily trafficked users table. Concurrently, a long-running analytics query is executing SELECT COUNT(*) FROM users;. Shortly after the ALTER TABLE command is issued, new application requests attempting SELECT * FROM users WHERE id = 1; begin to time out, eventually leading to a connection pool exhaustion and a full outage. Which of the following best explains the root cause of this outage?
Consider a banking application where transfer_funds(sender_id, receiver_id, amount) is implemented. To prevent race conditions and ensure atomicity, the accounts table rows for both sender and receiver are locked. If two concurrent transactions attempt to transfer funds between the same two accounts (e.g., A -> B and B -> A), which of the following strategies is most effective in preventing deadlocks while maintaining data integrity?
A microservices architecture needs to ensure that a critical background job, process_daily_reports, runs on only one instance at a time across multiple application servers. This job also performs complex, multi-step database operations within a single transaction. Which locking mechanism is the most appropriate and why?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
Sign in & RegisterDiscussion
0Join the discussion