Locking and Deadlocks
Lock Types Overview
Section titled “Lock Types Overview”PostgreSQL uses a multi-level locking system that operates at different granularities. Understanding Each lock type is essential for diagnosing performance issues and preventing deadlocks.
Lock Granularity
Section titled “Lock Granularity”| Level | Scope | Overhead | Concurrency | Example |
|---|---|---|---|---|
| Row | Single tuple | High | Highest | SELECT ... FOR UPDATE |
| Page | 8KB page | Medium | Medium | Internal page locks during heap operations |
| Table | Entire relation | Low | Lowest | LOCK TABLEDDL operations |
| Advisory | Application-defined | None | N/A | pg_advisory_lock() |
PostgreSQL does not use page-level locks for user-visible operations. Page-level locks are only used Internally during heap operations and are held for very short durations. Users interact with Row-level and table-level locks.
Lock Modes (Table-Level)
Section titled “Lock Modes (Table-Level)”PostgreSQL defines eight table-level lock modes. Each SQL command acquires specific locks Automatically.
| Lock Mode | Acquired By | Conflicts With |
|---|---|---|
| ACCESS SHARE | SELECT | ACCESS EXCLUSIVE |
| 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 (without FULL), CREATE INDEX CONCURRENTLY | ROW EXCLUSIVE, 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 TRIGGERSome ALTER TABLE | ROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
| EXCLUSIVE | REFRESH MATERIALIZED VIEW (non-concurrent) | ROW SHARE, ROW EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
| ACCESS EXCLUSIVE | DROP TABLE``TRUNCATE``ALTER TABLE``VACUUM FULL``LOCK TABLE | All lock modes |
Lock Compatibility Matrix
Section titled “Lock Compatibility Matrix”| Request \ Held | AS | RS | RX | SRE | S | SRE2 | X | AE |
|---|---|---|---|---|---|---|---|---|
| ACCESS SHARE | Y | Y | Y | Y | Y | Y | Y | N |
| ROW SHARE | Y | Y | Y | Y | Y | Y | N | N |
| ROW EXCLUSIVE | Y | Y | Y | Y | N | N | N | N |
| SHARE UPDATE EXCLUSIVE | Y | Y | Y | Y | N | N | N | N |
| SHARE | Y | Y | N | N | Y | N | N | N |
| SHARE ROW EXCL | Y | Y | N | N | N | N | N | N |
| EXCLUSIVE | Y | N | N | N | N | N | N | N |
| ACCESS EXCL | N | N | N | N | N | N | N | N |
Row-Level Locks
Section titled “Row-Level Locks”Row-level locks are more granular than table-level locks. They block modifications to specific rows But allow concurrent access to other rows in the same table.
Implicit Row Locks
Section titled “Implicit Row Locks”Every UPDATE``DELETEOr SELECT FOR UPDATE/SHARE acquires a row-level lock. These are Implemented as transaction-level locks — they are held until the transaction commits or rolls back.
-- UPDATE implicitly locks the rowUPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
-- DELETE implicitly locks the rowDELETE FROM orders WHERE order_id = 42;FOR UPDATE / FOR SHARE
Section titled “FOR UPDATE / FOR SHARE”-- Lock rows for update (prevents concurrent modifications)SELECT * FROM accounts WHERE account_id IN (1, 2, 3) FOR UPDATE;
-- Lock rows for update, non-blocking (skip already-locked rows)SELECT * FROM tasksWHERE status = "pending'ORDER BY created_atLIMIT 1FOR UPDATE SKIP LOCKED;
-- Lock rows in shared mode (prevents writes, allows other shared locks)SELECT * FROM config WHERE key = 'global_settings' FOR SHARE;
-- Lock specific tables in a multi-table querySELECT * FROM orders oJOIN order_items oi ON o.order_id = oi.order_idWHERE o.order_id = 42FOR UPDATE OF o; -- only locks rows in the orders table
-- NOWAIT: fail immediately if a row is lockedSELECT * FROM accounts WHERE account_id = 1 FOR UPDATE NOWAIT;-- ERROR: could not obtain lock on rowFOR NO KEY UPDATE / FOR KEY SHARE (PostgreSQL 9.4+)
Section titled “FOR NO KEY UPDATE / FOR KEY SHARE (PostgreSQL 9.4+)”These are weaker variants that do not conflict with each other:
-- FOR NO KEY UPDATE: does not conflict with FOR KEY SHARE-- Use case: update non-key columns while allowing concurrent FOR KEY SHARE readsSELECT * FROM products WHERE product_id = 42 FOR NO KEY UPDATE;
-- FOR KEY SHARE: does not conflict with FOR NO KEY UPDATE-- Use case: read a row and check its key for a foreign key constraintSELECT * FROM products WHERE product_id = 42 FOR KEY SHARE;| Lock Mode | Blocks FOR UPDATE | Blocks FOR NO KEY UPDATE | Blocks FOR SHARE | Blocks FOR KEY SHARE |
|---|---|---|---|---|
FOR UPDATE | Yes | Yes | Yes | Yes |
FOR NO KEY UPDATE | Yes | Yes | Yes | No |
FOR SHARE | Yes | Yes | Yes | Yes |
FOR KEY SHARE | Yes | No | Yes | Yes |
Explicit Table Locking
Section titled “Explicit Table Locking”-- Lock a table explicitly (holds the lock until end of transaction)LOCK TABLE accounts IN ACCESS EXCLUSIVE MODE;LOCK TABLE accounts IN SHARE MODE;LOCK TABLE accounts IN ROW EXCLUSIVE MODE;
-- NOWAIT: fail immediately if lock cannot be acquiredLOCK TABLE accounts IN ACCESS EXCLUSIVE MODE NOWAIT;Deadlocks
Section titled “Deadlocks”A deadlock occurs when two or more transactions hold locks that the other needs, creating a circular Wait. PostgreSQL detects deadlocks automatically and aborts one of the transactions.
Deadlock Example
Section titled “Deadlock Example”T1: BEGIN;T1: UPDATE accounts SET balance = balance - 500 WHERE id = 1; -- locks row 1
T2: BEGIN;T2: UPDATE accounts SET balance = balance - 300 WHERE id = 2; -- locks row 2
T1: UPDATE accounts SET balance = balance + 300 WHERE id = 2; -- blocks, waiting for T2T2: UPDATE accounts SET balance = balance + 500 WHERE id = 1; -- blocks, waiting for T1
-- DEADLOCK DETECTED-- PostgreSQL aborts T2 (the "victim") with error 40P01-- T1 proceeds normallyDeadlock Detection
Section titled “Deadlock Detection”PostgreSQL runs deadlock detection periodically (not continuously). When the deadlock detector runs:
- It builds a wait-for graph from
pg_locks - It checks for cycles in the graph
- If a cycle is found, it aborts the transaction with the least work done (youngest xid)
-- Monitor for deadlock errorsSELECT datname, deadlocks FROM pg_stat_database;Deadlock Prevention Strategies
Section titled “Deadlock Prevention Strategies”- Consistent access order: Always access tables and rows in the same order across all transactions.
-- Always update lower-ID accounts firstCREATE OR REPLACE FUNCTION transfer(from_id INTEGER, to_id INTEGER, amount NUMERIC)RETURNS VOID AS $$BEGIN UPDATE accounts SET balance = balance - amount WHERE account_id = LEAST(from_id, to_id);
UPDATE accounts SET balance = balance + amount WHERE account_id = GREATEST(from_id, to_id);END;$$ LANGUAGE plpgsql;- Short transactions: Minimize the time locks are held.
-- BAD: long transaction holding locksBEGIN;SELECT * FROM orders WHERE customer_id = 42 FOR UPDATE; -- lock held-- ... application does complex calculations for 5 seconds ...UPDATE orders SET status = 'processing' WHERE customer_id = 42;COMMIT;
-- GOOD: compute first, then lock briefly-- ... application computes what to do ...BEGIN;SELECT * FROM orders WHERE customer_id = 42 FOR UPDATE; -- lock held brieflyUPDATE orders SET status = 'processing' WHERE customer_id = 42;COMMIT;- SKIP LOCKED: Non-blocking queue pattern for concurrent workers.
-- Worker picks up next available task without blockingSELECT * FROM tasksWHERE status = 'pending'ORDER BY priority DESC, created_at ASCLIMIT 1FOR UPDATE SKIP LOCKED;- Retry logic: Always handle deadlocks with retry at the application level.
import timeimport random
MAX_RETRIES = 5BASE_DELAY = 0.1 # 100ms
for attempt in range(MAX_RETRIES): try: execute_transfer(from_id, to_id, amount) break except OperationalError as e: if 'deadlock detected' in str(e).lower(): delay = BASE_DELAY * (2 ** attempt) + random.uniform(0, BASE_DELAY) time.sleep(delay) else: raiseelse: raise MaxRetriesExceeded(f"Failed after {MAX_RETRIES} attempts")Advisory Locks
Section titled “Advisory Locks”Advisory locks are application-level locks that are not tied to any table or row. They are managed Entirely by the application and enforced by PostgreSQL.
Session-Level Advisory Locks
Section titled “Session-Level Advisory Locks”-- Lock by integer (blocks until available)SELECT pg_advisory_lock(12345);
-- Try-lock (returns immediately, TRUE if acquired)SELECT pg_advisory_try_lock(12345);
-- UnlockSELECT pg_advisory_unlock(12345);
-- Lock by two integers (useful for (tenant_id, resource_id))SELECT pg_advisory_lock(42, 100);
-- Session-level locks are held until explicitly released or session ends-- They survive COMMIT and ROLLBACKTransaction-Level Advisory Locks
Section titled “Transaction-Level Advisory Locks”-- Transaction-level advisory locks (auto-released on COMMIT or ROLLBACK)SELECT pg_advisory_xact_lock(12345);SELECT pg_advisory_xact_lock_shared(12345);Use Cases
Section titled “Use Cases”| Use Case | Advisory Lock Pattern | Notes |
|---|---|---|
| Prevent concurrent job execution | pg_advisory_lock(job_type_id) | Blocks until previous job finishes |
| Distributed rate limiting | pg_advisory_lock(user_id) with timeout | 1 lock per user |
| Prevent duplicate inserts | pg_advisory_xact_lock(hash(data)) | Auto-released on commit/rollback |
| Coordinate deployments | pg_advisory_lock(migration_id) | Only one migration runs at a time |
Advisory locks do not conflict with regular row or table locks. They exist in a separate namespace. Advisory locks on the same bigint value from different sessions conflict, regardless of which Application or connection acquired them.
Lock Monitoring
Section titled “Lock Monitoring”pg_locks
Section titled “pg_locks”-- All currently held locksSELECT l.pid, l.locktype, l.mode, l.granted, a.datname, a.relname, a.query, a.wait_event_type, a.wait_event, EXTRACT(EPOCH FROM (now() - a.query_start)) AS duration_secondsFROM pg_locks lJOIN pg_stat_activity a ON l.pid = a.pidWHERE l.granted = FALSE -- only blocked locksORDER BY a.query_start;pg_stat_activity
Section titled “pg_stat_activity”-- Sessions waiting for locksSELECT pid, usename, datname, state, wait_event_type, wait_event, query, EXTRACT(EPOCH FROM (now() - query_start)) AS wait_secondsFROM pg_stat_activityWHERE wait_event_type = 'Lock'ORDER BY query_start;
-- Long-running transactions (potential lock holders)SELECT pid, usename, state, EXTRACT(EPOCH FROM (now() - xact_start)) AS xact_duration, EXTRACT(EPOCH FROM (now() - query_start)) AS query_duration, queryFROM pg_stat_activityWHERE state != 'idle' AND now() - xact_start > INTERVAL '5 minutes'ORDER BY xact_start;Lock Tree View
Section titled “Lock Tree View”-- Show which sessions are blocking whichSELECT blocked.pid AS blocked_pid, blocked.query AS blocked_query, blocking.pid AS blocking_pid, blocking.query AS blocking_queryFROM pg_stat_activity blockedJOIN pg_locks blocked_locks ON blocked.pid = blocked_locks.pidJOIN pg_locks blocking_locks ON blocked_locks.locktype = blocking_locks.locktype AND blocked_locks.database IS NOT DISTINCT FROM blocking_locks.database AND blocked_locks.relation IS NOT DISTINCT FROM blocking_locks.relation AND blocked_locks.page IS NOT DISTINCT FROM blocking_locks.page AND blocked_locks.tuple IS NOT DISTINCT FROM blocking_locks.tuple AND blocked_locks.virtualxid IS NOT DISTINCT FROM blocking_locks.virtualxid AND blocked_locks.transactionid IS NOT DISTINCT FROM blocking_locks.transactionid AND blocked_locks.classid IS NOT DISTINCT FROM blocking_locks.classid AND blocked_locks.objid IS NOT DISTINCT FROM blocking_locks.objid AND blocked_locks.objsubid IS NOT DISTINCT FROM blocking_locks.objsubid AND blocked_locks.pid != blocking_locks.pidJOIN pg_stat_activity blocking ON blocking_locks.pid = blocking.pidWHERE NOT blocked_locks.granted;MVCC Implementation Details
Section titled “MVCC Implementation Details”Row Visibility Rules
Section titled “Row Visibility Rules”Each row (tuple) in PostgreSQL has two hidden system columns:
| Column | Meaning |
|---|---|
xmin | Transaction ID that inserted this row version |
xmax | Transaction ID that deleted/updated this row (0 = still visible) |
ctid | Physical location (block number, offset within block) |
Visibility for a transaction with snapshot (xmin_snap, xmax_snap):
A row is VISIBLE to the current transaction if: 1. xmin is committed AND xmin < xmax_snap (inserted before snapshot) 2. xmax = 0 (not deleted) OR xmax is aborted (deleter rolled back) OR xmax >= xmax_snap (deleted after snapshot, or by in-progress tx)
A row is INVISIBLE if: 1. xmin is aborted (inserter rolled back) 2. xmin is in-progress and xmin != current tx 3. xmax is committed AND xmax < xmax_snap (deleted before snapshot)Why VACUUM Is Necessary
Section titled “Why VACUUM Is Necessary”When a row is updated or deleted, PostgreSQL does not physically remove the old row version. Instead, it marks the old version as dead by setting xmax to the deleting transaction’s ID. These Dead tuples consume disk space and slow down scans until VACUUM reclaims them.
-- Check for table bloatSELECT relname, pg_size_pretty(pg_total_relation_size(relid)) AS total_size, pg_size_pretty(pg_relation_size(relid)) AS table_size, pg_size_pretty(pg_total_relation_size(relid) - pg_relation_size(relid)) AS index_size, n_dead_tup, n_live_tup, ROUND(n_dead_tup::NUMERIC / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 2) AS dead_pctFROM pg_stat_user_tablesORDER BY n_dead_tup DESC;Transaction ID Wraparound
Section titled “Transaction ID Wraparound”PostgreSQL uses 32-bit transaction IDs (approximately 4 billion). When the counter wraps around, old Data appears to be in the future and becomes invisible. Autovacuum prevents this by freezing old Tuples:
-- Check how close databases are to wraparoundSELECT datname, age(datfrozenxid) AS xid_age, pg_size_pretty(pg_database_size(datname)) AS db_sizeFROM pg_databaseORDER BY age(datfrozenxid) DESC;
-- Emergency: force freeze if xid_age is approaching 2 billionVACUUM FREEZE VERBOSE;Hot Standby and Replication Conflicts
Section titled “Hot Standby and Replication Conflicts”Replication Lag and Locks
Section titled “Replication Lag and Locks”On a streaming replica (hot standby), queries may conflict with replayed WAL records:
| Conflict Type | What Happens |
|---|---|
| AccessExclusiveLock | Replica query blocked by DDL replay |
| TableLock | Replica query blocked by table lock replay |
| SnapshotConflict | Replica query needs rows being vacuumed on primary |
| BufferPin | Replica holds a buffer pin on a page being replayed |
-- On the replica: view replication conflictsSELECT * FROM pg_stat_database_conflicts;
-- Configuration: how long replica queries wait before being canceled-- max_standby_streaming_delay = 30s (default)-- max_standby_archive_delay = 30s (default)Long-Running Transactions
Section titled “Long-Running Transactions”Idle in Transaction
Section titled “Idle in Transaction”A session that has BEGIN but is not actively executing queries holds a snapshot that prevents VACUUM from reclaiming dead tuples.
-- Find idle-in-transaction sessionsSELECT pid, usename, datname, EXTRACT(EPOCH FROM (now() - xact_start)) AS idle_seconds, queryFROM pg_stat_activityWHERE state = 'idle in transaction'ORDER BY xact_start;
-- Kill idle-in-transaction sessions (be careful)SELECT pg_terminate_backend(pid)FROM pg_stat_activityWHERE state = 'idle in transaction' AND now() - xact_start > INTERVAL '10 minutes';statement_timeout and lock_timeout
Section titled “statement_timeout and lock_timeout”-- Abort queries that run too longSET statement_timeout = '30s';SET lock_timeout = '10s';
-- Per-role defaultsALTER ROLE app_user SET statement_timeout = '30s';ALTER ROLE app_user SET lock_timeout = '10s';
-- Per-database defaultsALTER DATABASE mydb SET statement_timeout = '30s';
-- Per-session override (for known long-running maintenance)SET LOCAL statement_timeout = '0'; -- disable for this transaction| Timeout | What It Aborts | Default |
|---|---|---|
statement_timeout | Any statement exceeding duration | 0 (disabled) |
lock_timeout | Any statement waiting for a lock exceeding duration | 0 (disabled) |
idle_in_transaction_session_timeout | Sessions idle in transaction | 0 (disabled) |
Lock Escalation
Section titled “Lock Escalation”Unlike SQL Server and Oracle, PostgreSQL does not perform automatic lock escalation from Row-level to table-level locks. Row locks and table locks are independent mechanisms. However, Acquiring too many row locks does consume significant shared memory for the lock table. If you need To update millions of rows, consider:
-- Process in batches to avoid holding too many row locksDO $$DECLARE batch_size INTEGER := 10000; rows_updated INTEGER;BEGIN LOOP UPDATE large_table SET status = 'archived' WHERE status = 'active' LIMIT batch_size;
GET DIAGNOSTICS rows_updated = ROW_COUNT; EXIT WHEN rows_updated = 0;
COMMIT; END LOOP;END $$;Optimistic Locking Patterns
Section titled “Optimistic Locking Patterns”Version Column
Section titled “Version Column”CREATE TABLE inventory ( product_id INTEGER PRIMARY KEY, quantity INTEGER NOT NULL, version INTEGER NOT NULL DEFAULT 1, CHECK (quantity >= 0));
-- Application reads: product_id=42, quantity=100, version=5-- Application computes: new_quantity = 97
UPDATE inventorySET quantity = 97, version = version + 1WHERE product_id = 42 AND version = 5;-- If rows_affected = 1: success-- If rows_affected = 0: conflict (someone else modified the row), retryUpdated_at Timestamp
Section titled “Updated_at Timestamp”CREATE TABLE documents ( doc_id INTEGER PRIMARY KEY, title TEXT, body TEXT, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
-- Application reads: doc_id=1, updated_at='2024-01-15T10:30:00Z'-- Application modifies title and submits
UPDATE documentsSET title = 'New Title', updated_at = NOW()WHERE doc_id = 1 AND updated_at = '2024-01-15T10:30:00Z';Compare and Swap (CAS)
Section titled “Compare and Swap (CAS)”-- Atomically update only if current value matches expectedUPDATE accountsSET balance = balance - 100WHERE account_id = 1 AND balance >= 100;
-- Check result count: 1 = success, 0 = insufficient fundsCommon Pitfalls
Section titled “Common Pitfalls”Not Setting lock_timeout
Section titled “Not Setting lock_timeout”A query waiting for a lock can block indefinitely. If the lock holder has a long-running transaction Or a crashed session (though PostgreSQL detects crashed backends via TCP keepalive), waiting Queries pile up. Always set lock_timeout:
SET lock_timeout = '10s';-- After 10s of waiting for a lock: ERROR: canceling statement due to lock timeoutForgetting That FOR UPDATE Blocks Concurrent Reads in REPEATABLE READ
Section titled “Forgetting That FOR UPDATE Blocks Concurrent Reads in REPEATABLE READ”In REPEATABLE READ``SELECT ... FOR UPDATE blocks if another transaction has modified the row (even if committed). In READ COMMITTED``SELECT ... FOR UPDATE re-evaluates the row after Acquiring the lock. Know your isolation level.
Advisory Lock Leaks
Section titled “Advisory Lock Leaks”Session-level advisory locks persist until the session ends or the lock is explicitly released. If Your application crashes or the connection pool drops the connection, the lock is automatically Released when the TCP connection closes. However, with PgBouncer in session mode, a recycled Connection may still hold a lock from a previous session. Use transaction-level advisory locks (pg_advisory_xact_lock) for automatic cleanup.
Not Handling Deadlock Errors
Section titled “Not Handling Deadlock Errors”Deadlocks are a normal occurrence in concurrent systems. If your application does not catch error Code 40P01 and retry, users will see unexplained failures. Implement retry logic with exponential Backoff in every transaction that acquires locks.
Long Transactions Holding Row Locks
Section titled “Long Transactions Holding Row Locks”A transaction that opens with BEGINThen makes an HTTP call or waits for user input while holding Row locks, blocks all other transactions that need those rows. Minimize transaction duration: do all Read-only work before BEGINThen lock and modify within the transaction.
Using LOCK TABLE When Row Locks Suffice
Section titled “Using LOCK TABLE When Row Locks Suffice”LOCK TABLE ... IN ACCESS EXCLUSIVE MODE blocks all reads and writes on the entire table. Use the Most specific lock that satisfies your requirement: SELECT ... FOR UPDATE for row locks, SELECT ... FOR UPDATE OF table_name for specific tables in a multi-table query.
Lock Duration and Transaction Boundaries
Section titled “Lock Duration and Transaction Boundaries”Lock Release on Commit or Rollback
Section titled “Lock Release on Commit or Rollback”All locks acquired during a transaction (row locks, table locks, advisory locks) are released when The transaction ends. There is no way to release a lock before the transaction commits or rolls Back, except for SAVEPOINT + ROLLBACK TO SAVEPOINT which releases locks acquired after the Savepoint.
BEGIN;
-- Acquire row lockSELECT * FROM accounts WHERE account_id = 1 FOR UPDATE;
SAVEPOINT sp1;
-- Acquire another row lockSELECT * FROM accounts WHERE account_id = 2 FOR UPDATE;
-- This releases the lock on account_id = 2 but keeps the lock on account_id = 1ROLLBACK TO SAVEPOINT sp1;
-- Both locks are released on COMMIT or ROLLBACKCOMMIT;