02-database-design-sql

Transactions and Concurrency: ACID, Isolation, and Locking

A staff-engineer guide to ACID guarantees, isolation levels, locking, and deadlocks in production relational databases.

August 14, 2026
backend-engineeracidisolationlockingdeadlocksconcurrency

Transactions and Concurrency

Transactions are how a database protects correctness when hundreds of requests hit the same rows at the same time. Getting isolation levels and locking wrong doesn't show up in a unit test — it shows up three months into production as a race condition that corrupts an account balance or double-books a seat. This guide covers ACID, every standard isolation level, how locking actually works in InnoDB, and how to reason about deadlocks instead of just retrying and hoping.


1. ACID Properties

PropertyGuaranteeWhat breaks it if missing
AtomicityA transaction's writes all happen, or none doA payment debits one account but a crash prevents crediting the other
ConsistencyA transaction moves the database from one valid state to another, respecting constraintsA CHECK constraint, foreign key, or application invariant is violated mid-write
IsolationConcurrent transactions don't see each other's uncommitted intermediate stateOne transaction reads another's half-finished write, acting on data that's about to be rolled back
DurabilityOnce committed, data survives a crash, power loss, or restartA "successful" payment vanishes after a server restart
sql
START TRANSACTION;
 
UPDATE accounts SET balance = balance - 5000 WHERE id = 1 AND balance >= 5000;
-- Application checks affected row count here; if 0, insufficient funds, ROLLBACK
 
UPDATE accounts SET balance = balance + 5000 WHERE id = 2;
 
COMMIT;   -- Atomicity: both updates happen together, or neither does
💡

Consistency is partly your job. The database enforces the constraints you declare (CHECK, NOT NULL, FOREIGN KEY, UNIQUE), but application-level invariants — "a transfer can't leave a balance negative," "an order total must equal the sum of its line items" — are only as consistent as the code and transaction boundaries you write. ACID gives you the mechanism; you still have to use it correctly.


2. Concurrency Anomalies

Before isolation levels make sense, you need to understand the specific ways concurrent transactions can corrupt what a transaction "sees."

AnomalyDefinitionExample
Dirty readReading another transaction's uncommitted (possibly to-be-rolled-back) changesYou see a balance mid-transfer, before the transaction commits or aborts
Non-repeatable readRe-reading the same row within a transaction returns different committed valuesYou read a price twice in one transaction and get two different values because another transaction committed a change in between
Phantom readRe-running the same range query within a transaction returns a different set of rowsYou count "orders with status = PENDING" twice and get a different count because a row was inserted in between
Lost updateTwo transactions read the same value, both write based on it, and one overwrite silently discards the other's changeTwo requests both read stock = 10, both compute 9, both write 9 — one decrement is lost

3. Isolation Levels

The SQL standard defines four isolation levels, each permitting a strict subset of the anomalies above in exchange for better concurrency.

Isolation levelDirty readNon-repeatable readPhantom readTypical use
READ UNCOMMITTEDPossiblePossiblePossibleAlmost never used in production; analytics scans that tolerate imprecision
READ COMMITTEDPreventedPossiblePossiblePostgreSQL's default; most OLTP web apps
REPEATABLE READPreventedPreventedPossible in standard SQL (InnoDB actually prevents most phantoms via gap locks/MVCC)MySQL/InnoDB's default
SERIALIZABLEPreventedPreventedPreventedFinancial ledgers, inventory reservation, anything requiring full correctness
sql
-- Set isolation level for the current session (MySQL)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
 
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
-- ... business logic ...
COMMIT;
⚠️

MySQL and PostgreSQL default to different isolation levels. MySQL/InnoDB defaults to REPEATABLE READ; PostgreSQL defaults to READ COMMITTED. Code that "just works" against a local PostgreSQL instance can behave subtly differently — different anomaly exposure, different locking behavior — when the same queries run against MySQL in production. Always set isolation level explicitly for correctness-critical transactions instead of relying on the engine default.

InnoDB's REPEATABLE READ is stronger than the SQL standard requires — it uses MVCC (multi-version concurrency control) snapshots plus gap locks to prevent most phantom reads too, which the standard doesn't strictly guarantee at that level. This is a well-known InnoDB-specific enhancement; don't assume the same phantom-read protection exists on every database claiming REPEATABLE READ.

How MVCC actually prevents dirty/non-repeatable reads

💡

MVCC avoids the classic "reader blocks writer, writer blocks reader" problem entirely for SELECT statements — readers see a consistent snapshot instead of blocking on locks held by writers. This is why InnoDB and PostgreSQL can offer strong isolation without grinding read-heavy workloads to a halt: plain reads don't take row locks at all under MVCC.


