Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Intermediate

Transactions and ACID

How databases guarantee correctness — BEGIN, COMMIT, ROLLBACK, savepoints, isolation levels, and the concurrency anomalies that each level prevents

12–16 min April 2026
Section 10 · Advanced SQL Objects
Advanced SQL Objects · 7 modulesModule 47

// Part 01

What a Transaction Is — and Why It Exists

A transaction is a group of SQL statements that the database treats as a single indivisible unit of work. Either all statements in the transaction succeed and are permanently saved, or none of them take effect. There is no in-between state visible to other users or sessions.

The classic example: a customer pays ₹500 for a FreshCart order. Two things must happen — the customer's wallet balance decreases by ₹500 AND FreshCart's account increases by ₹500. If the first statement succeeds but the database crashes before the second, the customer has lost ₹500 with nothing to show for it. A transaction prevents this: both updates are wrapped in a single unit. If the crash happens mid-transaction, the database rolls back the first update on restart. Either both succeed or neither does.

Transaction anatomy — BEGIN, COMMIT, ROLLBACK
-- Start a transaction
BEGIN;               -- or: START TRANSACTION;

-- All statements between BEGIN and COMMIT are one unit
UPDATE wallets SET balance = balance - 500 WHERE user_id = 42;
UPDATE wallets SET balance = balance + 500 WHERE merchant_id = 99;
INSERT INTO payment_log (user_id, amount, status) VALUES (42, 500, 'success');

-- If all succeeded: make permanent
COMMIT;

-- If anything went wrong: undo everything back to BEGIN
ROLLBACK;

-- Without a transaction (autocommit mode):
-- Each statement is its own transaction
-- UPDATE wallets SET balance = balance - 500 WHERE user_id = 42;
-- ↑ This is committed immediately — no way to roll it back if the next statement fails
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…

// Part 02

ACID — The Four Guarantees

ACID is the set of four properties that define what a reliable database transaction must guarantee. These are not optional features — they are the contract every SQL database makes about how transactions behave under normal operation, concurrent access, and failure scenarios.

A
Atomicity

All or nothing — the transaction never partially completes

Every statement in a transaction either fully succeeds (all changes applied) or fully fails (no changes applied). If any statement in the transaction raises an error, the database automatically rolls back all previous statements in that transaction. Partial commits are impossible — the database will never show a state where some statements of a transaction applied and others did not.

FreshCart example

A FreshCart order involves: INSERT into orders, INSERT into order_items (one per product), UPDATE products (decrement stock), INSERT into payment_log. Atomicity guarantees that if the payment_log insert fails, the order and order_items and stock changes are also rolled back. The customer's order either fully exists or does not exist at all.

C
Consistency

Transactions can only take the database from one valid state to another

A transaction must bring the database from one consistent state to another consistent state. Consistency is defined by the constraints, rules, and invariants declared in the schema — NOT NULL, UNIQUE, CHECK, FOREIGN KEY constraints, and any application-level business rules. A transaction that would violate any constraint is rejected entirely.

FreshCart example

FreshCart's order_items table has a FK to products. Consistency means a transaction cannot insert an order_item referencing a non-existent product_id — the FK constraint is checked and the transaction is rejected. After a successful order, the sum of line_total in order_items should equal orders.total_amount — this business invariant must hold after every commit.

I
Isolation

Concurrent transactions don't interfere with each other

Each transaction executes as if it were the only transaction running, even when hundreds of transactions run simultaneously. Changes made by an in-progress transaction are not visible to other transactions until the first transaction commits (at the default isolation level). This prevents one transaction from reading dirty, inconsistent intermediate states written by another in-flight transaction.

FreshCart example

Two FreshCart customers simultaneously try to buy the last unit of Amul Butter (stock = 1). Without isolation, both transactions could read stock = 1, both deduct 1, and both succeed — leaving stock at -1 (an impossible state). Isolation ensures one transaction wins and the other sees stock = 0 and fails, maintaining the invariant that stock >= 0.

D
Durability

Committed data survives crashes

Once a transaction commits, its changes are permanently recorded — even if the database server crashes immediately after COMMIT returns. The database achieves this through write-ahead logging (WAL): changes are written to a persistent log before they are applied to the main data files. On restart after a crash, the database replays the WAL to recover any committed changes that had not yet been written to main storage.

FreshCart example

A customer's FreshCart order is placed at 11:59 PM. The server crashes at 12:00 AM. On restart, the order is still there — the COMMIT was written to the WAL before the crash. The customer's order confirmation email was correct — the data is durable. Without durability, a crash after COMMIT could silently lose the order.

