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.
| Property | Guarantee | Mechanism | What 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.
| Anomaly | What happens | Example |
|---|---|---|
| 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 |
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 level | Dirty read | Non-repeatable read | Phantom read | Default 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 |
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
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
}
}
}
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)
| Aspect | Optimistic | Pessimistic |
|---|---|---|
| When the conflict surfaces | At write time (0 rows affected) | Never β the second transaction simply waits |
| Lock held | None | Row-level lock for the transaction's duration |
| Throughput under low contention | Excellent β no blocking | Unnecessary overhead for rare conflicts |
| Behavior under high contention | Retry storms β most writers lose the race and must retry | Serializes cleanly β writers queue instead of failing |
| Deadlock risk | None β no locks to deadlock on | Present β see Section 5 |
| Typical fit | Web applications, low-conflict read-heavy tables | High-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 LOCKEDfor queue/worker-competing-for-rows patterns instead of plainFOR 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
SQLExceptionthe same in a transactional method β a deadlock-detected error is recoverable by retry; a constraint violation is not
Interview Questions
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.
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.