PostgreSQL Concurrency: Locks, MVCC, and Why Your Queries Are Waiting
That 'idle in transaction' row is silently holding a lock on your hottest table. This deep dive into PostgreSQL's concurrency model — from row-level locks to predicate locking to deadlock detection — explains what's actually happening under the hood and how to stop your queries from queueing up at 2 AM.
# PostgreSQL Concurrency: Locks, MVCC, and Why Your Queries Are Waiting
Three years ago I sat on a production incident call at 2:14 AM. A routine data migration — five million rows, batched, with UPDATE … WHERE status = 'pending' LIMIT 1000 — had locked the orders table. Customers couldn't place orders. The queue depth on our API was climbing by 300 requests per minute.
The root cause? A single pg_dump in --serializable isolation was holding a predicate lock that blocked every concurrent INSERT$. Nobody on the call knew what a predicate lock was. Everyone had assumed PostgreSQL "just handles concurrency."
It does — until you don't understand how. This post is about building that understanding.
I'm going to walk through PostgreSQL's concurrency machinery from the ground up, but not as a textbook — as a field manual. Everything here comes from actual production incidents.
The Foundation: MVCC Without the Hand-Waving
Every relational database must solve a fundamental problem: what does a query see when another transaction is modifying the same rows at the same time? PostgreSQL's answer is Multi-Version Concurrency Control (MVCC).
The model is deceptively simple: every row has multiple versions, and each transaction sees the version that was visible at its start time. No read locks. Writers never block readers. Readers never block writers.
Here's how it actually works under the hood. Every row carries four hidden system columns:
| Column | Meaning |
|---|---|
xmin | Transaction ID that created this row version |
xmax | Transaction ID that deleted (or updated, which is delete+insert) this row version |
cmin | Command ID within the creating transaction |
cmax | Command ID within the deleting transaction |
When you run SELECT * FROM orders WHERE id = 42$, PostgreSQL does not check locks. It checks visibility:
-- What PostgreSQL actually evaluates, conceptually:
-- "Is this row version visible to my transaction?"
-- A row version is visible if:
-- 1. xmin is committed AND is before my snapshot
-- 2. xmax is NULL (not deleted) OR xmax is not yet committed OR xmax > my snapshot
This is why you can SELECT from a table while another connection is UPDATEing every row in it. The SELECT$ never waits on a lock for the data rows. It reads the old versions, snapshotted at the start of its transaction.
Let's prove it:
-- Session 1: start a transaction and update a row
BEGIN;
UPDATE orders SET status = 'processing' WHERE id = 1;
-- Do NOT commit yet
-- Session 2: read the same row
SELECT * FROM orders WHERE id = 1;
-- Returns immediately! Sees the old version (status before update).
-- No lock wait. No blocking.
-- Session 3: try to update the same row
UPDATE orders SET status = 'shipped' WHERE id = 1;
-- BLOCKS! Session 3 waits for Session 1 to COMMIT or ROLLBACK.
-- This is a row-level write lock, not an MVCC issue.
The Lock Taxonomy: What Actually Blocks What
PostgreSQL has eleven lock levels (confusingly called "lock modes"). Most day-to-day work only touches three. But when things go wrong, the obscure ones matter.
Here's the full matrix. I'm including the rare ones because production incidents don't care about rarity:
| Lock Mode | Acquired By | Conflicts With | Common Symptom |
|---|---|---|---|
AccessShareLock | SELECT | ACCESS EXCLUSIVE only | SELECT$ hangs during ALTER TABLE$ |
RowShareLock | SELECT ... FOR UPDATE / FOR SHARE | EXCLUSIVE, ACCESS EXCLUSIVE | Read-for-update blocked by VACUUM FULL |
RowExclusiveLock | INSERT, UPDATE, DELETE | SHARE, SHARE ROW EXCLUSIVE$, EXCLUSIVE$, ACCESS EXCLUSIVE | DML blocked by CREATE INDEX CONCURRENTLY |
ShareUpdateExclusiveLock | ANALYZE$, CREATE INDEX CONCURRENTLY$, VACUUM | SHARE UPDATE EXCLUSIVE$, SHARE$, SHARE ROW EXCLUSIVE$, EXCLUSIVE$, ACCESS EXCLUSIVE | Two VACUUM$s running on same table |
ShareLock | CREATE INDEX (non-concurrent) | ROW EXCLUSIVE$, SHARE UPDATE EXCLUSIVE$, SHARE ROW EXCLUSIVE$, EXCLUSIVE$, ACCESS EXCLUSIVE | Index creation blocks writes |
ShareRowExclusiveLock | CREATE TRIGGER$, some ALTER TABLE | ROW EXCLUSIVE$, SHARE UPDATE EXCLUSIVE$, SHARE$, SHARE ROW EXCLUSIVE$, EXCLUSIVE$, ACCESS EXCLUSIVE | Trigger creation blocks writes |
ExclusiveLock | REFRESH MATERIALIZED VIEW CONCURRENTLY | ROW SHARE$, ROW EXCLUSIVE$, SHARE UPDATE EXCLUSIVE$, SHARE$, SHARE ROW EXCLUSIVE$, EXCLUSIVE$, ACCESS EXCLUSIVE | MV refresh blocks reads and writes |
AccessExclusiveLock | ALTER TABLE$, DROP TABLE$, VACUUM FULL$, REINDEX | Everything | The "whole table is frozen" lock |
The one that catches everyone: AccessShareLock$ + AccessExclusiveLock$. Your SELECT$ queries seem innocent — they only take AccessShareLock$. But AccessShareLock$ conflicts with AccessExclusiveLock$. So if someone runs ALTER TABLE orders ADD COLUMN tracking_id UUID$, every concurrent SELECT$ on orders$ queues behind it. And because the ALTER$ needs AccessExclusiveLock$, which conflicts with AccessShareLock$, it queues behind every existing SELECT$.
This is the classic "migration takes down the site" scenario:
-- Session 1: a long-running SELECT
SELECT * FROM orders WHERE created_at > NOW() - INTERVAL '30 days';
-- Holds AccessShareLock on orders
-- Session 2: an ALTER TABLE
ALTER TABLE orders ADD COLUMN shipping_label TEXT;
-- Tries to grab AccessExclusiveLock. Blocks because Session 1 holds AccessShareLock.
-- Sessions 3-300: new SELECT queries
SELECT * FROM orders WHERE id = 42;
-- They queue BEHIND Session 2's ALTER TABLE, even though normally SELECT wouldn't block.
-- PostgreSQL's lock queue is FIFO — the SELECTs wait for the ALTER to finish first.
Every database occasionally hits this. The fix is the same every time: set a lock timeout before running DDL.
-- Never run ALTER TABLE without this:
SET lock_timeout = '2s';
ALTER TABLE orders ADD COLUMN shipping_label TEXT;
-- If it can't grab the lock in 2 seconds, it fails fast instead of queueing up
If it fails, retry during a low-traffic window, or use a tool like pgroll or resharper that handles lock acquisition with exponential backoff.
Row-Level Locks: The Quicksand
MVCC handles reads beautifully. But when two transactions want to write the same row, PostgreSQL falls back to good old-fashioned row locking. The lock lives in the row's xmax — if you see a non-zero xmax from an uncommitted transaction, that transaction holds the write lock.
There are four row-level lock strengths:
| Lock | SQL | Blocks |
|---|---|---|
FOR UPDATE | SELECT ... FOR UPDATE | FOR UPDATE$, FOR NO KEY UPDATE$, FOR SHARE$, FOR KEY SHARE |
FOR NO KEY UPDATE | UPDATE$ (implicitly) | FOR UPDATE$, FOR NO KEY UPDATE$ |
FOR SHARE | SELECT ... FOR SHARE | FOR UPDATE$, FOR NO KEY UPDATE$ |
FOR KEY SHARE | INSERT$ referencing a FK (implicitly) | FOR UPDATE$, FOR NO KEY UPDATE$ |
The one that causes confusion: FOR KEY SHARE$. When you INSERT$ a row that references a foreign key, PostgreSQL takes a FOR KEY SHARE$ lock on the referenced row. This prevents the parent row from being deleted or having its key changed while the child insert is in progress:
-- Session 1:
BEGIN;
INSERT INTO order_items (order_id, product_id, quantity)
VALUES (42, 7, 3);
-- Takes FOR KEY SHARE lock on orders.id = 42
-- Does NOT block SELECTs on orders
-- Session 2:
DELETE FROM orders WHERE id = 42;
-- BLOCKS! DELETE takes FOR UPDATE, which conflicts with FOR KEY SHARE
-- Even though Session 1 hasn't committed yet
This is actually correct behaviour — you don't want to delete an order that has in-flight items being inserted. But it manifests as a mysterious lock wait that developers don't expect.
Predicate Locks and Serializable Isolation: The Heavy Artillery
Serializable isolation in PostgreSQL doesn't use lock-based serialization (the way older databases do). Instead, PostgreSQL uses Serializable Snapshot Isolation (SSI), which tracks read-write dependencies between concurrent transactions and rolls back transactions that would create serialization anomalies.
The implementation relies on SIREAD locks (serializable read locks), also called predicate locks. These are not traditional locks that block — they're more like assertions that a read was valid at a specific point. If a concurrent write would invalidate that assertion, one transaction is rolled back.
-- Serializable transactions track what they've read
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- This SELECT establishes an SIREAD lock on the predicate:
-- "I read all orders where user_id = 123 AND status = 'active'"
SELECT COUNT(*) FROM orders WHERE user_id = 123 AND status = 'active';
-- Returns: 3
-- Meanwhile, another serializable transaction runs:
-- INSERT INTO orders (user_id, status) VALUES (123, 'active');
-- This write would change the result of the SELECT above.
-- PostgreSQL detects the rw-dependency.
-- If Session 1 tries to COMMIT:
COMMIT;
-- ERROR: could not serialize access due to read/write dependencies among transactions
-- DETAIL: Reason code: Canceled on commitment due to a read/write conflict.
The key difference from traditional locking: neither transaction blocked. They both ran concurrently. PostgreSQL detected the anomaly and rolled one back at commit time.
The practical rule for serializable: always code a retry loop. Serializable transactions will get serialization failures. It's not a bug — it's how the isolation level works:
import psycopg2
from psycopg2 import errors
def transfer_funds(conn, from_acct: int, to_acct: int, amount: int, max_retries: int = 3):
for attempt in range(max_retries):
try:
with conn:
with conn.cursor() as cur:
cur.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
cur.execute(
"UPDATE accounts SET balance = balance - %s WHERE id = %s",
(amount, from_acct)
)
cur.execute(
"UPDATE accounts SET balance = balance + %s WHERE id = %s",
(amount, to_acct)
)
return True
except errors.SerializationFailure:
if attempt == max_retries - 1:
raise
continue
When to use serializable: financial transactions where correctness matters more than throughput. When not to: high-throughput user-facing reads. Serializable in PostgreSQL can be surprisingly low-overhead (the SIREAD locks use efficient in-memory data structures), but the retry logic adds tail latency.
Spotting Lock Problems Before They Spot You
PostgreSQL exposes two invaluable views for lock debugging:
pg_locks — What's locked right now
SELECT
l.pid,
l.locktype,
l.mode,
l.granted,
l.relation::regclass AS table_name,
a.query_start,
a.state,
a.query
FROM pg_locks l
JOIN pg_stat_activity a ON l.pid = a.pid
WHERE l.locktype = 'relation'
AND l.relation IS NOT NULL
AND a.pid != pg_backend_pid()
ORDER BY a.query_start;
pg_blocking_pids() — Who's blocking whom
-- Find the blocker chain
SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query,
blocking.state AS blocking_state,
age(now(), blocking.query_start) AS blocking_duration
FROM pg_stat_activity blocked
CROSS JOIN LATERAL pg_blocking_pids(blocked.pid) AS blocker_pid
JOIN pg_stat_activity blocking ON blocking.pid = blocker_pid
WHERE blocked.wait_event_type = 'Lock'
ORDER BY blocking_duration DESC;
I have this query saved as a Postgres alias. It's the first thing I run during any incident involving query slowness or timeouts. The blocking_state column is the most revealing — if it shows idle in transaction$, someone opened a transaction, did some work, and walked away without committing.
The "Idle in Transaction" Epidemic
This is the single most common concurrency problem in PostgreSQL applications. The pattern:
# A Rails controller action:
def process_order
order = Order.find(params[:id])
order.update!(status: 'processing')
# ... send an email here (synchronous, 2 seconds) ...
# ... call an external shipping API ...
order.update!(shipping_label: label)
# COMMIT happens automatically at the end of the controller action
end
Between the first UPDATE$ and the final UPDATE$, the transaction holds row locks on orders$ for the duration of two HTTP calls. That's 2–5 seconds of lock holding for what should be a 5-millisecond database operation.
The fix is mechanical: do I/O outside the transaction. Either move the email/API call before the transaction starts, or after it commits:
def process_order
order = Order.find(params[:id])
# I/O first
ShippingService.create_label(order)
EmailService.send_confirmation(order)
# Then the transactional update — fast, no waiting
Order.transaction do
order.update!(status: 'processing', shipping_label: label)
end
end
The same applies in any language. In Go:
// BAD: I/O inside transaction
func processOrder(db *sql.DB, orderID int) error {
tx, _ := db.Begin()
tx.Exec("UPDATE orders SET status = 'processing' WHERE id = $1", orderID)
sendEmail(orderID) // 2-second HTTP call while holding row lock!
tx.Exec("UPDATE orders SET shipping_label = $1 WHERE id = $2", label, orderID)
return tx.Commit()
}
// GOOD: I/O outside transaction
func processOrder(db *sql.DB, orderID int) error {
label := createShippingLabel(orderID) // I/O first
sendEmail(orderID) // I/O first
// Then fast, transactional update
tx, _ := db.Begin()
tx.Exec("UPDATE orders SET status = 'processing', shipping_label = $1 WHERE id = $2", label, orderID)
return tx.Commit()
}
To catch these before they reach production, set idle_in_transaction_session_timeout:
-- Kill any transaction that sits idle for more than 30 seconds
ALTER DATABASE myapp SET idle_in_transaction_session_timeout = '30s';
It's a blunt instrument — it'll kill legitimate long-running transactions too. But if your application has "idle in transaction" connections today, this is the fastest safety net you can deploy. You can always exclude specific sessions.
Deadlocks: It's Almost Always a Lock Ordering Problem
PostgreSQL detects deadlocks automatically and resolves them by aborting one of the transactions. The victim gets:
ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 67890;
blocked by process 98765.
Process 98765 waits for ShareLock on transaction 54321;
blocked by process 12345.
HINT: See server log for query details.
Deadlocks are not random. They're deterministic — two (or more) transactions lock resources in different orders. The classic example:
-- Transaction A:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- Locks id=1
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- Wants to lock id=2
-- Transaction B (concurrent):
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2; -- Locks id=2
UPDATE accounts SET balance = balance + 50 WHERE id = 1; -- Wants to lock id=1
-- DEADLOCK: A holds 1, wants 2. B holds 2, wants 1.
``$
The fix is also deterministic: **establish a consistent lock ordering.** In the accounts example, always lock the lower ID first:sql
-- A single function that enforces ordering:
UPDATE accounts SET balance = balance - $3 WHERE id = LEAST($1, $2);
UPDATE accounts SET balance = balance + $3 WHERE id = GREATEST($1, $2);
$
This eliminates the deadlock entirely. No two concurrent transfers can deadlock because both always lock the lower ID first.
The harder case: deadlocks caused by foreign key cascading. When you DELETE$ a parent row, PostgreSQL locks the child rows (to enforce referential integrity). If two concurrent deletes cascade to overlapping child rows in different orders, you get a deadlock even though your application code never explicitly locks child rows:
-- Session 1: DELETE FROM users WHERE id = 5;
-- Cascades to delete all posts by user 5 (posts 10, 20, 30)
-- Locks posts in order: 10, 20, 30
-- Session 2: DELETE FROM users WHERE id = 7;
-- Cascades to delete all posts by user 7 (posts 20, 40, 50)
-- Locks posts in order: 20, 40, 50
-- Session 1 holds lock on post 10, wants post 20 (held by Session 2)
-- Session 2 holds lock on post 20, wants post 40
-- But wait — both sessions are also competing for post 20's lock...
-- DEADLOCK if timing is unlucky
``$
The fix: **add an index on the foreign key column.** PostgreSQL locks child rows by scanning the FK column. Without an index, it does a sequential scan, locking *every* row in the child table — and the lock order is physical row order, which depends on table bloat, vacuum state, and cosmic rays. With an index, the lock order is deterministic (index order), which dramatically reduces deadlock probability:sql
-- This index saves you from mysterious FK deadlocks:
CREATE INDEX CONCURRENTLY posts_user_id_idx ON posts(user_id);
`$
Advisory Locks: The Power Tool Nobody Uses Correctly
PostgreSQL has a mechanism called advisory locks — application-level locks that don't correspond to any database object. They're incredibly useful for distributed coordination, but most developers don't know they exist.
-- Session-level advisory lock (held until transaction ends or explicit unlock):
SELECT pg_advisory_lock(42);
-- ... do exclusive work ...
SELECT pg_advisory_unlock(42);
-- Transaction-level advisory lock (released on COMMIT/ROLLBACK):
SELECT pg_advisory_xact_lock(42);
-- Released automatically at end of transaction
The killer use case: distributed job scheduling without Redis.
-- Multiple workers compete to process the same job.
-- Exactly one will get the lock:
SELECT pg_try_advisory_lock(42) AS acquired;
-- Returns true for exactly one worker. That worker processes the job.
No Redis. No ZooKeeper. No external dependency. Just Postgres.
The gotcha: advisory locks are global across the entire database cluster. Lock ID 42$ is lock 42$ for everyone. If two different applications use advisory locks in the same database and accidentally pick the same ID, they'll block each other. The convention to prevent this:
-- Use two-argument advisory locks with a namespace:
-- First argument = application ID (unique per app)
-- Second argument = resource ID within that app
SELECT pg_advisory_lock(hashtext('myapp_jobs'), 42);
The hashtext() function converts a string to an integer — it's stable within a PostgreSQL major version but not across versions or platforms. For anything that needs to survive upgrades, store the app ID in a configuration table.
Another production lesson: never call pg_advisory_lock() (blocking) in a hot path. If the lock is contested, your query blocks silently. No timeout. Use pg_try_advisory_lock()$ or set lock_timeout$:
SET lock_timeout = '100ms';
SELECT pg_advisory_lock(42);
-- If not acquired in 100ms, raises: ERROR: canceling statement due to lock timeout
``$
---
## Practical Recommendations
After years of PostgreSQL concurrency debugging, here's what I configure on every project:
### 1. Set a global `idle_in_transaction_session_timeout`sql
ALTER SYSTEM SET idle_in_transaction_session_timeout = '5min';
SELECT pg_reload_conf();
Start conservative (5 minutes) then tighten based on your application's transaction patterns. The goal is to prevent the "idle in transaction holding locks" scenario from lasting longer than necessary.
### 2. Set `lock_timeout$ in your migration runner
Every migration tool (Alembic, Flyway, golang-migrate) should set a lock timeout before running DDL:sql
SET lock_timeout = '2s';
-- Your migration SQL here
If the migration can't grab its locks in 2 seconds, it fails fast instead of building a queue that takes down your application. You can retry the migration during off-peak hours.
### 3. Add indexes on ALL foreign key columnssql
-- Find missing FK indexes:
SELECT
conname AS constraint_name,
conrelid::regclass AS table_name,
a.attname AS column_name
FROM pg_constraint c
JOIN pg_attribute a ON a.attnum = ANY(c.conkey) AND a.attrelid = c.conrelid
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid
AND i.indkey[0] = a.attnum
);
Every FK column without an index is a potential deadlock source on `DELETE$ and a guaranteed sequential scan on `JOIN$.
### 4. Use `FOR UPDATE SKIP LOCKED$ for queue-like workloads
If you're implementing a job queue in PostgreSQL (which is fine for moderate throughput — don't let anyone tell you otherwise), use `SKIP LOCKED$ to avoid contention:sql
-- Instead of: SELECT * FROM jobs WHERE status = 'pending' LIMIT 1 FOR UPDATE
-- (which blocks if another worker is processing any pending job)
-- Use:
SELECT * FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;
-- Skips rows locked by other workers. Never blocks.
### 5. Monitor lock wait timessql
-- Check for queries that spend time waiting for locks:
SELECT
pid,
wait_event_type,
wait_event,
state,
query_start,
query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
AND state != 'idle';
`
Wire this into your monitoring stack. Prometheus with postgres_exporter$ captures this via pg_stat_activity$. Alert on sustained lock waits.
Summary
PostgreSQL's concurrency model is brilliant — when you understand it. The problems start when developers assume it works the same way as MySQL, or when ORMs hide transaction boundaries in ways that produce "idle in transaction" without anyone noticing.
The invariants worth remembering:
- MVCC means readers never block writers and vice versa. If something is blocking, it's write-write or explicit locking.
- DDL takes AccessExclusiveLock$ — it conflicts with
SELECT$. Always setlock_timeout$ before running migrations. - "idle in transaction" is the most expensive three words in any Postgres setup. Set
idle_in_transaction_session_timeout$. - Advisory locks are the best-kept secret in Postgres. Use them for application-level coordination before reaching for Redis.
- Missing FK indexes cause deadlocks. They also cause slow JOIN$s, but the deadlock is harder to diagnose.
- `SKIP LOCKED$ is your queue's best friend. Use it in any job-processing pattern in Postgres.
PostgreSQL gives you the tools — incredible visibility into locks, fine-grained control over locking behaviour, and a concurrency model that scales remarkably well. The trade is that you have to learn how it works. I hope this post saves you a few 2 AM incident calls.