Transactions in DBMS Explained — ACID, Isolation Levels & Real-World Pitfalls
Every production system that handles money, inventory, or user data relies on one idea to stay sane under pressure: transactions. Without them, a server crash mid-operation could leave your database in a half-written, permanently corrupt state — money gone from one account but never arrived in another, an order placed but stock never decremented. This isn't a theoretical risk. It's the kind of bug that makes the news.
Transactions solve the problem of partial failure. They wrap a group of database operations in an all-or-nothing envelope. If every operation succeeds, the changes are committed and become permanent. If anything goes wrong — a constraint violation, a network blip, a disk error — the entire group is rolled back as if it never happened. The database always stays consistent.
By the end of this article you'll understand not just what ACID means but WHY each property exists and what breaks without it. You'll know how isolation levels trade correctness for performance, what phenomena like phantom reads and dirty reads actually look like in practice, how the database engine enforces all of this under the hood with locks and MVCC, and the production gotchas that senior engineers get bitten by. This is the transaction knowledge that separates people who use databases from people who understand them.
ACID Properties — What They Actually Guarantee (and What They Don't)
ACID is an acronym, but each letter represents an independent guarantee with real mechanical meaning. Let's walk through each one honestly.
Atomicity means the transaction is indivisible. The database engine logs every change to a Write-Ahead Log (WAL) before applying it. On crash or rollback, it replays the log in reverse to undo partial work. No change is visible until the entire transaction commits.
Consistency is the most misunderstood. The database doesn't guarantee your business logic is correct — you do. Consistency means the database enforces its own integrity constraints (foreign keys, NOT NULL, CHECK constraints, unique indexes) and that your transaction moves it from one valid state to another. If your code has a bug that violates a business rule the DB doesn't know about, that's on you.
Isolation means concurrent transactions don't step on each other. This is the hardest property to achieve efficiently, which is why it comes in degrees (isolation levels). Full isolation would mean serializing every transaction — correct but catastrophically slow.
Durability means once a transaction is committed, it survives crashes. The WAL is flushed to disk before the commit acknowledgement is sent back to your application. If the database says 'committed', it means it. This is why fsync calls matter and why disabling them for performance is dangerous in production.
-- Scenario: Transfer $200 from Alice's account to Bob's account. -- This is the canonical ACID demonstration. -- Start an explicit transaction block BEGIN; -- Step 1: Deduct from sender. We use SELECT FOR UPDATE to lock -- Alice's row so no concurrent transaction can read a stale balance. SELECT balance FROM accounts WHERE account_id = 'alice' FOR UPDATE; -- Assume the query returns: balance = 500 -- Step 2: Apply the deduction. CHECK constraint on the table -- prevents balance going negative — Consistency enforced by DB. UPDATE accounts SET balance = balance - 200 WHERE account_id = 'alice'; -- Step 3: Credit the receiver. UPDATE accounts SET balance = balance + 200 WHERE account_id = 'bob'; -- Step 4: Record the transfer for audit purposes. INSERT INTO transfer_log (from_account, to_account, amount, transferred_at) VALUES ('alice', 'bob', 200, NOW()); -- Step 5: Commit. Only at this point are ALL changes made permanent -- and visible to other transactions. The WAL is flushed to disk here. COMMIT; -- If ANY step above had failed (e.g., alice had insufficient balance -- and the CHECK constraint fired), the database would execute: -- ROLLBACK; -- ...and every change above would be undone. Alice keeps her $500. -- Bob gets nothing. The log stays empty. Database is consistent.
UPDATE 1
INSERT 0 1
COMMIT
Isolation Levels, Concurrency Anomalies, and the Performance Trade-off
Isolation is a spectrum. Full serializability is perfectly correct but kills concurrency. Read Uncommitted is blazing fast but dangerously incorrect. The four standard ISO isolation levels let you pick your spot on that spectrum deliberately.
Dirty Read: Transaction A reads data written by Transaction B that hasn't committed yet. If B rolls back, A has read data that never existed.
Non-Repeatable Read: Transaction A reads a row, Transaction B updates and commits that row, and when A reads it again it gets a different value within the same transaction.
Phantom Read: Transaction A queries a range of rows (e.g., WHERE age > 30). Transaction B inserts a new row that matches the range and commits. A re-runs the same query and sees an extra row that 'appeared' — a phantom.
Read Uncommitted prevents nothing. Read Committed (PostgreSQL's default) prevents dirty reads. Repeatable Read prevents dirty and non-repeatable reads but allows phantoms. Serializable prevents all three.
Most production databases default to Read Committed, which is a pragmatic choice — it protects against the most dangerous anomaly (dirty reads) while keeping throughput high. But 'pragmatic' means you still have to think. A financial report running at Read Committed can return internally inconsistent numbers if rows change mid-query.
-- ============================================================ -- DEMONSTRATING A NON-REPEATABLE READ at READ COMMITTED level -- Run these two sessions simultaneously to observe the anomaly. -- ============================================================ -- ---- SESSION A (start first) ---- BEGIN; -- Session A starts a transaction -- First read: product price is 100 SELECT price FROM products WHERE product_id = 42; -- Returns: price = 100 -- (Pause here — now run Session B below and let it commit) -- Second read of the SAME row in the SAME transaction SELECT price FROM products WHERE product_id = 42; -- At READ COMMITTED: Returns price = 150 (non-repeatable read!) -- At REPEATABLE READ: Still returns 100 (anomaly prevented) COMMIT; -- ---- SESSION B (run while Session A is paused) ---- BEGIN; -- A pricing engine updates the product mid-flight UPDATE products SET price = 150 WHERE product_id = 42; COMMIT; -- Session A can now see this committed change -- ============================================================ -- FIX: Upgrade isolation level for Session A -- ============================================================ -- Tell PostgreSQL this transaction needs stronger isolation BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ; SELECT price FROM products WHERE product_id = 42; -- Returns: 100 -- Even after Session B commits its UPDATE, this transaction -- sees a consistent snapshot of data as of the moment BEGIN ran. SELECT price FROM products WHERE product_id = 42; -- Still returns: 100 — anomaly eliminated COMMIT;
price
-------
100
-- Session B commits UPDATE
-- Session A, Second read (READ COMMITTED — anomaly present):
price
-------
150
-- Session A, Second read (REPEATABLE READ — anomaly gone):
price
-------
100
Deadlocks, Savepoints and the Internals of Transaction Management
Deadlocks are an inevitable consequence of locking. Transaction A locks row 1 and wants row 2. Transaction B locks row 2 and wants row 1. Both wait forever — a circular dependency. The database detects this using a wait-for graph. When it finds a cycle, it picks one transaction as the victim (usually the one with the least work done) and kills it with an error, allowing the other to proceed.
The important production insight: deadlocks aren't bugs you eliminate; they're events you handle. Your application must catch the deadlock error code and retry the transaction. If you don't retry, the user just gets an error for a perfectly valid operation.
Lock ordering is the classic prevention strategy: always acquire locks in the same order across all transactions. If every transaction locks accounts by ascending account_id, the circular dependency can never form.
Savepoints give you partial rollback within a transaction. Instead of aborting the entire transaction on a non-fatal error, you roll back to a named checkpoint and continue. This is incredibly useful in batch processing — process 1000 rows, use a savepoint every 100, so a bad row only loses 100 rows of work, not all 1000.
Under the hood, the database engine uses two key structures: the lock manager (tracks who holds what lock and who's waiting) and the transaction log / WAL (records every page modification so the system can undo or redo work). These two structures together make ACID possible.
import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.SQLException; import java.sql.Savepoint; public class TransactionWithRetryAndSavepoint { // PostgreSQL error code for serialization failure / deadlock private static final String DEADLOCK_ERROR_CODE = "40P01"; private static final String SERIALIZATION_ERROR_CODE = "40001"; private static final int MAX_RETRY_ATTEMPTS = 3; public static void main(String[] args) throws Exception { String jdbcUrl = "jdbc:postgresql://localhost:5432/forge_bank"; String username = "forge_user"; String password = "forge_pass"; try (Connection conn = DriverManager.getConnection(jdbcUrl, username, password)) { processBatchOrdersWithRetry(conn); } } /** * Processes a batch of orders. Uses savepoints so a single bad * order doesn't abort the entire batch. Retries on deadlock. */ static void processBatchOrdersWithRetry(Connection conn) throws SQLException { int attemptNumber = 0; boolean transactionSucceeded = false; while (!transactionSucceeded && attemptNumber < MAX_RETRY_ATTEMPTS) { attemptNumber++; try { // Turn off auto-commit so we control the transaction boundary conn.setAutoCommit(false); // Set isolation level — Repeatable Read for consistent inventory reads conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ); processBatchWithSavepoints(conn); // All orders processed — make every change permanent conn.commit(); System.out.println("Batch committed successfully on attempt " + attemptNumber); transactionSucceeded = true; } catch (SQLException sqlEx) { String sqlState = sqlEx.getSQLState(); if (DEADLOCK_ERROR_CODE.equals(sqlState) || SERIALIZATION_ERROR_CODE.equals(sqlState)) { // Deadlock or serialization failure — safe to retry System.out.printf("Attempt %d failed with %s (%s). Retrying...%n", attemptNumber, sqlState, sqlEx.getMessage()); conn.rollback(); // Undo everything before retrying try { // Brief back-off to reduce contention before retry Thread.sleep(50L * attemptNumber); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } } else { // Non-retryable error (e.g., constraint violation) — give up conn.rollback(); throw new RuntimeException("Non-retryable DB error: " + sqlEx.getMessage(), sqlEx); } } finally { // Always restore auto-commit so connection pool gets a clean connection conn.setAutoCommit(true); } } if (!transactionSucceeded) { throw new RuntimeException("Batch failed after " + MAX_RETRY_ATTEMPTS + " attempts."); } } /** * Inserts three orders using savepoints. A failure on order 2 * rolls back only order 2, keeping orders 1 and 3. */ static void processBatchWithSavepoints(Connection conn) throws SQLException { String insertOrderSql = "INSERT INTO orders (customer_id, product_id, quantity) VALUES (?, ?, ?)"; // Simulate three orders from the batch int[][] orderBatch = { {101, 42, 2}, // customer 101 wants 2 of product 42 {102, 99, 1}, // customer 102 wants 1 of product 99 (will simulate failure) {103, 17, 5} // customer 103 wants 5 of product 17 }; for (int[] order : orderBatch) { int customerId = order[0]; int productId = order[1]; int quantity = order[2]; // Create a savepoint BEFORE each order attempt Savepoint beforeOrder = conn.setSavepoint("before_order_" + customerId); try (PreparedStatement insertStmt = conn.prepareStatement(insertOrderSql)) { insertStmt.setInt(1, customerId); insertStmt.setInt(2, productId); insertStmt.setInt(3, quantity); insertStmt.executeUpdate(); System.out.printf("Order inserted: customer=%d product=%d qty=%d%n", customerId, productId, quantity); } catch (SQLException orderEx) { // Roll back ONLY this order, not the whole batch System.out.printf("Order for customer %d failed: %s — rolling back to savepoint%n", customerId, orderEx.getMessage()); conn.rollback(beforeOrder); // The savepoint is our safety net // Release the savepoint to free resources conn.releaseSavepoint(beforeOrder); } } } }
Order for customer 102 failed: ERROR: insert or update on table "orders" violates foreign key constraint — rolling back to savepoint
Order inserted: customer=103 product=17 qty=5
Batch committed successfully on attempt 1
| Isolation Level | Dirty Read | Non-Repeatable Read | Phantom Read | Typical Use Case | Performance Cost |
|---|---|---|---|---|---|
| Read Uncommitted | Possible | Possible | Possible | Approximate analytics where speed > accuracy | Lowest |
| Read Committed (default) | Prevented | Possible | Possible | General OLTP — e-commerce, user operations | Low |
| Repeatable Read | Prevented | Prevented | Possible* | Financial reporting, inventory snapshots | Medium |
| Serializable | Prevented | Prevented | Prevented | Banking transfers, booking systems | Highest |
| Snapshot Isolation (MVCC) | Prevented | Prevented | Prevented** | PostgreSQL default for Serializable | Medium-High |
🎯 Key Takeaways
- Atomicity is enforced by the Write-Ahead Log — the DB replays it in reverse on failure, not by holding operations in memory hoping nothing goes wrong.
- Isolation is a performance dial, not a binary switch — default Read Committed prevents dirty reads but still exposes you to lost updates and phantom reads that can corrupt business data.
- Deadlocks are not bugs to eliminate — they're events to handle. Your application layer MUST catch and retry on SQLState 40P01/40001 or users silently lose their writes.
- Savepoints let you achieve partial rollback inside a transaction — essential for batch processing where aborting an entire batch over one bad row is unacceptably expensive.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Keeping transactions open across network calls or user input — Symptom: database connections pile up, lock contention spikes, application becomes unresponsive under load — Fix: open the transaction as late as possible and commit as early as possible. Never wait for an HTTP response, a message queue acknowledgement, or user input inside a transaction boundary. Fetch all the data you need, do your business logic, then open and close the transaction in a tight window.
- ✕Mistake 2: Not handling deadlock errors in application code — Symptom: users get intermittent '40P01 deadlock detected' errors that are logged and swallowed, resulting in data not being written with no retry — Fix: wrap your transaction execution in a retry loop that specifically catches SQLState 40P01 (deadlock) and 40001 (serialization failure), rolls back, waits briefly with exponential back-off, and retries up to a safe maximum. Any other SQL error should propagate immediately without retry.
- ✕Mistake 3: Assuming Read Committed prevents all concurrency problems — Symptom: two concurrent requests both read the same row (e.g., remaining ticket count = 1), both decide there's enough stock, and both commit inserts — resulting in oversold inventory or double-booking — Fix: use SELECT FOR UPDATE to acquire an exclusive row lock before making a decision based on that row's value, or upgrade the transaction isolation level to Repeatable Read / Serializable for that specific operation. Read Committed only prevents dirty reads — it does nothing to prevent the lost update or read-then-write race condition.
Interview Questions on This Topic
- QExplain the difference between Repeatable Read and Serializable isolation. Can you give a concrete example of a scenario that Repeatable Read allows but Serializable prevents?
- QHow does MVCC (Multi-Version Concurrency Control) differ from traditional lock-based concurrency control, and what are the trade-offs of each approach for a write-heavy workload?
- QA senior engineer tells you 'our deadlocks went away after we added an index.' Why could adding an index reduce deadlocks even though indexes seem unrelated to locking?
Frequently Asked Questions
What is the difference between a transaction and a savepoint in a database?
A transaction is the outermost all-or-nothing boundary — either everything commits or everything rolls back. A savepoint is a named checkpoint inside a transaction that allows partial rollback. You can roll back to a savepoint without aborting the whole transaction, making savepoints ideal for error recovery in batch operations where you want to skip a bad record and continue processing the rest.
Why do most databases default to Read Committed instead of Serializable?
Serializable isolation forces the database to detect and prevent all concurrency anomalies, which typically requires aggressive locking or expensive conflict detection. This dramatically reduces throughput under high concurrency. Read Committed prevents only dirty reads — the most dangerous anomaly — while allowing much higher parallelism. Most OLTP applications are designed with optimistic assumptions about conflict that make Read Committed an acceptable trade-off, with specific critical paths upgraded to higher isolation as needed.
Can a transaction span multiple database connections or multiple databases?
Not with standard local transactions. A local transaction is bound to a single database connection. To coordinate transactions across multiple databases or services, you need a distributed transaction protocol like Two-Phase Commit (2PC), or you need to adopt a saga pattern with compensating transactions. 2PC provides strong guarantees but introduces a coordinator bottleneck and blocking failure modes. The saga pattern trades strong consistency for availability and requires explicit rollback logic for each step.
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.