Transactions & ACID Properties
TL;DR — Quick Summary
- ACID: Atomicity (all or nothing), Consistency (valid state to valid state), Isolation (concurrent = sequential), Durability (committed = permanent).
- Transaction syntax:
BEGIN / START TRANSACTION→ operations →COMMIT(save) orROLLBACK(undo). - Keep transactions short — long transactions hold locks and block other users.
- READ COMMITTED is the right default for most apps; use SERIALIZABLE only when absolute consistency is required.
Lesson Overview
💳 The Problem Transactions Solve
Imagine transferring $100 from Alice's bank account to Bob's. This requires two operations:
- Deduct $100 from Alice
- Add $100 to Bob
What if the server crashes between step 1 and step 2? Alice has lost $100, Bob received nothing. The money vanished. This is the exact problem transactions solve — by grouping operations so they either all succeed or all fail together.
⚗️ ACID — The Four Guarantees
- Atomicity: A transaction is all or nothing. Either ALL operations succeed and are committed, or a failure causes ALL of them to be rolled back. No partial transactions.
- Consistency: A transaction brings the database from one valid state to another valid state. All rules (constraints, triggers, foreign keys) must be satisfied before committing.
- Isolation: Concurrent transactions behave as if they ran sequentially. One transaction's in-progress changes are not visible to other transactions (depending on the isolation level).
- Durability: Once committed, data is permanently saved — even if the server crashes immediately after. Achieved via write-ahead logs (WAL) written to disk before the commit returns.
🔒 Isolation Levels — The Consistency Dial
Isolation comes at a cost — higher isolation = more locking = less concurrency. SQL provides four levels:
- READ UNCOMMITTED: Can read other transactions' uncommitted changes (dirty reads). Almost never used.
- READ COMMITTED: Only reads committed data. Default in PostgreSQL and Oracle. Prevents dirty reads.
- REPEATABLE READ: Data read in a transaction stays consistent throughout. Default in MySQL InnoDB. Prevents non-repeatable reads.
- SERIALIZABLE: Transactions behave as if fully sequential. Safest but slowest. Prevents phantom reads.
For most applications, READ COMMITTED is the right default. Only escalate to SERIALIZABLE for financial operations where absolute consistency is worth the performance cost.
Conceptual Deep Dive
Architecture & Data Flow
Implementation Lab
-- The problem: what if we crash between these two statements?
-- Solution: wrap them in a transaction
START TRANSACTION; -- or BEGIN;
-- Step 1: deduct from sender
UPDATE accounts
SET balance = balance - 100
WHERE id = 1 AND balance >= 100; -- check sufficient funds
-- Verify the update succeeded (check rows affected)
-- In application code, check affected_rows = 1
-- Step 2: add to recipient
UPDATE accounts
SET balance = balance + 100
WHERE id = 2;
-- If both succeeded: permanently save
COMMIT;
-- If anything failed: undo everything
-- ROLLBACK;
-- Verify
SELECT id, balance FROM accounts WHERE id IN (1, 2);const mysql = require('mysql2/promise');
async function transferMoney(senderId, recipientId, amount) {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
// Check and deduct from sender
const [sender] = await connection.execute(
'SELECT balance FROM accounts WHERE id = ? FOR UPDATE'FROM accounts WHERE id = ? FOR UPDATE', // row-level lock
[senderId]
);
if (sender[0].balance < amount) {
throw new Error('Insufficient funds');
}
await connection.execute(
'UPDATE accounts SET balance = balance - ? WHERE id = ?'SET balance = balance - ? WHERE id = ?',
[amount, senderId]
);
// Add to recipient
await connection.execute(
'UPDATE accounts SET balance = balance + ? WHERE id = ?'SET balance = balance + ? WHERE id = ?',
[amount, recipientId]
);
// Log the transaction
await connection.execute(
'INSERT INTO transfer_log (from_id, to_id, amount, created_at) VALUES (?, ?, ?, NOW())'INTO transfer_log (from_id, to_id, amount, created_at) VALUES (?, ?, ?, NOW())',
[senderId, recipientId, amount]
);
await connection.commit(); // ALL or NOTHINGNOTHING
return { success: true };
} catch (error) {
await connection.rollback(); // undo everything
throw error;
} finally {
connection.release();
}
}-- Savepoints let you rollback to a specific point within a transaction
-- Without rolling back the entire thing
START TRANSACTION;
INSERT INTO orders (user_id, total) VALUES (1, 150.00);
SAVEPOINT after_order; -- mark this point
INSERT INTO order_items (order_id, product_id, qty) VALUES (LAST_INSERT_ID(), 5, 2);
SAVEPOINT after_items;
-- Try to apply a discount coupon
UPDATE coupons SET used = TRUE WHERE code = 'SAVE20';
-- If coupon is invalid, rollback just the coupon step
ROLLBACK TO SAVEPOINT after_items;
-- The order and items are still in the transaction, coupon update is undone
-- Continue and commit what's valid
COMMIT;Best Practices — Interactive Comparison
Always wrap transactions in try/catch — an uncaught error leaves locks held and blocks other users
// If ANY line throws, the transaction stays openANY line throws, the transaction stays open
// holding row locks — blocking all other users!
async function transfer(from, to, amount) {
await db.query('BEGIN');
await db.query(
'UPDATE accounts SET balance = balance - ? WHERE id = ?'SET balance = balance - ? WHERE id = ?',
[amount, from]
);
// If this line throws, BEGIN was never COMMITedthis line throws, BEGIN was never COMMITed
// or ROLLBACKed — locks are held indefinitely
await db.query(
'UPDATE accounts SET balance = balance + ? WHERE id = ?'SET balance = balance + ? WHERE id = ?',
[amount, to]
);
await db.query('COMMIT');
}// If ANY line throws, the transaction stays openANY line throws, the transaction stays open
// holding row locks — blocking all other users!
async function transfer(from, to, amount) {
await db.query('BEGIN');
await db.query(
'UPDATE accounts SET balance = balance - ? WHERE id = ?'SET balance = balance - ? WHERE id = ?',
[amount, from]
);
// If this line throws, BEGIN was never COMMITedthis line throws, BEGIN was never COMMITed
// or ROLLBACKed — locks are held indefinitely
await db.query(
'UPDATE accounts SET balance = balance + ? WHERE id = ?'SET balance = balance + ? WHERE id = ?',
[amount, to]
);
await db.query('COMMIT');
}// ROLLBACK always runs on error — locks released immediately
async function transfer(from, to, amount) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
await conn.execute(
'UPDATE accounts SET balance = balance - ? WHERE id = ?'SET balance = balance - ? WHERE id = ?',
[amount, from]
);
await conn.execute(
'UPDATE accounts SET balance = balance + ? WHERE id = ?'SET balance = balance + ? WHERE id = ?',
[amount, to]
);
await conn.commit();
} catch (err) {
await conn.rollback(); // always undoes changes on error
throw err;
} finally {
conn.release(); // always returns connection to pool
}
}Pro Tips — Senior Dev Insights
In PostgreSQL, wrap all DB operations in an application's request lifecycle with a single transaction per request — simpler rollback on any error and better consistency.
Use SELECT ... FOR UPDATE SKIP LOCKED for job queues — multiple workers can pull jobs without deadlocking each other.
PostgreSQL's DEFERRABLE INITIALLY DEFERRED constraint mode lets you temporarily violate constraints within a transaction (useful for circular foreign keys) as long as everything is consistent at commit time.
Two-Phase Commit (2PC) extends ACID guarantees across multiple databases — essential for microservices that each have their own database and need cross-service transactional consistency.
⚖️ Isolation Levels — Anomalies and Performance
| Feature | READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE |
|---|---|---|---|---|
| Prevents dirty reads | ||||
| Prevents non-repeatable reads | ||||
| Prevents phantom reads | ||||
| Concurrency / Performance | Highest | High | Medium | Lowest |
| Default in PostgreSQL | ||||
| Default in MySQL InnoDB |
Common Developer Pitfalls
Forgetting to handle transaction errors in application code — if an exception occurs and you don't ROLLBACK, the transaction stays open holding locks.
Making transactions too large — wrapping an entire multi-minute operation in a single transaction causes lock contention that kills database performance.
Sending emails or calling external APIs inside a database transaction — if the transaction rolls back, you can't un-send an email.
Assuming NoSQL databases are ACID — most NoSQL databases sacrifice ACID (especially across multiple documents/keys) for performance and scalability.
Interview Mastery
Using a bank transfer: Atomicity — deducting from Account A and crediting Account B must both succeed, or neither happens (no partial transfer). Consistency — total money in the system stays the same before and after; no constraint is violated. Isolation — if two transfers happen simultaneously, they don't interfere with each other (no one reads a balance mid-update). Durability — once the transfer is committed, it survives a server crash; the database writes to disk before confirming success.
A deadlock occurs when two transactions are each waiting for a lock that the other holds. Example: Transaction A locks Row 1, then wants Row 2. Transaction B locks Row 2, then wants Row 1. Both wait forever. Databases detect deadlocks automatically and kill one transaction (with an error). Prevention strategies: (1) always acquire locks in the same order, (2) keep transactions short to reduce lock hold time, (3) use SELECT ... FOR UPDATE SKIP LOCKED for queue processing.
COMMIT permanently saves all changes made in the current transaction to the database. The changes become visible to other transactions and are written durably to disk. ROLLBACK undoes all changes made since the transaction started (or to the last savepoint), returning the database to its state before the transaction began. Think of a transaction as a draft — COMMIT publishes it, ROLLBACK discards it.
Real-World Blueprint
"Stripe processes millions of payments per day. Every successful charge involves multiple database writes: debit the customer's card, credit the merchant's account, update the charge status, create audit log entries, update balance sheets. All of these must be atomic — if logging fails after the money moves, Stripe can't have a charge with no record. PostgreSQL transactions guarantee that either all of it commits together, or none of it does. This ACID guarantee is what separates payment processors from chaos."
Hands-on Lab Exercises
Simulate a bank transfer with a transaction. Mid-transaction, manually cause a failure (divide by zero, constraint violation) and verify the ROLLBACK undoes all changes.
Observe a deadlock: open two database sessions, and in each one begin a transaction that locks a row the other needs. Watch the database resolve it.
Test isolation levels: open two sessions, set one to READ UNCOMMITTED, and make uncommitted changes in the other — observe the dirty read.
Use SAVEPOINTS to implement a multi-step order process where failed optional steps (like coupon application) can be rolled back without canceling the entire order.
Real-World Practice Scenarios
An e-commerce checkout must: create an order, deduct inventory, charge the customer, and send a confirmation email. Which of these should be in a database transaction and which shouldn't?
A long-running report query is locking rows for 5 minutes, blocking all writes to the orders table. What isolation level and query approach should you use?
Two users simultaneously try to book the last seat on a flight. How do transactions and row-level locking prevent double-booking?
Your application sometimes creates duplicate orders. A developer suggests wrapping the order creation in a transaction. Would this fix the problem, and if not, what would?
Transactions & ACID Properties
TL;DR — Quick Summary
- ACID: Atomicity (all or nothing), Consistency (valid state to valid state), Isolation (concurrent = sequential), Durability (committed = permanent).
- Transaction syntax:
BEGIN / START TRANSACTION→ operations →COMMIT(save) orROLLBACK(undo). - Keep transactions short — long transactions hold locks and block other users.
- READ COMMITTED is the right default for most apps; use SERIALIZABLE only when absolute consistency is required.
Overview
💳 The Problem Transactions Solve
Imagine transferring $100 from Alice's bank account to Bob's. This requires two operations:
- Deduct $100 from Alice
- Add $100 to Bob
What if the server crashes between step 1 and step 2? Alice has lost $100, Bob received nothing. The money vanished. This is the exact problem transactions solve — by grouping operations so they either all succeed or all fail together.
⚗️ ACID — The Four Guarantees
- Atomicity: A transaction is all or nothing. Either ALL operations succeed and are committed, or a failure causes ALL of them to be rolled back. No partial transactions.
- Consistency: A transaction brings the database from one valid state to another valid state. All rules (constraints, triggers, foreign keys) must be satisfied before committing.
- Isolation: Concurrent transactions behave as if they ran sequentially. One transaction's in-progress changes are not visible to other transactions (depending on the isolation level).
- Durability: Once committed, data is permanently saved — even if the server crashes immediately after. Achieved via write-ahead logs (WAL) written to disk before the commit returns.
🔒 Isolation Levels — The Consistency Dial
Isolation comes at a cost — higher isolation = more locking = less concurrency. SQL provides four levels:
- READ UNCOMMITTED: Can read other transactions' uncommitted changes (dirty reads). Almost never used.
- READ COMMITTED: Only reads committed data. Default in PostgreSQL and Oracle. Prevents dirty reads.
- REPEATABLE READ: Data read in a transaction stays consistent throughout. Default in MySQL InnoDB. Prevents non-repeatable reads.
- SERIALIZABLE: Transactions behave as if fully sequential. Safest but slowest. Prevents phantom reads.
For most applications, READ COMMITTED is the right default. Only escalate to SERIALIZABLE for financial operations where absolute consistency is worth the performance cost.
Architecture & Logic Flow
⚖️ Isolation Levels — Anomalies and Performance
| Feature | READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE |
|---|---|---|---|---|
| Prevents dirty reads | ||||
| Prevents non-repeatable reads | ||||
| Prevents phantom reads | ||||
| Concurrency / Performance | Highest | High | Medium | Lowest |
| Default in PostgreSQL | ||||
| Default in MySQL InnoDB |
Deep Dive Analysis
Here's a concrete way to understand isolation levels. Imagine two transactions running simultaneously: <strong>Dirty Read (READ UNCOMMITTED):</strong> Transaction B reads data that Transaction A has changed but not yet committed. If A rolls back, B read 'phantom' data that never officially existed. <strong>Non-Repeatable Read (READ COMMITTED issue):</strong> Transaction B reads a row, then Transaction A updates it and commits, then B reads it again — and gets a different value! Same query, different result within one transaction. <strong>Phantom Read (REPEATABLE READ issue):</strong> Transaction B counts 10 rows matching a condition. Transaction A inserts a new matching row and commits. B counts again — 11 rows! The phantom row appeared mid-transaction.
Implementation Reference
-- The problem: what if we crash between these two statements?
-- Solution: wrap them in a transaction
START TRANSACTION; -- or BEGIN;
-- Step 1: deduct from sender
UPDATE accounts
SET balance = balance - 100
WHERE id = 1 AND balance >= 100; -- check sufficient funds
-- Verify the update succeeded (check rows affected)
-- In application code, check affected_rows = 1
-- Step 2: add to recipient
UPDATE accounts
SET balance = balance + 100
WHERE id = 2;
-- If both succeeded: permanently save
COMMIT;
-- If anything failed: undo everything
-- ROLLBACK;
-- Verify
SELECT id, balance FROM accounts WHERE id IN (1, 2);const mysql = require('mysql2/promise');
async function transferMoney(senderId, recipientId, amount) {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
// Check and deduct from sender
const [sender] = await connection.execute(
'SELECT balance FROM accounts WHERE id = ? FOR UPDATE', // row-level lock
[senderId]
);
if (sender[0].balance < amount) {
throw new Error('Insufficient funds');
}
await connection.execute(
'UPDATE accounts SET balance = balance - ? WHERE id = ?',
[amount, senderId]
);
// Add to recipient
await connection.execute(
'UPDATE accounts SET balance = balance + ? WHERE id = ?',
[amount, recipientId]
);
// Log the transaction
await connection.execute(
'INSERT INTO transfer_log (from_id, to_id, amount, created_at) VALUES (?, ?, ?, NOW())',
[senderId, recipientId, amount]
);
await connection.commit(); // ALL or NOTHING
return { success: true };
} catch (error) {
await connection.rollback(); // undo everything
throw error;
} finally {
connection.release();
}
}-- Savepoints let you rollback to a specific point within a transaction
-- Without rolling back the entire thing
START TRANSACTION;
INSERT INTO orders (user_id, total) VALUES (1, 150.00);
SAVEPOINT after_order; -- mark this point
INSERT INTO order_items (order_id, product_id, qty) VALUES (LAST_INSERT_ID(), 5, 2);
SAVEPOINT after_items;
-- Try to apply a discount coupon
UPDATE coupons SET used = TRUE WHERE code = 'SAVE20';
-- If coupon is invalid, rollback just the coupon step
ROLLBACK TO SAVEPOINT after_items;
-- The order and items are still in the transaction, coupon update is undone
-- Continue and commit what's valid
COMMIT;Comparative Best Practices
Always wrap transactions in try/catch — an uncaught error leaves locks held and blocks other users
// If ANY line throws, the transaction stays openANY line throws, the transaction stays open
// holding row locks — blocking all other users!
async function transfer(from, to, amount) {
await db.query('BEGIN');
await db.query(
'UPDATE accounts SET balance = balance - ? WHERE id = ?'SET balance = balance - ? WHERE id = ?',
[amount, from]
);
// If this line throws, BEGIN was never COMMITedthis line throws, BEGIN was never COMMITed
// or ROLLBACKed — locks are held indefinitely
await db.query(
'UPDATE accounts SET balance = balance + ? WHERE id = ?'SET balance = balance + ? WHERE id = ?',
[amount, to]
);
await db.query('COMMIT');
}// If ANY line throws, the transaction stays openANY line throws, the transaction stays open
// holding row locks — blocking all other users!
async function transfer(from, to, amount) {
await db.query('BEGIN');
await db.query(
'UPDATE accounts SET balance = balance - ? WHERE id = ?'SET balance = balance - ? WHERE id = ?',
[amount, from]
);
// If this line throws, BEGIN was never COMMITedthis line throws, BEGIN was never COMMITed
// or ROLLBACKed — locks are held indefinitely
await db.query(
'UPDATE accounts SET balance = balance + ? WHERE id = ?'SET balance = balance + ? WHERE id = ?',
[amount, to]
);
await db.query('COMMIT');
}// ROLLBACK always runs on error — locks released immediately
async function transfer(from, to, amount) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
await conn.execute(
'UPDATE accounts SET balance = balance - ? WHERE id = ?'SET balance = balance - ? WHERE id = ?',
[amount, from]
);
await conn.execute(
'UPDATE accounts SET balance = balance + ? WHERE id = ?'SET balance = balance + ? WHERE id = ?',
[amount, to]
);
await conn.commit();
} catch (err) {
await conn.rollback(); // always undoes changes on error
throw err;
} finally {
conn.release(); // always returns connection to pool
}
}Common Pitfalls
- •Forgetting to handle transaction errors in application code — if an exception occurs and you don't ROLLBACK, the transaction stays open holding locks.
- •Making transactions too large — wrapping an entire multi-minute operation in a single transaction causes lock contention that kills database performance.
- •Sending emails or calling external APIs inside a database transaction — if the transaction rolls back, you can't un-send an email.
- •Assuming NoSQL databases are ACID — most NoSQL databases sacrifice ACID (especially across multiple documents/keys) for performance and scalability.
Key Takeaways
Hands-on Practice
- ✓Simulate a bank transfer with a transaction. Mid-transaction, manually cause a failure (divide by zero, constraint violation) and verify the ROLLBACK undoes all changes.
- ✓Observe a deadlock: open two database sessions, and in each one begin a transaction that locks a row the other needs. Watch the database resolve it.
- ✓Test isolation levels: open two sessions, set one to READ UNCOMMITTED, and make uncommitted changes in the other — observe the dirty read.
- ✓Use SAVEPOINTS to implement a multi-step order process where failed optional steps (like coupon application) can be rolled back without canceling the entire order.
Expert Pro Tips
Interview Preparation
Q: Explain ACID properties with a real-world example.
Master Answer:
Using a bank transfer: <strong>Atomicity</strong> — deducting from Account A and crediting Account B must both succeed, or neither happens (no partial transfer). <strong>Consistency</strong> — total money in the system stays the same before and after; no constraint is violated. <strong>Isolation</strong> — if two transfers happen simultaneously, they don't interfere with each other (no one reads a balance mid-update). <strong>Durability</strong> — once the transfer is committed, it survives a server crash; the database writes to disk before confirming success.
Q: What is a deadlock and how does it occur?
Master Answer:
A <strong>deadlock</strong> occurs when two transactions are each waiting for a lock that the other holds. Example: Transaction A locks Row 1, then wants Row 2. Transaction B locks Row 2, then wants Row 1. Both wait forever. Databases detect deadlocks automatically and kill one transaction (with an error). Prevention strategies: (1) always acquire locks in the same order, (2) keep transactions short to reduce lock hold time, (3) use <code>SELECT ... FOR UPDATE SKIP LOCKED</code> for queue processing.
Q: What is the difference between ROLLBACK and COMMIT?
Master Answer:
<strong>COMMIT</strong> permanently saves all changes made in the current transaction to the database. The changes become visible to other transactions and are written durably to disk. <strong>ROLLBACK</strong> undoes all changes made since the transaction started (or to the last savepoint), returning the database to its state before the transaction began. Think of a transaction as a draft — COMMIT publishes it, ROLLBACK discards it.
Industrial Blueprint
"Stripe processes millions of payments per day. Every successful charge involves multiple database writes: debit the customer's card, credit the merchant's account, update the charge status, create audit log entries, update balance sheets. All of these must be atomic — if logging fails after the money moves, Stripe can't have a charge with no record. PostgreSQL transactions guarantee that either all of it commits together, or none of it does. This ACID guarantee is what separates payment processors from chaos."
Simulated Scenarios
© 2026 DevHub Engineering • All Proprietary Rights Reserved
Generated on March 7, 2026 • Ver: 4.0.2
Document Class: Master Education
Confidential Information • Licensed to User