Transactions (ACID)

What the database engine actually does when it commits β€” concurrency anomalies, isolation levels, row locking, deadlocks, and raw JDBC transaction control

← Back to Index

What Is a Database Transaction β€” and Why Does It Exist?

A transaction is a sequence of SQL statements the database engine treats as a single, indivisible unit of work, bounded by BEGIN and either COMMIT or ROLLBACK. Every higher-level abstraction you use β€” JTA's UserTransaction, Spring's @Transactional, JPA's EntityTransaction β€” is ultimately a wrapper around exactly this mechanism: turning off autocommit on a JDBC connection, running statements, and issuing commit() or rollback() on that same connection. This page covers what the database engine itself does to make that guarantee hold β€” the mechanism every framework in this Bible delegates to.

-- WITHOUT an explicit transaction (autocommit ON, the JDBC default):
-- each statement commits independently the instant it runs.
INSERT INTO orders (customer_id, status, total) VALUES (501, 'PENDING', 129.90);
-- ↑ committed immediately, order row now permanent
UPDATE products SET stock = stock - 2 WHERE id = 42;
-- ↑ if the connection drops or this statement fails, the order above
--   already exists in the database with no stock ever decremented.
--   Two customers can now buy the last 2 units of the same product.

-- WITH an explicit transaction: both statements share one fate.
BEGIN;
INSERT INTO orders (customer_id, status, total)
    VALUES (501, 'PENDING', 129.90)
    RETURNING id;                                 -- id = 8842
UPDATE products SET stock = stock - 2
    WHERE id = 42 AND stock >= 2;                 -- guards against overselling
-- application checks affected-row count here:
-- 0 rows updated β†’ insufficient stock β†’ ROLLBACK, reject the order
-- 1 row updated  β†’ proceed β†’ COMMIT
COMMIT;

ACID β€” What the Engine Actually Does for Each Guarantee

ACID is recited far more often than it's understood. Each letter corresponds to a distinct mechanism inside the storage engine β€” knowing which mechanism enforces which guarantee is what lets you reason about failure modes instead of just reciting definitions.

PropertyGuaranteeMechanismWhat it does NOT cover
Atomicity All statements in the transaction take effect, or none do Write-ahead log (WAL) β€” changes are logged before applied; an incomplete transaction is undone by replaying the log's inverse on recovery Partial failure across independently committed transactions in different services β€” that's what the Saga pattern exists for
Consistency Every constraint (FK, unique, check) holds before and after The database engine's constraint checker, evaluated at commit time (or per-statement, depending on the constraint) Business-logic consistency β€” "this order total matches its line items" is your application's responsibility, not the engine's
Isolation Concurrent transactions don't corrupt each other's view of the data Row locks and/or MVCC snapshots β€” mechanism and strength depend entirely on the isolation level (Section 3) Perfect isolation by default β€” the default level on most engines (READ COMMITTED) still permits non-repeatable and phantom reads
Durability Once COMMIT returns, the data survives a crash WAL entries are fsync'd to disk before the commit acknowledgement is returned to the client β€” the data pages themselves can be flushed lazily afterward Storage hardware failure, a misconfigured fsync/synchronous_commit setting, or a replica that hasn't caught up β€” these are operational concerns, not the engine's default guarantee

Concurrency Anomalies β€” What Isolation Actually Protects Against

Isolation levels exist to prevent specific, well-defined anomalies that occur when two transactions run at the same time against the same rows. You cannot reason about which isolation level to choose without first being precise about which anomaly you're preventing.

AnomalyWhat happensExample
Dirty read T1 reads a row T2 modified but hasn't committed. T2 rolls back β€” T1 acted on data that never existed T2 sets stock = 0 mid-transaction; T1 reads 0 and blocks the sale; T2 rolls back and stock was actually 40 all along
Non-repeatable read T1 reads a row twice in the same transaction and gets two different values because T2 committed a change in between T1 reads product.price = 19.90, does some validation, reads it again for the final charge β€” now it's 24.90 because a price update committed in between
Phantom read T1 runs the same range query twice and gets a different set of rows because T2 inserted or deleted a matching row T1 counts orders WHERE status = 'PENDING' twice for a report; T2 inserts a new pending order between the two counts β€” the totals don't match within the same transaction
Lost update T1 and T2 both read the same row, both compute a new value from what they read, and whichever commits last silently overwrites the other's change β€” not just stale data, an actual write disappears T1 and T2 both read stock = 10, both decrement by their own quantity and write back independently β€” the write with the later commit wins, and one customer's stock decrement is erased entirely, oversold inventory follows
Lost update is not prevented by READ COMMITTED β€” this is the anomaly that causes real oversold-inventory incidents