// Part 03

BEGIN, COMMIT, and ROLLBACK in Practice

Complete transaction patterns
-- Pattern 1: Simple two-statement transaction
BEGIN;
  UPDATE orders SET order_status = 'Delivered', delivery_date = CURRENT_DATE
  WHERE order_id = 1001;

  INSERT INTO delivery_log (order_id, delivered_at, delivered_by)
  VALUES (1001, NOW(), 'Rider-42');
COMMIT;

-- Pattern 2: Transaction with error check and conditional rollback
BEGIN;
  UPDATE products SET in_stock = false WHERE product_id = 55;
  UPDATE order_items SET unit_price = unit_price * 1.05
  WHERE product_id = 55;
  -- If either fails, ROLLBACK is implicit on error in most configs
  -- In application code, check the error and issue ROLLBACK explicitly
COMMIT;

-- Pattern 3: Manual ROLLBACK when business logic fails
BEGIN;
  SELECT stock_quantity INTO v_stock FROM products WHERE product_id = 55;
  IF v_stock < order_qty THEN
    ROLLBACK;    -- insufficient stock — undo and exit
    RETURN;
  END IF;
  UPDATE products SET stock_quantity = stock_quantity - order_qty
  WHERE product_id = 55;
COMMIT;

-- Pattern 4: DDL in transactions (PostgreSQL — not MySQL)
BEGIN;
  ALTER TABLE orders ADD COLUMN delivery_partner_id INTEGER;
  UPDATE orders SET delivery_partner_id = 1 WHERE store_id = 'ST001';
COMMIT;
-- If the UPDATE fails, the ALTER TABLE is also rolled back
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…

// Part 04

SAVEPOINT — Partial Rollback Within a Transaction

A savepoint marks a point within a transaction that you can roll back to without abandoning the entire transaction. This enables partial rollback — undoing only the most recent statements while keeping earlier work intact.

SAVEPOINT syntax
-- Full pattern: BEGIN → work → SAVEPOINT → more work → ROLLBACK TO or RELEASE
BEGIN;

  INSERT INTO orders (customer_id, store_id, total_amount, order_status)
  VALUES (42, 'ST001', 1250.00, 'Processing');

  SAVEPOINT after_order;    -- mark this point

  INSERT INTO order_items (order_id, product_id, quantity, line_total)
  VALUES (currval('orders_order_id_seq'), 99, 2, 1250.00);
  -- ↑ if product_id 99 doesn't exist → FK violation

  -- If the order_items insert failed:
  ROLLBACK TO SAVEPOINT after_order;   -- undo only the order_items insert
  -- The order INSERT is still intact — we rolled back to after_order
  -- Now we can try a different product or handle the error

  -- After fixing the problem:
  INSERT INTO order_items (order_id, product_id, quantity, line_total)
  VALUES (currval('orders_order_id_seq'), 5, 2, 1250.00);

  RELEASE SAVEPOINT after_order;  -- discard the savepoint (optional cleanup)

COMMIT;  -- commit both the order and the corrected order_items

-- Multiple savepoints in one transaction:
SAVEPOINT sp1;
SAVEPOINT sp2;
ROLLBACK TO SAVEPOINT sp1;  -- rolls back sp2 AND everything after sp1
-- sp2 is automatically released when rolling back past it
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…

// Part 05

Autocommit — The Default Mode

When you run SQL statements outside of an explicit BEGIN...COMMIT block, most databases operate in autocommit mode — each statement is its own implicit transaction that is committed immediately after it succeeds. This is convenient for ad-hoc queries but dangerous for multi-statement operations that must succeed or fail together.

Autocommit mode — behaviour and implications
-- AUTOCOMMIT ON (default in most clients):
-- Each statement below is its own transaction — committed immediately

UPDATE orders SET order_status = 'Delivered' WHERE order_id = 1001;
-- ↑ COMMITTED immediately — no way to roll this back

INSERT INTO delivery_log VALUES (1001, NOW());
-- ↑ COMMITTED immediately — independent of the UPDATE above

-- If the INSERT fails, the UPDATE is already permanent
-- This is the bug: the order is marked Delivered but has no delivery log

-- SOLUTION: wrap in explicit transaction
BEGIN;
  UPDATE orders SET order_status = 'Delivered' WHERE order_id = 1001;
  INSERT INTO delivery_log VALUES (1001, NOW());
