Skip to content

Locking and Deadlocks

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.

LevelScopeOverheadConcurrencyExample
RowSingle tupleHighHighestSELECT ... FOR UPDATE
Page8KB pageMediumMediumInternal page locks during heap operations
TableEntire relationLowLowestLOCK TABLEDDL operations
AdvisoryApplication-definedNoneN/Apg_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.

PostgreSQL defines eight table-level lock modes. Each SQL command acquires specific locks Automatically.

Lock ModeAcquired ByConflicts With
ACCESS SHARESELECTACCESS EXCLUSIVE
ROW SHARESELECT FOR UPDATE/SHAREEXCLUSIVE, ACCESS EXCLUSIVE
ROW EXCLUSIVEINSERT``UPDATE``DELETESHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
SHARE UPDATE EXCLUSIVEVACUUM (without FULL), CREATE INDEX CONCURRENTLYROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
SHARECREATE INDEX (non-concurrent)ROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
SHARE ROW EXCLUSIVECREATE TRIGGERSome ALTER TABLEROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
EXCLUSIVEREFRESH MATERIALIZED VIEW (non-concurrent)ROW SHARE, ROW EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
ACCESS EXCLUSIVEDROP TABLE``TRUNCATE``ALTER TABLE``VACUUM FULL``LOCK TABLEAll lock modes
Request \ HeldASRSRXSRESSRE2XAE
ACCESS SHAREYYYYYYYN
ROW SHAREYYYYYYNN
ROW EXCLUSIVEYYYYNNNN
SHARE UPDATE EXCLUSIVEYYYYNNNN
SHAREYYNNYNNN
SHARE ROW EXCLYYNNNNNN
EXCLUSIVEYNNNNNNN
ACCESS EXCLNNNNNNNN

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.

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 row
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
-- DELETE implicitly locks the row
DELETE FROM orders WHERE order_id = 42;
-- 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 tasks
WHERE status = "pending'
ORDER BY created_at
LIMIT 1
FOR 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 query
SELECT * FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
WHERE o.order_id = 42
FOR UPDATE OF o; -- only locks rows in the orders table
-- NOWAIT: fail immediately if a row is locked
SELECT * FROM accounts WHERE account_id = 1 FOR UPDATE NOWAIT;
-- ERROR: could not obtain lock on row

FOR 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 reads
SELECT * 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 constraint
SELECT * FROM products WHERE product_id = 42 FOR KEY SHARE;
Lock ModeBlocks FOR UPDATEBlocks FOR NO KEY UPDATEBlocks FOR SHAREBlocks FOR KEY SHARE
FOR UPDATEYesYesYesYes
FOR NO KEY UPDATEYesYesYesNo
FOR SHAREYesYesYesYes
FOR KEY SHAREYesNoYesYes
-- 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 acquired
LOCK TABLE accounts IN ACCESS EXCLUSIVE MODE NOWAIT;

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.

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 T2
T2: 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 normally

PostgreSQL runs deadlock detection periodically (not continuously). When the deadlock detector runs:

  1. It builds a wait-for graph from pg_locks
  2. It checks for cycles in the graph
  3. If a cycle is found, it aborts the transaction with the least work done (youngest xid)
-- Monitor for deadlock errors
SELECT datname, deadlocks FROM pg_stat_database;
  1. Consistent access order: Always access tables and rows in the same order across all transactions.
-- Always update lower-ID accounts first
CREATE 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;
  1. Short transactions: Minimize the time locks are held.
-- BAD: long transaction holding locks
BEGIN;
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 briefly
UPDATE orders SET status = 'processing' WHERE customer_id = 42;
COMMIT;
  1. SKIP LOCKED: Non-blocking queue pattern for concurrent workers.
-- Worker picks up next available task without blocking
SELECT * FROM tasks
WHERE status = 'pending'
ORDER BY priority DESC, created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED;
  1. Retry logic: Always handle deadlocks with retry at the application level.
import time
import random
MAX_RETRIES = 5
BASE_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:
raise
else:
raise MaxRetriesExceeded(f"Failed after {MAX_RETRIES} attempts")

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.

