Transactions, Isolation & Locks: A PostgreSQL and MySQL Guide

A comprehensive guide to PostgreSQL and MySQL transactions, isolation levels, and locking.

15 May 2026 Engineering

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 version
  • xmax: the transaction ID that deleted or updated this row version (0 if the row is still live)
PostgreSQL: MVCC hidden columns
-- 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.

Unlike MySQL's InnoDB, which stores old row versions in a separate undo log, PostgreSQL keeps all versions (called "tuples") directly in the main table files. Reads are fast, but dead tuples accumulate and need periodic VACUUM to reclaim space.
MySQL
MySQL's InnoDB also uses MVCC, but stores old row versions in a separate undo log. The current row in the B-tree is always the latest version; older versions are reconstructed from the undo chain. The end behavior is the same (readers don't block writers), but the storage layout is different.

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.

Real World Order fulfillment reads an uncommitted inventory update
Transaction A (Fulfillment Service)
T1 BEGIN;
T2 -- waiting...
T4 SELECT stock FROM products WHERE id=1;
-- reads: 0 (dirty!)
T5 -- "Out of stock! Cancel order."
-- Acts on bad data!
Transaction B (Warehouse Worker)
T1 BEGIN;
T3 UPDATE products SET stock=0 WHERE id=1;
-- not committed yet
T4 -- still open...
T6 ROLLBACK; -- stock remains 100!
Worth noting: PostgreSQL never produces dirty reads, even at 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.

Problem Report calculates wrong total because mid-report update
Transaction A (Report Generator)
T1 BEGIN;
T2 SELECT balance FROM accounts WHERE id=1;
-- returns: 10000
T3 -- computing other metrics...
T5 SELECT balance FROM accounts WHERE id=1;
-- returns: 3000 ← different!
T6 -- Report is inconsistent!
Transaction B (Transfer)
T1 BEGIN;
T2 -- waiting...
T4 UPDATE accounts SET balance=3000
WHERE id=1; COMMIT;
 
 
PostgreSQL: Reproduce non-repeatable read
-- 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.

PostgreSQL: Phantom read demo
-- 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.

Problem Two concurrent "add to cart" operations on the same inventory
Transaction A (Customer 1)
T1 BEGIN;
T2 SELECT stock FROM products WHERE id=42;
-- reads: 5
T3 -- user picks 2 items, processing...
T5 UPDATE products SET stock=3 WHERE id=42;
COMMIT; -- 5 - 2 = 3
Transaction B (Customer 2)
T1 BEGIN;
T2 SELECT stock FROM products WHERE id=42;
-- also reads: 5
T3 -- user picks 3 items, processing...
T6 UPDATE products SET stock=2 WHERE id=42;
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.

PostgreSQL: Write skew: on-call schedule
-- 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.

PostgreSQL: READ COMMITTED behavior
-- 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;

The "read-modify-write" pattern (SELECT → application logic → UPDATE) is broken at READ COMMITTED without explicit locking. The time between your SELECT and your UPDATE is an open window for lost updates, and this is probably the most common source of subtle data corruption bugs I've seen in production Go and Java services.

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.

PostgreSQL: REPEATABLE READ with conflict detection
-- 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.

MySQL
MySQL's REPEATABLE READ takes the snapshot at the first read, same concept. Furthermore, InnoDB does prevent phantom reads by default at this level in most cases. For plain reads, it uses the MVCC snapshot. For locking reads (like 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.

PostgreSQL: SERIALIZABLE stops write skew
-- 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.

PostgreSQL's SSI doesn't take table locks for reads, so read throughput barely changes. The practical cost is that some transactions will abort and need to be retried. Make sure your application has retry logic before reaching for this level.

Setting the isolation level

PostgreSQL: All ways to set 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:

PostgreSQL: Row-level lock modes
-- 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

PostgreSQL: Safe money transfer with explicit locking
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 here

IF 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:

PostgreSQL: NOWAIT and SKIP LOCKED
-- 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.
MySQL
MySQL supports the same syntax: 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

PostgreSQL & MySQL: Enabling row-level locks
-- 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

PostgreSQL & MySQL: Releasing row-level locks
-- 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

PostgreSQL: Table-level locks (LOCK TABLE)
-- 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 & MySQL: Controlling lock wait duration
-- 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

PostgreSQL: Enabling and explicitly releasing 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

PostgreSQL: Controlling implicit locking behaviors
-- 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;

Row-level locks in PostgreSQL are always held until the transaction ends. There's no mid-transaction unlock. What you can control: which mode you acquire (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.

🔒 Pessimistic Locking
  • 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
🌱 Optimistic Locking
  • 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
PostgreSQL: Optimistic locking with version column
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

PostgreSQL: 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.

Working with an ORM? You usually don't have to write this SQL by hand. Modern ORMs like Hibernate, Prisma, Entity Framework, and ActiveRecord have built-in support for optimistic locking. You just flag a column as @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).

Classic Deadlock Transfer A→B and B→A simultaneously
Transaction A (Alice to Bob)
T1 BEGIN;
T2 SELECT * FROM accounts WHERE id=1
FOR UPDATE; -- locks Alice ✓
T4 SELECT * FROM accounts WHERE id=2
FOR UPDATE; -- waiting for Bob's lock...
T6 ERROR: deadlock detected!
ROLLBACK (chosen as victim)
Transaction B (Bob to Alice)
T1 BEGIN;
T3 SELECT * FROM accounts WHERE id=2
FOR UPDATE; -- locks Bob ✓
T5 SELECT * FROM accounts WHERE id=1
FOR UPDATE; -- waiting for Alice's lock...
T6 Transaction B proceeds.
COMMIT; ✓
PostgreSQL: The fix: always lock in consistent order
-- 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

PostgreSQL: Monitor locks and waiting queries
-- 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

  1. Always acquire locks in a consistent, deterministic order (e.g., ascending primary key).
  2. Keep transactions short. The longer a transaction stays open, the longer its locks are held and the higher the chance of a cycle.
  3. Use NOWAIT or SKIP LOCKED for better application-level control over lock waits.
  4. Enable log_lock_waits = on in 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.

PostgreSQL: Advisory locks for distributed coordination
-- 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

READ COMMITTED
Default
General-purpose. Use for most reads and writes. Add explicit locks where needed.
REPEATABLE READ
Reports
Long-running reads that need a consistent snapshot. Analytics, reporting, exports.
SERIALIZABLE
Financial
Any logic where correctness depends on reads and writes being atomic together.

Choosing a locking strategy

Decision guide in pseudo-SQL comments
-- 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 readsREAD COMMITTED (default in PG)
Non-repeatable readsREPEATABLE READ
Phantom readsREPEATABLE READ in both PG and MySQL InnoDB
Lost updatesSELECT ... FOR UPDATE or REPEATABLE READ (PG detects conflict)
Write skewSERIALIZABLE only
DeadlocksConsistent lock ordering + short transactions