COMMIT;
-- Now both succeed together or both are rolled back together

-- Checking autocommit mode in PostgreSQL:
SHOW autocommit;        -- shows current setting
-- In psql: echo :AUTOCOMMIT

-- In different clients:
-- psql:          autocommit OFF by default in interactive mode
-- DBeaver:       autocommit ON by default
-- Python psycopg2: autocommit OFF by default (manual commit required)
-- SQLAlchemy:    autocommit OFF by default (session.commit() required)
⚠️ Important
Never run multi-statement DML (UPDATE + INSERT, or multiple UPDATEs that must succeed together) in autocommit mode. Each statement commits independently — a failure in statement 2 does not undo statement 1. Always wrap multi-statement operations in explicit BEGIN...COMMIT blocks. When in doubt, use explicit transactions — the overhead is negligible for the correctness guarantee they provide.

// Part 06

Isolation Levels — Controlling What Concurrent Transactions See

Isolation is a spectrum, not a binary. Higher isolation provides stronger correctness guarantees but reduces concurrency (more waiting). Lower isolation allows more concurrent access but permits certain anomalies — reads that see inconsistent or phantom data. SQL defines four isolation levels, each preventing a specific set of concurrency anomalies.

The three concurrency anomalies

Dirty Read

Transaction A reads data written by Transaction B that has not yet committed. If B rolls back, A read data that never officially existed.

Example

Customer A reads their wallet balance. Customer B is mid-transaction adding a refund — their UPDATE is not yet committed. A reads the inflated balance, sees ₹1,200 instead of the real ₹800. B rolls back. The ₹1,200 A read was a ghost.

Non-Repeatable Read

Transaction A reads a row, then reads it again and gets a different value because Transaction B committed a change between A's two reads.

Example

A settlement report reads FreshCart order #1001 total_amount = ₹850 at the start of the report. Midway through the report, a refund transaction commits and changes #1001 to ₹750. The report re-reads #1001 and gets ₹750. The same row returned two different values in the same report run.

Phantom Read

Transaction A runs a query and gets N rows. Transaction B inserts or deletes rows that match A's WHERE condition and commits. A re-runs the same query and gets a different number of rows.

Example

An inventory query counts 5 products in the Dairy category. Between two executions of the same count query, another transaction inserts a new Dairy product. The second execution returns 6. Same query, different row count — the new row is a "phantom".

The four isolation levels

READ UNCOMMITTED

Prevents

Nothing — all anomalies possible

Allows

Dirty reads, non-repeatable reads, phantom reads

Use when

Almost never — only for approximate counts or reporting where speed matters more than accuracy. Not supported in PostgreSQL (maps to READ COMMITTED).

READ COMMITTED (PostgreSQL default)

Prevents

Dirty reads — only sees committed data

Allows

Non-repeatable reads, phantom reads

Use when

Default for most OLTP workloads. Each statement sees a fresh snapshot of committed data. Sufficient for most web application transactions.

REPEATABLE READ

Prevents

Dirty reads, non-repeatable reads

Allows