Of the four anomalies, lost update is the one developers most often assume their default isolation level already handles. It doesn't, on most engines, unless the write itself is expressed as an atomic operation. The fix is either a single UPDATE ... SET stock = stock - ? statement (the database computes the new value from the current row under its own lock, so there is no separate "read then write" window), or explicit locking (Section 4), or optimistic version checking (Section 7) β€” never a naive read-modify-write from application code.

Isolation Levels β€” And Where Real Engines Diverge From the Standard

The SQL standard defines four isolation levels in terms of which anomalies they permit. In practice, the actual engines you deploy to implement these levels through different mechanisms β€” locking versus MVCC snapshots β€” and some provide stronger guarantees than the standard requires for a given level. Knowing your specific engine's behavior, not just the ANSI table, is what separates correct concurrency reasoning from cargo-culting.

Isolation levelDirty readNon-repeatable readPhantom readDefault on
READ UNCOMMITTED Possible Possible Possible Almost nothing in production β€” reading data that was never committed is rarely acceptable
READ COMMITTED Prevented Possible Possible PostgreSQL, Oracle, SQL Server β€” good default throughput/safety balance for OLTP
REPEATABLE READ Prevented Prevented Possible per the standard MySQL/InnoDB
SERIALIZABLE Prevented Prevented Prevented Financial reconciliation, regulatory reporting β€” correct but expensive under contention
Two engines both call it "REPEATABLE READ" and both quietly exceed the standard

PostgreSQL's REPEATABLE READ is snapshot isolation: the transaction sees a consistent snapshot of the database taken at its first statement. Because every read for the rest of the transaction comes from that same frozen snapshot, phantom rows genuinely cannot appear β€” Postgres prevents phantom reads at this level in practice, despite the SQL standard only requiring it at SERIALIZABLE. What Postgres does not prevent here is a write-write conflict: if two transactions both try to update the same row from their respective snapshots, the second to commit gets a serialization failure and must retry.

MySQL/InnoDB's REPEATABLE READ also prevents most phantom reads in practice, but through a completely different mechanism: gap locking and next-key locks, which lock the ranges between index entries, not just matching rows β€” so a concurrent INSERT that would land inside an already-queried range is blocked outright rather than merely invisible. This is also precisely why InnoDB deadlocks more often under REPEATABLE READ than READ COMMITTED: gap locks conflict far more frequently than exact-row locks.

Row Locking β€” Shared, Exclusive, and SKIP LOCKED

A shared lock (read lock) allows other transactions to also read the row but blocks writers. An exclusive lock (write lock) blocks everyone else from reading or writing that row until the lock is released at commit or rollback. SELECT ... FOR UPDATE explicitly takes an exclusive lock on the selected rows for the duration of the transaction.

-- Prevent a lost update on inventory by locking the row before reading it
BEGIN;
SELECT stock FROM products WHERE id = 42 FOR UPDATE;
-- any other transaction's SELECT ... FOR UPDATE on id=42 now blocks here
UPDATE products SET stock = stock - 2 WHERE id = 42;
COMMIT;   -- lock released

The production pattern: FOR UPDATE SKIP LOCKED

A naive job queue where multiple worker processes poll the same table with FOR UPDATE serializes every worker onto whichever row is locked first β€” the second worker blocks and waits. SKIP LOCKED tells the engine to simply skip rows already locked by another transaction, letting concurrent workers each grab a different row instead of queuing behind each other. This is the real mechanism behind most hand-rolled outbox and job-queue implementations, before reaching for a dedicated message broker.

