ACID names four properties a database transaction can provide: Atomicity, Consistency, Isolation, and Durability. A transaction groups reads and writes behind one commit decision. The engine can roll the group back, constrain concurrent histories, and recover committed state after a crash, but only within the database and durability settings actually used.

Understanding ACID is essential for designing systems that handle money, inventory, or any data where partial updates are unacceptable.

The Four Properties

Atomicity

All operations in a transaction succeed together, or none of them take effect. There is no partial commit.

Example: transferring $100 from Account A to Account B requires two writes: debit A and credit B. If the debit succeeds but the credit fails (crash, constraint violation), the transaction rolls back — Account A is not debited.

BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 'A';
UPDATE accounts SET balance = balance + 100 WHERE id = 'B';
COMMIT;  -- both succeed, or ROLLBACK undoes both

The useful test is a failure trace, not the happy path:

EventWithout one transactionWith one transaction
Debit A from 400Visible immediatelyTentative change owned by the transaction
Process crashes before crediting BA stays at 100 disappearedRecovery aborts the uncommitted transaction; A returns to $500
Both writes finish and COMMIT succeedsTwo unrelated writes happened to finishDebit and credit become one committed outcome

Atomicity does not make the transfer request safe to repeat. If the client times out after the server commits, retrying the same transaction can transfer another $100. Store an idempotency key or transfer ID under a uniqueness constraint so a retry returns the first result instead of applying the business operation again.

Consistency

A committed transaction moves the database from one valid state to another according to the invariants the system enforces. The database can enforce a CHECK (balance >= 0), a foreign key, a uniqueness constraint, or a trigger. It cannot infer application rules that were never encoded, such as “the sum of the two ledger entries must be zero” or “only one active booking may exist across these rows.” Those invariants need constraints, locked rows or predicates, or a serializable transaction that performs the complete check and write.

Note: consistency in ACID is about database-level rules (constraints, foreign keys, triggers). It is different from the “C” in CAP theorem, which refers to distributed consistency.

Isolation

Isolation controls which effects of concurrent transactions may be observed and which histories the engine permits. It is not an absolute ban on intermediate states: SQL Read Uncommitted may expose uncommitted writes, while stronger levels restrict dirty reads, non-repeatable reads, phantom reads, and broader serialization anomalies. The configured isolation level and engine implementation define the contract:

SQL levelDirty readNon-repeatable readPhantom readSerialization anomaly
Read UncommittedPossiblePossiblePossiblePossible
Read CommittedPreventedPossiblePossiblePossible
Repeatable ReadPreventedPreventedPossiblePossible
SerializablePreventedPreventedPreventedPrevented
  • Dirty read: reading uncommitted data from another transaction (which may roll back).
  • Non-repeatable read: reading the same row twice in a transaction and getting different values because another transaction committed a change between reads.
  • Phantom read: a query returns different rows on two executions because another transaction inserted or deleted rows.

This table states the SQL standard’s minimum guarantees, not a portable prediction of every engine. PostgreSQL maps Read Uncommitted to Read Committed and its Repeatable Read implementation also prevents phantoms, yet still permits serialization anomalies. SQL Server can implement Read Committed with locks or row versioning depending on configuration. Always read the target engine’s isolation documentation before choosing a level.

MVCC and snapshot isolation

Isolation is implemented with locking, MVCC/versioning, or both. See Database Locks for lock modes, granularity, and escalation. Under MVCC, writes create row versions and a snapshot decides which versions a statement may see. PostgreSQL Read Committed takes a new snapshot per statement; Repeatable Read and Serializable use a transaction snapshot. SQL Server uses row versioning for READ_COMMITTED_SNAPSHOT and SNAPSHOT when configured.

MVCC reduces ordinary reader-writer blocking; it does not mean reads and writes can never block. Writers still conflict with writers, explicit locks can block readers, schema changes need stronger locks, and old versions must be retained while a snapshot can still see them. PostgreSQL reclaims dead tuples through VACUUM; SQL Server stores row versions in its version store.

Write skew — the anomaly the table misses

The dirty/non-repeatable/phantom list does not capture every bad history. Snapshot isolation can permit write skew:

Transaction ATransaction B
Reads Alice and Bob: both are on callReads the same snapshot: both are on call
Sets Alice off callSets Bob off call
Commits because it changed only AliceCommits because it changed only Bob

Each transaction preserved “someone else is on call” in its snapshot; together they leave nobody on call. Lock every row or range that carries the invariant, encode the invariant as a database constraint where possible, or use true Serializable isolation. In PostgreSQL, Serializable detects a dangerous read/write dependency and aborts one transaction with SQLSTATE 40001. Retry the whole transaction—including the reads and decisions—with bounded backoff; retrying only the final UPDATE reuses a decision made from a stale snapshot.

Durability

Once a transaction commits under the engine’s durable configuration, its changes survive a database or operating-system crash. With write-ahead logging (WAL), the engine records enough redo information before the corresponding data pages are written. A strict commit waits until the commit record reaches durable storage; recovery replays committed WAL after a crash.

NOTE

Durability is a configured contract. Group commit can preserve local durability while several transactions share one log flush. PostgreSQL synchronous_commit = off may acknowledge before local WAL is flushed and accepts a bounded crash-loss window for lower commit latency. Replication is a separate choice about surviving node loss and availability: asynchronous replicas may lag, while synchronous replication adds a remote acknowledgement to the commit path. See Replication.

A committed row can still disappear when storage hardware lies about flush completion, the filesystem or controller is misconfigured, or the operator restores an older backup. ACID describes the database protocol; the end-to-end durability claim also depends on the storage stack and recovery procedure.

Beyond a Single Database

ACID guarantees are easy within one database engine. The moment a unit of work spans two databases or services, atomicity is no longer free:

  • Two-Phase Commit (2PC) — a coordinator asks every participant to prepare (vote), then commit or abort all together. A participant that has prepared but cannot learn the coordinator’s decision may have to retain locks and recovery state until the decision becomes available. Presumed-abort/commit variants, replicated coordinators, and timeout policies change the operational boundary but do not let a prepared participant decide independently.
  • Saga pattern — instead of one distributed transaction, run a sequence of local ACID transactions, each with a compensating action or forward-recovery step when later work fails. You trade one atomic commit for explicit intermediate states, idempotency, compensation limits, and eventual completion. Choose it only when the business operation can tolerate those semantics. See Distributed Transactions.

Also note that concurrent transactions can produce deadlocks (two transactions each holding a lock the other needs); the engine picks a victim and rolls it back, so transactional code must be retry-safe — see Deadlocks. Retrying is safe only when the application can also resolve an ambiguous commit result without applying the business operation twice.

Pitfalls

Choosing the Wrong Isolation Level

What goes wrong: using Read Committed for financial calculations allows non-repeatable reads — a balance check and a debit in the same transaction can see different values if another transaction commits between them.

Why it happens: Read Committed is the default and feels “safe enough.”

Mitigation: Repeatable Read stabilizes repeated reads and, depending on the engine, may protect a same-row update. For a decision spanning multiple rows or a predicate, use Serializable, explicitly lock every row or range that carries the invariant, or validate optimistic version tokens for every protected record before committing.

Long-Running Transactions

What goes wrong: a transaction holds locks for seconds or minutes, blocking other transactions and causing timeouts.

Why it happens: application code performs slow operations (HTTP calls, file I/O) inside a transaction.

Mitigation: keep transactions short. Do all I/O outside the transaction. Only open the transaction for the database operations themselves.

Tradeoffs

Transaction semantics

ACID describes behavior inside a transaction boundary. Use one transaction when several reads and writes must commit or abort together and when their invariants can be enforced by that database. Choose the weakest isolation level that still prevents the anomalies that would violate those invariants; stronger isolation adds coordination, retries, or blocking. When the boundary spans services, decide separately between a distributed commit protocol and explicit local transactions with messages, idempotency, and compensation.

Replication, consistency, and availability

Replication is a different design axis. An ACID database can serve stale reads from asynchronous replicas, acknowledge a locally durable write before replication, or provide linearizable reads through a leader or quorum. During a network partition, its topology and configuration—not the acronym ACID—determine whether a side rejects writes, routes them elsewhere, or accepts divergent writes that need conflict resolution.

Distributed decisionStricter coordinationLess-coordinated alternativeCost to make explicit
Read freshnessLeader, quorum, or linearizable readReplica read with bounded staleness or eventual convergenceLatency and availability versus stale results
Write behavior during partitionReject or delay writes without the required leader/quorumAccept writes in multiple partitions and reconcileReduced write availability versus conflict semantics
Node-loss durabilityWait for synchronous replica acknowledgementAcknowledge after local durability and replicate asynchronouslyCommit latency versus acknowledged-write loss on failover

Choose transaction semantics from the invariants of one operation. Choose replica freshness, partition behavior, and failover durability from the distributed read/write contract of each data path. Neither decision substitutes for the other.

Isolation level cost

LevelAnomalies preventedLock contentionWhen to use
Read CommittedDirty readsLowIndependent point reads and writes whose invariants are enforced by constraints
Repeatable ReadDirty + non-repeatable; vendor implementations may prevent moreMediumStable transaction snapshots whose cross-row invariants have separate protection
SerializableSerialization anomaliesHighest coordination or abort ratePredicate-spanning financial, inventory, and booking invariants
Snapshot (SQL Server)Dirty, non-repeatable, and phantom reads; write skew remains possibleLow read-write contention (versioned)High-read workloads whose cross-row invariants have separate protection

Decision rule: start with Read Committed (the default). Use Repeatable Read when repeated row reads need a stable view. Use Snapshot isolation for a consistent transaction snapshot with low read-write contention, but protect cross-row invariants separately because write skew remains possible. Use Serializable when predicate-spanning invariants must hold under concurrency.

// Optimistic concurrency as a lighter alternative to Serializable
// EF Core: rowversion column prevents lost updates without table locks
public sealed class Account
{
    public int Id { get; set; }
    public decimal Balance { get; set; }
    [Timestamp]
    public byte[] RowVersion { get; set; } = [];  // EF Core concurrency token
}
 
// If another transaction committed between our read and write,
// EF throws DbUpdateConcurrencyException — retry or surface conflict to user
await db.SaveChangesAsync();

Questions

References