Phantom reads (in SQL standard; PostgreSQL's REPEATABLE READ also prevents phantoms)

Use when

Long-running reports and analytics that must see a consistent snapshot throughout. Financial reports that read the same row multiple times.

SERIALIZABLE (strongest)

Prevents

All anomalies — dirty reads, non-repeatable reads, phantom reads, serialisation anomalies

Allows

Nothing — equivalent to transactions running one at a time

Use when

Financial transfers, inventory management, any scenario where correctness absolutely cannot be compromised. Lowest concurrency — highest contention.

Setting isolation level
-- Set for the current transaction (must be set before any DML)
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
  UPDATE products SET in_stock = false WHERE product_id = 55;
  -- Other transactions trying to read product 55 may wait or see conflicts
COMMIT;

-- Set for the current session (all subsequent transactions use this level)
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- Check current isolation level
SHOW transaction_isolation;

-- Common defaults:
-- PostgreSQL: READ COMMITTED
-- MySQL InnoDB: REPEATABLE READ
-- SQL Server: READ COMMITTED
-- Oracle: READ COMMITTED
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…

// Part 07

Locking — How Isolation Is Enforced

Databases enforce isolation through locking — transactions acquire locks on rows or tables they access, preventing conflicting operations by other transactions until the lock is released. Understanding the two primary lock types explains why concurrent transactions sometimes wait for each other.

Row-level locks — shared and exclusive
-- SHARED LOCK (S-lock): acquired for reads
-- Multiple transactions can hold shared locks on the same row simultaneously
-- (Multiple readers are fine — they do not conflict with each other)
SELECT * FROM orders WHERE order_id = 1;   -- acquires shared lock (briefly)

-- For explicit shared lock (prevents others from modifying until released):
SELECT * FROM orders WHERE order_id = 1 FOR SHARE;
-- Other transactions can still read but cannot UPDATE/DELETE until your TX ends

-- EXCLUSIVE LOCK (X-lock): acquired for writes
-- Only one transaction can hold an exclusive lock on a row at a time
-- Blocks all other reads (at SERIALIZABLE) or writes (at READ COMMITTED)
SELECT * FROM orders WHERE order_id = 1 FOR UPDATE;
-- Now locked exclusively — other transactions trying to UPDATE/DELETE this row
-- must WAIT until your transaction commits or rolls back

-- SKIP LOCKED: don't wait — skip rows that are already locked
-- (useful for queue processing — skip rows another worker has locked)
SELECT * FROM orders
WHERE order_status = 'Processing'
LIMIT 1
FOR UPDATE SKIP LOCKED;

-- NOWAIT: fail immediately if lock cannot be acquired (don't wait)
SELECT * FROM orders WHERE order_id = 1
FOR UPDATE NOWAIT;   -- raises ERROR immediately if row is locked
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…

Deadlock — when two transactions wait for each other

Deadlock — cause and prevention
-- DEADLOCK: Transaction A holds lock on row 1, wants row 2
--            Transaction B holds lock on row 2, wants row 1
--            Both wait forever — deadlock detected and one is killed

-- Session A:
BEGIN;
UPDATE orders SET order_status = 'Delivered' WHERE order_id = 1;  -- locks row 1
-- (waits for Session B to release row 2...)
UPDATE orders SET total_amount = 900 WHERE order_id = 2;          -- wants row 2

-- Session B (concurrent):
BEGIN;
UPDATE orders SET total_amount = 850 WHERE order_id = 2;  -- locks row 2
-- (waits for Session A to release row 1...)
UPDATE orders SET order_status = 'Cancelled' WHERE order_id = 1;  -- wants row 1
-- → DEADLOCK detected: one session is automatically rolled back

-- PREVENTION: always acquire locks in the same order
-- Both sessions should lock row 1 first, then row 2
-- If both follow the same order, one will wait (not deadlock)

-- In PostgreSQL: deadlock detected automatically, one transaction rolled back
-- ERROR: deadlock detected
-- DETAIL: Process 1234 waits for ShareLock on transaction 5678
--         Process 5678 waits for ShareLock on transaction 1234
-- HINT:   See server log for query details.

// Part 08

Transactions in Real Application Code

In application code, transactions are managed through the database driver or ORM. The pattern is consistent: open a connection, begin a transaction, execute statements, commit on success, rollback on any error.

Transaction patterns in Python (psycopg2)
import psycopg2

conn = psycopg2.connect(dsn)

# Pattern 1: Manual transaction management
try:
    cur = conn.cursor()
    cur.execute("""
        UPDATE orders
        SET order_status = 'Delivered', delivery_date = CURRENT_DATE
        WHERE order_id = %s
    """, (order_id,))

    cur.execute("""
        INSERT INTO delivery_log (order_id, delivered_at, delivered_by)
        VALUES (%s, NOW(), %s)
    """, (order_id, rider_id))

    conn.commit()          # both succeed → commit
except Exception as e:
    conn.rollback()        # any error → rollback both
    raise e
finally:
    cur.close()

# Pattern 2: Context manager (cleaner — auto-commits or rolls back)
with conn:                 # conn acts as context manager
    with conn.cursor() as cur:
        cur.execute("UPDATE wallets SET balance = balance - %s WHERE user_id = %s",
                    (amount, user_id))
        cur.execute("UPDATE wallets SET balance = balance + %s WHERE merchant_id = %s",
                    (amount, merchant_id))
# Automatically COMMITs on exit; ROLLBACKs on exception

# Pattern 3: SQLAlchemy ORM
from sqlalchemy.orm import Session

with Session(engine) as session:
    with session.begin():              # starts transaction
        order = session.get(Order, order_id)
        order.status = 'Delivered'
        session.add(DeliveryLog(order_id=order_id, delivered_at=datetime.now()))
    # session.begin() context manager auto-commits on success, rolls back on error
Transaction patterns in Node.js (pg library)
const { Pool } = require('pg');
const pool = new Pool();

async function processPayment(userId, merchantId, amount) {
  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    // Deduct from customer wallet
    const deductResult = await client.query(
      'UPDATE wallets SET balance = balance - $1 WHERE user_id = $2 RETURNING balance',
      [amount, userId]
    );

    if (deductResult.rows[0].balance < 0) {
      throw new Error('Insufficient balance');
    }

    // Credit merchant wallet
    await client.query(
      'UPDATE wallets SET balance = balance + $1 WHERE merchant_id = $2',
      [amount, merchantId]
    );

    // Log the payment
    await client.query(
      'INSERT INTO payment_log (user_id, merchant_id, amount) VALUES ($1, $2, $3)',
      [userId, merchantId, amount]
    );

    await client.query('COMMIT');
    return { success: true };

  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

// Part 09

What This Looks Like at Work

You are a backend engineer at Venmo. A critical bug is reported: occasionally, a customer's UPI payment succeeds (money deducted from their account) but the merchant never receives the credit. This is a classic atomicity failure — two statements that must succeed together are not wrapped in a transaction.

9:00 AM
Bug reported — customer deducted, merchant not credited
Investigation reveals the payment service runs two separate UPDATE statements — one for the customer deduction, one for the merchant credit — without a transaction. The database crashes between the two statements 1 in 50,000 times. Adapted for FreshCart: order placed (INSERT) but payment not logged (INSERT fails).
9:30 AM
Reproduce the bug scenario
The broken pattern: two independent autocommit statements. The fix: wrap both in a transaction.
The bug — two separate autocommit statements
-- BROKEN: two independent statements — NOT atomic
-- Statement 1 commits immediately
UPDATE orders SET order_status = 'Delivered' WHERE order_id = 1001;
-- ↑ committed — order is now 'Delivered'

-- <<< server crashes here >>>

-- Statement 2 never runs
INSERT INTO payment_log (order_id, amount, status)
VALUES (1001, 850.00, 'settled');
-- ↑ never executed — order is 'Delivered' but no payment record exists

-- Result: order appears delivered, payment settlement is missing
-- Financial reconciliation shows discrepancy
The fix — wrap in explicit transaction
-- FIXED: atomic transaction — both succeed or neither does
BEGIN;

UPDATE orders
  SET order_status = 'Delivered',
      delivery_date = CURRENT_DATE
WHERE order_id = 1001;

INSERT INTO payment_log (order_id, amount, status, settled_at)
VALUES (1001, 850.00, 'settled', NOW());

-- Only after both statements succeed:
COMMIT;

-- If server crashes after BEGIN but before COMMIT:
-- On restart, the database replays the WAL and rolls back
-- the partial transaction — order_status is NOT changed
-- Both statements are undone atomically

-- If the INSERT fails (e.g. duplicate payment_log entry):
-- ROLLBACK automatically issued — order_status is also undone
-- No partial state visible to any other transaction
Loading FreshCart DB…
Ctrl + Enter to run
Loading FreshCart database in your browser…
10:15 AM
Fix deployed — payment atomicity restored
The fix wraps the UPDATE and INSERT in a single BEGIN...COMMIT block. Both operations now succeed together or neither does. The financial reconciliation discrepancy drops from 1 in 50,000 to zero. Database durability ensures that even if the server crashes immediately after COMMIT, both changes survive the restart via WAL replay.

🎯 Pro Tip

Any time your application logic requires "do X then Y, and both must succeed," wrap both in an explicit database transaction. Do not rely on application-level compensation (trying to undo X if Y fails) — it is complex, error-prone, and creates race conditions. The database transaction is simpler, more reliable, and handles crashes that application-level compensation cannot.

// Part 10

Interview Prep — 5 Questions With Complete Answers

Q: What are ACID properties and why do they matter?

ACID is the set of four properties that define the reliability guarantees of database transactions. Atomicity means a transaction is all-or-nothing — every statement in it succeeds and is committed, or no statement takes effect. Partial completion is impossible. Consistency means a transaction can only bring the database from one valid state to another — all constraints, foreign keys, and schema rules are enforced, and any transaction that would violate them is rejected. Isolation means concurrent transactions do not interfere with each other — each transaction sees a consistent view of data and changes from one transaction are not visible to others until committed. Durability means once a transaction commits, its changes survive permanently — even a server crash immediately after COMMIT cannot lose committed data, because changes are written to a write-ahead log before being applied.

Why they matter: without Atomicity, a payment system could deduct money from a customer without crediting the merchant if it crashes mid-operation. Without Consistency, a database could contain orphaned records (orders referencing non-existent customers). Without Isolation, two concurrent transactions could both read a stock count of 1, both decrement it, and leave the count at -1. Without Durability, a confirmed order could disappear after a server crash.

ACID properties are implemented through specific mechanisms: Atomicity via transaction rollback on error; Consistency via constraint checking at commit time; Isolation via locking and MVCC (Multi-Version Concurrency Control); Durability via write-ahead logging. These are not optional features — they are the foundational contract that makes relational databases trustworthy for financial, medical, and any critical application data.

Q: What is the difference between COMMIT and ROLLBACK?

COMMIT makes all changes made since the last BEGIN permanent. Once COMMIT executes, the changes are written to durable storage and become visible to all other sessions. The transaction ends and all locks acquired during it are released. COMMIT is irreversible — once committed, changes cannot be undone through a ROLLBACK (only through a new transaction that explicitly reverses the changes).

ROLLBACK undoes all changes made since the last BEGIN (or since the last SAVEPOINT if using ROLLBACK TO SAVEPOINT). The database reverts to the exact state it was in before the transaction started. All modified rows are restored to their pre-transaction values. All locks are released. A ROLLBACK can be issued explicitly (by the application when it detects an error or business rule violation) or automatically (by the database when it detects a constraint violation, deadlock, or crash recovery).

The practical distinction matters when a transaction contains multiple statements: if statement 3 of 5 fails, ROLLBACK undoes statements 1 and 2 as well — the database never shows a state where only some of the transaction's changes applied. This is the guarantee applications rely on for correctness. In autocommit mode (the default in most SQL clients), each individual statement is its own implicit transaction that commits immediately — there is nothing to roll back if a later statement fails. This is why multi-statement operations that must be atomic must always be wrapped in an explicit BEGIN...COMMIT block.

Q: What are database isolation levels and when would you change from the default?

SQL defines four isolation levels representing a spectrum from lowest to highest isolation: READ UNCOMMITTED (allows dirty reads — reading uncommitted data from other transactions), READ COMMITTED (prevents dirty reads — only reads committed data; this is the PostgreSQL default), REPEATABLE READ (prevents dirty reads and non-repeatable reads — if a transaction reads a row twice, it gets the same value both times), and SERIALIZABLE (prevents all anomalies — equivalent to running transactions one at a time, no concurrency anomalies possible).

The default (READ COMMITTED) is appropriate for most OLTP workloads — web requests, API handlers, individual user operations. Each statement sees the latest committed data, preventing dirty reads while allowing good concurrency. Change to REPEATABLE READ when: a transaction reads the same data multiple times and must see consistent values throughout (a financial report that sums balances across multiple queries within the same transaction must use REPEATABLE READ or higher, or it could show inconsistent totals if accounts are updated between reads).

Change to SERIALIZABLE when: two concurrent transactions could make mutually inconsistent decisions based on data they each read. The classic example is two doctors both reading that a room is empty and both assigning a patient to it — at READ COMMITTED both see empty and both assign, resulting in double booking. At SERIALIZABLE, one transaction wins and the other sees a conflict and is forced to retry. Use SERIALIZABLE for inventory allocation, appointment booking, any "read then write based on the read" pattern where the read-to-write decision must be exclusive. The cost is lower throughput — the database detects more conflicts and forces retries. In PostgreSQL, SERIALIZABLE is implemented via SSI (Serializable Snapshot Isolation) which reduces the conflict rate compared to traditional lock-based serializable isolation.

Q: What is a dirty read and which isolation level prevents it?

A dirty read occurs when Transaction A reads data that Transaction B has written but not yet committed. If Transaction B subsequently rolls back, Transaction A has read data that effectively never existed — a "dirty" (invalid) read. The term "dirty" refers to the uncommitted write being read before it is confirmed as permanent.

Example: a loyalty point balance adjustment transaction starts, credits a customer 500 points (balance goes from 1,000 to 1,500), but has not yet committed. Another transaction reads the customer's balance to check eligibility for a premium offer — it sees 1,500 points and approves the offer. The first transaction then rolls back (perhaps the order that earned the points was cancelled) — the balance reverts to 1,000 points. The customer was approved for a premium offer based on points they do not actually have.

READ UNCOMMITTED allows dirty reads — it is the only isolation level that does. READ COMMITTED (and all higher levels) prevents dirty reads by ensuring a transaction only sees data from committed transactions. PostgreSQL does not support READ UNCOMMITTED — SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED is silently promoted to READ COMMITTED. In practice, dirty reads are almost never acceptable — READ COMMITTED is the minimum viable isolation level for any application where data correctness matters. READ UNCOMMITTED is sometimes used for approximate analytics (counting rows where a small error is acceptable) to avoid lock contention, but this use case is rare.

Q: What is a deadlock and how do you prevent it?

A deadlock occurs when two or more transactions each hold a lock that the other needs, creating a circular wait that can never resolve. Transaction A holds a lock on Row 1 and wants Row 2. Transaction B holds a lock on Row 2 and wants Row 1. Neither can proceed. The database detects this cycle and automatically rolls back one of the transactions (typically the one that has done less work or holds fewer locks), allowing the other to proceed. The rolled-back transaction receives an error and must be retried by the application.

Deadlocks are not errors in the database — they are a natural consequence of concurrent transactions accessing shared data. The database handles them correctly. The application must handle the deadlock error by retrying the rolled-back transaction. The real question is how to minimise their frequency.

Prevention strategies: first, always acquire locks in a consistent order. If every transaction that needs to lock both Row 1 and Row 2 always locks Row 1 before Row 2, deadlocks between them are impossible (one will wait for the other to finish, not create a cycle). Second, keep transactions short — the longer a transaction holds locks, the higher the probability another transaction needs those same locks. Commit as soon as the work is done. Third, use SELECT FOR UPDATE SKIP LOCKED for queue-based processing — each worker picks a different row and never contends with other workers. Fourth, avoid user interaction within a transaction — never hold a database transaction open while waiting for a user to click a button (the transaction would hold locks for seconds or minutes, causing widespread contention). Fifth, use optimistic locking (version numbers or timestamps) instead of pessimistic locking (SELECT FOR UPDATE) when conflicts are rare — check the version number at UPDATE time and retry only if it changed.

// Part 11

Errors You Will Hit — And Exactly Why They Happen

ERROR: current transaction is aborted, commands ignored until end of transaction block

Cause: A previous statement in the current transaction raised an error. PostgreSQL puts the transaction into an aborted state — all subsequent statements are ignored and return this error. The transaction cannot proceed in this state. This happens when, for example, a constraint violation or duplicate key error occurs mid-transaction and the application continues sending statements instead of issuing ROLLBACK.

Fix: Issue ROLLBACK immediately after detecting any error in a transaction: ROLLBACK; This ends the aborted transaction and returns the connection to a clean state. Then either retry the entire transaction from BEGIN or handle the error at the application level. In application code, always catch exceptions in the transaction block and execute ROLLBACK before re-raising or returning. Never re-use a database connection that has an aborted transaction — all queries on that connection will fail until ROLLBACK is issued.

ERROR: deadlock detected — process waited for lock, then was selected as deadlock victim

Cause: Two concurrent transactions each hold a lock needed by the other, creating a circular wait. PostgreSQL's deadlock detector runs periodically, identifies the cycle, and rolls back one transaction (the 'victim') to break the deadlock. The victim transaction receives this error. The other transaction is allowed to proceed and complete.

Fix: The application must handle deadlock errors by retrying the entire transaction. Implement exponential backoff — wait a short random time before retrying to avoid immediately deadlocking again. To reduce deadlock frequency: ensure all transactions that access the same set of rows acquire locks in the same order (lock customer before merchant, not merchant before customer). Keep transactions as short as possible. Use SELECT FOR UPDATE only when genuinely needed. Consider whether SERIALIZABLE isolation (which handles some of these conflicts differently via SSI) would be more appropriate for the workload.

Data inconsistency — order marked 'Delivered' but no payment record exists

Cause: Two statements that must succeed together are running in autocommit mode — each commits independently. The UPDATE committed, then the server crashed or the application errored before the INSERT could run. The result is a partial state that violates application-level invariants.

Fix: Wrap all statements that must succeed together in an explicit transaction: BEGIN; UPDATE ...; INSERT ...; COMMIT; With a transaction, if the INSERT fails for any reason, the UPDATE is automatically rolled back. The database will never show a state where one committed and the other did not. Audit existing code for any multi-statement operations that lack explicit BEGIN/COMMIT and add them. Add a CHECK or trigger that enforces the business invariant at the database level if possible — a delivered order must have a payment record.

ERROR: could not serialize access due to concurrent update — serialization failure

Cause: Using SERIALIZABLE isolation level. Two concurrent transactions read and wrote overlapping data in a way that is not equivalent to any serial ordering. PostgreSQL's SSI (Serializable Snapshot Isolation) detected a serialization anomaly and rolled back one transaction to preserve serializability. This is not a bug — it is the correct behaviour of SERIALIZABLE isolation.

Fix: The application must retry serialization failures. Implement a retry loop: catch the serialization error (error code 40001 in PostgreSQL), wait a brief random interval, and retry the entire transaction from BEGIN. Most serialization failures are transient — the retry will typically succeed because the conflicting transaction has already committed and the window of conflict is gone. If serialization failures are frequent, reconsider whether SERIALIZABLE is necessary — REPEATABLE READ or READ COMMITTED may be sufficient for the workload with appropriate application-level conflict detection.

Transaction holds locks for minutes — other queries queue up and time out

Cause: A long-running transaction is holding row or table locks while performing slow work (large aggregations, waiting for external API responses, or — worst — waiting for user input). Any other transaction that needs those same rows must wait for the lock to be released. As the wait grows, query timeouts begin cascading across the application.

Fix: Keep transactions as short as possible. Do all computation, validation, and external calls OUTSIDE the transaction. Only open BEGIN when you are ready to immediately execute the DML statements. Never make network calls (API requests, file reads) inside a transaction. Never wait for user interaction inside a transaction. Set statement_timeout (per-session timeout) and lock_timeout (maximum wait for a lock) in PostgreSQL: SET lock_timeout = '5s' — queries that cannot acquire a lock within 5 seconds fail instead of waiting indefinitely, preventing cascading timeouts.

Try It Yourself

Write a complete transaction scenario for FreshCart's order fulfilment process. The scenario: a customer (customer_id = 1) places an order for 2 units of product_id = 1. Write the SQL transaction that: (1) verifies the customer exists and is active (loyalty_tier != null), (2) checks product_id = 1 is in stock, (3) inserts a new order into the orders table with store_id = 'ST001', total_amount = quantity × unit_price, order_status = 'Processing', (4) inserts the order line into order_items. Wrap all validation and DML in a BEGIN...ROLLBACK block (use ROLLBACK at the end since this is a playground — in production it would be COMMIT). Also show what a SAVEPOINT would look like between the order INSERT and the order_items INSERT, and write the SELECT queries that verify each step.

🎯 Key Takeaways

  • A transaction is a group of statements treated as one indivisible unit — all succeed (COMMIT) or all are undone (ROLLBACK). No partial state is ever visible to other sessions.
  • ACID: Atomicity (all or nothing), Consistency (only valid states), Isolation (concurrent transactions don't interfere), Durability (committed data survives crashes via write-ahead logging).
  • BEGIN starts a transaction. COMMIT makes it permanent. ROLLBACK undoes everything since BEGIN. In autocommit mode, each statement is its own implicit transaction — no rollback is possible if a later statement fails.
  • SAVEPOINT marks a point within a transaction for partial rollback. ROLLBACK TO SAVEPOINT undoes only work since the savepoint, not the entire transaction. RELEASE SAVEPOINT discards it.
  • Four isolation levels: READ UNCOMMITTED (allows dirty reads — PostgreSQL promotes to READ COMMITTED), READ COMMITTED (default — prevents dirty reads), REPEATABLE READ (prevents dirty and non-repeatable reads), SERIALIZABLE (prevents all anomalies).
  • Dirty read: reading uncommitted data. Non-repeatable read: same row returns different value on second read. Phantom read: same WHERE returns different row count on second execution.
  • SELECT FOR UPDATE acquires an exclusive lock for the transaction. SKIP LOCKED skips already-locked rows — ideal for queue processing. NOWAIT fails immediately if lock cannot be acquired.
  • Deadlock: two transactions wait for each other's locks. Database detects and rolls back one. Prevention: always acquire locks in the same order across all transactions. Application must retry on deadlock error.
  • Never hold a transaction open during network calls, user interaction, or slow computation. Locks held by long transactions block all concurrent access to those rows.
  • Always use explicit BEGIN...COMMIT for multi-statement DML. Never rely on autocommit for operations that must be atomic. The overhead of explicit transactions is negligible — the correctness guarantee is invaluable.

What comes next

In Module 48, you learn ACID Properties — the four guarantees every reliable database provides, how each property is enforced at the engine level, and where transactions, isolation levels, and the WAL all fit together.

Module 48 → ACID Properties
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...