ACID - atomicity
ACID atomicity guarantees that a database transaction is treated as a single indivisible unit: it either completes entirely or not at all.
Theory
TL;DR
- Think of mailing an envelope with cash: the envelope delivers both, or neither arrives if lost.
- All operations write to a log before commit; any failure triggers a rollback to the pre-transaction state.
- Two separate SQL statements outside a transaction have no atomicity guarantee.
- Multi-step changes that depend on each other → wrap in a transaction. Read-only queries → skip it.
Quick example
-- Bank transfer: atomicity in action (PostgreSQL)
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- Deduct from Alice
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- Credit Bob
COMMIT;
-- Both succeed: Alice $900, Bob $1100
-- If the second UPDATE fails (e.g., constraint violation):
-- Engine auto-triggers ROLLBACK
-- Result: no changes. Alice $1000, Bob $1000Both updates happen as one. If anything breaks between BEGIN and COMMIT, the database undoes everything automatically.
Key difference
Without a transaction, two separate UPDATE statements are two independent operations. One can succeed and the other fail, leaving balances inconsistent. Wrapping them in BEGIN/COMMIT makes the database treat them as a single logical step with automatic rollback on any failure.
When to use
- Multi-table updates that depend on each other → wrap in a transaction.
- Bank transfers, order creation, inventory deduction → always atomic.
- Single-row idempotent inserts → no transaction needed.
- Read-heavy queries → skip to avoid holding unnecessary locks.
- Distributed systems (microservices) → use 2PC or the saga pattern instead of a single DB transaction.
How atomicity works internally
PostgreSQL uses Write-Ahead Logging (WAL): before modifying any data page, the engine writes the operation to a log file and marks the transaction boundaries. On COMMIT, the log flushes to disk. If a crash happens before that flush, WAL replays only committed transactions on recovery, and incomplete ones get rolled back using before-images stored in undo logs. MVCC (Multi-Version Concurrency Control) creates snapshots so concurrent transactions see a consistent view throughout this process.
One thing that trips teams in production: a long-running transaction holds locks on every row it touches. Seen this cause a 10-minute outage on a Rails app where a batch job opened a BEGIN and never committed fast enough.
Common mistakes
Mistake: nested BEGIN without savepoints
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
BEGIN; -- PostgreSQL ignores this and warns, but continues the outer tx
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
ROLLBACK; -- Rolls back the ENTIRE outer transaction, not just the inner blockMost developers expect the inner ROLLBACK to undo only the second update. It does not. Use SAVEPOINT for partial rollbacks:
BEGIN;
SAVEPOINT sp1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
ROLLBACK TO sp1; -- Undoes only the second update
COMMIT;Mistake: forgetting client.release() in Node.js
const client = await pool.connect();
await client.query('BEGIN');
// ... queries ...
await client.query('COMMIT');
// No client.release() here → connection leak under load, pool exhaustsAlways use finally { client.release(); }. No exceptions.
Mistake: long-running transactions
BEGIN;
SELECT * FROM large_table; -- Runs for 10 minutes, holds shared locks
-- All writers are blocked the entire timeSet a statement timeout. Use READ ONLY transactions where possible. Keep transactions under 30 seconds in production.
Mistake: assuming an app crash rolls back everything
A COMMIT that flushed to WAL survives a crash. If your app dies between sending COMMIT and receiving acknowledgment, the transaction may still be committed on the database side. Check pg_stat_activity after unexpected crashes, and use PREPARE TRANSACTION for distributed scenarios.
Real-world usage
- PostgreSQL: WAL + 2PC for XA distributed transactions in banking systems.
- MySQL (InnoDB): binlog + undo logs; Shopify uses this for order processing.
- MongoDB: multi-document transactions since v4.0; Stripe uses them for payment atomicity.
- SQL Server: TempDB versioning; common in enterprise ERP on Azure SQL.
- Microservices: a DB transaction cannot span two services, so teams use sagas with Kafka Streams or the outbox pattern instead.
Follow-up questions
Q: How does atomicity interact with isolation?
A: Atomicity is about all-or-nothing for a single transaction. Isolation controls what concurrent transactions can see during execution. PostgreSQL provides both through MVCC snapshots.
Q: How does rollback actually work under the hood?
A: The engine reads the undo log (before-images of modified rows) and applies them in reverse order. WAL replays only committed transactions on crash recovery, so incomplete work is discarded.
Q: What is the difference between atomicity and idempotency?
A: Atomicity means a transaction either fully completes or fully reverts. Idempotency means repeating the same operation produces the same result. A POST /transfer endpoint can be idempotent (via a unique transfer ID) even if the underlying DB transaction is atomic.
Q: How do you handle atomicity across microservices?
A: A single DB transaction cannot span two services. The standard approaches are 2PC (prepare/commit phases, used in XA with Java Spring) or the saga pattern, where each service has a compensating transaction that undoes its step if a later step fails.
Q: What happens if a transaction exceeds the lock timeout?
A: The engine aborts the transaction with an error (code 40P01 for deadlocks in PostgreSQL). Design around this with NOWAIT or SKIP LOCKED. Monitor pg_locks to catch long wait chains before they become outages.
Examples
Basic: bank transfer with automatic rollback
-- PostgreSQL: atomic fund transfer
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- Alice: $1000 -> $900
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- Bob: $1000 -> $1100
COMMIT;
-- Both rows updated. Total balance preserved: $2000
-- Simulate a failure:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 999; -- id=999 does not exist
-- Constraint violation triggers automatic ROLLBACK
-- Alice stays at $1000. No partial state.The key invariant here is total balance. After any transaction, successful or failed, the sum of all account balances must stay the same. Atomicity is what enforces that.
Intermediate: e-commerce order creation (Node.js + PostgreSQL)
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function createOrder(userId, items) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Reserve inventory for each item
for (const item of items) {
await client.query(
'UPDATE inventory SET stock = stock - $1 WHERE product_id = $2 AND stock >= $1',
[item.quantity, item.productId]
);
}
// Create the order record
const result = await client.query(
'INSERT INTO orders (user_id, total) VALUES ($1, $2) RETURNING id',
[userId, items.reduce((sum, i) => sum + i.price * i.quantity, 0)]
);
await client.query('COMMIT');
return result.rows[0].id; // orderId=123, inventory already deducted
} catch (err) {
await client.query('ROLLBACK');
throw err; // No inventory change, no order record. Clean state.
} finally {
client.release(); // Always release, even on error
}
}If inventory deduction succeeds but the order insert fails, the catch block rolls back all inventory changes. The user sees an error. Nothing is half-applied.
Senior: deadlock in concurrent transfers
-- Session 1 (Tx1):
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 1; -- Acquires lock on row 1
UPDATE accounts SET balance = balance + 50 WHERE id = 2; -- Waits for row 2
-- Session 2 (Tx2, running simultaneously):
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2; -- Acquires lock on row 2
UPDATE accounts SET balance = balance + 50 WHERE id = 1; -- Waits for row 1
-- Deadlock: Tx1 waits for Tx2, Tx2 waits for Tx1
-- PostgreSQL detects the cycle, aborts Tx2 with error 40P01
-- Tx1 commits. Tx2 must retry.Deadlocks are a direct consequence of atomicity plus isolation: each transaction holds locks until it commits. The fix is simple but easy to forget. Always lock rows in the same order across transactions. Lock id=1 before id=2 in every transfer, and the cycle cannot form.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.