Skip to main content

Transactions & ACID

Transactions & ACID

A transaction is a sequence of operations that the database treats as a single unit — either all succeed or all fail. Without transactions, a partial failure (power cut, crash, network drop) leaves your data in an inconsistent state.


What is ACID?

ACID is a set of guarantees that a database transaction must satisfy.

A — Atomicity

All operations in a transaction succeed together, or none of them do.

BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE id = 'alice';
UPDATE accounts SET balance = balance + 1000 WHERE id = 'bob';
COMMIT;

If the second UPDATE fails (Bob's account doesn't exist), the first UPDATE is also rolled back. Alice's money is never lost.

Without atomicity: Alice's ₹1000 disappears from her account but never appears in Bob's.

C — Consistency

A transaction takes the database from one valid state to another valid state. All constraints, rules, and cascades are enforced.

-- Constraint: balance cannot go negative
ALTER TABLE accounts ADD CONSTRAINT balance_positive CHECK (balance >= 0);

BEGIN;
UPDATE accounts SET balance = balance - 5000 WHERE id = 'alice'; -- alice has ₹1000
-- This violates the CHECK constraint → entire transaction is rolled back
COMMIT;

Consistency ensures your business rules (foreign keys, unique constraints, check constraints) are never violated by a transaction.

I — Isolation

Concurrent transactions don't interfere with each other. Each transaction sees a consistent snapshot of the database.

Without isolation, one transaction can see partial results of another in-progress transaction — causing bizarre bugs.

D — Durability

Once a transaction is committed, it's permanent — even if the server crashes immediately after.

Databases achieve durability via Write-Ahead Logging (WAL): every change is written to a log on disk before being applied to the data files. On crash, the log is replayed.


Isolation Levels

Isolation is a spectrum. Stronger isolation = fewer anomalies but more locking = lower throughput. Choose the level that matches your use case.

The Anomalies

Dirty Read — reading uncommitted data from another transaction.

Transaction A                      Transaction B
BEGIN;
UPDATE orders SET total = 500
WHERE id = 1;
-- not yet committed
SELECT total FROM orders WHERE id = 1;
-- reads 500 (dirty! A hasn't committed)
ROLLBACK;
-- A rolled back, 500 was wrong
-- B used wrong data

Non-Repeatable Read — reading the same row twice in a transaction and getting different values (because another committed transaction changed it).

Transaction A                      Transaction B
BEGIN;
SELECT total FROM orders
WHERE id = 1; -- returns 200

BEGIN;
UPDATE orders SET total = 500 WHERE id = 1;
COMMIT;

SELECT total FROM orders
WHERE id = 1; -- returns 500 (changed!)
-- A got two different values for the same read

Phantom Read — a query returns different rows when re-run within the same transaction (because another committed transaction inserted/deleted rows).

Transaction A                      Transaction B
BEGIN;
SELECT COUNT(*) FROM orders
WHERE status = 'PENDING'; -- 10

BEGIN;
INSERT INTO orders (status) VALUES ('PENDING');
COMMIT;

SELECT COUNT(*) FROM orders
WHERE status = 'PENDING'; -- 11 (phantom row appeared!)

Isolation Levels and What They Prevent

Isolation LevelDirty ReadNon-Repeatable ReadPhantom Read
Read UncommittedPossiblePossiblePossible
Read CommittedPreventedPossiblePossible
Repeatable ReadPreventedPreventedPossible (prevented in MySQL)
SerializablePreventedPreventedPrevented

Read Uncommitted

No isolation. Transactions can see uncommitted changes of others. Almost never used — the risks outweigh any performance benefit.

Read Committed (default in PostgreSQL)

Each statement sees only committed data. The most common default.

-- Set for a session
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

Good for: most web application workloads.

Repeatable Read (default in MySQL InnoDB)

Once a row is read in a transaction, re-reading it always returns the same value — even if another transaction commits a change.

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

Good for: financial calculations where you need consistent reads within a transaction (calculating totals, generating reports).

Serializable

The strictest level. Transactions execute as if they were run one after another (serially), even if they run concurrently.

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

Good for: bank transfers, inventory reservation, anywhere correctness is critical and you can't afford anomalies.

Cost: More locking and contention, lower throughput. Use only where necessary.


Pessimistic vs Optimistic Locking

Pessimistic Locking

Lock the row at read time. No one else can modify it until your transaction commits.

BEGIN;

-- Lock the row — other transactions block until we commit
SELECT * FROM orders WHERE id = 123 FOR UPDATE;

-- Safe to update — we own the lock
UPDATE orders SET status = 'PROCESSING' WHERE id = 123;

COMMIT;

FOR UPDATE — exclusive lock, blocks readers and writers. FOR SHARE — shared lock, blocks writers but allows readers.

Use when: The contention is high and you need guaranteed exclusive access (inventory reservation, seat booking).

Downside: Blocked transactions wait → potential deadlocks, lower throughput.

Optimistic Locking

Don't lock at read time. Before updating, check that nothing changed since you read it.

-- Add a version column
ALTER TABLE orders ADD COLUMN version INT DEFAULT 1;
-- Read the record
SELECT id, total, version FROM orders WHERE id = 123;
-- Returns: { id: 123, total: 500, version: 4 }

-- Try to update — only succeed if version hasn't changed
UPDATE orders
SET total = 600, version = version + 1
WHERE id = 123 AND version = 4; -- conditional on version

-- Check rows affected
-- 1 row affected → success, no concurrent modification
-- 0 rows affected → someone else changed it, retry
// Application code
async function updateOrder(orderId, newTotal) {
const order = await db.query('SELECT id, total, version FROM orders WHERE id = $1', [orderId]);

const result = await db.query(
'UPDATE orders SET total = $1, version = version + 1 WHERE id = $2 AND version = $3',
[newTotal, orderId, order.version]
);

if (result.rowCount === 0) {
throw new ConflictError('Order was modified by another process. Please retry.');
}
}

Use when: Contention is low and retries are acceptable. Reads are frequent, writes are rare.

Downside: Must handle retry logic. Bad for high-contention scenarios (you'd retry constantly).


Deadlocks

A deadlock occurs when two transactions each hold a lock the other needs.

Transaction A                      Transaction B
BEGIN;
Lock row: orders WHERE id = 1

BEGIN;
Lock row: orders WHERE id = 2

UPDATE orders WHERE id = 2; -- Waiting for A to release lock on row 2
(waiting for B)

UPDATE orders WHERE id = 1;
-- Waiting for A to release lock on row 1
(waiting for A)

-- DEADLOCK! Both are waiting for each other.

The database detects deadlocks and kills one transaction (rolls it back). The surviving transaction completes.

How to prevent deadlocks

1. Consistent ordering — always acquire locks in the same order.

// Bad — A locks order 1 then 2, B locks order 2 then 1 → deadlock possible
async function transferStock(fromId, toId, qty) {
await lockRow(fromId);
await lockRow(toId);
// ...
}

// Good — always lock the lower ID first → consistent order, no deadlock
async function transferStock(fromId, toId, qty) {
const [first, second] = [fromId, toId].sort();
await lockRow(first);
await lockRow(second);
// ...
}

2. Keep transactions short — the less time a transaction holds locks, the lower the chance of conflict.

3. Retry on deadlock — catch the deadlock error and retry the transaction.

async function withRetryOnDeadlock(fn, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
const isDeadlock = err.code === '40P01'; // PostgreSQL deadlock error code
if (isDeadlock && attempt < maxRetries) {
await new Promise(r => setTimeout(r, attempt * 100)); // back off
continue;
}
throw err;
}
}
}