4. Locking in InnoDB

Isolation levels describe the guarantee; locks are the mechanism that enforces it for writes (and for SELECT ... FOR UPDATE reads).

Lock types

Lock typeAcquired byBlocks
Shared lock (S)SELECT ... LOCK IN SHARE MODE / FOR SHAREOther transactions' exclusive locks on the same row
Exclusive lock (X)UPDATE, DELETE, SELECT ... FOR UPDATEAny other lock (shared or exclusive) on the same row
Record lockLocks a single index recordOther transactions modifying that exact row
Gap lockLocks the space between index recordsInsertions into that gap (prevents phantoms)
Next-key lockRecord lock + gap lock combined (InnoDB's default for REPEATABLE READ)Both modifying the row and inserting adjacent to it
sql
-- Explicit locking read: reserve rows for update within a transaction
START TRANSACTION;
 
SELECT quantity FROM inventory
WHERE product_id = 55 AND warehouse_id = 1
FOR UPDATE;   -- exclusive lock held until COMMIT/ROLLBACK
 
-- Application checks quantity >= requested amount here
UPDATE inventory SET quantity = quantity - 3
WHERE product_id = 55 AND warehouse_id = 1;
 
COMMIT;   -- lock released
⚠️

SELECT ... FOR UPDATE is how you prevent the classic lost update race — two concurrent requests both reading quantity = 10, both deciding there's enough stock, and both decrementing, leaving stock at 9 when it should be 8. Without the lock, both SELECTs succeed and both UPDATEs apply, silently overselling inventory. An alternative that avoids holding a lock across a round trip: a single atomic UPDATE inventory SET quantity = quantity - 3 WHERE product_id = 55 AND quantity >= 3, then checking the affected row count — this is usually the better pattern where it fits the business logic.

Optimistic vs pessimistic locking

sql
-- Optimistic locking: no lock held, conflict detected at write time
-- Table has a `version` column
UPDATE products
SET price_cents = 45000, version = version + 1
WHERE id = 55 AND version = 7;
-- If another transaction already bumped version to 8, this affects 0 rows.
-- Application detects 0 rows affected and retries or surfaces a conflict.
AspectPessimistic lockingOptimistic locking
MechanismAcquire lock before reading/modifyingCompare a version/timestamp column on write
Contention costBlocks concurrent transactionsNo blocking; conflicts detected, not prevented
Best forHigh write contention on the same rowsLow contention, long user-think-time between read and write
Failure modeWaiting transactions queue up (or deadlock)Failed writes must be retried by the application
Typical implementationSELECT ... FOR UPDATEversion column, or updated_at timestamp check

Optimistic locking is the right default for anything involving human think-time — e.g., a user editing a document form for two minutes before saving. Holding a database lock for the duration of a human interaction is a reliable way to exhaust your connection pool. Reserve pessimistic locking (FOR UPDATE) for short, tight, machine-speed critical sections.


5. Deadlocks

A deadlock happens when two transactions each hold a lock the other needs, and neither can proceed.

sql
-- Transaction A
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- ... (some delay) ...
UPDATE accounts SET balance = balance + 100 WHERE id = 2;   -- may deadlock
COMMIT;
 
-- Transaction B (running concurrently, opposite order)
START TRANSACTION;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
-- ... (some delay) ...
UPDATE accounts SET balance = balance + 50 WHERE id = 1;    -- may deadlock
COMMIT;
🚨

InnoDB automatically detects deadlocks and kills one of the two transactions (typically the one that has done less work, or based on the deadlock detection heuristic) with error 1213 (ER_LOCK_DEADLOCK). This is not a bug you can prevent at the database level entirely — your application must catch this error and retry the transaction. A service that doesn't handle deadlock errors will surface them as unexplained 500s under concurrent load, usually only noticed once traffic is high enough for lock ordering to actually collide.

Preventing deadlocks

TechniqueHow it helps
Consistent lock orderingAlways acquire locks on rows in the same order (e.g., always by ascending id) across every transaction that touches the same tables
Keep transactions shortFewer statements between BEGIN and COMMIT means less time holding locks
Reduce lock scopeIndex the columns in your WHERE clause — an unindexed UPDATE can lock far more rows (or even a full table scan's worth of gap locks) than necessary
Retry with backoffCatch deadlock errors in application code and retry the transaction, usually with jittered backoff
Lower isolation where safeREAD COMMITTED takes fewer gap locks than REPEATABLE READ, reducing deadlock surface — but re-evaluate anomaly exposure before switching
java
// Typical application-level retry pattern for deadlocks (pseudocode structure)
int attempts = 0;
while (attempts < 3) {
    try {
        transactionTemplate.execute(status -> {
            accountRepository.debit(fromId, amount);
            accountRepository.credit(toId, amount);
            return null;
        });
        break;
    } catch (DeadlockLoserDataAccessException e) {
        attempts++;
        if (attempts == 3) throw e;
        sleepWithJitter(attempts);
    }
}

Consistent lock ordering is the single most effective deadlock prevention technique. If every transaction that touches accounts 1 and 2 always locks the lower id first, the circular-wait condition that causes a deadlock simply can't form. This is worth enforcing as a code-review rule for any multi-row transactional update path (fund transfers, seat reservations, inventory swaps).


6. Transaction Scope in Application Code

🚨

Never make a network call (payment gateway, email service, another microservice) inside an open database transaction. If that call is slow or hangs, every lock your transaction holds stays held for as long as the network call takes — potentially seconds, potentially never returning. This is one of the most common causes of a database-wide outage: one slow external dependency, called from inside a transaction, cascades into connection pool exhaustion for the entire service. Do the external call before opening the transaction (validate first) or after committing (fire the side effect once data is durably saved, ideally via an outbox pattern for reliability).

java
// BAD: external call inside the transaction boundary
@Transactional
public void placeOrder(OrderRequest request) {
    Order order = orderRepository.save(toOrder(request));
    paymentGateway.charge(order);   // network call while DB locks are held!
    inventoryRepository.decrement(order.getItems());
}
 
// BETTER: keep the transaction to DB work only; use an outbox for the side effect
@Transactional
public void placeOrder(OrderRequest request) {
    Order order = orderRepository.save(toOrder(request));
    inventoryRepository.decrement(order.getItems());
    outboxRepository.save(new OutboxEvent("PAYMENT_REQUESTED", order.getId()));
    // A separate async worker reads the outbox and calls paymentGateway.charge(...)
    // outside of any open DB transaction, with its own retry logic.
}

Key takeaways

  • ACID's four properties aren't abstract — atomicity and isolation are what make concurrent writes to the same row safe; durability is what a COMMIT actually promises.
  • MySQL/InnoDB defaults to REPEATABLE READ; PostgreSQL defaults to READ COMMITTED — never assume the default, set isolation explicitly for correctness-critical code.
  • InnoDB's MVCC lets readers see a consistent snapshot without blocking writers, which is why strong isolation doesn't have to mean poor read throughput.
  • Use SELECT ... FOR UPDATE (pessimistic) for short, high-contention critical sections; use a version column (optimistic) when there's human think-time between read and write.
  • Deadlocks are normal under concurrency, not a sign of a broken system — the fix is application-level retry logic plus consistent lock ordering, not trying to eliminate them entirely.
  • Never hold a database transaction open across a network call — it's one of the most common causes of cascading production outages under load.
  • An atomic conditional UPDATE ... WHERE quantity >= ? is often simpler and safer than SELECT ... FOR UPDATE followed by a separate UPDATE.
  • Keep transactions as short as possible; every statement inside BEGIN/COMMIT extends how long your locks block everyone else.

Interview Questions

  • Explain each of the four ACID properties with a concrete example of what breaks if it's missing.
  • What is a dirty read, and which isolation level prevents it?
  • What is the difference between a non-repeatable read and a phantom read?
  • Why do MySQL and PostgreSQL have different default isolation levels, and why does that matter for portability?
  • How does MVCC allow readers to avoid blocking on writer locks?
  • What is the difference between a record lock, a gap lock, and a next-key lock in InnoDB?
  • When would you choose optimistic locking over pessimistic locking? Give a concrete example of each.
  • Walk through how a lost update happens with two concurrent SELECT + UPDATE sequences, and how you'd prevent it.
  • What causes a deadlock, and how does InnoDB resolve one once detected?
  • What is the single most effective technique for preventing deadlocks in a multi-row transactional workflow?
  • Why is it dangerous to call an external HTTP API inside an open database transaction?
  • How would you implement a reliable "charge payment after order is saved" flow without holding locks across the payment call?
  • What does SELECT ... FOR UPDATE actually do, and what's an alternative single-statement pattern that avoids holding a lock across a round trip?
  • Why might lowering isolation from REPEATABLE READ to READ COMMITTED reduce deadlocks, and what do you give up by doing so?