ACID in practice
A transaction is a fence: BEGIN…COMMIT makes a group of writes all-or-nothing, and SAVEPOINT carves rollback points inside it. But ACID is not concurrency control — by default it does not serialize.
A payment service debits one account and credits another in two UPDATE statements. One night the process crashes between them: 1,200 customers were debited but never credited, and the money simply vanished from the ledger. The fix was three words the original author left out — BEGIN and COMMIT. A transaction would have made both writes land together or not at all. ACID is not academic; it is the difference between a ledger and a rumour.
What each letter actually buys you
If you’ve ever debugged a half-applied batch or wondered why “wrapping it in a transaction” still caused corruption, the answer almost always lives in which letter you assumed versus which one you actually got.
ACID is four guarantees, and engineers conflate the one they don’t get with the ones they do.
- Atomicity — every statement between
BEGINandCOMMITlands together, or none does. Any error, or aROLLBACK, throws the whole batch away. The half-finished state is never visible and never persisted. - Consistency — a committed transaction leaves the database satisfying every constraint (foreign keys,
CHECK, unique,NOT NULL). If a statement would violate one, the transaction errors and you must roll back. Postgres enforces the rules; your invariants (“balance never goes negative”) you encode as constraints or they aren’t guaranteed. - Isolation — concurrent transactions don’t see each other’s uncommitted work. Crucially, isolation has levels: the default,
READ COMMITTED, is a weak promise, not full serializability. This is the letter people assume gives them more than it does. - Durability — once
COMMITreturns, the change survives a crash or power loss. Postgres guarantees this by flushing the write-ahead log (WAL) to disk before acknowledging the commit.
-- Atomic money transfer: both rows move, or neither does.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- only now is the transfer durable and visible to othersIf the second UPDATE errors (say account 2 was deleted), the transaction enters an aborted state; the first UPDATE is discarded on ROLLBACK. No money leaks.
BEGIN, COMMIT, ROLLBACK, and the implicit transaction
Every statement you run is already in a transaction. Outside an explicit BEGIN, Postgres wraps each single statement in its own transaction that commits automatically (autocommit). That is why one UPDATE accounts SET balance = balance * 1.05 is itself atomic — all matched rows change or none do, even without BEGIN.
You write BEGIN only when you need multiple statements to be one atomic unit. After an error inside a transaction block, Postgres rejects every further statement with current transaction is aborted until you ROLLBACK (or COMMIT, which Postgres turns into a rollback). There is no “ignore the error and keep going” — unless you use a savepoint.
SAVEPOINT: partial rollback inside one transaction
A SAVEPOINT is a named marker you can roll back to without abandoning the whole transaction. It lets one transaction tolerate a recoverable error in part of its work.
BEGIN;
INSERT INTO jobs (id, status) VALUES (501, 'queued');
SAVEPOINT try_dedupe;
-- this might violate a unique index if the job already exists:
INSERT INTO jobs (id, status) VALUES (501, 'queued');
-- it errors; instead of losing the whole transaction, undo just to the savepoint:
ROLLBACK TO SAVEPOINT try_dedupe;
-- the first INSERT is still alive; continue and commit it:
COMMIT;This is exactly what most drivers do under the hood for “nested transactions” — there are no real nested transactions in Postgres, only savepoints. The cost is real, though: each savepoint consumes a subtransaction ID, and a transaction that opens tens of thousands of them can trigger the notorious subtransaction (subxid) overflow — once a backend exceeds 64 live subtransactions, others must consult pg_subtrans on disk, and read latency on a busy primary can jump from microseconds to milliseconds. Savepoints are a scalpel, not a loop body.
▸Common mistake
The most common ACID misread: “I wrapped it in a transaction, so concurrent updates are safe.” They aren’t. Two transfers that SELECT the same balance under the default READ COMMITTED level can both read 500, both subtract 100, and both write 400 — a lost update — even though each is perfectly atomic and durable. Atomicity is about your statements as a unit; it says nothing about interleaving with other sessions. Serializing concurrent access is the job of isolation levels and explicit locks, the next lessons in this unit.
What a transaction does NOT give you
A transaction by default is not a mutex and not serializable. Under READ COMMITTED (Postgres’s default) each statement sees a fresh snapshot taken at statement start, so two concurrent read-modify-write sequences can race. You get atomicity and durability for free; you do not get protection from lost updates, write skew, or phantom rows unless you raise the isolation level or take explicit row locks. Knowing this boundary is the whole reason the rest of this unit exists.
Two concurrent sessions each run, under the default isolation level: BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; COMMIT. The starting balance is 500. What is guaranteed?
Order the lifecycle of an atomic two-step transfer that recovers from a duplicate-insert error using a savepoint:
- 1 BEGIN — open an explicit transaction so the following statements are one unit
- 2 Run the first write (debit account 1)
- 3 SAVEPOINT before the write that might fail
- 4 Attempt the risky write; on error, ROLLBACK TO SAVEPOINT to undo only that step
- 5 COMMIT — make the surviving writes durable and visible
- 01What does atomicity guarantee, and what is the smallest unit it already applies to without writing BEGIN?
- 02How is SAVEPOINT different from ROLLBACK, and what is its production cost at scale?
- 03Why does wrapping read-modify-write logic in a transaction NOT prevent a lost update by default?
ACID is four distinct promises and seniors keep them separate. Atomicity: BEGIN…COMMIT makes a group of writes all-or-nothing; any error poisons the transaction until you ROLLBACK, and even a single autocommit statement is atomic. Consistency: a commit must leave every constraint satisfied — Postgres enforces the declared rules, you encode the business invariants as constraints. Isolation: concurrent transactions hide their uncommitted work, but isolation is leveled, and the default READ COMMITTED is a weak promise that does not serialize. Durability: once COMMIT returns, the WAL is on disk and the change survives a crash. SAVEPOINT adds partial rollback inside one transaction, with a real subxid cost at scale. The one thing a transaction does not give you by default is protection from concurrent read-modify-write races — that needs the isolation levels and explicit locks you’ll meet next, including the lost-update problem that SELECT FOR UPDATE exists to solve. Now when you see a “lost update” or “balance went negative” incident report, your first question is which letter of ACID was assumed but not actually active.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.