SQL Transactions & ACID Properties Explained — Internals, Isolation Levels and Production Pitfalls
Every meaningful application eventually hits the same wall: multiple things need to happen together or not at all. A bank transfer, an e-commerce order, a seat reservation — all of them involve at least two writes that must succeed or fail as a unit. Without a formal guarantee, your database becomes a minefield where a crashed server at exactly the wrong millisecond can leave your data in a half-written, corrupted state that's incredibly hard to detect and even harder to fix.
SQL transactions solve this by wrapping a group of operations in a protective envelope. The database engine commits to four iron-clad guarantees — Atomicity, Consistency, Isolation, and Durability — collectively called ACID. These aren't just theoretical ideals. They're enforced by real mechanisms: write-ahead logs, lock managers, MVCC (Multi-Version Concurrency Control), and buffer pool management. Understanding how the engine actually implements these guarantees is what separates developers who use transactions from engineers who design systems that stay correct under load, failure, and concurrency.
By the end of this article you'll understand exactly what each ACID property does mechanically, how isolation levels let you trade consistency for performance, what really happens inside the database during a COMMIT and ROLLBACK, and the production mistakes that bite even experienced teams. You'll walk away with runnable code, a mental model you can draw on a whiteboard, and the confidence to answer ACID questions in any senior engineering interview.
Atomicity and Durability — The Write-Ahead Log Is the Real Hero
Atomicity means all-or-nothing. Either every statement in a transaction lands in the database, or none of them do. But how does a database engine actually enforce this when the server can die at any microsecond?
The answer is the Write-Ahead Log (WAL). Before any data page is modified in memory, the engine writes an intent record to the WAL — an append-only file on disk. If the server crashes mid-transaction, the WAL lets the engine replay or undo operations on restart. This is called crash recovery, and it's what makes Atomicity a real guarantee rather than a hopeful suggestion.
Durability is the flip side: once you get a COMMIT acknowledgement, the data is safe even if the server immediately crashes. That acknowledgement is only sent after the WAL record is flushed to disk (fsync). This is why COMMIT can feel slightly slow — it's waiting on a real disk write, not just a memory operation. Disabling fsync (a setting some developers turn off for 'performance') breaks Durability entirely and is a production disaster waiting to happen.
Atomic and durable together mean you can reason about failure modes cleanly: a transaction either committed (and is permanent) or it didn't (and left no trace).
-- Scenario: Transfer $500 from Alice's account to Bob's account. -- Both the debit and credit must succeed together, or neither applies. BEGIN; -- Start the transaction envelope -- Step 1: Deduct $500 from Alice. If her balance is insufficient, -- we want the whole operation to roll back, not just skip this step. UPDATE accounts SET balance = balance - 500 WHERE account_id = 'alice_001' AND balance >= 500; -- Guard against overdraft at the SQL level -- ROW_COUNT() / GET DIAGNOSTICS lets us check whether the UPDATE -- actually matched a row. If Alice had insufficient funds, 0 rows -- were affected and we need to abort manually. DO $$ BEGIN IF NOT FOUND THEN -- PostgreSQL sets FOUND after each DML statement RAISE EXCEPTION 'Insufficient funds or account not found for alice_001'; END IF; END; $$; -- Step 2: Credit $500 to Bob. This only runs if Step 1 succeeded. UPDATE accounts SET balance = balance + 500 WHERE account_id = 'bob_002'; -- Step 3: Log the transfer for audit purposes. INSERT INTO transfer_audit (from_account, to_account, amount, transferred_at) VALUES ('alice_001', 'bob_002', 500.00, NOW()); -- Only if ALL three steps succeeded do we make the changes permanent. -- The WAL flush to disk happens here — this is what makes it durable. COMMIT; -- If ANY step raises an exception, PostgreSQL automatically rolls back -- the entire transaction. You can also trigger this explicitly: -- ROLLBACK;
UPDATE 1
UPDATE 1
INSERT 0 1
COMMIT
-- On insufficient funds (Alice balance < 500):
ERROR: Insufficient funds or account not found for alice_001
ROLLBACK -- All changes are automatically undone, Alice keeps her balance
Isolation Levels — Where ACID Gets Complicated and Performance Gets Real
Isolation is the trickiest ACID property because it's not binary — it's a spectrum. 'Full' isolation (Serializable) means transactions behave as if they ran one after another, but it's expensive. Most databases default to something weaker, and that's where bugs hide.
The SQL standard defines four isolation levels, each preventing a specific class of read anomaly. Understanding what each level allows is essential for production systems.
Read Uncommitted — a transaction can read rows that another transaction has modified but not yet committed (dirty reads). Almost never appropriate.
Read Committed — you only see committed data, but the same SELECT inside one transaction can return different rows if another transaction commits between them (non-repeatable reads). This is PostgreSQL's and SQL Server's default.
Repeatable Read — the same SELECT always returns the same rows within a transaction, but you might see new rows that were inserted and committed by other transactions (phantom reads). MySQL InnoDB's default.
Serializable — full isolation. No dirty reads, no non-repeatable reads, no phantom reads. The database either uses strict locking or predicate-level MVCC (Serializable Snapshot Isolation in PostgreSQL).
The key insight: lower isolation = more concurrency = more anomaly risk. Picking the right level is a deliberate engineering decision, not a default-and-forget.
-- This demo shows a classic non-repeatable read at READ COMMITTED -- and how REPEATABLE READ prevents it. -- Run Session A and Session B in two separate database connections. -- ============================================================ -- SETUP (run once before the demo) -- ============================================================ CREATE TABLE product_inventory ( product_id SERIAL PRIMARY KEY, product_name VARCHAR(100) NOT NULL, stock_count INT NOT NULL ); INSERT INTO product_inventory (product_name, stock_count) VALUES ('Mechanical Keyboard', 10); -- ============================================================ -- SESSION A (e.g. your application processing an order) -- ============================================================ -- Default isolation in PostgreSQL is READ COMMITTED. -- Switch to REPEATABLE READ to prevent non-repeatable reads. BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- First read: sees stock_count = 10 SELECT product_name, stock_count FROM product_inventory WHERE product_id = 1; -- Output: Mechanical Keyboard | 10 -- *** PAUSE HERE. Switch to Session B and run its block. *** -- Second read: at READ COMMITTED this returns 3 (another transaction -- sold 7 units and committed between our two SELECTs — non-repeatable!). -- At REPEATABLE READ this still returns 10, because PostgreSQL took -- a snapshot at the start of our transaction. Session B's commit -- is invisible to us until we start a new transaction. SELECT product_name, stock_count FROM product_inventory WHERE product_id = 1; -- READ COMMITTED output: Mechanical Keyboard | 3 (data changed!) -- REPEATABLE READ output: Mechanical Keyboard | 10 (stable snapshot) COMMIT; -- ============================================================ -- SESSION B (another user buying 7 keyboards simultaneously) -- ============================================================ BEGIN; UPDATE product_inventory SET stock_count = stock_count - 7 -- Sell 7 units WHERE product_id = 1; COMMIT; -- This commits WHILE Session A is still running -- ============================================================ -- SERIALIZABLE — Preventing Phantom Reads -- ============================================================ -- A phantom read happens when a new ROW appears between two SELECTs -- in the same transaction. Example: an ORDER audit that totals -- all orders, while another transaction inserts a new order mid-audit. BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- Count all high-value orders SELECT COUNT(*), SUM(order_total) FROM orders WHERE order_total > 1000; -- Without SERIALIZABLE, a new order > $1000 inserted and committed -- by another session between this and the next SELECT would show up, -- making your report inconsistent. SELECT order_id, order_total FROM orders WHERE order_total > 1000 ORDER BY order_total DESC; COMMIT;
product_name | stock_count
----------------------+------------
Mechanical Keyboard | 10
-- Session B commits (stock_count is now 3 in the database)
-- Session A, second SELECT at READ COMMITTED:
product_name | stock_count
----------------------+------------
Mechanical Keyboard | 3 -- Changed! Non-repeatable read occurred.
-- Session A, second SELECT at REPEATABLE READ:
product_name | stock_count
----------------------+------------
Mechanical Keyboard | 10 -- Stable. Snapshot from transaction start.
Consistency, Savepoints and Long Transaction Dangers in Production
Consistency is the ACID property that's most misunderstood. It doesn't mean 'the data is correct' in a business logic sense — that's your application's job. What it means is that every transaction takes the database from one valid state to another, where 'valid' is defined by your schema constraints: foreign keys, CHECK constraints, UNIQUE indexes, NOT NULL columns, and triggers.
If you try to commit a transaction that violates a foreign key constraint, the database rejects the entire transaction. The constraint layer is the last line of defense before bad data lands.
SAVEPOINTs extend this by letting you create partial rollback points inside a long transaction. Instead of aborting everything on a recoverable error, you can roll back to a named savepoint and retry just that sub-operation. This is invaluable for bulk import scripts and multi-step wizards.
But here's the production trap nobody warns you about: long-running transactions are expensive regardless of what they're doing. In PostgreSQL, an open transaction holds back the VACUUM process from reclaiming dead rows, causing table bloat. In MySQL InnoDB, long transactions hold undo log space, increasing rollback cost. In SQL Server, held locks under READ COMMITTED LOCK escalate to table locks under load. The rule of thumb: keep transactions as short as possible. Do any heavy computation before opening BEGIN, not inside it.
-- Demonstrates SAVEPOINTs for partial rollback in a bulk import scenario, -- and shows consistency enforcement via foreign key constraints. -- ============================================================ -- SCHEMA (demonstrates consistency enforcement) -- ============================================================ CREATE TABLE departments ( dept_id SERIAL PRIMARY KEY, dept_name VARCHAR(100) NOT NULL UNIQUE ); CREATE TABLE employees ( emp_id SERIAL PRIMARY KEY, full_name VARCHAR(150) NOT NULL, dept_id INT NOT NULL REFERENCES departments(dept_id), -- FK enforces consistency salary NUMERIC(10,2) CHECK (salary > 0) -- CHECK constraint ); INSERT INTO departments (dept_name) VALUES ('Engineering'), ('Marketing'); -- ============================================================ -- SAVEPOINT DEMO — Bulk import with recoverable per-row errors -- ============================================================ BEGIN; -- Insert first employee successfully SAVEPOINT before_emp_1; INSERT INTO employees (full_name, dept_id, salary) VALUES ('Priya Sharma', 1, 95000.00); -- dept_id 1 = Engineering, valid -- Success — keep this savepoint active -- Attempt to insert an employee with an invalid dept_id (FK violation) SAVEPOINT before_emp_2; INSERT INTO employees (full_name, dept_id, salary) VALUES ('Carlos Mendez', 999, 80000.00); -- dept_id 999 does NOT exist! -- This will ERROR. We catch it at the application layer and roll back -- ONLY to before_emp_2, not the entire transaction. ROLLBACK TO SAVEPOINT before_emp_2; -- Priya's insert is still alive. Carlos's failed insert is undone. -- Log the failure for the import report: INSERT INTO import_error_log (attempted_name, reason, logged_at) VALUES ('Carlos Mendez', 'dept_id 999 not found', NOW()); -- Insert third employee successfully SAVEPOINT before_emp_3; INSERT INTO employees (full_name, dept_id, salary) VALUES ('Aisha Okonkwo', 2, 72000.00); -- dept_id 2 = Marketing, valid -- Attempt a negative salary — violates CHECK constraint SAVEPOINT before_emp_4; INSERT INTO employees (full_name, dept_id, salary) VALUES ('Tom Briggs', 1, -5000.00); -- Negative salary, CHECK fails ROLLBACK TO SAVEPOINT before_emp_4; INSERT INTO import_error_log (attempted_name, reason, logged_at) VALUES ('Tom Briggs', 'Salary must be positive', NOW()); -- Release savepoints we no longer need (frees internal resources) RELEASE SAVEPOINT before_emp_1; RELEASE SAVEPOINT before_emp_3; -- Commit the partial success: Priya and Aisha are inserted, -- Carlos and Tom are logged as errors but NOT in employees table. COMMIT; -- Verify results SELECT e.full_name, d.dept_name, e.salary FROM employees e JOIN departments d ON e.dept_id = d.dept_id ORDER BY e.emp_id; SELECT attempted_name, reason FROM import_error_log;
full_name | dept_name | salary
----------------+-------------+-----------
Priya Sharma | Engineering | 95000.00
Aisha Okonkwo | Marketing | 72000.00
-- import_error_log query result:
attempted_name | reason
----------------+------------------------------
Carlos Mendez | dept_id 999 not found
Tom Briggs | Salary must be positive
| Isolation Level | Dirty Read | Non-Repeatable Read | Phantom Read | Performance Impact | Typical Use Case |
|---|---|---|---|---|---|
| READ UNCOMMITTED | Possible | Possible | Possible | Lowest overhead | Rarely appropriate — analytics on non-critical data only |
| READ COMMITTED (default PG/MSSQL) | Prevented | Possible | Possible | Low overhead | OLTP default — web apps, APIs where slight read variance is acceptable |
| REPEATABLE READ (default MySQL) | Prevented | Prevented | Possible (prevented in MySQL InnoDB via gap locks) | Moderate — snapshot held for transaction duration | Financial reads, report generation within one transaction |
| SERIALIZABLE | Prevented | Prevented | Prevented | Highest — may cause serialization failures requiring retry | Regulatory compliance, double-booking prevention, inventory systems |
🎯 Key Takeaways
- The WAL (Write-Ahead Log) is the actual mechanism behind both Atomicity and Durability — COMMIT is slow by design because it waits for an fsync. Disabling fsync for speed is trading correctness for performance, and the trade is almost never worth it in production.
- Isolation is a spectrum, not a switch. READ COMMITTED prevents dirty reads but allows non-repeatable reads; this catches even experienced developers off guard in multi-step transactions. Choose your isolation level deliberately based on what anomalies your business logic can and cannot tolerate.
- SAVEPOINTs let you do partial rollbacks inside a long transaction — invaluable for bulk imports where per-row errors shouldn't abort the entire batch. But they don't change the long-transaction cost: the transaction still holds its locks and MVCC snapshot for its full lifetime.
- Consistency is enforced by constraints (FK, CHECK, UNIQUE, NOT NULL), not by your application code alone. Define constraints at the database layer as a last line of defense — any data that violates them will be rejected at COMMIT time regardless of how it got there.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Wrapping entire request handlers in a single transaction — Symptom: database connection pool exhaustion under load; p99 latency spikes as transactions queue behind each other holding row locks for hundreds of milliseconds — Fix: Open the transaction as late as possible and commit as early as possible. Move all validation, external API calls, and computation OUTSIDE the BEGIN...COMMIT block. A transaction should contain only the actual DML statements, ideally completing in under 10ms.
- ✕Mistake 2: Silently swallowing exceptions inside a transaction without rolling back — Symptom: your application log shows an error but the transaction keeps running and eventually commits a partial state (e.g. an order created without its line items) — Fix: Always propagate or explicitly handle database exceptions. In application code (Java/Python/Node), wrap transaction logic in try/catch/finally and call rollback() in the catch block before rethrowing. Never let a caught exception allow execution to reach COMMIT.
- ✕Mistake 3: Assuming AUTOCOMMIT=OFF when it's actually ON (or vice versa) — Symptom: In MySQL and many drivers, every single statement is its own transaction by default (AUTOCOMMIT=1). Developers write INSERT/UPDATE statements thinking they can ROLLBACK later, only to find the changes are already permanently committed — Fix: Explicitly call BEGIN (or START TRANSACTION) before your multi-statement logic regardless of your driver's default. In production code, never rely on implicit transaction modes — always be explicit. Check your connection's autocommit setting with SELECT @@autocommit (MySQL) or SHOW autocommit (PG sessions).
Interview Questions on This Topic
- QExplain what happens mechanically inside a PostgreSQL or MySQL database engine between the moment you type COMMIT and the moment your application gets the success response back — what disk operations occur, in what order, and why?
- QYour team is seeing intermittent 'deadlock detected' errors on a high-traffic orders table. Walk me through how you'd diagnose the root cause, what query you'd run to find the offending transactions, and two different strategies to resolve deadlocks without simply retrying at the application layer.
- QA colleague suggests switching your entire application from READ COMMITTED to READ UNCOMMITTED to solve a reporting performance problem. What specific data anomalies would this introduce, can you give a concrete business example where that anomaly would cause a real financial error, and what alternative would you recommend instead?
Frequently Asked Questions
What is the difference between ROLLBACK and ROLLBACK TO SAVEPOINT in SQL?
A plain ROLLBACK undoes every single change made since BEGIN and terminates the transaction entirely — you'd have to start a new transaction to do anything else. ROLLBACK TO SAVEPOINT undoes only the changes made since that named savepoint was created, but keeps the transaction open so you can continue with more statements. SAVEPOINTs are perfect for handling recoverable errors in complex multi-step operations without losing all the work done before the error occurred.
Does every SQL database support ACID transactions?
Most production-grade relational databases do — PostgreSQL, MySQL InnoDB, Oracle, and SQL Server all support full ACID. However, MySQL's older MyISAM storage engine does not support transactions at all (it's been largely superseded by InnoDB). NoSQL databases like MongoDB added multi-document ACID transactions in version 4.0, but historically traded some ACID guarantees for horizontal scalability. Always verify the specific storage engine or configuration, not just the database name.
If two transactions update the same row simultaneously, which one wins?
It depends on the isolation level and the database engine. Under locking-based isolation, the second transaction blocks (waits) until the first one commits or rolls back, then applies its update to the now-committed row. Under MVCC (like PostgreSQL's Serializable Snapshot Isolation), the second transaction may receive a serialization failure error and must be retried by the application — this is correct behavior, not a bug. Under REPEATABLE READ in MySQL InnoDB, the second UPDATE blocks on a row lock. The short answer: the database prevents data corruption, but your application must be prepared to retry transactions that get serialization failures.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.