-- Lock by integer (blocks until available)
SELECT pg_advisory_lock(12345);
-- Try-lock (returns immediately, TRUE if acquired)
SELECT pg_advisory_try_lock(12345);
-- Unlock
SELECT 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 ROLLBACK
-- Transaction-level advisory locks (auto-released on COMMIT or ROLLBACK)
SELECT pg_advisory_xact_lock(12345);
SELECT pg_advisory_xact_lock_shared(12345);
Use CaseAdvisory Lock PatternNotes
Prevent concurrent job executionpg_advisory_lock(job_type_id)Blocks until previous job finishes
Distributed rate limitingpg_advisory_lock(user_id) with timeout1 lock per user
Prevent duplicate insertspg_advisory_xact_lock(hash(data))Auto-released on commit/rollback
Coordinate deploymentspg_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.

-- All currently held locks
SELECT
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_seconds
FROM pg_locks l
JOIN pg_stat_activity a ON l.pid = a.pid
WHERE l.granted = FALSE -- only blocked locks
ORDER BY a.query_start;
-- Sessions waiting for locks
SELECT
pid,
usename,
datname,
state,
wait_event_type,
wait_event,
query,
EXTRACT(EPOCH FROM (now() - query_start)) AS wait_seconds
FROM pg_stat_activity
WHERE 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,
query
FROM pg_stat_activity
WHERE state != 'idle'
AND now() - xact_start > INTERVAL '5 minutes'
ORDER BY xact_start;
-- Show which sessions are blocking which
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 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.pid
JOIN pg_stat_activity blocking ON blocking_locks.pid = blocking.pid
WHERE NOT blocked_locks.granted;

Each row (tuple) in PostgreSQL has two hidden system columns:

ColumnMeaning
xminTransaction ID that inserted this row version
xmaxTransaction ID that deleted/updated this row (0 = still visible)
ctidPhysical 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)

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 bloat
SELECT 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_pct
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

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 wraparound
SELECT datname, age(datfrozenxid) AS xid_age,
pg_size_pretty(pg_database_size(datname)) AS db_size
FROM pg_database
ORDER BY age(datfrozenxid) DESC;
-- Emergency: force freeze if xid_age is approaching 2 billion
VACUUM FREEZE VERBOSE;

On a streaming replica (hot standby), queries may conflict with replayed WAL records:

Conflict TypeWhat Happens
AccessExclusiveLockReplica query blocked by DDL replay
TableLockReplica query blocked by table lock replay
SnapshotConflictReplica query needs rows being vacuumed on primary
BufferPinReplica holds a buffer pin on a page being replayed
-- On the replica: view replication conflicts
SELECT * 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)

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 sessions
SELECT pid, usename, datname,
EXTRACT(EPOCH FROM (now() - xact_start)) AS idle_seconds,
query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_start;
-- Kill idle-in-transaction sessions (be careful)
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND now() - xact_start > INTERVAL '10 minutes';
-- Abort queries that run too long
SET statement_timeout = '30s';
SET lock_timeout = '10s';
-- Per-role defaults
ALTER ROLE app_user SET statement_timeout = '30s';
ALTER ROLE app_user SET lock_timeout = '10s';
-- Per-database defaults
ALTER DATABASE mydb SET statement_timeout = '30s';
-- Per-session override (for known long-running maintenance)
SET LOCAL statement_timeout = '0'; -- disable for this transaction
TimeoutWhat It AbortsDefault
statement_timeoutAny statement exceeding duration0 (disabled)
lock_timeoutAny statement waiting for a lock exceeding duration0 (disabled)
idle_in_transaction_session_timeoutSessions idle in transaction0 (disabled)

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 locks
DO $$
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 $$;
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 inventory
SET quantity = 97, version = version + 1
WHERE product_id = 42 AND version = 5;
-- If rows_affected = 1: success
-- If rows_affected = 0: conflict (someone else modified the row), retry
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 documents
SET title = 'New Title', updated_at = NOW()
WHERE doc_id = 1 AND updated_at = '2024-01-15T10:30:00Z';
-- Atomically update only if current value matches expected
UPDATE accounts
SET balance = balance - 100
WHERE account_id = 1 AND balance >= 100;
-- Check result count: 1 = success, 0 = insufficient funds

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 timeout

Forgetting 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.

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.

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.

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.

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.

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 lock
SELECT * FROM accounts WHERE account_id = 1 FOR UPDATE;
SAVEPOINT sp1;
-- Acquire another row lock
SELECT * FROM accounts WHERE account_id = 2 FOR UPDATE;
-- This releases the lock on account_id = 2 but keeps the lock on account_id = 1
ROLLBACK TO SAVEPOINT sp1;
-- Both locks are released on COMMIT or ROLLBACK
COMMIT;