4. Use SELECT FOR UPDATE SKIP LOCKED — skip rows already locked instead of waiting.

-- Useful for job queues — each worker picks an unlocked job
SELECT * FROM jobs
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;

Common Transaction Patterns

The Unit of Work pattern

Wrap multiple repository operations in a single transaction.

async function placeOrder(userId, items) {
const client = await pool.connect();

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

// Create the order
const { rows: [order] } = await client.query(
'INSERT INTO orders (user_id, status, total) VALUES ($1, $2, $3) RETURNING *',
[userId, 'PENDING', calculateTotal(items)]
);

// Insert order items
for (const item of items) {
await client.query(
'INSERT INTO order_items (order_id, product_id, qty, price) VALUES ($1, $2, $3, $4)',
[order.id, item.productId, item.qty, item.price]
);
}

// Decrement inventory
for (const item of items) {
const { rowCount } = await client.query(
'UPDATE products SET stock = stock - $1 WHERE id = $2 AND stock >= $1',
[item.qty, item.productId]
);
if (rowCount === 0) {
throw new Error(`Insufficient stock for product ${item.productId}`);
}
}

await client.query('COMMIT');
return order;

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

If inventory is insufficient, the entire transaction rolls back — no order created, no partial items inserted.

Savepoints

Partial rollback within a transaction.

BEGIN;
INSERT INTO orders (...) VALUES (...);

SAVEPOINT before_notification;

INSERT INTO notifications (...) VALUES (...); -- this might fail (email invalid)

-- If notification fails, rollback only to savepoint
ROLLBACK TO SAVEPOINT before_notification;

-- Order is still intact, notification skipped
COMMIT;

Two-Phase Commit (2PC) — Distributed Transactions

When a transaction spans multiple databases or services, you need distributed coordination.

Coordinator

Phase 1 — PREPARE
→ Ask all participants: "Can you commit?"
→ DB1: "Yes, I'm ready" (locks resources, writes to WAL)
→ DB2: "Yes, I'm ready" (same)

Phase 2 — COMMIT
→ All said yes → send COMMIT to all
→ All commit simultaneously
→ If any said No → send ROLLBACK to all

Problem: If the coordinator crashes between Phase 1 and 2, participants are stuck holding locks.

In practice: 2PC is used internally by distributed databases. For microservices, use the Saga Pattern instead — a sequence of local transactions with compensating actions on failure.


Interview definition (short answer)

"ACID guarantees Atomicity (all-or-nothing), Consistency (constraints always hold), Isolation (concurrent transactions don't interfere), and Durability (committed data survives crashes). Isolation level is a tradeoff between anomaly prevention and throughput — Read Committed is the practical default, Serializable for critical financial operations. Deadlocks are prevented by consistent lock ordering and short transactions."