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, DELETE or 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 |