Locking
Locking is how PostgreSQL controls concurrent access to data. When multiple transactions run at the same time, locks ensure they don't corrupt each other's work. Understanding locks is essential for writing high-throughput, deadlock-free production code.
Row Locks
Lock individual rows to prevent concurrent modifications
What are Row Locks?
Row locks allow you to lock specific rows inside a transaction so that other transactions cannot modify (or in some cases even read) those rows until your transaction finishes. PostgreSQL gives you four row-level lock modes.
FOR UPDATE — The Most Common Row Lock
SELECT ... FOR UPDATE is the pattern you use when you want to read a row and then update it, making sure nobody else changes it in between. This is the classic optimistic-to-pessimistic lock pattern.
Real example: Two users are trying to book the last concert ticket at the same time. Without a lock, both could see "1 ticket left" and both could "buy" it — overselling by 1.
-- T1 reads: tickets = 1 SELECT tickets FROM inventory WHERE id = 1; -- T2 also reads: tickets = 1 SELECT tickets FROM inventory WHERE id = 1; -- T1 updates: tickets = 0 ✅ UPDATE inventory SET tickets = 0 WHERE id = 1; -- T2 also updates: tickets = 0 ❌ (oversold!) UPDATE inventory SET tickets = 0 WHERE id = 1;
-- T1 locks the row SELECT tickets FROM inventory WHERE id = 1 FOR UPDATE; -- T2 now BLOCKS here -- T1 updates and commits UPDATE inventory SET tickets = 0 WHERE id = 1; COMMIT; -- T2 unblocks, sees tickets = 0 -- T2 can then handle "out of stock"
SKIP LOCKED — Non-blocking Queue Pattern
In job queues, you want workers to grab jobs without blocking each other. SKIP LOCKED skips rows that are already locked by another transaction instead of waiting. This is the foundation of PostgreSQL-as-a-queue.
NOWAIT — Fail Fast Instead of Waiting
By default, FOR UPDATE blocks (waits) if the row is already locked. With NOWAIT, if the lock cannot be acquired immediately, PostgreSQL raises an error right away — perfect when you'd rather retry than wait.
Interactive: Row Lock Simulator
Step through two concurrent transactions to see how FOR UPDATE works. Run steps in order and observe blocking behavior.
🔐 Row Lock Demo — Ticket Booking
-- Run T1 Step 1 and T2 Step 1 to start both transactions...
Table Locks
Lock entire tables for schema changes and bulk operations
What are Table Locks?
Table locks apply to an entire table, not just specific rows. PostgreSQL acquires table locks automatically for most DDL (schema change) operations. You can also acquire them manually with LOCK TABLE. There are 8 table lock modes ranging from very permissive to fully exclusive.
LOCK TABLE manually. The database acquires the right lock automatically. The key skill is knowing which operations conflict so you can avoid blocking production traffic during deployments.The 8 Table Lock Modes
From weakest (most permissive) to strongest (most exclusive). "Conflicts with" tells you which other locks it blocks:
| Lock Mode | Acquired By | Allows Concurrent | Blocks |
|---|---|---|---|
| ACCESS SHARE | SELECT | Almost everything | ACCESS EXCLUSIVE only |
| ROW SHARE | SELECT FOR UPDATE / SHARE | Most reads & writes | EXCLUSIVE, ACCESS EXCLUSIVE |
| ROW EXCLUSIVE | INSERT, UPDATE, DELETE | Reads, other DML | SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
| SHARE UPDATE EXCLUSIVE | VACUUM, CREATE INDEX CONCURRENTLY | Reads, DML | Other schema changes |
| SHARE | CREATE INDEX (non-concurrent) | Reads only | Writes |
| SHARE ROW EXCLUSIVE | CREATE TRIGGER, some foreign key ops | Reads | Most writes and schema changes |
| EXCLUSIVE | Rare, explicit LOCK TABLE | SELECT only | Everything except ACCESS SHARE |
| ACCESS EXCLUSIVE | ALTER TABLE, DROP TABLE, TRUNCATE, VACUUM FULL | Nothing | Everything — including SELECT |
Access Share — What SELECT Does
ACCESS SHARE is the weakest lock. A plain SELECT acquires it. It only conflicts with ACCESS EXCLUSIVE, meaning selects run freely even while writes are happening. This is why reads don't block writes in PostgreSQL (MVCC does the heavy lifting).
Row Share — SELECT FOR UPDATE
ROW SHARE is acquired at the table level when you do SELECT ... FOR UPDATE or FOR SHARE. It's a weak table lock — it still allows other transactions to read and write normally. It only conflicts with full table EXCLUSIVE locks.
Row Exclusive — What DML Acquires
Every INSERT, UPDATE, and DELETE acquires a ROW EXCLUSIVE table lock automatically. Multiple transactions can hold ROW EXCLUSIVE simultaneously — they don't block each other at the table level. The actual row-level locks prevent conflicts on individual rows.
Access Exclusive — The Dangerous One
ACCESS EXCLUSIVE is acquired by ALTER TABLE, DROP TABLE, TRUNCATE, and VACUUM FULL. It conflicts with every other lock, including plain SELECT. This is why running ALTER TABLE on a busy production table blocks all queries — a very common incident in production.
-- This will block ALL reads and writes -- until it acquires the lock ALTER TABLE orders ADD COLUMN notes TEXT; -- If there are long-running queries, -- this waits → queues up → outage!
-- Use lock_timeout to fail fast SET lock_timeout = '2s'; ALTER TABLE orders ADD COLUMN notes TEXT; -- Or add column with a DEFAULT separately: -- 1. ALTER TABLE orders ADD COLUMN notes TEXT; -- 2. ALTER TABLE orders ALTER COLUMN notes -- SET DEFAULT 'none'; -- In Postgres 11+, ADD COLUMN with DEFAULT -- is instant (stored in catalog).
ALTER TABLE users ADD COLUMN preferences JSONB DEFAULT '{}' on a 50M-row table during peak hours. It waited to acquire ACCESS EXCLUSIVE, which blocked all user-facing queries. The site was down for 4 minutes. Always use lock_timeout and deploy schema changes during low-traffic windows.Lock Compatibility Matrix
This shows which lock modes conflict with each other. ✗ = conflict (blocks), ✓ = compatible (allowed concurrently).
| Held ↓ / Requested → | AS | RS | RE | SUE | SH | SRE | EX | AE |
|---|---|---|---|---|---|---|---|---|
| ACCESS SHARE (AS) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ |
| ROW SHARE (RS) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ |
| ROW EXCLUSIVE (RE) | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ |
| SHARE UPD EXC (SUE) | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
| SHARE (SH) | ✓ | ✓ | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ |
| SHARE ROW EXC (SRE) | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
| EXCLUSIVE (EX) | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
| ACCESS EXCLUSIVE (AE) | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
LOCK TABLE — Manual Table Locking
You can manually acquire a table lock when needed. The most common use case is ensuring consistency during bulk operations or batch inserts.
Deadlocks
Detection, resolution, and how to prevent them in production
What is a Deadlock?
A deadlock happens when two or more transactions are each waiting for the other to release a lock — and none can proceed. It's a circular wait that would last forever if PostgreSQL didn't detect and resolve it.
ERROR: deadlock detected. The victim transaction must then be retried.Classic Deadlock Example
The most common cause: two transactions lock rows in different orders.
BEGIN; -- Locks Account A UPDATE accounts SET balance = balance - 100 WHERE id = 'A'; -- ✅ locked A -- Now tries to lock B -- But T2 already has B! UPDATE accounts SET balance = balance + 100 WHERE id = 'B'; -- ⏳ WAITING...
BEGIN; -- Locks Account B UPDATE accounts SET balance = balance - 50 WHERE id = 'B'; -- ✅ locked B -- Now tries to lock A -- But T1 already has A! UPDATE accounts SET balance = balance + 50 WHERE id = 'A'; -- ⏳ WAITING...
ERROR: deadlock detected DETAIL: Process 1234 waits for ShareLock on transaction 5678; blocked by process 9012.Detection — How PostgreSQL Finds Deadlocks
PostgreSQL runs a background deadlock detector that periodically checks the lock wait graph for cycles. The detection delay is controlled by deadlock_timeout (default: 1 second). When a cycle is found, the "lightest" transaction (fewest locks held) is chosen as the victim and rolled back.
Resolution — What Happens After Detection
PostgreSQL resolves deadlocks by aborting one transaction (the victim). The victim gets an error, its changes are rolled back, and the other transaction(s) can proceed. Your application code must handle this error and retry.
Prevention — The Golden Rules
Most deadlocks are preventable by following simple discipline in your codebase:
The Fix: Consistent Lock Ordering
-- Transfer A → B UPDATE accounts SET balance = balance - 100 WHERE id = 'A'; -- locks A first UPDATE accounts SET balance = balance + 100 WHERE id = 'B'; -- then B -- Transfer B → A (in another transaction) UPDATE accounts SET balance = balance - 50 WHERE id = 'B'; -- locks B first ← OPPOSITE! UPDATE accounts SET balance = balance + 50 WHERE id = 'A';
-- Always lock the LOWER ID first -- Transfer A(1) → B(2) SELECT * FROM accounts WHERE id IN (1, 2) ORDER BY id -- ← sort ensures order 1, 2 FOR UPDATE; -- locks both at once -- Transfer B(2) → A(1) SELECT * FROM accounts WHERE id IN (1, 2) ORDER BY id -- ← same order: 1, 2 FOR UPDATE; -- no circular wait!
Interactive: Deadlock Simulator
Watch a deadlock form step by step, then see PostgreSQL detect and resolve it.
💀 Deadlock Demo — Account Transfer
-- Start both transactions, then watch the deadlock form...
Finding Lock Waits in Production
These queries help you diagnose locking issues on a live database without restarting anything.
Topic 7 Summary
What you've learned + what to remember for production
Key Takeaways
| Concept | What it is | When to use |
|---|---|---|
SELECT FOR UPDATE |
Exclusive row lock — no one else can modify locked rows | Read-then-modify patterns (ticket booking, balance transfer, inventory) |
FOR UPDATE SKIP LOCKED |
Skip already-locked rows, don't wait | Job queues, parallel workers |
FOR UPDATE NOWAIT |
Fail immediately if row is already locked | When waiting is worse than failing fast |
| ACCESS SHARE | Table-level lock acquired by SELECT | Automatic — only blocked by ALTER TABLE / DROP |
| ROW EXCLUSIVE | Table-level lock acquired by INSERT/UPDATE/DELETE | Automatic — multiple transactions can hold simultaneously |
| ACCESS EXCLUSIVE | Full table lockdown by ALTER TABLE, TRUNCATE | DDL changes — always use lock_timeout in prod |
| Deadlock | Circular lock wait — PostgreSQL aborts one victim | Prevented by: consistent lock order, short transactions, upfront locking |
Production Checklist
SELECT FOR UPDATE inside a transaction when you plan to update a row. Never read outside, then update inside.lock_timeout before ALTER TABLE. Use CREATE INDEX CONCURRENTLY to avoid table locks during index creation.deadlock detected error. Keep transactions short.pg_stat_activity and pg_blocking_pids() to find what's blocking what in production without any downtime.