-- Worker pattern: N processes competing for pending outbox events,
-- each one claiming a different batch instead of blocking on the same rows
BEGIN;
SELECT id, payload FROM outbox_events
    WHERE status = 'PENDING'
    ORDER BY created_at
    LIMIT 10
    FOR UPDATE SKIP LOCKED;

UPDATE outbox_events SET status = 'PROCESSING'
    WHERE id = ANY(:claimed_ids);
COMMIT;
-- a second worker running the exact same query concurrently gets the
-- NEXT 10 unlocked rows, not a block waiting for the first worker's lock

Deadlocks β€” How They Form and How the Engine Resolves Them

A deadlock happens when two transactions each hold a lock the other one needs, and each is waiting for the other to release it. Neither can proceed. This is not a bug in the database β€” it's an inherent risk of any system that locks resources, and it is entirely preventable at the application layer.

-- Transaction 1: transferring stock from product 42 to product 77
-- Transaction 2: transferring stock from product 77 to product 42
-- Running concurrently, in opposite lock order:

-- T1                                   T2
-- ─────────────────────────────────────────────────────────────
-- BEGIN;                               BEGIN;
-- UPDATE products SET stock=stock-5    UPDATE products SET stock=stock-3
--   WHERE id = 42;   -- locks 42         WHERE id = 77;   -- locks 77
--                                        (both locks acquired so far)
-- UPDATE products SET stock=stock+5    UPDATE products SET stock=stock+3
--   WHERE id = 77;   -- BLOCKS,          WHERE id = 42;   -- BLOCKS,
--   waiting for T2 to release 77         waiting for T1 to release 42
--
-- Neither can proceed β†’ circular wait β†’ DEADLOCK
The engine breaks the deadlock for you β€” but your code must handle it

Every production database engine runs a deadlock detector that periodically checks the "waits-for" graph among active locks. When it finds a cycle, it picks one transaction as the victim β€” typically the one that has done the least work, or the one that would be cheapest to roll back β€” and aborts it with a deadlock-detected error (SQLState 40001 on most engines), while letting the other proceed. Your application must catch this specific error and retry the aborted transaction; treating it as a generic failure and giving up loses the operation.

The structural fix β€” the one that actually prevents deadlocks instead of just handling them after the fact β€” is always acquiring locks on multiple rows in the same, consistent order across your entire codebase (for example, always by ascending primary key):

// Sort by ID before locking β€” every caller now acquires locks in the same
// order, so the circular wait above becomes structurally impossible
public void transferStock(Long fromProductId, Long toProductId, int quantity) {
    Long first  = Math.min(fromProductId, toProductId);
    Long second = Math.max(fromProductId, toProductId);

    // Lock in ascending ID order regardless of transfer direction
    em.find(Product.class, first,  LockModeType.PESSIMISTIC_WRITE);
    em.find(Product.class, second, LockModeType.PESSIMISTIC_WRITE);

    // ... apply the actual stock movement using fromProductId / toProductId
}

Raw JDBC Transaction Control β€” What Every Framework Wraps

Autocommit is true by default on a JDBC Connection β€” every statement commits on its own the instant it executes. Multi-statement atomicity requires explicitly disabling it, and explicitly restoring it before the connection goes back to the pool, since a pooled connection with autocommit still disabled will silently corrupt the next borrower's behavior.

public void placeOrder(Long customerId, Long productId, int quantity, BigDecimal total) throws SQLException {
    try (Connection conn = dataSource.getConnection()) {
        conn.setAutoCommit(false);

        try {
            Long orderId;
            try (PreparedStatement insertOrder = conn.prepareStatement(
                    "INSERT INTO orders (customer_id, status, total) VALUES (?, 'PENDING', ?)",
                    Statement.RETURN_GENERATED_KEYS)) {
                insertOrder.setLong(1, customerId);
                insertOrder.setBigDecimal(2, total);
                insertOrder.executeUpdate();

                try (ResultSet keys = insertOrder.getGeneratedKeys()) {
                    keys.next();
                    orderId = keys.getLong(1);
                }
            }

            // Savepoint: if the stock decrement fails due to insufficient stock,
            // roll back only this part and mark the order as failed β€”
            // without undoing the order row that was just inserted above.
            Savepoint beforeStockUpdate = conn.setSavepoint("beforeStockUpdate");

            try (PreparedStatement decrementStock = conn.prepareStatement(
                    "UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?")) {
                decrementStock.setInt(1, quantity);
                decrementStock.setLong(2, productId);
                decrementStock.setInt(3, quantity);

                if (decrementStock.executeUpdate() == 0) {
                    conn.rollback(beforeStockUpdate);
                    markOrderFailed(conn, orderId, "INSUFFICIENT_STOCK");
                } else {
                    conn.releaseSavepoint(beforeStockUpdate);
                }
            }

            conn.commit();

        } catch (SQLException e) {
            conn.rollback();
            throw e;
        } finally {
            conn.setAutoCommit(true);   // mandatory before returning to the pool
        }
    }
}
Deadlock retry with raw JDBC

