ACID Fundamentals — Atomicity, Consistency, Isolation & Durability from First Principles
Before @Transactional, propagation, and isolation annotations mean anything, you need the four guarantees they're built on top of — what a database actually promises when it says a transaction committed, independent of any framework or ORM.
Learning objectives
- Explain atomicity as an all-or-nothing guarantee and describe what a database does internally to honor it after a mid-transaction crash.
- Distinguish ACID consistency from CAP-theorem consistency, and explain why consistency is a joint responsibility between the database and the schema's own constraints.
- Describe why concurrent transactions can interfere with each other even when each one is individually correct, as grounding for isolation levels covered elsewhere in this course.
- Explain what 'committed' actually guarantees about data survival, and the conceptual role of write-ahead logging in making that guarantee cheap.
Story
Picture a bank clerk handling a transfer: debit ₹10,000 from your savings account, then credit it to your friend's current account. Those are two separate operations against two separate rows. Now imagine the server crashes — power cut, process kill, doesn't matter — in the gap between those two steps. Your account has already been debited. Your friend's account was never credited. Ten thousand rupees have simply evaporated from the system's point of view, and nobody did anything wrong in the application code; the crash just happened at an unlucky moment.
Every real system that moves money, inventory, or any multi-step state faces this exact shape of problem, and no amount of careful application code can fix it alone, because the failure can happen at the OS or hardware level, entirely outside the application's control. The only fix is a guarantee from the layer that actually writes the bytes: either every step in the unit of work lands, or none of them do. That guarantee is atomicity, and it's the first letter in ACID specifically because without it, nothing else matters — a system that can silently leave work half-finished is not safe to build anything else on top of.
Atomicity means a transaction is treated as a single indivisible unit, no matter how many individual statements it contains. If it has ten steps and the ninth one fails, the database doesn't leave the first eight in place and skip the broken one — it reverts all ten, as if none of them had ever been attempted. The system that was in some valid state before the transaction started returns to that exact same valid state. There is no in-between state visible to anyone, ever, not even for a few milliseconds, not even after a crash.
Here's the mechanism that makes this possible, in concept (the deep internals — undo logs, buffer pools, ARIES recovery — are a database-engineering topic in their own right, but the shape of the idea is simple and worth having solid). Before a database engine physically changes a row's data in a way that can't be trivially reversed, it first records what that row looked like before the change — a before-image. Think of this as a running diary the engine keeps as it works: "before I changed row X, it looked like this." If the transaction succeeds all the way to commit, that diary is simply discarded; nobody needs it anymore. But if the transaction aborts for any reason — an application-level rollback, a constraint violation, a crash — the engine walks that diary backward and restores every row to what it recorded, undoing changes in reverse order until the system is back exactly where it started.
This is why a crash doesn't require any application-level cleanup code. When the database restarts after an unexpected shutdown, part of its recovery process is specifically to look for any transactions that were in progress but never committed, and undo their partial work automatically, using exactly that before-image diary. The application never has to detect "did my transfer half-happen?" — the guarantee is that it structurally cannot have half-happened. Either the commit was acknowledged, in which case every step landed, or it wasn't, in which case the database has already erased all trace of the attempt by the time anyone can observe it.
One sharp edge worth internalizing early: atomicity only covers things the database itself can undo. If your transaction, as one of its steps, calls an external email API or triggers an SMS, and then the transaction later rolls back for an unrelated reason, that email has already been sent — the database has no undo log for someone else's SMTP server. This is why side effects with real-world consequences (notifications, payments to third-party gateways, webhook calls) are deliberately kept outside the atomic unit, or deferred until after commit is confirmed, rather than interleaved with the database writes they're reacting to. Atomicity is a guarantee about your transaction's own writes to your own database — nothing more, nothing less — and conflating the two is one of the most common design mistakes in systems that look correct in testing and then misbehave in production under real failure conditions.
💻 Code example
-- Atomicity, demonstrated with a wallet transfer. -- Everything between BEGIN and COMMIT is one indivisible unit. BEGIN; -- Step 1: debit the sender. The engine records this row's -- before-image internally before applying the change. UPDATE wallets SET balance = balance - 500 WHERE user_id = 1; -- Imagine a crash or an application error happens right here, -- after step 1 but before step 2. Nothing has been lost yet -- -- the debit above is not visible to any other session, and on -- restart the engine's recovery process would undo it using the -- before-image, as if it never ran. -- Step 2: credit the receiver. UPDATE wallets SET balance = balance + 500 WHERE user_id = 2; -- Only when BOTH steps have been applied does the engine make -- the change permanent and visible to everyone else. COMMIT; -- If anything had gone wrong before COMMIT, the correct response -- is an explicit rollback -- both updates vanish together: -- ROLLBACK;
Story
An e-commerce warehouse system tracks stock counts per product. One invariant the business absolutely depends on: stock can never go negative. Two orders come in for the last unit of a product at nearly the same instant. If the system lets both orders succeed because each one independently checked "stock is 1, so decrementing to 0 is fine" without accounting for the other's concurrent check, stock ends up at -1. Nothing crashed, no step was half-applied — atomicity was fine, every individual UPDATE fully committed — but the system still ended up in a state that violates a rule the business cannot tolerate. That's a consistency failure, and it's a genuinely different kind of problem than atomicity solves.
Consistency guarantees that a transaction can only move the database from one valid state to another valid state. "Valid" here means every constraint the schema declares — primary keys, foreign keys, uniqueness, CHECK constraints — holds both before the transaction and after it. If a transaction's changes would violate any of those constraints, the database refuses to commit it; the transaction aborts instead, and whatever partial work it did gets undone by the atomicity machinery you just saw. Consistency and atomicity are tightly coupled for exactly this reason: a constraint violation is one of the main reasons a transaction needs to be rolled back in the first place.
Here's the part that trips up almost everyone, including experienced engineers: there is no dedicated "consistency engine" inside a database, the way there's a dedicated lock manager for isolation or a dedicated write-ahead log for durability. Consistency isn't a mechanism the database runs — it's an emergent property that falls out of two things working together: the database correctly enforcing whatever constraints you declared, and you actually declaring the right constraints in the first place. A database with zero CHECK constraints and zero foreign keys will happily commit a transaction that drives stock to -1, orphans a row, or creates two users with the same email — not because the database is broken, but because nobody told it those states were invalid. Consistency is quite literally: Atomicity + Isolation (the infrastructure) combined with Constraints (the business rules you wrote down). Leave the second half out, and the first half can't save you.
This is also the single most common point of confusion in interviews and in casual conversation, so it's worth being precise: ACID consistency and the "C" in the CAP theorem are not the same concept, despite sharing a letter and a word. ACID consistency is about a single database never violating its own declared integrity constraints — it's a statement about schema invariants. CAP consistency is about whether every node in a distributed system sees the same value for the same piece of data at the same time — it's a statement about replication and agreement across machines. A system can be perfectly ACID-consistent on a single node (every constraint always holds) while being eventually-consistent in the CAP sense across replicas (a read on replica B might briefly return a stale value that replica A has already updated, even though replica A's local state never violated a single constraint). Treating these as the same word with the same meaning is a fast way to confuse two orthogonal engineering concerns that happen to collide on vocabulary.
The practical takeaway: when you design a schema, every invariant your business logic actually depends on — not just the ones that are convenient to express — belongs in the schema itself as a real constraint, not only in application code. Application-level validation is useful for fast, friendly error messages, but it is not a substitute for a database-enforced CHECK or UNIQUE constraint, because application code can have bugs, can be bypassed by a second code path you forgot about, or can race against itself under concurrency in exactly the stock-count scenario above. The database's constraint enforcement is the actual backstop; everything else is a convenience layer in front of it.
💻 Code example
-- Consistency enforced where it actually matters: the schema. CREATE TABLE product_stock ( product_id BIGINT PRIMARY KEY, quantity INT NOT NULL, -- Invariant the business depends on: never let stock go negative. -- This is enforced by the database itself, not just app code. CONSTRAINT stock_never_negative CHECK (quantity >= 0) ); BEGIN; -- Both of two concurrent "last unit" orders attempt this: UPDATE product_stock SET quantity = quantity - 1 WHERE product_id = 42; -- If this UPDATE would drive quantity below zero, the CHECK -- constraint rejects the commit outright -- the transaction -- aborts and atomicity's rollback machinery undoes it, instead -- of silently landing an invalid state: -- ERROR: new row for relation "product_stock" violates check -- constraint "stock_never_negative" COMMIT; -- Note what this constraint does NOT solve on its own: it stops -- the invalid END state from being committed, but two concurrent -- transactions both reading quantity = 1 and both deciding to -- decrement is an isolation problem, not a consistency one -- -- the two concerns work together, not interchangeably.
Story
Two transactions, A and B, both touch the same row at almost the same moment. Transaction A reads a product's price, does some calculation based on it, and writes an update. Transaction B, running concurrently, also reads that same price mid-calculation — possibly a half-updated value A hasn't committed yet, possibly the original value that's about to become stale the instant A commits. Depending on timing, B's own calculation can end up based on a reality that never actually existed as a stable, committed state. Each transaction, read in isolation, looks completely correct. The bug only exists in the interleaving of the two.
This is the core problem isolation exists to solve: a transaction should behave as if it has the entire database to itself, even when, physically, dozens or thousands of other transactions are running against the same tables at the same instant. Isolation is the guarantee that the final result of running transactions concurrently matches some valid result of running those same transactions one at a time, in some order — even though, under the hood, their actual execution overlapped in time for performance reasons.
Why can't the database just... not let this happen, ever, by default? Because perfect isolation — literally running every transaction as if every other transaction didn't exist, with zero interference possible — is extremely expensive. It typically means locking data aggressively enough that most concurrent transactions simply wait their turn, which collapses throughput on any system handling meaningful concurrent load. So every real database gives you a dial instead of a fixed answer: weaker isolation is faster but allows specific, well-defined categories of interference (a transaction seeing another's uncommitted writes, a transaction getting different answers to the same query run twice within itself, and so on); stronger isolation closes those gaps but costs more in locking or version-tracking overhead. This course's Spring-specific material covers that dial — the standard isolation levels, what each one permits or forbids, and how @Transactional's isolation attribute maps onto it — in full depth; what matters here is just locking in why the dial needs to exist at all: concurrent access to shared mutable state is the raw material every one of those anomalies is made from, and it's a property of concurrency itself, not of any particular framework or database vendor.
It's worth being precise about what isolation is not responsible for. Isolation doesn't make your business invariants correct — that's consistency's job, enforced via constraints. It doesn't make a half-finished transaction disappear cleanly on crash — that's atomicity. And it doesn't make a commit survive a power outage — that's durability, coming up next. Isolation's entire, narrow job is to make sure that transactions running at the same time don't see or cause interference effects that wouldn't be possible if they'd simply run one after another. That's a real and separate guarantee, and conflating it with "my data is correct" or "my transaction is safe from crashes" is a common source of confusion — a system can have airtight isolation and still corrupt its own data if nobody declared the right constraints, and it can have airtight isolation and still lose a committed transaction if the durability story underneath it is broken.
The practical instinct to build here, independent of any specific isolation level's name: whenever two pieces of code read a value, do something with it, and write a result back — a balance, a stock count, a counter, a status flag — ask yourself what happens if another transaction does the exact same read-modify-write in the gap between your read and your write. If the answer is "something breaks," you have a concurrency hazard that isolation levels, explicit locking, or optimistic version checks exist to close, and which one is the right tool depends on how often that collision actually happens in practice versus how expensive a wrong answer would be.
💻 Code example
-- Isolation's problem, made concrete without any ORM involved. -- Two sessions, same row, overlapping in time. -- SESSION A SESSION B -- -------------------------------------- -------------------------------------- BEGIN; -- reads price = 100 SELECT price FROM products WHERE id = 7; -- BEGIN; -- -- also reads price = 100, before A commits -- SELECT price FROM products WHERE id = 7; -- A applies a 10% discount based on the -- value it read, and commits. UPDATE products SET price = 90 WHERE id = 7; COMMIT; -- -- B applies its OWN independent 5% discount -- -- to the value it read (100) -- not to A's -- -- already-committed 90 -- and overwrites it. -- UPDATE products SET price = 95 WHERE id = 7; -- COMMIT; -- Final price is 95 -- A's discount was silently lost, even -- though neither transaction individually did anything wrong. -- This exact interleaving hazard, and the specific standard -- isolation levels that close different slices of it, are -- covered in depth in this course's @Transactional chapter.
Story
A flight booking system confirms a seat to a customer — the screen says "Booking Confirmed," a confirmation number is shown, maybe an email is already queued. One millisecond later, the data center loses power. When it comes back up minutes later, is that booking still there? If the database had only updated its in-memory state and hadn't yet gotten the change onto physical disk when the power died, the answer could be no — and a customer who was told "confirmed" now has no seat, no record, and no idea anything went wrong until they show up at the airport. That is precisely the failure durability exists to make impossible: once a transaction has been acknowledged as committed, its effects must survive any crash that happens immediately afterward, full stop, with zero exceptions for timing.
Durability guarantees that the moment a client receives a commit acknowledgment, the data backing that commit has been written to non-volatile storage — physical disk, not just RAM — in a way that will still be there after a reboot, a crash, a power loss, or a process kill. This is the guarantee that lets every other promise in ACID actually matter in practice: atomicity and consistency and isolation are all about what happens during a transaction's lifetime, but durability is the one that says the outcome, once confirmed, is permanent. Without it, "COMMIT" would just be a word with no teeth.
Here's the engineering problem durability has to solve, and the conceptual trick nearly every production database uses to solve it cheaply. A database's actual data files — tables, indexes, the on-disk structures a query reads from — are large and complex, organized for fast lookup, not for fast writing. If every single small transaction had to immediately update those complex structures directly on disk before acknowledging commit, two things go wrong: it's slow, because writing to arbitrary scattered locations on disk (random I/O) is dramatically slower than writing sequentially; and it's dangerous, because if a crash happens in the middle of modifying one of those complex structures, you can end up with a corrupted, half-written data file that's now unreadable, not just a missing update.
The fix is write-ahead logging, and the core idea is simple even without getting into any specific database's internals: before touching the real data files at all, the engine first appends a compact description of the change — "this is what's about to happen" — to a simple, sequential log file. Appending to the end of a log is fast (sequential I/O), and it's safe (a half-written log entry is trivially detectable and ignorable on restart, unlike a half-written complex data structure). The engine only tells the client "committed" once that log entry has actually been forced onto physical disk — not just handed to the operating system's write buffer, which could still be lost on a power cut, but genuinely flushed to the disk itself. Once that log entry is durably on disk, the commit is safe to acknowledge, because even if the actual data files haven't caught up yet, the engine's crash-recovery process can replay that log forward and reconstruct the committed change, no matter when the crash happens afterward. The slow, complex updates to the real data files can then happen later, in the background, at the engine's convenience, because the log already guarantees the outcome is recoverable.
This is also why durability has a real, tunable cost that shows up in production decisions: forcing data onto physical disk on every single commit (a real fsync-equivalent operation, conceptually) is the expensive part of a transaction's latency, and some systems offer a way to relax it — acknowledge commit slightly before the flush is fully guaranteed, trading a small, bounded risk of losing the very last few committed transactions in an extremely rare crash window for meaningfully lower commit latency. Whether that trade is acceptable is a business decision, not a technical one: a financial ledger almost never makes that trade, while a high-throughput analytics-ingestion pipeline sometimes does. Knowing that this trade-off exists, and that it's durability's cost being tuned, is the conceptual anchor — the specific settings that expose it differ by database engine and aren't the point here.
💻 Code example
-- Durability isn't something you write SQL to "turn on" -- it's -- the default guarantee behind a bare COMMIT. What you CAN see -- and reason about is the trade-off databases expose around it. BEGIN; INSERT INTO bookings (id, flight_no, seat, customer_id) VALUES (9001, 'AI-202', '14C', 55); -- The instant this COMMIT returns successfully, the engine -- guarantees the booking row's change has been forced onto -- physical, non-volatile storage via its write-ahead log -- -- not just handed to an in-memory buffer that a power loss -- one millisecond later could still erase. COMMIT; -- A conceptual illustration of the tunable trade-off durability -- exposes (syntax here is illustrative, not tied to one engine): -- -- SET durability_mode = 'strict'; -- every commit waits for a -- -- real disk flush before -- -- acknowledging -- safest, -- -- highest commit latency. -- -- SET durability_mode = 'relaxed'; -- commit acknowledged -- -- slightly before the flush -- -- is guaranteed -- faster, -- -- with a small, bounded -- -- window of risk on the -- -- rarest crash timing. -- -- A banking ledger almost always stays in strict mode; a -- high-volume analytics ingestion path sometimes accepts the -- relaxed trade for throughput -- the choice is a business -- decision made visible through durability's own knob.
Want a visual for this concept?
Generate a diagram tailored to “ACID Fundamentals — Atomicity, Consistency, Isolation & Durability from First Principles” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →