Autocommit: Without BEGIN, every statement is its own transaction
BEGIN: Starts a transaction block (START TRANSACTION is the standard spelling)
COMMIT: Makes changes durable and visible to others
ROLLBACK: Discards every change since BEGIN
Aborted State: After any error inside a block, Postgres rejects further statements (current transaction is aborted) until ROLLBACK (or rollback to a savepoint)
-- Transfer stock between two products atomicallyBEGIN;UPDATE products SET stock = stock - 5 WHERE sku = 'ACC-001';UPDATE products SET stock = stock + 5 WHERE sku = 'ACC-003';SELECT sku, stock FROM products WHERE sku IN ('ACC-001', 'ACC-003') ORDER BY sku;ROLLBACK; -- COMMIT in real life
SAVEPOINT
What: A named point inside a transaction you can roll back to without losing earlier work
Use: Try a risky step, recover from an expected error, continue
Cost: Each savepoint creates a subtransaction — thousands in one transaction hurt performance (subtrans SLRU overflow)
BEGIN;INSERT INTO categories (name) VALUES ('Toys');SAVEPOINT before_dupe;INSERT INTO categories (name) VALUES ('Books'); -- violates UNIQUE(name) → error, txn now abortedROLLBACK TO SAVEPOINT before_dupe; -- recovered: 'Toys' insert still aliveINSERT INTO categories (name) VALUES ('Garden');SELECT name FROM categories WHERE name IN ('Toys', 'Garden', 'Books') ORDER BY name;RELEASE SAVEPOINT before_dupe;ROLLBACK;
Isolation Levels
Read Phenomena
Dirty Read: See another transaction’s uncommitted data
Non-Repeatable Read: Re-reading a row gives a different value (someone committed an update)
Phantom Read: Re-running a range query returns new/missing rows
Serialization Anomaly: Result impossible under any serial order (e.g. write skew)
Postgres Behaviour
Level
Dirty
Non-Repeatable
Phantom
Anomaly
Postgres Notes
READ UNCOMMITTED
No
Possible
Possible
Possible
Treated as READ COMMITTED
READ COMMITTED (default)
No
Possible
Possible
Possible
New snapshot per statement
REPEATABLE READ
No
No
No (stricter than standard)
Possible
One snapshot per transaction; concurrent update → error 40001
SERIALIZABLE
No
No
No
No
SSI — detects dangerous patterns, aborts one txn with 40001
Setting the Level
BEGIN ISOLATION LEVEL REPEATABLE READ;SELECT count(*) FROM orders; -- snapshot taken at the first query-- other sessions' commits are invisible from here onSELECT count(*) FROM orders; -- same answer, guaranteedCOMMIT;BEGIN;SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- must be the first statement in the txnSHOW transaction_isolation;COMMIT;-- Read-only reporting transaction that can't fail with serialization errorsBEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;SELECT sum(amount) FROM payments WHERE status = 'succeeded';COMMIT;
Lost Update and How to Prevent It
READ COMMITTED, two sessions both run "read stock → subtract in app → write":Session A: SELECT stock FROM products WHERE id = 5; -- 10Session B: SELECT stock FROM products WHERE id = 5; -- 10Session A: UPDATE products SET stock = 9 WHERE id = 5; COMMIT;Session B: UPDATE products SET stock = 9 WHERE id = 5; COMMIT; -- A's sale is lost (should be 8)
Fix
How
Trade-off
Atomic UPDATE
SET stock = stock - 1
Best when logic fits in SQL
SELECT … FOR UPDATE
Lock the row before reading
Pessimistic; others wait
Optimistic Version Column
UPDATE … WHERE id = $1 AND version = $2 → 0 rows = retry
No locks held; app must retry
REPEATABLE READ / SERIALIZABLE
DB raises 40001 on conflict
App must retry the whole txn
BEGIN;-- Atomic decrement with a guard: 0 rows updated = out of stockUPDATE products SET stock = stock - 1WHERE id = 5 AND stock > 0RETURNING stock; -- USB-C Hub has 0 → returns nothingROLLBACK;
BEGIN;-- Pessimistic: lock the order and its items' products before changing themSELECT o.id, o.statusFROM orders oWHERE o.id = 10FOR UPDATE; -- other txns trying to lock/update order 10 now waitUPDATE orders SET status = 'paid' WHERE id = 10;ROLLBACK;-- Lock only one table of a joinBEGIN;SELECT p.id, p.stockFROM products p JOIN order_items oi ON oi.product_id = p.idWHERE oi.order_id = 10FOR UPDATE OF p NOWAIT; -- error 55P03 if someone else holds itROLLBACK;
Job Queue With FOR UPDATE SKIP LOCKED
Pattern: Many workers poll the same table; each grabs a different unlocked job — no double processing, no blocking
Why It Works: SKIP LOCKED ignores rows already locked by other workers’ open transactions
Index: A partial index on pending jobs keeps the poll cheap
CREATE TABLE IF NOT EXISTS jobs ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, kind text NOT NULL, payload jsonb NOT NULL DEFAULT '{}', status text NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed')), attempts int NOT NULL DEFAULT 0, run_after timestamptz NOT NULL DEFAULT now(), locked_by text, created_at timestamptz NOT NULL DEFAULT now());CREATE INDEX IF NOT EXISTS jobs_queued_idx ON jobs (run_after, id) WHERE status = 'queued';INSERT INTO jobs (kind, payload)SELECT 'send_receipt', jsonb_build_object('order_id', id)FROM orders WHERE status = 'delivered';
-- Worker loop body (each worker runs this; claim + mark in one statement)BEGIN;WITH next_job AS ( SELECT id FROM jobs WHERE status = 'queued' AND run_after <= now() ORDER BY run_after, id LIMIT 1 FOR UPDATE SKIP LOCKED)UPDATE jobs jSET status = 'running', attempts = attempts + 1, locked_by = 'worker-1'FROM next_jobWHERE j.id = next_job.idRETURNING j.id, j.kind, j.payload;-- … do the work in the app …UPDATE jobs SET status = 'done' WHERE locked_by = 'worker-1' AND status = 'running';COMMIT;SELECT status, count(*) FROM jobs GROUP BY status ORDER BY status;
Two Workers at Once: Worker 2 running the same claim while worker 1’s transaction is open gets the next job, not a wait (verified with two concurrent sessions: A claimed job 2, B immediately got job 3)
Crash Safety: If you claim and process inside one transaction, a crash rolls back to queued. If you commit the claim first (short transactions), add a timeout sweeper that resets stale running jobs
At Scale: Fine for thousands of jobs/sec; beyond that, or for fan-out, consider a dedicated broker
Advisory Locks
Concept
What: Application-defined locks on an arbitrary bigint key — Postgres just tracks who holds the number
Session-level: pg_advisory_lock(key) — held until unlocked or the session ends (careful with connection pools — see Database Connection Pooling)
Transaction-level: pg_advisory_xact_lock(key) — released automatically at commit/rollback (safer)
Try Variants: pg_try_advisory_lock(key) returns false instead of waiting
Use Case
Example
Single-runner cron
Only one instance of a nightly job runs
Serialize per entity
One balance recalculation per user at a time
Migrations
Tools like Flyway/Rails take an advisory lock so two deploys don’t migrate together
-- Only one process may rebuild the report for user 42 at a timeBEGIN;SELECT pg_advisory_xact_lock(hashtext('rebuild_report'), 42); -- two-int form: (namespace, id)-- … rebuild …COMMIT; -- lock released-- Cron guard: skip if another instance is already runningSELECT pg_try_advisory_lock(777) AS got_lock; -- true for the first caller, false for othersSELECT pg_advisory_unlock(777);-- See who holds advisory locksSELECT locktype, classid, objid, pid, granted FROM pg_locks WHERE locktype = 'advisory';
Table Locks and DDL
Every Statement Takes a Table Lock: SELECT → ACCESS SHARE, UPDATE/DELETE/INSERT → ROW EXCLUSIVE, most ALTER TABLE / DROP / TRUNCATE → ACCESS EXCLUSIVE
The Queue Problem: An ALTER TABLE waiting behind a long SELECT blocks every later query on the table — always SET lock_timeout for migrations
Explicit: LOCK TABLE t IN SHARE ROW EXCLUSIVE MODE; (rarely needed)
BEGIN;SET LOCAL lock_timeout = '2s'; -- give up instead of stalling the appSET LOCAL statement_timeout = '30s';ALTER TABLE products ADD COLUMN IF NOT EXISTS weight_g int;ROLLBACK;
Deadlocks
Cause: A locks row 1 then wants row 2; B locks row 2 then wants row 1
Postgres Response: After deadlock_timeout (1s) it detects the cycle and aborts one transaction with error 40P01
Prevention: Lock rows in a consistent order (e.g. ORDER BY id … FOR UPDATE), keep transactions short — see Deadlock
BEGIN;-- Lock all rows you need up front, in id orderSELECT id FROM products WHERE id IN (7, 3, 9) ORDER BY id FOR UPDATE;ROLLBACK;
Retry Logic for 40001 / 40P01
for attempt in 1..5: try: BEGIN ISOLATION LEVEL SERIALIZABLE ... statements ... COMMIT break except SQLSTATE in ('40001' serialization_failure, '40P01' deadlock_detected): ROLLBACK sleep(random backoff)
Interview Questions
“Default isolation level in Postgres?”: READ COMMITTED (per-statement snapshot)
“How does Postgres avoid readers blocking writers?”: MVCC — readers see a snapshot of committed row versions
“Build a job queue in Postgres?”: SELECT … FOR UPDATE SKIP LOCKED LIMIT 1 inside a transaction
“Prevent lost updates?”: Atomic UPDATE … SET x = x - 1, FOR UPDATE, optimistic versioning, or SERIALIZABLE + retry
“What is an advisory lock?”: An app-defined mutex keyed by a number, session- or transaction-scoped
“Why did my migration take the site down?”: ACCESS EXCLUSIVE lock queued behind a long query — use lock_timeout
Summary
Transactions: BEGIN/COMMIT/ROLLBACK; errors abort the block; SAVEPOINT for partial recovery