Catch the deadlock SQLState explicitly and retry a bounded number of times, rather than treating every SQLException the same way:

int attempts = 0;
while (true) {
    try {
        placeOrder(customerId, productId, quantity, total);
        break;
    } catch (SQLException e) {
        if ("40001".equals(e.getSQLState()) && ++attempts < 3) {
            continue;   // deadlock victim β€” safe to retry
        }
        throw new RuntimeException("Order failed after retries", e);
    }
}

Optimistic vs Pessimistic Locking β€” At the SQL Level

Both strategies solve the lost-update anomaly from Section 2, but at opposite points in time: pessimistic locking prevents the conflict from ever happening; optimistic locking lets it happen and detects it at write time.

-- PESSIMISTIC: acquire the lock up front (see Section 4) β€” nobody else
-- can even read-for-update this row until this transaction ends.
SELECT stock FROM products WHERE id = 42 FOR UPDATE;
UPDATE products SET stock = stock - 2 WHERE id = 42;

-- OPTIMISTIC: no lock at all β€” read the current version, write only if
-- the version hasn't changed since. If another transaction committed
-- first, this UPDATE affects 0 rows and the application must detect it.
SELECT stock, version FROM products WHERE id = 42;   -- version = 7
UPDATE products
    SET stock = stock - 2, version = version + 1
    WHERE id = 42 AND version = 7;
-- affected rows == 0 β†’ someone else updated first β†’ application retries
--   or surfaces a conflict to the caller (JPA does exactly this via @Version,
--   throwing OptimisticLockException β€” see JPA & Hibernate)
AspectOptimisticPessimistic
When the conflict surfacesAt write time (0 rows affected)Never β€” the second transaction simply waits
Lock heldNoneRow-level lock for the transaction's duration
Throughput under low contentionExcellent β€” no blockingUnnecessary overhead for rare conflicts
Behavior under high contentionRetry storms β€” most writers lose the race and must retrySerializes cleanly β€” writers queue instead of failing
Deadlock riskNone β€” no locks to deadlock onPresent β€” see Section 5
Typical fitWeb applications, low-conflict read-heavy tablesHigh-contention counters (flash-sale inventory), financial ledgers

Best Practices and Common Pitfalls

βœ… Do

  • Keep transactions as short as possible β€” start late, commit early. No HTTP calls, no file I/O, no waiting on user input while a transaction is open
  • Express read-modify-write operations as a single atomic UPDATE ... SET x = x - ? statement whenever possible β€” it closes the lost-update window without needing any lock at all
  • Choose the isolation level deliberately, per query if your driver allows it β€” don't raise it globally "just in case"; know exactly which anomaly you're preventing and why
  • Use FOR UPDATE SKIP LOCKED for queue/worker-competing-for-rows patterns instead of plain FOR UPDATE, which serializes workers unnecessarily
  • Acquire locks on multiple rows in one consistent, codebase-wide order (e.g. ascending primary key) β€” this eliminates entire classes of deadlocks structurally, not reactively
  • Catch the deadlock SQLState explicitly and retry with a bounded attempt count β€” a well-designed transaction should be safely retryable
  • Explicitly reset autocommit(true) before returning a connection to the pool

