Skip to content

Locking and Deadlocks | Databases

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