Who this module is for: You can write queries and design schemas. Now you need to understand how to group multiple operations into an atomic unit — so that either all of them succeed or none of them do. This is the mechanism that prevents your application from leaving the database in a half-updated, inconsistent state.
The Problem Transactions Solve
Imagine a bank transfer: debit $100 from Alice, credit $100 to Bob.
sql
What happens if the server crashes, the network drops, or an error occurs between the two statements? Alice has lost $100 and Bob never received it. The money has vanished.
Transactions prevent this by grouping operations: either both updates happen, or neither does.
BEGIN, COMMIT, ROLLBACK
sql
If anything goes wrong between BEGIN and COMMIT:
sql
Without a BEGIN, every statement is its own transaction — it auto-commits immediately. This is fine for isolated statements, but dangerous for multi-step operations.
With a BEGIN, nothing is visible to other connections until COMMIT. If the session disconnects before COMMIT, PostgreSQL automatically rolls back the transaction.
ACID: What It Actually Means
A — Atomicity: All operations in a transaction succeed, or none do. There is no partial success.
sql
C — Consistency: A transaction brings the database from one valid state to another. Constraints (NOT NULL, FOREIGN KEY, CHECK) are enforced at commit time.
sql
I — Isolation: Transactions run as if they are the only transaction in the system. Other transactions' uncommitted changes are invisible.
sql
D — Durability: Once committed, changes survive crashes. PostgreSQL's Write-Ahead Log (WAL) ensures this.
Isolation Levels in Practice
The SQL standard defines four isolation levels; PostgreSQL accepts all four names but only implements three distinct behaviors — READ UNCOMMITTED is accepted syntactically and silently mapped to READ COMMITTED (Postgres never does dirty reads). The default, and the one you will use for almost everything, is READ COMMITTED.
READ COMMITTED (default)
Each query in a transaction sees a fresh snapshot of committed data at the time that query starts. This means a transaction can see different data at the beginning vs. the end if other transactions commit in between.
sql
This is acceptable for most application reads. Each query gets a consistent view; the transaction as a whole does not.
REPEATABLE READ
The entire transaction sees the same snapshot of data as of when the transaction started. New inserts/updates by other transactions are invisible for the duration.
sql
Use this when your transaction needs a consistent view across multiple queries — for example, generating a report where all queries must see the same data point in time.
SERIALIZABLE
The strongest level. Transactions execute as if they were serialised one after another. PostgreSQL detects situations where concurrent transactions could produce results different from any serial execution and fails one of them.
sql
Use this sparingly — it adds overhead and can cause transaction failures that need retrying.
Rule for most applications: use the default READ COMMITTED. Move to REPEATABLE READ only when you need a consistent multi-query snapshot. Use SERIALIZABLE only for complex financial operations where non-serialisable anomalies are a real risk.
SAVEPOINT — Partial Rollback
A SAVEPOINT marks a point within a transaction you can roll back to without abandoning the entire transaction.
sql
Transaction Best Practices
Keep transactions short
A transaction holds locks on the rows it modifies until it commits. Long-running transactions block other operations and accumulate dead tuples (see Phase 3, autovacuum).
sql
Never ignore transaction errors
In application code, check for errors after every database call. In PostgreSQL, once an error occurs inside a transaction, the transaction is aborted — all subsequent statements fail until you ROLLBACK.
sql
In application code (Node.js example):
javascript
SELECT ... FOR UPDATE — Pessimistic Locking
When multiple transactions need to read and then modify the same row, you need to prevent another transaction from changing that row between your SELECT and your UPDATE.
sql
Without FOR UPDATE, two sessions could read the same balance, both decide they can proceed, and both subtract — leading to a negative balance.
FOR UPDATE SKIP LOCKED — useful for job queues:
sql
Multiple workers can run this concurrently — each claims a different task because SKIP LOCKED skips rows already locked by another worker.
Common Transaction Patterns
Safe inventory deduction
sql
Atomic counter increment
sql
Creating a record and its related records
sql
Practical Exercise: Safe Bank Transfers
sql
Summary
Concept
Key Takeaway
BEGIN / COMMIT / ROLLBACK
Group operations atomically; ROLLBACK undoes all changes since BEGIN
Auto-commit
Without BEGIN, every statement is its own transaction
READ COMMITTED (default)
Each query sees the latest committed data; use for most application work
REPEATABLE READ
Entire transaction sees same snapshot; use for consistent multi-query reports
SERIALIZABLE
Strongest isolation; use for complex financial operations
SAVEPOINT
Partial rollback to a named point within a transaction
Keep transactions short
Long-held locks block other transactions and cause dead tuple accumulation
Handle errors
After any error, a transaction is aborted — you must ROLLBACK before continuing
FOR UPDATE
Lock rows at read time to prevent concurrent modification
FOR UPDATE SKIP LOCKED
Claim rows without blocking — the correct pattern for job queues
Module P-4 covers schema design for real applications — normalisation, the correct data types for money and time, soft deletes, audit fields, and the migration tools that keep schemas manageable as they evolve.
Next: P-4 — Schema Design for Real Applications →
Knowledge Check
You are designing a job queue system in PostgreSQL where multiple background workers concurrently claim and process pending jobs from a tasks table. If multiple workers frequently attempt to claim the exact same job simultaneously, which concurrency control mechanism is the most efficient and robust choice to prevent locking contention and ensure high throughput?
During a complex financial reconciliation process, an application uses the default READ COMMITTED isolation level. The application executes a SELECT COUNT(*) on a ledger table, then performs some internal logic, and executes the exact same SELECT COUNT(*) again within the same transaction. The second query returns a different result than the first. What is the fundamental reason for this behavior?
A backend developer has written an API endpoint that opens a database transaction, queries a pending_orders table, makes an HTTP request to a third-party payment gateway that takes 4 seconds, and finally updates the order status to confirmed before committing. From a database performance perspective, why is this an anti-pattern?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
UPDATE accounts SET balance = balance -100WHERE id =1;-- AliceUPDATE accounts SET balance = balance +100WHERE id =2;-- Bob
BEGIN;-- start a transactionUPDATE accounts SET balance = balance -100WHERE id =1;UPDATE accounts SET balance = balance +100WHERE id =2;COMMIT;-- make both changes permanent
BEGIN;UPDATE accounts SET balance = balance -100WHERE id =1;-- Something goes wrong (application error, network failure, etc.)ROLLBACK;-- undo the debit — Alice gets her $100 back
BEGIN;UPDATE accounts SET balance = balance -100WHERE id =1;-- Crash here → the UPDATE is rolled back. Alice keeps her $100.
BEGIN;UPDATE accounts SET balance = balance -1000WHERE id =1;-- balance would go to -500-- CHECK (balance >= 0) fires at COMMITCOMMIT;-- ERROR: check constraint violated → automatic rollback
-- Session A:BEGIN;UPDATE tasks SETstatus='in_progress'WHERE id =1;-- NOT committed yet-- Session B (concurrent):SELECTstatusFROM tasks WHERE id =1;-- Returns 'todo' — Session A's change is not visible until committed
-- Session A:BEGIN;SELECTCOUNT(*)FROM tasks;-- returns 100-- Session B (commits a new task while A is open):INSERTINTO tasks (...)VALUES(...);COMMIT;-- Session A continues:SELECTCOUNT(*)FROM tasks;-- returns 101 — sees B's committed changeCOMMIT;
SETTRANSACTIONISOLATIONLEVELREPEATABLEREAD;BEGIN;SELECTCOUNT(*)FROM tasks;-- returns 100-- Session B inserts and commits a task while we waitSELECTCOUNT(*)FROM tasks;-- still returns 100 — snapshot is frozenCOMMIT;
SETTRANSACTIONISOLATIONLEVELSERIALIZABLE;-- Used for financial operations where concurrent updates to related rows-- could produce incorrect results even if each individual update is correct
BEGIN;INSERTINTO orders (customer_id, total)VALUES(1,150.00);SAVEPOINT before_items;-- mark this pointINSERTINTO order_items (order_id, product_id, quantity)VALUES(1,1,2);INSERTINTO order_items (order_id, product_id, quantity)VALUES(1,999,1);-- ↑ This fails: product 999 doesn't exist (foreign key violation)-- Roll back to the savepoint — the order INSERT is preserved,-- but the order_items INSERTs are undoneROLLBACKTOSAVEPOINT before_items;-- Try again with correct dataINSERTINTO order_items (order_id, product_id, quantity)VALUES(1,2,1);COMMIT;-- order + 1 item committed successfully
-- ❌ SLOW: fetching from an external API inside a transactionBEGIN;SELECT*FROM orders WHEREstatus='pending';-- ... application calls payment API (100ms to 5 seconds) ...UPDATE orders SETstatus='confirmed'WHERE id = $id;COMMIT;-- Locks held for 5+ seconds-- ✅ FAST: do external work outside the transaction-- 1. Fetch pending orders (no transaction needed for reads in most cases)SELECT*FROM orders WHEREstatus='pending';-- 2. Call payment API outside the transaction (no locks held)-- 3. Open transaction only for the writeBEGIN;UPDATE orders SETstatus='confirmed'WHERE id = $id;COMMIT;
BEGIN;INSERTINTO tasks (...)VALUES(...);-- If this INSERT fails (e.g., foreign key violation):INSERTINTO invalid_table (...)VALUES(...);-- ERROR-- This subsequent SELECT will also fail:SELECT*FROM tasks;-- ERROR: current transaction is aborted, commands ignored until end of transaction blockROLLBACK;-- must rollback before doing anything else
const client =await pool.connect();try{await client.query('BEGIN');await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2',[100,1]);await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2',[100,2]);await client.query('COMMIT');}catch(err){await client.query('ROLLBACK');throw err;// re-throw so the caller knows something went wrong}finally{ client.release();// always return the connection to the pool}
BEGIN;-- Lock the row immediately — no one else can update it until we commitSELECT balance FROM accounts WHERE id =1FORUPDATE;-- Application logic: check if balance >= amount-- (the row is locked, so the balance cannot change while we check)UPDATE accounts SET balance = balance -100WHERE id =1;COMMIT;
-- Claim the next available task without blocking on locked rowsBEGIN;SELECT id, title
FROM tasks
WHEREstatus='todo'ORDERBY created_at
LIMIT1FORUPDATE SKIP LOCKED;-- skip rows locked by other workers-- Update the claimed taskUPDATE tasks SETstatus='in_progress'WHERE id = $id;COMMIT;
BEGIN;-- Check and lock in one stepUPDATE products
SET stock = stock - $quantity
WHERE id = $product_id AND stock >= $quantity
RETURNING stock;-- If no rows returned, stock was insufficient — rollback-- Application checks: if rowCount === 0, rollbackCOMMIT;
-- This is safe — the entire operation is atomicUPDATE page_views
SET count = count +1WHERE page_id = $page_id;-- No transaction needed — a single UPDATE is always atomic
BEGIN;INSERTINTO orders (customer_id,status)VALUES($customer_id,'pending')RETURNING id AS order_id;-- Use the returned order_idINSERTINTO order_items (order_id, product_id, quantity)VALUES($order_id, $product_id, $quantity);-- Update inventoryUPDATE products SET stock = stock - $quantity WHERE id = $product_id;COMMIT;
CREATETABLE bank_accounts ( id BIGSERIAL PRIMARYKEY, owner TEXTNOTNULL, balance NUMERIC(15,2)NOTNULLCHECK(balance >=0));INSERTINTO bank_accounts (owner, balance)VALUES('Alice',1000.00),('Bob',500.00);-- Write a transfer functionCREATEORREPLACEFUNCTION transfer( from_id BIGINT, to_id BIGINT, amount NUMERIC)RETURNS VOID AS $$
BEGIN-- Validate amountIF amount <=0THEN RAISE EXCEPTION 'Transfer amount must be positive: %', amount;ENDIF;-- Update both accounts in a single statement — one UPDATE acquiring both-- row locks atomically avoids the deadlock risk that two separate UPDATEs-- would have if concurrent transfers locked the same two rows in different orders.UPDATE bank_accounts
SET balance = balance +CASEWHEN id = from_id THEN-amount ELSE amount ENDWHERE id IN(from_id, to_id);END;$$ LANGUAGE plpgsql;-- Test the transferBEGIN;SELECT transfer(1,2,200);-- Alice sends $200 to BobCOMMIT;SELECT owner, balance FROM bank_accounts;-- Alice: 800.00, Bob: 700.00-- Test that CHECK constraint prevents overdraftBEGIN;SELECT transfer(1,2,1000);-- Alice tries to send $1000 (only has $800)-- ERROR: new row for relation "bank_accounts" violates check constraintROLLBACK;SELECT owner, balance FROM bank_accounts;-- Alice still has 800.00 — rollback preserved the balance