Locking and Deadlocks | Databases
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 |