❌ Don't

  • Don't leave a connection idle mid-transaction while waiting on an external system β€” this is the single most common cause of "connection pool exhausted" incidents in production
  • Don't assume READ COMMITTED prevents lost updates β€” it doesn't; only atomic statements, explicit locking, or optimistic version checks do
  • Don't set SERIALIZABLE globally hoping it "fixes concurrency bugs" β€” it multiplies serialization failures under load and every writer needs a retry loop to survive it
  • Don't assume your isolation level's behavior matches the ANSI standard exactly β€” verify against your specific engine (Section 3); code that's correct on PostgreSQL can behave differently on MySQL
  • Don't use pessimistic locking on read-heavy, low-conflict tables β€” the lock overhead costs far more throughput than the rare conflict it prevents
  • Don't treat every SQLException the same in a transactional method β€” a deadlock-detected error is recoverable by retry; a constraint violation is not

Interview Questions

πŸŽ“ Junior level

Q: What is a database transaction, and what do COMMIT and ROLLBACK do?
A transaction is a group of SQL statements the database treats as one indivisible unit, started with BEGIN. COMMIT makes every change in the transaction permanent and visible to other transactions. ROLLBACK undoes every change made since BEGIN, as if none of it ever happened.

Q: What is the difference between a dirty read and a non-repeatable read?
A dirty read is reading data another transaction hasn't committed yet β€” that data might never actually exist if the other transaction rolls back. A non-repeatable read is reading the same already-committed row twice within one transaction and getting two different values, because another transaction committed a change to it in between the two reads.

Q: What is the difference between optimistic and pessimistic locking?
Pessimistic locking acquires a database lock up front (SELECT ... FOR UPDATE), so no other transaction can touch that row until the lock is released. Optimistic locking takes no lock at all; it reads a version number, and the final write only succeeds if the version hasn't changed β€” otherwise it fails and the caller retries.

πŸ”₯ Senior level

Q: Explain why PostgreSQL's REPEATABLE READ prevents phantom reads even though the SQL standard doesn't require that at this level.
PostgreSQL implements REPEATABLE READ as snapshot isolation: the transaction takes a consistent snapshot of the entire database at its first statement, and every subsequent read in that transaction is served from that same frozen snapshot regardless of what other transactions commit afterward. Because the snapshot itself cannot gain new rows, a phantom read is structurally impossible β€” not merely policy-prevented, but mechanically impossible given how MVCC visibility works. What Postgres does not prevent at this level is a write-write conflict: if two transactions both try to update the same row based on their respective snapshots, the second to commit receives a serialization failure and must retry the whole transaction. This is a different failure mode than locking-based engines, where the second writer simply blocks instead of failing.

Q: Describe how a deadlock forms between two transactions and how the database resolves it. How do you prevent it at the application level?
A deadlock forms when transaction A holds a lock transaction B needs, and B simultaneously holds a lock A needs β€” a circular wait where neither can proceed. The database's deadlock detector periodically scans the waits-for graph among active locks; when it finds a cycle, it picks one transaction as the victim (typically the cheapest to roll back) and aborts it with a deadlock error, letting the other proceed. The application must catch that specific error (SQLState 40001) and retry the aborted transaction β€” treating it as a fatal error loses the operation. The real prevention, though, is structural: always acquire locks on multiple rows in the same consistent order across the entire codebase (for example, sorted by ascending primary key before locking), which makes the circular wait condition impossible to construct in the first place, regardless of which direction the business operation logically flows.

Q: You're building a flash-sale inventory decrement that will see extremely high write contention on a small number of product rows. Would you choose SELECT ... FOR UPDATE or optimistic locking with a version column, and what failure mode does your choice carry under load?
Pessimistic locking (FOR UPDATE) is the correct choice here specifically because contention is high and concentrated on a few rows. Optimistic locking under this exact scenario produces a retry storm: nearly every concurrent writer reads the same version, all but one lose the race at commit time, and all of those retry β€” re-reading, recomputing, and re-attempting the write, often colliding again on the next attempt too. That retry amplification can make optimistic locking perform worse than pessimistic locking under exactly the high-contention condition optimistic locking is usually praised for avoiding elsewhere. Pessimistic locking instead serializes writers cleanly through the lock queue β€” worse latency per request under load, but no wasted retry work and no risk of an update being silently lost. The trade-off to watch is deadlock risk: with multiple rows locked per transaction, lock ordering (previous question) becomes mandatory, not optional.