Transactions, Isolation & Locks: A PostgreSQL and MySQL Guide
A comprehensive guide to PostgreSQL and MySQL transactions, isolation levels, and locking.
From dirty reads to MVCC internals, deadlock detection, and production locking patterns. Runnable SQL for every concept.
The Concurrency Problem
Imagine a bank. Two tellers simultaneously process withdrawals from the same account. The balance is ₹10,000. Teller A reads it, Teller B reads it. Teller A approves a ₹7,000 withdrawal and writes ₹3,000 back. Teller B approves a ₹6,000 withdrawal and writes ₹4,000 back. The bank just lost ₹3,000 with no errors logged anywhere.
Without concurrency control, databases do exactly this. A transaction is a group of SQL statements treated as a single unit. They need to satisfy four properties:
- Atomic: all statements succeed or all fail together.
- Consistent: the database moves from one valid state to another.
- Isolated: concurrent transactions don't corrupt each other's view.
- Durable: committed data survives crashes.
Isolation is the hard one. It's a spectrum. Stronger isolation means fewer anomalies but more contention.
How PostgreSQL Actually Works: MVCC
Before isolation levels make sense, you need to understand what's underneath. PostgreSQL uses Multi-Version Concurrency Control (MVCC). Instead of freezing a row with a lock every time someone reads it, PostgreSQL keeps multiple versions of every row simultaneously.
To see how this works in practice, we can peek under the hood. Every row in a PostgreSQL heap has two hidden system columns:
xmin: the transaction ID (XID) that created this row versionxmax: the transaction ID that deleted or updated this row version (0 if the row is still live)
-- Create our demo table (we'll use this throughout) CREATE TABLE accounts ( id SERIAL PRIMARY KEY, owner TEXT NOT NULL, balance NUMERIC(12,2) NOT NULL DEFAULT 0 );INSERT INTO accounts (owner, balance) VALUES (‘Alice’, 10000.00), (‘Bob’, 5000.00);
— Peek at the hidden MVCC columns SELECT xmin, xmax, id, owner, balance FROM accounts;
— Example output: — xmin | xmax | id | owner | balance — ------+------+----+-------+---------- — 712 | 0 | 1 | Alice | 10000.00 — 712 | 0 | 2 | Bob | 5000.00
— xmax = 0 means “alive” — no one has deleted/updated this version yet
When you UPDATE a row, PostgreSQL doesn't modify it in place. It marks the old version with xmax = current_txn_id (making it "dead" to future transactions) and inserts a fresh row version with the new data. Readers can still see the old version during their snapshot window. This is why reads in PostgreSQL almost never block writes and writes never block reads.
The Six Read Anomalies
These are the subtle concurrency bugs that will page you at 2 AM if your isolation levels are wrong. The SQL standard names four of them, but out in the wild, you'll run into six.
1. Dirty Read
Transaction A reads data written by Transaction B that hasn't committed yet. If B rolls back, A has acted on data that never really existed.
-- reads: 0 (dirty!)
-- Acts on bad data!
-- not committed yet
READ UNCOMMITTED. It simply treats that level identically to READ COMMITTED. However, MySQL's InnoDB does allow dirty reads at this level, and will actually let you read uncommitted data from other transactions.
2. Dirty Write
Transaction A overwrites data that Transaction B wrote but hasn't committed yet. PostgreSQL prevents this at all isolation levels by making any writer wait for an in-progress writer on the same row to either commit or rollback.
3. Non-Repeatable Read (Read Skew)
You read the same row twice in one transaction and get different values because another transaction committed a change between your two reads.
-- returns: 10000
-- returns: 3000 ← different!
WHERE id=1; COMMIT;
-- Session 1 (READ COMMITTED — default) BEGIN; SELECT balance FROM accounts WHERE id = 1; -- → 10000.00— [In Session 2, run: UPDATE accounts SET balance=3000 WHERE id=1; COMMIT;]
SELECT balance FROM accounts WHERE id = 1; — → 3000.00 ← DIFFERENT VALUE! Non-repeatable read. COMMIT;
4. Phantom Read
You run the same range query twice in one transaction. The second time, extra rows appear (or disappear) because another transaction inserted/deleted rows matching your criteria between your queries.
-- Setup CREATE TABLE orders ( id SERIAL PRIMARY KEY, customer TEXT, amount NUMERIC(10,2), created_at TIMESTAMPTZ DEFAULT NOW() ); INSERT INTO orders (customer, amount) VALUES ('alice', 500), ('alice', 300);— Session 1 (READ COMMITTED) BEGIN; SELECT COUNT(*) FROM orders WHERE customer = ‘alice’; — → 2
— [Session 2: INSERT INTO orders(customer,amount) VALUES(‘alice’,900); COMMIT;]
SELECT COUNT(*) FROM orders WHERE customer = ‘alice’; — → 3 ← Phantom! A new row appeared. COMMIT;
5. Lost Update
Both transactions read the same value, compute an update based on what they read, and the second write silently overwrites the first. No error, no warning. The data just changes without a trace.
-- reads: 5
COMMIT; -- 5 - 2 = 3
-- also reads: 5
COMMIT; -- 5-3=2, OVERWRITES T1!
⚠ Result: stock=2, but should be 0. Customer 1's purchase was silently overwritten. You've oversold by 2 items.
6. Write Skew
Two transactions each read overlapping data, make a decision based on what they saw, and each update a different subset. Neither write alone breaks a constraint. Together, they do.
-- Constraint: at least 1 doctor must be on-call at all times CREATE TABLE doctors ( id SERIAL PRIMARY KEY, name TEXT, on_call BOOLEAN DEFAULT TRUE ); INSERT INTO doctors (name) VALUES ('Dr. Mehta'), ('Dr. Sharma');— Session 1 (Dr. Mehta wants to go off-call) BEGIN; SELECT COUNT(*) FROM doctors WHERE on_call = TRUE; — → 2 (safe to proceed)
— [Session 2: Dr. Sharma also checks → 2, also decides to go off-call] — [Session 2: UPDATE doctors SET on_call=false WHERE name=‘Dr. Sharma’; COMMIT;]
UPDATE doctors SET on_call = FALSE WHERE name = ‘Dr. Mehta’; COMMIT; — Result: 0 doctors on-call. The hospital is unguarded. — Only SERIALIZABLE isolation prevents this.
The Four Isolation Levels
The SQL standard defines four isolation levels. Each one specifies which anomalies are permitted. The tradeoff is simple: stronger guarantees require more coordination between concurrent transactions, and that costs throughput.
| Isolation Level | Dirty Read | Non-Repeatable | Phantom Read | Write Skew | Performance |
|---|---|---|---|---|---|
| READ UNCOMMITTED | Possible (PG: never) | Yes | Yes | Yes | ⚡⚡⚡⚡ |
| READ COMMITTED | Never | Yes | Yes | Yes | ⚡⚡⚡ |
| REPEATABLE READ | Never | Never | Possible (PG & MySQL: almost never) | Yes | ⚡⚡ |
| SERIALIZABLE | Never | Never | Never | Never | ⚡ |
READ COMMITTED
PostgreSQL's default, and MySQL InnoDB's default too. Each statement inside the transaction sees a fresh snapshot of committed data at the moment it runs. That prevents dirty reads, but if you run the same query twice in the same transaction, you can get different results.
-- This is the default, but you can set it explicitly: BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;SELECT balance FROM accounts WHERE id = 1; — Each SELECT gets a FRESH snapshot of committed data. — Between two SELECTs in this txn, another txn can commit changes.
COMMIT;
REPEATABLE READ
The entire transaction sees a single snapshot taken at the start of the first statement. Concurrent commits are invisible for the rest of the transaction. In PostgreSQL, this level also prevents phantom reads, which goes beyond what the SQL standard actually requires.
-- Session 1 BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ; SELECT balance FROM accounts WHERE id = 1; -- → 10000 (snapshot taken here)— [Session 2 commits: UPDATE accounts SET balance=3000 WHERE id=1;]
SELECT balance FROM accounts WHERE id = 1; — → 10000 ← Same snapshot! Non-repeatable read prevented.
— But if Session 1 now tries to UPDATE the same row: UPDATE accounts SET balance = balance - 500 WHERE id = 1; — ERROR: could not serialize access due to concurrent update — PostgreSQL detects that the row it would update was already — modified by a committed transaction. Safe failure!
ROLLBACK; — Application must retry the entire transaction.
FOR UPDATE), it automatically applies next-key and gap locks to prevent concurrent inserts.SERIALIZABLE
Transactions behave as if they executed one at a time in some serial order, even though they run concurrently. PostgreSQL implements this with Serializable Snapshot Isolation (SSI), which tracks read/write dependencies between transactions and aborts any that would produce results inconsistent with a serial execution. Reads don't escalate to locks.
-- Fixing the on-call doctor write skew from earlier:— Session 1 BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE; SELECT COUNT(*) FROM doctors WHERE on_call = TRUE; — → 2 (SSI tracks: “this txn read the on-call set”)
— [Session 2 (also SERIALIZABLE) reads count=2, updates Dr. Sharma → off-call, commits]
UPDATE doctors SET on_call = FALSE WHERE name = ‘Dr. Mehta’; COMMIT; — ERROR: could not serialize access due to read/write dependencies — among transactions — DETAIL: Reason code: Canceled on identification as a pivot, during commit attempt. — Hospital is saved. Application must retry.
Setting the isolation level
-- 1. Per transaction (most common) BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- or equivalent: BEGIN; SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;— 2. For current session SET default_transaction_isolation = ‘repeatable read’;
— 3. PostgreSQL global default (postgresql.conf) — default_transaction_isolation = ‘read committed’
— 4. MySQL equivalent — SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; — before BEGIN — SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
Explicit Locking
Even at higher isolation levels, the read-then-write pattern can still race. Explicit locks let you declare intent upfront. You're telling the database: "I will update this row, hold off anyone else who might touch it." This is pessimistic locking: assume conflict, prevent it early rather than detecting it after the fact.
Row-Level Lock Modes
PostgreSQL's SELECT ... FOR ... clause acquires row-level locks without modifying data. From strongest to weakest:
-- STRONGEST: Exclusive lock -- Blocks: all other FOR UPDATE/SHARE/NO KEY UPDATE/KEY SHARE, UPDATE, DELETE -- Does NOT block: plain SELECT (reads without locks) SELECT * FROM accounts WHERE id = 1 FOR UPDATE;— Weaker exclusive (good for non-PK updates) — Blocks: FOR UPDATE, FOR SHARE, DELETE, updates that change primary key — Allows: FOR KEY SHARE (FK checks can still proceed) SELECT * FROM accounts WHERE id = 1 FOR NO KEY UPDATE;
— Shared lock (I’m reading this; no one may write) — Blocks: FOR UPDATE, FOR NO KEY UPDATE, UPDATE, DELETE — Allows: other FOR SHARE and FOR KEY SHARE SELECT * FROM accounts WHERE id = 1 FOR SHARE;
— WEAKEST: For FK integrity checks — Blocks: FOR UPDATE and DELETE only (key-changing operations) — Allows: everything else including other writes SELECT * FROM accounts WHERE id = 1 FOR KEY SHARE;
Lock Compatibility Matrix
| Requested ↓ \ Held → | FOR KEY SHARE | FOR SHARE | FOR NO KEY UPDATE | FOR UPDATE |
|---|---|---|---|---|
| FOR KEY SHARE | ✓ | ✓ | ✓ | ✗ |
| FOR SHARE | ✓ | ✓ | ✗ | ✗ |
| FOR NO KEY UPDATE | ✓ | ✗ | ✗ | ✗ |
| FOR UPDATE | ✗ | ✗ | ✗ | ✗ |
Real Example: Fixing the Bank Transfer with FOR UPDATE
CREATE OR REPLACE FUNCTION transfer_funds( p_from_id INT, p_to_id INT, p_amount NUMERIC ) RETURNS VOID AS $$ DECLARE v_balance NUMERIC; BEGIN -- Always lock in a consistent order (lower id first) to avoid deadlocks SELECT balance INTO v_balance FROM accounts WHERE id = p_from_id FOR UPDATE; -- exclusive lock acquired hereIF v_balance < p_amount THEN RAISE EXCEPTION ‘Insufficient funds: % available, % requested’, v_balance, p_amount; END IF;
UPDATE accounts SET balance = balance - p_amount WHERE id = p_from_id;
UPDATE accounts SET balance = balance + p_amount WHERE id = p_to_id; END; $$ LANGUAGE plpgsql;
— Usage BEGIN; SELECT transfer_funds(1, 2, 2000.00); COMMIT;
NOWAIT and SKIP LOCKED
By default, FOR UPDATE waits if the row is already locked. Two modifiers change this behavior:
-- NOWAIT: fail immediately if row is locked BEGIN; SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT; -- ERROR: could not obtain lock on row in relation "accounts" -- Application can immediately try another row or strategy.— SKIP LOCKED: useful for job queues — Multiple workers pick up different jobs without contention CREATE TABLE jobs ( id SERIAL PRIMARY KEY, payload JSONB, status TEXT DEFAULT ‘pending’ );
— Worker process (run this in multiple concurrent sessions) BEGIN; SELECT id, payload FROM jobs WHERE status = ‘pending’ ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED; — Each worker atomically picks a different job. — No polling, no duplicate processing, no waiting.
UPDATE jobs SET status = ‘processing’ WHERE id = <returned_id>; COMMIT;
FOR UPDATE SKIP LOCKED is how you build a reliable job queue directly in PostgreSQL, without Redis or SQS. Rails Active Job, Django-Q, and several Go worker libraries use exactly this pattern. It works well up to around 100k jobs/min before you'd start thinking about a dedicated queue.
SELECT ... FOR UPDATE, FOR SHARE, NOWAIT, and SKIP LOCKED (since 8.0). The behavior is equivalent. MySQL also uses gap locks at REPEATABLE READ to prevent phantom reads. PostgreSQL doesn't need them because MVCC already handles phantoms at snapshot level.Enabling and Releasing Locks
Every lock type has a way to acquire it and a way to release it. Here's a complete reference across row locks, table locks, timeouts, and advisory locks.
Row-Level Locks: Acquiring
-- 1. Exclusive row lock (blocks all other writers and lockers) BEGIN; SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- Lock is now HELD on row id=1 -- It will be released automatically on COMMIT or ROLLBACK— 2. Shared row lock (others can read but not write) BEGIN; SELECT * FROM accounts WHERE id = 1 FOR SHARE;
— 3. Exclusive lock without blocking FK checks BEGIN; SELECT * FROM accounts WHERE id = 1 FOR NO KEY UPDATE;
— 4. Weakest — only blocks key-changing operations BEGIN; SELECT * FROM accounts WHERE id = 1 FOR KEY SHARE;
— 5. Lock multiple rows at once BEGIN; SELECT * FROM accounts WHERE owner = ‘Alice’ FOR UPDATE; — locks ALL matching rows simultaneously
— 6. Lock rows from multiple tables in one query (PostgreSQL) BEGIN; SELECT a.id, o.amount FROM accounts a JOIN orders o ON o.account_id = a.id WHERE a.id = 1 FOR UPDATE OF a — only lock rows in accounts table FOR SHARE OF o; — shared lock on orders rows
Row-Level Locks: Releasing
-- Row-level locks CANNOT be released mid-transaction. -- They are held until the transaction ends.— Release via COMMIT (success path) BEGIN; SELECT * FROM accounts WHERE id = 1 FOR UPDATE; — … do your work … COMMIT; — ← lock released here
— Release via ROLLBACK (abort path) BEGIN; SELECT * FROM accounts WHERE id = 1 FOR UPDATE; — something goes wrong… ROLLBACK; — ← lock also released here
— SAVEPOINT: partial rollback releases locks on rolled-back work BEGIN; SELECT * FROM accounts WHERE id = 1 FOR UPDATE; — held SAVEPOINT sp1; SELECT * FROM accounts WHERE id = 2 FOR UPDATE; — held ROLLBACK TO SAVEPOINT sp1; — releases lock on id=2, keeps lock on id=1 COMMIT; — releases lock on id=1
Table-Level Locks
-- Use table locks sparingly — they block the entire table. -- Most use cases are better served by row-level locks.— ACCESS SHARE: taken automatically by plain SELECT — Blocks only: ACCESS EXCLUSIVE (e.g. DROP TABLE) BEGIN; LOCK TABLE accounts IN ACCESS SHARE MODE;
— ROW SHARE: taken automatically by SELECT FOR UPDATE/FOR SHARE BEGIN; LOCK TABLE accounts IN ROW SHARE MODE;
— SHARE: prevents ALL writes; allows concurrent reads — Use case: “freeze” a table while rebuilding an index manually BEGIN; LOCK TABLE accounts IN SHARE MODE;
— SHARE ROW EXCLUSIVE: prevents writes + other SHARE locks BEGIN; LOCK TABLE accounts IN SHARE ROW EXCLUSIVE MODE;
— EXCLUSIVE: allows only reads (ACCESS SHARE) BEGIN; LOCK TABLE accounts IN EXCLUSIVE MODE;
— ACCESS EXCLUSIVE: the nuclear option — blocks everything — Automatically taken by: ALTER TABLE, DROP TABLE, TRUNCATE, VACUUM FULL — Use case: when you need exclusive control for schema migrations BEGIN; LOCK TABLE accounts IN ACCESS EXCLUSIVE MODE; — No other transaction can read OR write this table until COMMIT
— MySQL equivalent: — LOCK TABLES accounts READ; — shared — LOCK TABLES accounts WRITE; — exclusive — UNLOCK TABLES; — manual release (MySQL only)
LOCK TABLE ... IN ACCESS EXCLUSIVE MODE queues behind every active SELECT on the table, and every new query that arrives after it queues behind the lock request. On a busy table, this can cause a cascading connection pile-up within seconds. Always set a lock_timeout before any table-level lock in production.
Lock Timeouts
-- PostgreSQL: set per session or per transaction SET lock_timeout = '2s'; -- fail if lock not acquired within 2 seconds SET lock_timeout = '500ms'; -- milliseconds also work SET lock_timeout = 0; -- 0 = wait forever (default)— Apply only for the current transaction: BEGIN; SET LOCAL lock_timeout = ‘3s’; — resets after COMMIT/ROLLBACK SELECT * FROM accounts WHERE id = 1 FOR UPDATE; — ERROR: canceling statement due to lock timeout (if locked > 3s) COMMIT;
— statement_timeout: cancel any query that takes too long (not just locking) SET statement_timeout = ’10s’;
— Safe migration pattern: try lock, bail fast if table is busy BEGIN; SET LOCAL lock_timeout = ‘1s’; ALTER TABLE accounts ADD COLUMN region TEXT DEFAULT ‘IN’; COMMIT; — If the lock isn’t granted in 1s, the migration aborts cleanly — instead of holding up the entire connection pool.
— MySQL equivalent: — SET innodb_lock_wait_timeout = 5; — seconds (default: 50) — SET innodb_lock_wait_timeout = 0; — fail immediately if locked
Advisory Locks
-- Session-scoped: must be explicitly released SELECT pg_advisory_lock(42); -- blocks until acquired (exclusive) SELECT pg_advisory_lock_shared(42); -- blocks until acquired (shared) SELECT pg_try_advisory_lock(42); -- returns true/false immediately SELECT pg_try_advisory_lock_shared(42); -- non-blocking shared— RELEASE session advisory locks explicitly: SELECT pg_advisory_unlock(42); — release exclusive SELECT pg_advisory_unlock_shared(42); — release shared SELECT pg_advisory_unlock_all(); — release ALL session advisory locks
— Transaction-scoped: auto-released on COMMIT or ROLLBACK (preferred) BEGIN; SELECT pg_advisory_xact_lock(42); — exclusive, auto-released SELECT pg_advisory_xact_lock_shared(42); — shared, auto-released SELECT pg_try_advisory_xact_lock(42); — non-blocking, auto-released — No explicit unlock needed — COMMIT/ROLLBACK handles it COMMIT;
— Two-argument variant: namespace your locks to avoid key collisions — pg_advisory_lock(namespace_int, key_int) SELECT pg_advisory_lock(1001, 42); — app_id=1001, resource_id=42 SELECT pg_advisory_unlock(1001, 42);
— See all currently held advisory locks: SELECT pid, classid, objid, mode, granted FROM pg_locks WHERE locktype = ‘advisory’;
Controlling Implicit Locking
-- Plain SELECT takes no row-level locks at all (by design in PG). -- You cannot "disable" this; it's already the default. -- Just don't add FOR UPDATE / FOR SHARE if you don't need it.— Prevent long-running transactions from holding locks: SET idle_in_transaction_session_timeout = ’30s’; — Kills sessions that sit idle INSIDE a transaction for > 30s. — This releases their locks automatically. Critical for production.
— For DDL on large tables: avoid full ACCESS EXCLUSIVE with — PostgreSQL’s concurrent index build (no table lock held): CREATE INDEX CONCURRENTLY idx_accounts_owner ON accounts (owner); — Takes only ShareUpdateExclusiveLock — reads and writes continue. — Trade-off: slower, cannot run inside a transaction block.
— MySQL: disable the default gap locking (use with caution) — SET innodb_locks_unsafe_for_binlog = 1; — deprecated in 8.0 — Instead, use READ COMMITTED which naturally skips gap locks: — SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
— Inspect all currently held locks in your PostgreSQL instance: SELECT l.pid, l.locktype, l.relation::regclass AS table_name, l.mode, l.granted, a.query FROM pg_locks l JOIN pg_stat_activity a ON a.pid = l.pid WHERE l.pid <> pg_backend_pid() ORDER BY l.granted, l.pid;
FOR UPDATE vs FOR SHARE), how long you wait before giving up (lock_timeout), whether to skip locked rows or fail immediately (SKIP LOCKED, NOWAIT), and how fast idle-in-transaction sessions get killed (idle_in_transaction_session_timeout). Advisory locks are the only type you can release explicitly mid-session.
Optimistic Locking
Pessimistic locking holds database locks for the entire transaction, sometimes spanning multiple round-trips or user interactions. Optimistic locking instead assumes conflicts are unlikely, proceeds without locks, and checks for conflicts only at commit time using a version field.
- Lock acquired before read
- Others are blocked during your entire operation
- Good when conflict probability is high
- Can cause contention and queue buildup
- Best for: financial transfers, inventory decrement
- No lock acquired on read
- Conflict detected at write time via version check
- Good when conflict probability is low
- Higher throughput, occasional retries
- Best for: user profile edits, content updates
CREATE TABLE products ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, price NUMERIC(10,2), stock INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 1 -- optimistic lock version );INSERT INTO products (name, price, stock) VALUES (‘Laptop’, 75000, 10);
— Step 1: Read the row and note the version SELECT id, name, stock, version FROM products WHERE id = 1; — → id=1, name=‘Laptop’, stock=10, version=1
— Step 2: Application logic (no db connection held) — User sees stock=10, wants to buy 2 units
— Step 3: Update with version check (the critical step) UPDATE products SET stock = stock - 2, version = version + 1 — bump version on every write WHERE id = 1 AND version = 1; — optimistic check: “has someone else written?”
— Check affected rows in your application: — rows_affected = 1 → success, proceed — rows_affected = 0 → conflict! Someone else updated first. — Re-read the row and retry.
— PostgreSQL: use RETURNING to get the new state in one round-trip UPDATE products SET stock = stock - 2, version = version + 1 WHERE id = 1 AND version = 1 RETURNING id, stock, version; — If no row returned → conflict detected.
Timestamp-based optimistic locking
ALTER TABLE products ADD COLUMN updated_at TIMESTAMPTZ DEFAULT NOW();— Client reads: id=1, updated_at=‘2024-01-15 10:30:00’
UPDATE products SET price = 72000, updated_at = NOW() WHERE id = 1 AND updated_at = ‘2024-01-15 10:30:00’; — timestamp as version
— Caution: clock resolution (microseconds in PG) makes this — theoretically weaker than an integer version counter. — Prefer integer versions in high-concurrency systems.
@Version, and the ORM automatically injects the version check and throws an OptimisticLockException if a conflict occurs.
Deadlocks
A deadlock occurs when two or more transactions are each waiting for a lock held by the other. Neither can proceed. PostgreSQL detects this and breaks the cycle by rolling back one transaction (the victim).
FOR UPDATE; -- locks Alice ✓
FOR UPDATE; -- waiting for Bob's lock...
ROLLBACK (chosen as victim)
FOR UPDATE; -- locks Bob ✓
FOR UPDATE; -- waiting for Alice's lock...
COMMIT; ✓
-- WRONG: Each transaction locks its "from" account first -- → deadlock when both run simultaneously— CORRECT: Always lock accounts in ascending ID order CREATE OR REPLACE FUNCTION safe_transfer(from_id INT, to_id INT, amount NUMERIC) RETURNS VOID AS $$ DECLARE first_id INT := LEAST(from_id, to_id); second_id INT := GREATEST(from_id, to_id); BEGIN — Always acquire locks in ascending order regardless of direction PERFORM id FROM accounts WHERE id = first_id FOR UPDATE; PERFORM id FROM accounts WHERE id = second_id FOR UPDATE;
UPDATE accounts SET balance = balance - amount WHERE id = from_id; UPDATE accounts SET balance = balance + amount WHERE id = to_id; END; $$ LANGUAGE plpgsql;
— Now Alice→Bob and Bob→Alice both lock id=1 first, then id=2. — One waits. No cycle. No deadlock.
Detecting and Monitoring Deadlocks
-- Find currently waiting transactions SELECT pid, now() - query_start AS wait_duration, state, wait_event_type, wait_event, LEFT(query, 80) AS query_snippet FROM pg_stat_activity WHERE wait_event_type = 'Lock' ORDER BY wait_duration DESC;— See who’s blocking who 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_stat_activity blocking ON blocking.pid = ANY(pg_blocking_pids(blocked.pid)) WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;
— Configure deadlock timeout (default: 1 second) — In postgresql.conf: — deadlock_timeout = 1s — log_lock_waits = on ← log if a lock wait exceeds deadlock_timeout
- Always acquire locks in a consistent, deterministic order (e.g., ascending primary key).
- Keep transactions short. The longer a transaction stays open, the longer its locks are held and the higher the chance of a cycle.
- Use
NOWAITorSKIP LOCKEDfor better application-level control over lock waits. - Enable
log_lock_waits = onin production to catch slow-lock patterns before they become deadlocks.
PostgreSQL vs MySQL: Key Behavioral Differences
| Feature | PostgreSQL | MySQL (InnoDB) |
|---|---|---|
| MVCC Implementation | Heap-based (all versions in table files) | Undo log (current version in B-tree, old versions in undo) |
| Default Isolation Level | READ COMMITTED |
REPEATABLE READ |
| Phantom reads at REPEATABLE READ | Not possible (snapshot covers it) | Almost never (MVCC for plain reads, gap locks for locking reads) |
| READ UNCOMMITTED dirty reads | Never (treated as READ COMMITTED) | Possible (truly reads dirty data) |
| Serializable implementation | SSI (Serializable Snapshot Isolation) | Lock-based (S2PL / two-phase locking) |
| Serializable performance | Reads don't block; aborts on conflict | Extensive read locking; can cause contention |
| Gap locks | Not used (MVCC handles range isolation) | Used at REPEATABLE READ to prevent phantom inserts |
| Table-level locks | LOCK TABLE ... IN ... MODE |
LOCK TABLE ... READ/WRITE |
| Advisory locks | ✓ pg_advisory_lock() | No native equivalent |
Advisory Locks
Advisory locks are application-level locks stored inside PostgreSQL, not tied to any table or row. They're useful when you need a distributed mutex across multiple app servers sharing the same database, without reaching for Redis or an external coordination service.
-- Use case: Only one instance of a cron job should run at a time -- even across multiple app servers connected to the same PostgreSQL.SELECT pg_try_advisory_lock(12345) AS got_lock; — → true : this session owns the lock, proceed — → false : another session holds it, skip this run
— Transaction-scoped advisory lock (auto-released on COMMIT/ROLLBACK) BEGIN; SELECT pg_advisory_xact_lock(12345); — … do your critical work … COMMIT; — Lock released automatically.
— Use a hash of meaningful string as the lock key: SELECT pg_try_advisory_lock(hashtext(‘daily-report-job’));
Quick Reference
Choosing an isolation level
Choosing a locking strategy
-- Q: Will conflicts between concurrent transactions be common?
-- YES → pessimistic (FOR UPDATE)
-- NO → optimistic (version column)
— Q: Do I need a consistent multi-row snapshot for reads?
— YES → REPEATABLE READ or SERIALIZABLE
— NO → READ COMMITTED (default)
— Q: Am I doing read-modify-write on the same row?
— YES → FOR UPDATE on the initial SELECT
— NO → plain SELECT is fine
— Q: Do I need to prevent FK cascades from interfering?
— YES → FOR KEY SHARE (weaker, allows FK checks to bypass)
— NO → FOR UPDATE
— Q: Building a job queue?
— USE → SELECT … FOR UPDATE SKIP LOCKED LIMIT N
— Q: Need a global mutex (one job at a time, across servers)?
— USE → pg_try_advisory_lock(key) — PostgreSQL only
— Q: Am I getting deadlocks?
— FIX → always lock rows in ascending primary key order
— MONITOR → log_lock_waits = on, pg_blocking_pids()
Anomaly Prevention Matrix
| I want to prevent... | Minimum Strategy |
|---|---|
| Dirty reads | READ COMMITTED (default in PG) |
| Non-repeatable reads | REPEATABLE READ |
| Phantom reads | REPEATABLE READ in both PG and MySQL InnoDB |
| Lost updates | SELECT ... FOR UPDATE or REPEATABLE READ (PG detects conflict) |
| Write skew | SERIALIZABLE only |
| Deadlocks | Consistent lock ordering + short transactions |