Home CS Fundamentals Transactions in DBMS Explained — ACID, Isolation Levels & Real-World Pitfalls

Transactions in DBMS Explained — ACID, Isolation Levels & Real-World Pitfalls

In Plain English 🔥
Imagine you're at an ATM transferring $200 to a friend. Your bank needs to do two things: subtract $200 from your account AND add $200 to your friend's. A transaction is the guarantee that either BOTH things happen, or NEITHER does — so the bank never takes your money and forgets to give it to your friend. It's like a legal contract for your database: everything inside the contract succeeds together, or the whole deal is torn up and nothing changes.
⚡ Quick Answer
Imagine you're at an ATM transferring $200 to a friend. Your bank needs to do two things: subtract $200 from your account AND add $200 to your friend's. A transaction is the guarantee that either BOTH things happen, or NEITHER does — so the bank never takes your money and forgets to give it to your friend. It's like a legal contract for your database: everything inside the contract succeeds together, or the whole deal is torn up and nothing changes.

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.

BankTransfer.sql · SQL
1234567891011121314151617181920212223242526272829303132333435
-- 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.
▶ Output
UPDATE 1
UPDATE 1
INSERT 0 1
COMMIT
⚠️
Watch Out: Consistency Is NOT the DB's Job AloneThe 'C' in ACID only covers constraints the database knows about. If your application logic says 'a user can't have more than 3 active subscriptions' but there's no database constraint enforcing it, ACID won't save you. Two concurrent transactions can both check the count, both see 2, and both insert a third — leaving the user with 4. You need an application-level lock or a database constraint for true consistency.

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.

IsolationLevelDemo.sql · SQL
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
-- ============================================================
-- 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;
▶ Output
-- Session A, First read:
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
🔥
Interview Gold: How PostgreSQL Actually Implements IsolationPostgreSQL uses Multi-Version Concurrency Control (MVCC), not traditional read locks, to implement Read Committed and Repeatable Read. Every row has hidden xmin and xmax columns tracking which transaction created and deleted it. When you read, the engine returns only the row version that was committed before your transaction started — no read locks needed. This is why readers never block writers in Postgres. MySQL InnoDB uses a similar approach with its undo log. Knowing this separates candidates who memorized definitions from those who understand systems.

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.

TransactionWithRetryAndSavepoint.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
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);
            }
        }
    }
}
▶ Output
Order inserted: customer=101 product=42 qty=2
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
⚠️
Pro Tip: Always Restore Auto-Commit in the finally BlockConnection pools (HikariCP, c3p0) reuse connections. If your transaction code throws an exception after setAutoCommit(false) and you don't restore it in a finally block, the next request to use that pooled connection inherits manual-commit mode and its queries silently accumulate in an open transaction. This is one of the most insidious connection pool bugs in production Java applications.
Isolation LevelDirty ReadNon-Repeatable ReadPhantom ReadTypical Use CasePerformance Cost
Read UncommittedPossiblePossiblePossibleApproximate analytics where speed > accuracyLowest
Read Committed (default)PreventedPossiblePossibleGeneral OLTP — e-commerce, user operationsLow
Repeatable ReadPreventedPreventedPossible*Financial reporting, inventory snapshotsMedium
SerializablePreventedPreventedPreventedBanking transfers, booking systemsHighest
Snapshot Isolation (MVCC)PreventedPreventedPrevented**PostgreSQL default for SerializableMedium-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.

🔥
TheCodeForge Editorial Team Verified Author

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.

← PreviousIndexing in DBMSNext →Deadlock in DBMS
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged