ACID vs BASE
Every database makes a fundamental choice about how it handles data integrity. ACID (Atomicity, Consistency, Isolation, Durability) prioritises correctness at all costs, while BASE (Basically Available, Soft-state, Eventually consistent) trades strict correctness for availability and performance. Understanding these two philosophies — and the rich spectrum between them — is essential for choosing the right database and designing resilient distributed systems.
In this post we will dissect every ACID and BASE property with concrete examples, explore SQL isolation levels and the anomalies they prevent, examine distributed transaction patterns like 2PC and Saga, and build interactive animations that make these abstract concepts tangible.
ACID Properties
ACID is the gold standard for relational databases. Every property addresses a specific failure mode that can corrupt your data.
All or Nothing
A transaction is an indivisible unit of work. Either every operation within the transaction succeeds and is applied, or none of them are. There is no halfway state. If any step fails, the database rolls back all prior steps as if nothing happened.
Real-world example — bank transfer:
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 'A'; -- debit
UPDATE accounts SET balance = balance + 100 WHERE id = 'B'; -- credit
COMMIT;
If the server crashes after the debit but before the credit, atomicity guarantees the debit is rolled back. Money is neither created nor destroyed.
How it works: Databases use an undo log (also called a rollback segment). Before modifying any page, the original value is written to the undo log. On crash recovery, the database replays the undo log to reverse uncommitted changes. PostgreSQL uses a multi-version approach (old row versions stay in the heap), while InnoDB maintains a separate undo tablespace.
Invariants Always Hold
A transaction moves the database from one valid state to another valid state. All constraints — primary keys, foreign keys, CHECK constraints, triggers, and application-defined invariants — must be satisfied after the transaction commits.
Example:
ALTER TABLE accounts ADD CONSTRAINT positive_balance CHECK (balance >= 0);
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 1500 WHERE id = 'A'; -- A has $1000
-- CHECK constraint violated! balance would be -500
ROLLBACK; -- transaction cannot commit
Consistency is a partnership between the database and the application. The database enforces declared constraints; the application must ensure the business logic is correct. If your transfer logic debits without crediting, the database cannot catch that semantic error unless you encode it as a constraint.
Important nuance: The “C” in ACID is fundamentally different from the “C” in CAP theorem. ACID consistency means constraints are satisfied. CAP consistency means every read returns the most recent write across replicas.
Concurrent Transactions Don’t Interfere
Even though hundreds of transactions may execute concurrently, each one behaves as if it were the only transaction running. The result of concurrent execution is equivalent to some serial ordering of those transactions.
In practice, full serialisable isolation is expensive. Most databases offer weaker isolation levels that trade some anomalies for better performance. We cover these in detail in the next section.
Without isolation (no concurrency control):
-- T1: Transfer $100 from A to B
-- T2: Calculate total balance across all accounts
-- If T2 reads A after T1's debit but before T1's credit:
-- T2 sees $100 "missing" from the system!
-- With proper isolation, T2 either sees the state before T1 or after T1, never in between.
Committed Data Survives Crashes
Once a transaction commits, its changes are permanent. Even if the server loses power one millisecond after COMMIT returns, the data will be there when the system restarts.
How it works:
- Write-Ahead Logging (WAL): Before modifying data pages, the database writes the change to a sequential log file. The log is flushed to disk (
fsync) at commit time. On recovery, the WAL is replayed to reconstruct committed changes. - fsync: The
fsync()system call forces the OS to flush file data from the page cache to physical storage. Without it, a power failure could lose data that the application believed was safely on disk. - Checkpointing: Periodically, the database writes dirty pages to the data files and advances the checkpoint position in the WAL. This limits how much WAL must be replayed on recovery.
PostgreSQL’s WAL: PostgreSQL writes WAL records to pg_wal/. The synchronous_commit setting controls whether COMMIT waits for the WAL flush. Setting it to off improves performance but risks losing the last few transactions on crash — a deliberate trade of durability for speed.
MySQL/InnoDB: The innodb_flush_log_at_trx_commit setting offers three modes: 1 (flush on every commit — full durability), 2 (flush to OS buffer only), 0 (flush every second — up to 1s of data loss on crash).
▶ ACID Transaction: Bank Transfer
Watch a bank transfer succeed or fail with atomicity and durability guarantees.
Isolation Levels
The SQL standard defines four isolation levels, each progressively stricter. The level you choose determines which anomalies your transactions may encounter.
| Isolation Level | Dirty Read | Non-Repeatable Read | Phantom Read | Write Skew |
|---|---|---|---|---|
| Read Uncommitted | Possible | Possible | Possible | Possible |
| Read Committed | Prevented | Possible | Possible | Possible |
| Repeatable Read | Prevented | Prevented | Varies* | Possible |
| Serializable | Prevented | Prevented | Prevented | Prevented |
*MySQL/InnoDB’s Repeatable Read prevents phantoms via gap locks. PostgreSQL’s does not fully prevent them.
Read Uncommitted
The weakest level. A transaction can read data written by another transaction that has not yet committed. This is called a dirty read. If that other transaction rolls back, you have read data that never actually existed.
-- Session 1 -- Session 2
SET TRANSACTION ISOLATION LEVEL SET TRANSACTION ISOLATION LEVEL
READ UNCOMMITTED; READ UNCOMMITTED;
BEGIN; BEGIN;
UPDATE products SET price = 0
WHERE id = 42;
SELECT price FROM products
WHERE id = 42;
-- Returns 0! (uncommitted)
ROLLBACK;
-- Session 2 used price=0 for
-- a calculation, but it was
-- never real data.
Almost no production system uses this level. PostgreSQL doesn’t even implement it — READ UNCOMMITTED silently behaves as READ COMMITTED.
Read Committed
The default level in PostgreSQL and Oracle. Each statement within a transaction sees only data committed before that statement began. You never see uncommitted data, but the same query run twice might return different results if another transaction committed in between.
-- Session 1 -- Session 2
BEGIN; BEGIN;
SELECT balance FROM accounts
WHERE id = 'A'; -- returns 1000
UPDATE accounts SET balance = 500
WHERE id = 'A';
COMMIT;
SELECT balance FROM accounts
WHERE id = 'A'; -- returns 500!
-- Same query, different result
-- (non-repeatable read)
COMMIT;
Repeatable Read
The default level in MySQL/InnoDB. The transaction sees a consistent snapshot taken at the start of the first read. All subsequent reads return data from that snapshot, regardless of concurrent commits.
-- Session 1 (Repeatable Read) -- Session 2
BEGIN; BEGIN;
SELECT balance FROM accounts
WHERE id = 'A'; -- returns 1000
UPDATE accounts SET balance = 500
WHERE id = 'A';
COMMIT;
SELECT balance FROM accounts
WHERE id = 'A'; -- STILL 1000!
COMMIT;
MySQL vs PostgreSQL difference: MySQL/InnoDB uses gap locks and next-key locks to prevent phantom reads at this level. PostgreSQL uses MVCC snapshots, which prevents phantoms for SELECT but not for write-write conflicts involving new rows.
Serializable
The strictest level. Transactions execute as if they ran one after another (serial order). All anomalies are prevented, but throughput drops because the database must detect and abort conflicting transactions.
-- PostgreSQL's Serializable Snapshot Isolation (SSI)
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT sum(balance) FROM accounts; -- snapshot read
-- ... some logic ...
UPDATE accounts SET balance = balance + interest WHERE ...;
COMMIT; -- may fail with "could not serialize access"
PostgreSQL implements Serializable Snapshot Isolation (SSI), which detects dangerous read-write dependency cycles and aborts one of the conflicting transactions. MySQL uses two-phase locking (2PL) with shared and exclusive locks.
▶ Isolation Levels: Dirty Read vs Serializable
Two concurrent transactions T1 and T2. Compare Read Uncommitted (dirty read) vs Serializable (no anomaly).
Transaction Anomalies
Each anomaly represents a specific way that concurrent transactions can produce incorrect results. Understanding them is key to choosing the right isolation level.
Dirty Read
A transaction reads data written by a concurrent transaction that has not yet committed. If the writing transaction rolls back, the reading transaction has based its decisions on phantom data.
Scenario: T1 updates a product price from $100 to $0 (testing). T2 reads the $0 price and charges a customer nothing. T1 rolls back. The product was never free — but the customer was charged $0.
Non-Repeatable Read (Fuzzy Read)
A transaction reads the same row twice and gets different values because another transaction modified and committed the row in between.
Scenario: A report transaction reads employee Alice’s salary as $80,000. Meanwhile, a payroll transaction gives Alice a raise to $90,000 and commits. The report reads Alice’s salary again and gets $90,000. The report is internally inconsistent — it used $80,000 for one calculation and $90,000 for another.
Phantom Read
A transaction re-executes a query with a range condition and gets different rows because another transaction inserted or deleted rows that match the condition.
Scenario: T1 counts all employees in engineering: SELECT count(*) FROM employees WHERE dept = 'eng' → 50. T2 inserts a new engineer and commits. T1 re-runs the count → 51. The “phantom” row appeared between two identical queries.
Write Skew
Two transactions read overlapping data, make decisions based on what they read, and write to different rows — but the combined effect violates an invariant that neither transaction violated individually.
Scenario: Hospital rule: at least one doctor must be on call. Doctors Alice and Bob are both on call. T1 checks: “Two doctors on call, I can remove Alice.” Concurrently, T2 checks the same and removes Bob. Both commit. Now zero doctors are on call — invariant violated.
-- T1 -- T2
BEGIN; BEGIN;
SELECT count(*) FROM doctors SELECT count(*) FROM doctors
WHERE on_call = true; -- 2 WHERE on_call = true; -- 2
-- "Safe to remove one" -- "Safe to remove one"
UPDATE doctors SET on_call = false UPDATE doctors SET on_call = false
WHERE name = 'Alice'; WHERE name = 'Bob';
COMMIT; COMMIT;
-- Result: 0 doctors on call!
Only Serializable isolation prevents write skew. Repeatable Read doesn’t, because the two transactions write to different rows (no write-write conflict).
Lost Update
Two transactions read the same value, compute a new value based on it, and write back. The second write overwrites the first, losing its update.
Scenario: Counter value is 10. T1 reads 10, computes 10+1=11. T2 reads 10, computes 10+1=11. T1 writes 11. T2 writes 11. Expected result: 12. Actual: 11 — T1’s increment is lost.
-- Prevention: use atomic operations
UPDATE counters SET value = value + 1 WHERE id = 42;
-- Or use SELECT ... FOR UPDATE to acquire a row-level lock
BASE Properties
BASE is the philosophical opposite of ACID. It was popularised by the NoSQL movement and is rooted in the CAP theorem — if you need high availability in a partitioned network, you must relax consistency.
The System Always Responds
The system guarantees a response for every request, even during partial failures. The response might not contain the most recent data, but it will respond. This is in contrast to ACID systems that might block or reject requests to maintain consistency.
Example: A Cassandra cluster with a replication factor of 3 and consistency_level = ONE. Even if 2 of 3 replicas are down, the system can still serve reads and accept writes from the remaining replica. The data may be stale, but the system is available.
In practice: Amazon’s shopping cart (built on Dynamo) prioritised availability. Even during network partitions, customers could always add items to their cart. Conflicts were resolved later using vector clocks and “last-write-wins.”
State May Change Without Input
The system’s state can change over time even without new writes. This happens because of background replication, conflict resolution, and garbage collection. You cannot assume that data you read a moment ago is still valid.
Example: In an eventually consistent system, Node A might have user.email = "old@example.com". Without any new write to Node A, a background replication process propagates a newer value from Node B, changing it to "new@example.com". The state changed without any client writing to Node A.
Contrast with ACID: In a traditional RDBMS, data only changes when a transaction explicitly modifies it. The state is “hard” — it remains exactly as the last committed transaction left it.
The System Converges… Eventually
If no new updates are made, all replicas will eventually converge to the same value. The keyword is “eventually” — there is no bound on how long convergence takes (though in practice it’s typically milliseconds to seconds).
Example: You update your profile picture on a social network. For a few seconds, some users see the old picture (they’re hitting replicas that haven’t received the update yet). After replication completes, everyone sees the new picture.
DNS is perhaps the most widely-used eventually consistent system. When you update a DNS record, it can take hours for the change to propagate through the global DNS cache hierarchy (TTL-based).
Why NoSQL Chose BASE
Traditional RDBMS systems were designed for single-node or tightly-coupled clusters. When web-scale applications needed to serve millions of users across continents, they hit fundamental limits:
- Latency: Coordinating distributed transactions across data centers introduces 100ms+ of latency per round-trip. ACID’s synchronous coordination is too slow for global services.
- Availability: ACID requires all participants to agree before committing. If any node is unreachable, the transaction blocks. In a large distributed system, something is always failing.
- Throughput: Locking and serialization bottleneck write throughput. BASE systems can accept writes on any node without coordination, enabling linear write scaling.
Google’s Bigtable, Amazon’s DynamoDB, Apache Cassandra, and MongoDB (before v4.0) all adopted BASE semantics to achieve the availability and horizontal scalability that ACID could not easily provide at massive scale.
▶ Eventual Consistency: Replica Convergence
A write propagates across 3 replica nodes. Stale reads occur until replication completes.
ACID vs BASE Comparison
| Dimension | ACID | BASE |
|---|---|---|
| Consistency model | Strong (immediate) | Eventual |
| Availability | May block under failures | Always responds |
| Concurrency | Pessimistic (locks) or optimistic (MVCC) | Optimistic (conflict resolution) |
| Scaling | Vertical (scale-up) or limited horizontal | Horizontal (scale-out) |
| Performance | Lower write throughput (coordination overhead) | Higher write throughput (no coordination) |
| Data integrity | Guaranteed by constraints | Application-level enforcement |
| Use cases | Banking, inventory, booking systems | Social feeds, analytics, caching, IoT |
| Typical databases | PostgreSQL, MySQL, Oracle, SQL Server | Cassandra, DynamoDB, Couchbase, Riak |
| Failure handling | Rollback + retry | Conflict resolution + reconciliation |
| Programming model | Simple (database handles correctness) | Complex (application handles edge cases) |
When to Use ACID
- Financial transactions — money must never be created or destroyed
- Inventory management — you can’t sell the last item to two customers
- Booking/reservation systems — double-booking a hotel room is unacceptable
- Healthcare records — medical data integrity is a regulatory requirement
- Any domain where incorrect data is worse than slow or unavailable data
When to Use BASE
- Social media feeds — a few seconds of stale data is perfectly fine
- Analytics / event logging — approximate counts are acceptable
- Content delivery / caching — serving slightly stale content is better than serving nothing
- IoT sensor data — millions of writes per second, eventual aggregation
- Any domain where availability and speed matter more than immediate correctness
The Spectrum: Tunable Consistency
The ACID-vs-BASE choice is not binary. Many modern databases let you tune the trade-off per query:
Cassandra: Tunable Consistency
With replication factor 3, you can choose:
ONE— fastest, read from 1 replica (BASE-like)QUORUM— read from 2 of 3 replicas (strong-ish)ALL— read from all 3 replicas (ACID-like, but loses availability)
Rule: R + W > N gives you strong consistency (where R=read replicas, W=write replicas, N=total replicas).
MongoDB: Configurable Guarantees
Since v4.0, MongoDB supports multi-document ACID transactions. You can choose:
writeConcern: {w: 1}— acknowledge from primary only (fast)writeConcern: {w: "majority"}— durable across replicasreadConcern: "linearizable"— strongest read guarantee
Even Google’s Spanner proves that globally-distributed ACID is possible — using GPS-synchronised atomic clocks (TrueTime) to achieve external consistency. The trade-off is higher latency (commit waits for clock uncertainty to elapse).
Implementing Transactions in Distributed Systems
When data spans multiple services or databases, maintaining transactional guarantees becomes dramatically harder. Here are the most common patterns:
1. Two-Phase Commit (2PC)
The classic protocol for distributed ACID. A coordinator orchestrates the transaction across multiple participants.
- Phase 1 (Prepare): The coordinator asks each participant: “Can you commit?” Each participant writes to its WAL, acquires locks, and responds
YESorNO. - Phase 2 (Commit/Abort): If all participants voted YES, the coordinator sends
COMMIT. If any voted NO, it sendsABORTand all participants roll back.
Trade-offs:
- Blocking: If the coordinator crashes after Phase 1, participants hold locks indefinitely until the coordinator recovers.
- Single point of failure: The coordinator is critical. Its failure can stall the entire system.
- Latency: Two round-trips minimum, plus disk flushes at every participant.
- Strong guarantee: True distributed atomicity.
Used by: XA transactions (Java EE), distributed databases (CockroachDB, Spanner internally), some message queues.
2. Saga Pattern
A sequence of local transactions, each with a compensating action that undoes its effect. If any step fails, the saga executes compensating actions in reverse order.
// Order Saga
Step 1: Create Order → Compensate: Cancel Order
Step 2: Reserve Inventory → Compensate: Release Inventory
Step 3: Charge Payment → Compensate: Refund Payment
Step 4: Ship Order → Compensate: Cancel Shipment
// If Step 3 fails:
Compensate Step 2: Release Inventory
Compensate Step 1: Cancel Order
Orchestration vs Choreography:
- Orchestration: A central saga coordinator directs each step. Easier to understand, single point of control.
- Choreography: Each service listens for events and decides what to do next. More decoupled, but harder to debug.
Trade-offs: No distributed locks (better availability and performance). But you get eventual consistency and must carefully design compensating actions. Some actions are hard to compensate (e.g., sending an email).
3. TCC (Try-Confirm-Cancel)
A reservation-based pattern. Each participant implements three operations:
- Try: Reserve resources (e.g., put a hold on inventory, pre-authorise payment). No permanent changes.
- Confirm: Commit the reservation. Make it permanent.
- Cancel: Release the reservation. Undo the Try.
Advantage over Saga: The Try phase is a “tentative” operation. If confirmation never arrives (coordinator crash), a timeout automatically triggers Cancel. No permanently inconsistent state.
Used by: Payment systems (pre-auth → capture/void), airline seat reservations.
4. Outbox Pattern
Solves the dual-write problem: “How do I update a database AND publish an event atomically?”
-- Within a single ACID transaction:
BEGIN;
UPDATE orders SET status = 'confirmed' WHERE id = 123;
INSERT INTO outbox (aggregate_id, event_type, payload)
VALUES (123, 'OrderConfirmed', '{"orderId": 123}');
COMMIT;
-- A separate process (CDC or poller) reads the outbox
-- table and publishes events to Kafka/RabbitMQ.
How it works: Instead of writing to the database and the message broker in two separate calls (which can fail between the two), you write the event to an outbox table in the same database transaction. A separate process (often Debezium CDC) tails the outbox table and reliably publishes events.
Trade-off: At-least-once delivery (consumers must be idempotent). But you get guaranteed eventual delivery without distributed transactions.
MVCC (Multi-Version Concurrency Control)
MVCC is the secret behind modern databases’ ability to handle thousands of concurrent transactions without locking everything. Instead of blocking readers when a writer modifies a row, the database keeps multiple versions of each row and gives each transaction its own consistent “snapshot” of the data.
How MVCC Works
When a transaction modifies a row, the database doesn’t overwrite the old version. Instead, it creates a new version of the row, tagged with the writing transaction’s ID. The old version remains available for other transactions that started before the write.
PostgreSQL’s MVCC Implementation
PostgreSQL stores all row versions directly in the heap (the main table files). Each row version (called a “tuple”) has two hidden columns:
xmin— the transaction ID that created this version (via INSERT or UPDATE)xmax— the transaction ID that deleted or replaced this version (0 if still live)
-- You can actually see these hidden columns:
SELECT xmin, xmax, * FROM accounts WHERE id = 'A';
-- xmin: 1000, xmax: 0 → created by txn 1000, still live
-- After an UPDATE in txn 1050:
-- Old version: xmin=1000, xmax=1050 (dead, replaced by 1050)
-- New version: xmin=1050, xmax=0 (current live version)
A transaction with ID 1025 (started between 1000 and 1050) will see the old version because xmin=1000 ≤ 1025 and xmax=1050 > 1025 (txn 1050 hadn’t committed when 1025’s snapshot was taken).
VACUUM: Dead tuples accumulate over time. PostgreSQL’s VACUUM process reclaims space by removing tuples that are no longer visible to any active transaction. Autovacuum runs this automatically, but high-update tables may need tuning.
MySQL/InnoDB’s MVCC Implementation
InnoDB takes a different approach. The clustered index (primary key B-tree) always stores the latest version. Older versions are stored in a separate undo log (rollback segment), linked via a “roll pointer” in each row.
- To read an old version, InnoDB follows the roll pointer chain backwards through the undo log until it finds a version visible to the reading transaction.
- The undo log is also used for rollback (ACID Atomicity) — dual purpose.
- Purge thread: InnoDB’s purge thread removes undo log entries that are no longer needed (equivalent to PostgreSQL’s VACUUM).
Why MVCC Enables High Concurrency
| Approach | Readers Block Writers? | Writers Block Readers? | Concurrency |
|---|---|---|---|
| Exclusive Locking | Yes | Yes | Very Low |
| Read-Write Locks | Yes (writers wait) | Yes (readers wait for writers) | Low-Medium |
| MVCC | No | No | High |
This is why PostgreSQL and MySQL can handle thousands of concurrent connections with a mix of reads and writes. Long-running analytical queries don’t block short OLTP transactions, and vice versa.
The cost of MVCC: Storage overhead (multiple row versions), CPU overhead (visibility checks for every row), and the need for background cleanup (VACUUM/purge). In write-heavy workloads, dead tuple bloat can become a serious issue if autovacuum can’t keep up.
Summary
- ACID guarantees correctness through Atomicity (all-or-nothing), Consistency (invariants hold), Isolation (concurrent txns don’t interfere), and Durability (committed data survives crashes).
- Isolation levels trade anomaly prevention for performance: Read Uncommitted < Read Committed < Repeatable Read < Serializable.
- Transaction anomalies include dirty reads, non-repeatable reads, phantoms, write skew, and lost updates — each is a concrete failure mode to guard against.
- BASE trades consistency for availability: the system always responds, state is soft, and consistency is eventual.
- Modern databases offer a spectrum between ACID and BASE, with tunable consistency (Cassandra), optional transactions (MongoDB), and globally-distributed ACID (Spanner).
- Distributed transaction patterns (2PC, Saga, TCC, Outbox) each make different trade-offs between consistency, availability, and complexity.
- MVCC is what makes high-concurrency ACID practical — readers never block writers and vice versa, at the cost of storage and cleanup overhead.