ACID Properties in DBMS Explained — With Real-World Examples and Code
Every production database — whether it's powering your bank, your airline booking, or your online shopping cart — is fielding thousands of competing requests every second. Multiple users are reading, writing, and updating the same rows simultaneously. Without a strict rulebook governing how those operations happen, data would silently corrupt itself, balances would go negative, and duplicate orders would ship. ACID properties are that rulebook, and understanding them is the difference between building software that works and building software that occasionally loses money.
The problem ACID solves is deceptively subtle. It's not just about crashes. It's about the terrifying combination of concurrency (many users at once) and partial failure (anything can go wrong mid-operation). Before ACID principles were formalized by Jim Gray in the late 1970s, databases were brittle and unpredictable under load. ACID gave engineers a clear, testable guarantee: if your database claims to be ACID-compliant, you can rely on it under failure conditions that would otherwise silently destroy your data.
By the end of this article you'll be able to explain what each ACID property actually protects against (not just what the letters stand for), recognize when a violation would occur, write SQL that deliberately tests each property, and answer the tricky interview questions that trip up even experienced developers. You'll understand why BASE (the NoSQL alternative) exists and when to choose one over the other.
Atomicity — The 'All or Nothing' Guarantee That Saves Your Bank Balance
Atomicity means a transaction is treated as a single, indivisible unit of work. Every operation inside it either commits fully, or the entire transaction is rolled back — leaving the database exactly as it was before you started. There is no in-between state that persists.
The word comes from the Greek 'atomos' — uncuttable. A transaction cannot be cut in half. If your application crashes, the server reboots, or a query fails mid-transaction, the database undoes every change made so far. This is managed via a write-ahead log (WAL) or undo log, where the database records what it's about to change before it changes it.
Where atomicity gets interesting is in nested operations. Transferring money isn't one SQL statement — it's a debit followed by a credit. Both statements must succeed or neither can persist. Wrap them in a transaction and atomicity enforces this automatically. Without it, a debit with no matching credit is a real financial loss.
Note that atomicity is about failure handling, not concurrency. It answers the question: 'If something goes wrong mid-transaction, what state is my data in?' The answer: exactly the state it was in before the transaction started.
-- Create a simple accounts table to demonstrate atomicity CREATE TABLE bank_accounts ( account_id INT PRIMARY KEY, holder_name VARCHAR(100) NOT NULL, balance DECIMAL(12, 2) NOT NULL CHECK (balance >= 0) ); -- Seed with two accounts INSERT INTO bank_accounts VALUES (1, 'Alice', 1000.00); INSERT INTO bank_accounts VALUES (2, 'Bob', 500.00); -- ───────────────────────────────────────────────────── -- SCENARIO A: Successful atomic transfer -- Both the debit and credit must succeed together. -- ───────────────────────────────────────────────────── BEGIN TRANSACTION; -- Step 1: Debit Alice's account by $200 UPDATE bank_accounts SET balance = balance - 200.00 WHERE account_id = 1; -- Step 2: Credit Bob's account by $200 UPDATE bank_accounts SET balance = balance + 200.00 WHERE account_id = 2; COMMIT; -- Both changes are written to disk together -- Check the result — both balances changed SELECT account_id, holder_name, balance FROM bank_accounts; -- ───────────────────────────────────────────────────── -- SCENARIO B: Failed transfer — atomicity kicks in -- Simulate a failure after the debit but before the credit. -- ───────────────────────────────────────────────────── BEGIN TRANSACTION; -- Debit Alice successfully UPDATE bank_accounts SET balance = balance - 300.00 WHERE account_id = 1; -- Something goes wrong here — wrong account ID, app crash, etc. -- We call ROLLBACK to undo everything in this transaction. ROLLBACK; -- Alice's $300 is restored. Bob is untouched. -- Confirm Alice's balance is back to $800 (not $500) SELECT account_id, holder_name, balance FROM bank_accounts;
account_id | holder_name | balance
-----------+-------------+---------
1 | Alice | 800.00
2 | Bob | 700.00
-- After SCENARIO B (ROLLBACK):
account_id | holder_name | balance
-----------+-------------+---------
1 | Alice | 800.00 <- unchanged, rollback worked
2 | Bob | 700.00 <- unchanged
Consistency — Rules the Database Enforces So Your Code Doesn't Have To
Consistency guarantees that a transaction can only bring the database from one valid state to another valid state. It can never leave data in a state that violates the rules you've defined — foreign keys, CHECK constraints, unique constraints, triggers, and any business logic enforced at the database level.
Here's the key distinction: consistency is the property that connects ACID to your schema design. Atomicity, Isolation, and Durability are enforced by the database engine automatically. Consistency is a collaboration — the engine enforces the constraints you define, but you have to define the right constraints in the first place. If you don't add a CHECK constraint preventing negative balances, the database won't invent one for you.
Think of it this way: the database is a bouncer at a club. Consistency defines the dress code. If a transaction tries to leave data that violates the dress code (a foreign key that points to nothing, a balance that went negative, a duplicate primary key), the bouncer rejects the entire transaction. It never partially applies — the whole thing is rolled back.
Consistency also interacts with atomicity. An atomic transaction that would violate a constraint is automatically rolled back, because a committed half-transaction would leave data inconsistent. The two properties reinforce each other.
-- Demonstrate how constraints enforce consistency CREATE TABLE product_inventory ( product_id INT PRIMARY KEY, product_name VARCHAR(150) NOT NULL, stock_qty INT NOT NULL CHECK (stock_qty >= 0), -- Can't go negative unit_price DECIMAL(10,2) NOT NULL CHECK (unit_price > 0) ); CREATE TABLE order_items ( order_item_id INT PRIMARY KEY, product_id INT NOT NULL, quantity INT NOT NULL CHECK (quantity > 0), -- Foreign key ensures we can't order a product that doesn't exist FOREIGN KEY (product_id) REFERENCES product_inventory(product_id) ); -- Seed inventory INSERT INTO product_inventory VALUES (101, 'Wireless Keyboard', 50, 49.99); -- ───────────────────────────────────────────────────── -- CONSISTENCY TEST 1: Valid transaction — passes all rules -- ───────────────────────────────────────────────────── BEGIN TRANSACTION; INSERT INTO order_items VALUES (1, 101, 2); -- Valid: product 101 exists UPDATE product_inventory SET stock_qty = stock_qty - 2 -- Valid: 50 - 2 = 48, still >= 0 WHERE product_id = 101; COMMIT; SELECT product_name, stock_qty FROM product_inventory WHERE product_id = 101; -- ───────────────────────────────────────────────────── -- CONSISTENCY TEST 2: Violates CHECK constraint -- Trying to set stock to -1 breaks the rule. -- The database will REJECT this entire transaction. -- ───────────────────────────────────────────────────── BEGIN TRANSACTION; UPDATE product_inventory SET stock_qty = stock_qty - 9999 -- Would result in -9951, violating CHECK WHERE product_id = 101; COMMIT; -- This will throw an error and auto-rollback -- ───────────────────────────────────────────────────── -- CONSISTENCY TEST 3: Violates foreign key constraint -- Trying to order a product that does not exist. -- ───────────────────────────────────────────────────── BEGIN TRANSACTION; INSERT INTO order_items VALUES (2, 999, 1); -- product_id 999 doesn't exist! COMMIT; -- Foreign key violation — transaction rolled back
product_name | stock_qty
-------------------+----------
Wireless Keyboard | 48
-- CONSISTENCY TEST 2 output:
ERROR: new row for relation "product_inventory" violates check constraint
"product_inventory_stock_qty_check"
ROLLBACK
-- stock_qty remains 48 — database state unchanged
-- CONSISTENCY TEST 3 output:
ERROR: insert or update on table "order_items" violates foreign key constraint
"order_items_product_id_fkey"
ROLLBACK
Isolation — How the Database Keeps Concurrent Users Out of Each Other's Way
Isolation is the trickiest of the four properties, and the one most often misunderstood. It defines how much one in-progress transaction can 'see' of another in-progress transaction. In a perfect world, every transaction would run as if it were the only one in the system. In reality, that level of isolation kills performance, so databases offer a spectrum of isolation levels.
Without isolation, three nasty problems emerge. A dirty read happens when Transaction A reads data that Transaction B has changed but not yet committed — if B then rolls back, A was working with data that never actually existed. A non-repeatable read happens when A reads the same row twice and gets different values because B committed a change in between. A phantom read happens when A runs the same query twice and gets a different number of rows because B inserted or deleted rows in between.
SQL standard defines four isolation levels, each trading off safety for performance: READ UNCOMMITTED (no protection), READ COMMITTED (prevents dirty reads), REPEATABLE READ (prevents dirty and non-repeatable reads), and SERIALIZABLE (prevents all three, full isolation). Most production databases default to READ COMMITTED. PostgreSQL defaults to READ COMMITTED; MySQL InnoDB defaults to REPEATABLE READ.
Choosing the wrong isolation level is a real production bug. Too low and you get data anomalies. Too high and you get deadlocks and contention at scale.
-- This script demonstrates isolation levels by simulating two concurrent sessions. -- Run Session A commands in one terminal, Session B commands in another. -- We're using PostgreSQL syntax here. -- ─── SETUP ─────────────────────────────────────────── CREATE TABLE flight_seats ( seat_id INT PRIMARY KEY, flight_code VARCHAR(10) NOT NULL, is_booked BOOLEAN NOT NULL DEFAULT FALSE, passenger_name VARCHAR(100) ); INSERT INTO flight_seats VALUES (1, 'AA101', FALSE, NULL), (2, 'AA101', FALSE, NULL), (3, 'AA101', FALSE, NULL); -- ───────────────────────────────────────────────────── -- DIRTY READ DEMO (READ UNCOMMITTED level) -- ───────────────────────────────────────────────────── -- SESSION A: Start booking seat 1 but don't commit yet -- Run this FIRST in Session A terminal BEGIN; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; UPDATE flight_seats SET is_booked = TRUE, passenger_name = 'Alice' WHERE seat_id = 1; -- DO NOT COMMIT YET — simulate slow processing -- SESSION B: Read seat 1 while Session A hasn't committed -- Run this in Session B terminal WHILE Session A is still open BEGIN; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT seat_id, is_booked, passenger_name FROM flight_seats WHERE seat_id = 1; -- Danger! This might show is_booked=TRUE, passenger='Alice' -- even though Session A hasn't committed. -- If Session A rolls back, we read data that never existed. COMMIT; -- SESSION A: Now roll back (the booking 'never happened') ROLLBACK; -- ───────────────────────────────────────────────────── -- SAFE VERSION: Using SERIALIZABLE isolation -- This is how you prevent double-booking a seat. -- ───────────────────────────────────────────────────── -- SESSION A BEGIN; SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; SELECT seat_id FROM flight_seats WHERE flight_code = 'AA101' AND is_booked = FALSE FOR UPDATE; -- Lock all unbooked rows so Session B must wait UPDATE flight_seats SET is_booked = TRUE, passenger_name = 'Alice' WHERE seat_id = 1; COMMIT; -- Session B can now proceed and will see Alice already has seat 1 -- SESSION B (runs after Session A commits) BEGIN; SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; SELECT seat_id FROM flight_seats WHERE flight_code = 'AA101' AND is_booked = FALSE FOR UPDATE; -- Now only sees seats 2 and 3 as available — seat 1 is correctly taken UPDATE flight_seats SET is_booked = TRUE, passenger_name = 'Bob' WHERE seat_id = 2; COMMIT; -- Final state SELECT seat_id, is_booked, passenger_name FROM flight_seats ORDER BY seat_id;
seat_id | is_booked | passenger_name
--------+-----------+---------------
1 | t | Alice <- This data may never actually exist!
-- Final state after SERIALIZABLE demo:
seat_id | is_booked | passenger_name
--------+-----------+---------------
1 | t | Alice
2 | t | Bob
3 | f | NULL
Durability — Why Data Survives a Server Crash (and How Write-Ahead Logging Makes It Happen)
Durability means that once a transaction commits, that data is permanent — even if the server crashes one millisecond after the COMMIT returns. The database guarantees it will survive power loss, OS crashes, or hardware failure.
This sounds obvious, but it's genuinely hard to implement. The naive approach — writing directly to disk on every change — is too slow. Most databases use a Write-Ahead Log (WAL). Before any data page is modified, the change is written to a sequential log file on disk. Because sequential writes are dramatically faster than random writes, this gives you both performance and durability. On restart after a crash, the database replays the WAL to recover any changes that were committed but hadn't yet been flushed from memory to the main data files.
Durability is the property most affected by hardware choices. An SSD with a capacitor-backed write cache (enterprise SSDs have this; consumer SSDs often don't) can acknowledge writes faster. A RAID controller with a battery backup unit extends durability guarantees. Cloud databases (RDS, Cloud SQL, Azure SQL) handle this for you by replicating WAL logs across availability zones before acknowledging a commit.
There's one gotcha that catches many developers: durability only applies to committed transactions. If you COMMIT and the disk writes succeed, you're safe. If your application caches writes in memory and batches them, a crash before the batch commits means that data is gone — and ACID durability isn't to blame. Your application skipped the commit.
-- This script demonstrates how to verify durability-related settings -- and simulate durable vs. non-durable write behavior. -- Using PostgreSQL. -- ───────────────────────────────────────────────────── -- CHECK 1: Verify WAL (Write-Ahead Log) is enabled -- In PostgreSQL, wal_level controls what gets logged. -- 'replica' or 'logical' means WAL is fully active. -- ───────────────────────────────────────────────────── SHOW wal_level; -- Expected output: replica (the safe default in production PostgreSQL) -- ───────────────────────────────────────────────────── -- CHECK 2: synchronous_commit setting -- This is the dial between performance and durability. -- 'on' = Wait until WAL is flushed to disk before confirming COMMIT (DURABLE) -- 'off' = Confirm COMMIT immediately; WAL flush happens async (RISKY) -- ───────────────────────────────────────────────────── SHOW synchronous_commit; -- Safe default output: on -- ───────────────────────────────────────────────────── -- DEMO: A committed transaction that is durable -- ───────────────────────────────────────────────────── CREATE TABLE payment_audit_log ( log_id SERIAL PRIMARY KEY, transaction_ref VARCHAR(50) NOT NULL, amount DECIMAL(12,2) NOT NULL, committed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- This COMMIT is fully durable when synchronous_commit = on. -- The database will NOT return control to the client until this -- record is safely written to the WAL on disk. BEGIN; INSERT INTO payment_audit_log (transaction_ref, amount) VALUES ('TXN-2024-98234', 1500.00); -- If the server crashes right here (before COMMIT), nothing is written. -- That's correct — the transaction never completed. COMMIT; -- From this line forward, the record is guaranteed durable. -- Even a power cut won't lose it. -- Verify the record exists SELECT log_id, transaction_ref, amount, committed_at FROM payment_audit_log WHERE transaction_ref = 'TXN-2024-98234'; -- ───────────────────────────────────────────────────── -- WARNING PATTERN: Setting synchronous_commit = off per-session -- Only do this for genuinely non-critical, high-volume logging -- where occasional loss of last few records is acceptable. -- ───────────────────────────────────────────────────── SET LOCAL synchronous_commit = off; -- Affects this session only BEGIN; INSERT INTO payment_audit_log (transaction_ref, amount) VALUES ('TXN-2024-98235', 0.01); -- Low-value, high-frequency event COMMIT; -- COMMIT returns fast, but WAL flush is async. -- In a crash window of ~200ms, this record COULD be lost. -- Never use this for financial transactions.
wal_level
---------
replica
-- SHOW synchronous_commit;
synchronous_commit
------------------
on
-- SELECT after durable COMMIT:
log_id | transaction_ref | amount | committed_at
-------+-----------------+---------+------------------------------
1 | TXN-2024-98234 | 1500.00 | 2024-11-15 14:32:07.412+00
| Property | Problem It Solves | Mechanism | Who Enforces It | Real-World Analogy |
|---|---|---|---|---|
| Atomicity | Partial writes leaving data inconsistent | Undo log / rollback on failure | Database engine (automatically) | A vending machine that either dispenses the snack AND takes your money, or refunds everything — never takes money without dispensing |
| Consistency | Data violating business rules after a transaction | Constraints, triggers, FK checks evaluated at commit time | You (schema design) + DB engine (enforcement) | A tax form that won't let you submit if any required field is blank or invalid |
| Isolation | Concurrent transactions corrupting each other's reads and writes | Locks (pessimistic) or MVCC (optimistic) | Database engine, tuned by isolation level setting | Each customer in a bank has their own teller — they can't see or interfere with each other's transactions |
| Durability | Committed data lost due to crash or power failure | Write-Ahead Log (WAL) flushed to disk before COMMIT returns | Database engine + hardware/OS | A paper receipt that proves a transaction happened, even after the register loses power |
| READ UNCOMMITTED | Nothing — reads dirty, uncommitted data | No locks acquired on reads | N/A — highest risk, lowest overhead | Reading someone's draft email before they've decided to send it |
| READ COMMITTED | Dirty reads | Share lock released immediately after each read | DB engine at statement level | Only reading sent, finalized emails |
| REPEATABLE READ | Dirty reads + non-repeatable reads | Share lock held for the duration of the transaction | DB engine for duration of transaction | A snapshot of sent emails that doesn't change while you're reading it |
| SERIALIZABLE | All anomalies including phantom reads | Range locks or MVCC with conflict detection | DB engine — full sequential execution guarantee | One person uses the email system at a time — perfect accuracy, but slower |
🎯 Key Takeaways
- Atomicity is about failure recovery, not concurrency — it guarantees a transaction either fully commits or fully rolls back, preventing half-written states that silently corrupt data.
- Consistency is a shared responsibility — the database engine enforces constraints, but only the ones you actually define in your schema. Missing a CHECK or FOREIGN KEY constraint means the database has no rule to enforce.
- Isolation level is a dial, not a switch — READ COMMITTED is the safe default for most apps, but if you're handling inventory, bookings, or financial ledgers where double-processing is catastrophic, step up to REPEATABLE READ or SERIALIZABLE and use SELECT FOR UPDATE.
- Durability depends on your full stack, not just SQL — synchronous_commit, WAL configuration, hardware write caches, and cloud replication settings all affect whether 'committed' actually means 'safe'. Know what layer you're trusting.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Running multi-statement transactions without BEGIN/COMMIT — Symptom: Auto-commit mode silently commits each UPDATE or INSERT individually, so a crash between statements leaves data in a broken half-written state with no error message — Fix: Always explicitly wrap related statements in BEGIN TRANSACTION ... COMMIT. Audit your database client's auto-commit setting before writing any multi-step operation.
- ✕Mistake 2: Confusing Isolation with Consistency — Symptom: Developers add constraints (thinking this prevents concurrency issues) then wonder why two users can still double-book the same seat — Fix: Constraints enforce Consistency (valid data shape), not Isolation (who sees what, when). To prevent race conditions and double-bookings, you need the right Isolation level (SERIALIZABLE or REPEATABLE READ) combined with SELECT ... FOR UPDATE row-level locking.
- ✕Mistake 3: Assuming application-level retries restore Durability — Symptom: A crash occurs, the app retries the transaction and re-inserts the record, but now it's duplicated in the database — Fix: Implement idempotent transactions using a unique transaction reference or idempotency key column. Check for the key before inserting. Durability guarantees a committed record persists, but retry logic without idempotency creates duplicates, not safety.
Interview Questions on This Topic
- QCan you walk me through what happens, step by step, when a database server crashes in the middle of a committed transaction — and which ACID property protects the data, and exactly how?
- QWhat is the difference between a dirty read, a non-repeatable read, and a phantom read? Give me a concrete scenario where each would cause a real production bug, and tell me which isolation level prevents each one.
- QIf ACID gives such strong guarantees, why do NoSQL databases like Cassandra and DynamoDB use the BASE model instead? What specifically do you trade away, and when is that trade-off actually the right engineering decision?
Frequently Asked Questions
What are the 4 ACID properties in a database?
The four ACID properties are Atomicity (a transaction fully succeeds or fully rolls back — no partial writes), Consistency (every transaction leaves the database in a valid state that satisfies all defined constraints), Isolation (concurrent transactions don't interfere with each other's reads and writes), and Durability (once a transaction commits, the data is permanently saved even if the server crashes immediately after).
What is the difference between ACID and BASE in databases?
ACID (used by relational databases like PostgreSQL and MySQL) prioritizes correctness and consistency — every transaction is safe, but this requires locks and coordination that limit horizontal scalability. BASE (Basically Available, Soft state, Eventually consistent) is used by many NoSQL databases like Cassandra and DynamoDB — it trades strict consistency for high availability and massive write throughput, accepting that replicas may temporarily show slightly stale data that converges to the same value over time.
Does every database support all four ACID properties?
Traditional relational databases (PostgreSQL, MySQL InnoDB, Oracle, SQL Server) support all four ACID properties fully. Many NoSQL databases provide atomicity only at the single-document level (e.g., MongoDB) and offer weaker or configurable forms of consistency and isolation. Some systems like Cassandra prioritize availability over consistency by default but allow you to tune the trade-off per-operation. Always check the documentation for the specific guarantees of the database you're using before relying on ACID behavior.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.