In 09-01 you saw what a transaction does. Now it's time for what exactly it guarantees, and the answer fits in four letters: ACID — atomicity, consistency, isolation and durability. They aren't a marketing label: they're four concrete promises, each with a mechanism behind it, each with a known way of breaking, and each with a limit it's worth knowing the location of.

And there's a reward at the end of the lesson. The fourth letter will take us to the WAL, the log that makes your data survive a power cut and that closes the promise 05-04 left open about recovering a deletion. And the third will take us at last to MVCC, the model with which PostgreSQL lets one session read without blocking anybody while another writes the same row — the mechanism that explains, precisely, the dead rows, the bloat and the need for VACUUM that 08-05 left pending.

Contents

  1. The four guarantees at a glance
  2. Atomicity: all or nothing
  3. Consistency: from one valid state to another valid state
  4. Isolation: as if they were sequential
  5. Durability: the WAL, fsync and synchronous_commit
  6. MVCC: isolation without blocking reads
  7. From MVCC to bloat: why VACUUM exists
  8. How each engine implements isolation
  9. What ACID does not guarantee you
  10. Common Mistakes and Tips
  11. Exercises
  12. Conclusion

  1. The four guarantees at a glance

Letter Promises What breaks it Mechanism in PostgreSQL
Atomicity The transaction is applied whole or not at all A failure halfway with no way back The transaction's state in pg_xact + row versions (MVCC)
Consistency The database moves from a valid state to another valid one A rule nobody declared or checked CHECK, NOT NULL, UNIQUE, FOREIGN KEY… and your code
Isolation Concurrent transactions don't tread on each other Concurrency anomalies (09-04) MVCC + snapshots + locks
Durability What's committed survives a power cut A COMMIT that only reached memory WAL written and synced before the COMMIT

A useful way of remembering them: A and D protect you against failures (errors, crashes, power cuts); C and I protect you against the others (your own rules and the other sessions).

  1. Atomicity: all or nothing

It's the one you've already seen: the four steps of confirming an order are a single one. But the interesting question is how the engine does it, because PostgreSQL's answer surprises anyone coming from other systems.

In an engine with an undo log —InnoDB, Oracle— the UPDATE overwrites the row in place and stores a copy of the previous value elsewhere. The ROLLBACK consists of rereading that log and physically restoring the old values: the bigger the transaction, the more expensive undoing it is.

PostgreSQL doesn't do that. An UPDATE overwrites nothing: it writes a new version of the row and leaves the old one where it was, marked with the identifier of the transaction that replaced it. Undoing is then trivial:

In PostgreSQL, a ROLLBACK restores nothing: it just notes in pg_xact that this transaction aborted. From that instant, every row version it wrote becomes invisible to everybody, and the old ones are the good ones again.

The practical consequences of this design decision are large and you'll notice them at work:

PostgreSQL (pure MVCC) InnoDB / Oracle (undo log)
Cost of the COMMIT Proportional to what was written Very cheap
Cost of the ROLLBACK Practically zero, whatever the size Proportional to the size of the transaction
Undoing a DELETE of 10 million rows Instantaneous Can take longer than the DELETE itself
Price to pay The old versions pile up: VACUUM is needed The undo grows and has to be purged

That's why the BEGIN ... ROLLBACK that 08-05 recommended for analysing a DELETE with EXPLAIN ANALYZE is so cheap in PostgreSQL: undoing costs nothing. And that's why, in exchange, PostgreSQL needs a VACUUM the others don't — section 7.

  1. Consistency: from one valid state to another valid state

The C is the worst-explained letter of the four, because it sounds like "the data is correct" and it isn't that. What it promises is more modest and more useful:

If the database met every declared rule before the transaction, it will still meet them afterwards. The transaction may violate them during its execution; what it can't do is leave them violated on committing.

And there's the small print: "declared rules". The database guarantees exactly what you've told it, not one rule more. Everything from 05-01 counts:

Declared rule What it prevents in GreenStore
CHECK (stock >= 0) Negative stock
CHECK (rating BETWEEN 1 AND 5) A 7-star review
CHECK (status IN ('pending','paid',…)) An order in returned status
FOREIGN KEY (customer_id) REFERENCES customers(id) An order from a non-existent customer
UNIQUE (email) Two customers with the same email
NOT NULL An order with no date

And nothing in the schema prevents these: a customer reviewing a product they never bought; a delivered order having no lines at all; the sum of the lines not matching the amount charged; a product with active = FALSE being sold. They're undeclared business rules, and the database doesn't know they exist.

The stock case: two ways of guaranteeing the invariant

"Stock can never go negative" can be enforced in two ways, and it's worth understanding that they aren't equivalent.

Way 1 — declarative. It's the one GreenStore already has:

-- It's already in the schema (05-01)
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0)
UPDATE products SET stock = stock - 1 WHERE id = 13;   -- number 13 is at 0
ERROR:  new row for relation "products" violates check constraint "products_stock_check"

Advantage: it's inviolable. It doesn't matter who writes, from which application, in which language or at three in the morning. Drawback: the only possible response is an error, which additionally aborts the whole transaction (09-01) and forces your code to interpret a message.

Way 2 — in the statement. A conditional UPDATE that doesn't buy if there's no stock:

UPDATE products
SET    stock = stock - 1
WHERE  id = 13
  AND  stock >= 1;
UPDATE 0

Zero rows, with no error and nothing aborted. Your application reads the affected-row counter —05-03's UPDATE N, 09-01's :ROW_COUNT— and if it's 0 it replies "out of stock" to the customer. And there's an enormous property hidden in there: that UPDATE is also correct in the face of concurrency, because the check (stock >= 1) and the write happen in the same atomic statement. It's the same lesson as 05-05's upsert: checking and then acting in two statements is never safe.

The golden rule of the C: use both. The CHECK is the net that guarantees the invariant come what may; the conditional WHERE is what turns "exception" into "normal business flow". The first protects the data, the second protects the user experience.

What the database can't do for you is the part you haven't told it about. Rules like "a paid order must have at least one line" or "you can't review what you haven't bought" need a declared constraint that doesn't exist today, a trigger (10-05) or application code inside the same transaction. Consistency is a shared responsibility, and the database only signs its half.

  1. Isolation: as if they were sequential

The promise, in one sentence:

Several transactions running at the same time must produce the same result as if they'd run one after another, in some order.

You already saw it working in 09-01: while A had the matcha's stock at 39 uncommitted, B carried on reading 40. As far as B was concerned, A's transaction hadn't started yet; after the COMMIT, it had happened in its entirety. Never halfway.

And here's the nuance that makes this the most interesting of the four letters: isolation is negotiable. The other three are all or nothing, but of this one you can ask for more or less:

You want… You pay…
Perfect isolation (SERIALIZABLE) Less concurrency, and transactions that abort and have to be retried
Relaxed isolation (READ COMMITTED) More performance, and certain anomalies your code has to account for

That negotiation is the isolation levels, with their four rungs and their four anomalies, and it's the entire content of 09-04. Here it's enough to know the lever exists and that PostgreSQL ships with it set to READ COMMITTED by default.

  1. Durability: the WAL, fsync and synchronous_commit

The promise: if the server told you COMMIT, the data is there, even if you rip the cable out a microsecond later.

The problem is that writing to disk is slow and the data pages are scattered around the file. If every COMMIT had to write every modified page to disk, at their random positions, and wait for them to land, performance would be unacceptable. The solution, universal across all serious engines, is the write-ahead log: WAL (Write-Ahead Log).

flowchart TD
    A["UPDATE products<br/>SET stock = 39 WHERE id = 15"] --> B["1 · Modify the page<br/>in <b>shared_buffers</b> (RAM)"]
    A --> C["2 · Write the change into the<br/><b>WAL buffer</b> (RAM)"]
    C --> D["3 · <b>COMMIT</b>: flush the WAL to disk<br/>and <b>fsync</b> — it waits here"]
    D --> E["✅ The server replies COMMIT"]
    B -.->|"later, at a<br/><b>checkpoint</b>"| F["4 · The dirty pages<br/>go down to the data files"]
    G["💥 power cut"] -.-> H["On startup: <b>recovery</b><br/>the WAL is reread and what was<br/>committed but never reached step 4 is reapplied"]

The key idea is the order: the log first, the data afterwards (hence "write-ahead"). The WAL is a sequential file, and writing sequentially is orders of magnitude faster than writing at random positions. At the instant of the COMMIT the data files may be completely out of date; it doesn't matter, because the WAL already contains the recipe for rebuilding them.

And fsync is the critical word: writing isn't enough, because the operating system and the disk itself have their own caches. fsync is the call that forces the data to be physically on the persistent medium before carrying on.

The trade-off: synchronous_commit

That fsync is the only thing standing between your COMMIT and an instantaneous reply. PostgreSQL lets you negotiate it:

Value What it waits for before replying COMMIT What's lost if the machine goes down
on (default) For the WAL to be synced to disk Nothing
local The same, but without waiting for the replicas Nothing locally; possible replica lag
off It doesn't wait: it replies and syncs afterwards The last committed transactions (by default, up to 3× wal_writer_delay, around 600 ms)
remote_apply For a replica to have applied it and made it visible there Nothing, at the cost of considerable latency
-- For this transaction only: a bulk data load that can be repeated
SET LOCAL synchronous_commit = off;

It's a legitimate —and very profitable— setting on a re-runnable data load or on a metrics table. It's unacceptable on an order or a payment.

⚠️ What genuinely is outrageous: fsync = off. Don't confuse the two parameters. With synchronous_commit = off you can lose the last transactions, but the database stays intact. With fsync = off you can lose the entire database: the writes reach disk in any order and recovery leaves a corrupt, unrepairable data file. It's meant for disposable test benches and nothing else.

Replication and PITR, briefly

The WAL isn't only good for recovering from a crash. Since it's a complete, ordered log of everything that has changed, it's good for two more things we'll only name here, because they're administration material:

  • Replication. If you send the WAL to a second server and it applies it as it goes, you have a live copy of the database. It's the foundation of high availability and of read-only replicas for reports.
  • PITR (Point-In-Time Recovery). With a base backup plus all the subsequent archived WAL, you can restore the database to any specific instant, for example 2026-03-05 11:59:58, two seconds before that DELETE FROM orders; with no WHERE. This is the promise 05-04 left open: there's no "undo" for something already committed, but there is a time machine, provided somebody had configured archiving before the accident.

  1. MVCC: isolation without blocking reads

We've mentioned it three times already; time to open it up. MVCC stands for Multi-Version Concurrency Control.

The idea, in one sentence: each row can exist in several versions at once, and each transaction sees the version that corresponds to it according to when it started.

From that comes PostgreSQL's most valuable property in concurrency:

Readers never block writers, and writers never block readers.

That's why in 09-01 session B could read the matcha's stock without waiting a single millisecond while A was modifying it: A wrote a new version, and B carried on reading the old one, which as far as B was concerned was the good one.

The hidden columns xmin and xmax

Every row version carries two system columns you can query even though they don't appear in SELECT *:

Column Meaning
xmin Identifier of the transaction that created this version
xmax Identifier of the transaction that deleted or replaced it. It's 0 if the version is still current
ctid Physical position of the version: (page, index within the page)

Look at it live. Reload the database and run:

SELECT xmin, xmax, ctid, id, name, stock FROM products WHERE id = 15;
xmin xmax ctid id name stock
748 0 (0,15) 15 Ceremonial matcha green tea 30 g 40

(The transaction numbers depend on your installation; what matters is the relationships between them.) The row was created by transaction 748 —the loading INSERT— and nobody has touched it since (xmax = 0). Now modify it:

UPDATE products SET stock = stock - 1 WHERE id = 15;
SELECT xmin, xmax, ctid, id, stock FROM products WHERE id = 15;
xmin xmax ctid id stock
812 0 (0,21) 15 39

Three things have changed at once, and they're the whole explanation of MVCC:

  1. The xmin is different: this is a new row, created by transaction 812.
  2. The ctid is different: it's at another physical spot on the page. The old row is still there, at (0,15), with its xmax now set to 812.
  3. The table takes up one more row on disk, even though SELECT COUNT(*) still returns 20.

And that's literally what that sentence in 08-05 meant: "an UPDATE doesn't modify the row: it writes a new version and marks the old one as dead". Now you know what it marks it with: its xmax.

The snapshot

When a transaction needs to decide which versions it sees, it takes a snapshot: the list of which transactions were committed at that moment. With it, each version's visibility rule is straightforward:

A version is visible if its xmin is committed and earlier than my snapshot, and its xmax is empty, aborted, or later than my snapshot.

There's everything you saw in 09-01 with no explanation:

  • A sees its own change because the new version's xmin is its own transaction.
  • B doesn't see it because that xmin corresponds to a transaction not yet committed, and for B that's equivalent to non-existent.
  • After the COMMIT, B's next snapshot does include A, and the new version becomes visible.
  • And a ROLLBACK needs to delete nothing: it's enough for the transaction to be marked as aborted in pg_xact for its xmin to validate none of its versions.

When the snapshot is taken is exactly the difference between the isolation levels: in READ COMMITTED a new one is taken on every statement; in REPEATABLE READ and SERIALIZABLE, a single one for the whole transaction. That one sentence explains 90 % of 09-04.

  1. From MVCC to bloat: why VACUUM exists

And now the bill. If every UPDATE leaves a dead version and every DELETE just sets an xmax, the table only grows. The versions no longer visible to any transaction are the dead rows (dead tuples), and the space they occupy is 08-05's bloat.

UPDATE products SET stock = stock + 1 WHERE id = 15;   -- repeated 5 times
SELECT relname, n_live_tup, n_dead_tup
FROM   pg_stat_user_tables WHERE relname = 'products';
relname n_live_tup n_dead_tup
products 20 5

Twenty live rows and five corpses, after five updates of a single row. There's the full circle, and this is the whole chain 08-05 left half-finished:

flowchart LR
    A["I need <b>isolation</b><br/>without blocking reads"] --> B["<b>MVCC</b>: several versions<br/>of each row"]
    B --> C["Every UPDATE/DELETE leaves<br/><b>dead versions</b>"]
    C --> D["Tables and indexes <b>get fat</b>:<br/>the <i>bloat</i>"]
    D --> E["<b>VACUUM</b> marks that space<br/>as reusable"]
    E -.->|"it can't clean what<br/>an old transaction<br/>might still need"| F["Open transaction<br/>= bloat that isn't cleaned"]

And that explains the three things 08-05 announced and couldn't justify:

  1. Why there are dead rows. Because they're the old versions MVCC needed in order not to block anybody.
  2. Why an open transaction prevents cleaning. Because VACUUM can only remove a version if no live transaction can need it. A session idle in transaction for three hours holds a three-hour-old snapshot, and with it every dead version in the whole database. It's 09-01's section 11 damage, now with its mechanism.
  3. Why CREATE INDEX CONCURRENTLY exists. Because building a normal index needs to block the table's writes, and MVCC allows doing it without that lock in exchange for walking the table twice. The detail is in 09-05.

  1. How each engine implements isolation

Engine Model Detail
PostgreSQL Pure MVCC, versions in the table itself Readers and writers don't block each other. Price: VACUUM and bloat
Oracle MVCC with undo segments The old version is rebuilt from the undo. No bloat, but with the classic error ORA-01555: snapshot too old if the undo is recycled before a long query finishes
MySQL / InnoDB MVCC + locks An undo log for consistent reads, plus gap locks that lock key ranges and prevent phantoms in REPEATABLE READ
SQL Server Locks by default, MVCC optional By default, a reader blocks a writer and vice versa. With READ_COMMITTED_SNAPSHOT ON it switches to an MVCC-style model using tempdb as the version store
SQLite File locking One writer at a time across the whole database. With WAL enabled, readers don't block against the writer, but there's still a single writer

Dialect note: the vocabulary is deceptive. "REPEATABLE READ" means different things in PostgreSQL and in InnoDB, and "READ COMMITTED" doesn't behave the same in PostgreSQL as in SQL Server without snapshot. The isolation level isn't portable: it's the first thing to verify when migrating an application between engines, and the detail is in 09-04.

  1. What ACID does not guarantee you

Four honest limits, because ACID is cited far more often than it's understood:

  1. It doesn't guarantee your logic is correct. A perfectly atomic, consistent, isolated and durable transaction can reduce the wrong product's stock. ACID guarantees it will be done whole and forever; whether it was the right thing to do is your business.
  2. It doesn't guarantee the rules you didn't declare. The C only signs off what's in the schema (section 3).
  3. It doesn't extend beyond the database. This is the important one in the real world: if your transaction reduces the stock and also calls the payment gateway, the gateway doesn't take part in your ROLLBACK. You can undo the order; you can't undo the charge. That's why 09-03 will insist that no external call should live inside an open transaction, and that's why patterns like the outbox or sagas exist.
  4. It doesn't survive intact when spread across several databases. Coordinating a COMMIT between two servers demands a two-phase protocol, which is slow and fragile.

That last point is why the acronym BASE (Basically Available, Soft state, Eventually consistent) appears in distributed systems: instead of guaranteeing coherence at all times, you guarantee that the system converges to a coherent state. It's a deliberate trade-off, not a defective version of ACID — but it's a trade-off that only makes sense when the spreading is unavoidable. In a single-server relational database, ACID is complete, free and there's no reason at all to give it up.

Common Mistakes and Tips

  • Believing the C means "the data is correct". It means "the declared rules are respected". Whatever isn't in the schema isn't guaranteed.
  • Trusting validation to the application alone. Tomorrow there'll be a migration script, an intern with psql or a second application. The CHECK is always there; your if is only in your code.
  • Trusting the CHECK alone. It aborts the whole transaction and turns a normal business case —"out of stock"— into an exception. Add the WHERE stock >= 1 and read the affected rows.
  • Confusing synchronous_commit = off with fsync = off. The first risks the last transactions; the second risks the entire database.
  • Assuming a big ROLLBACK is expensive. In PostgreSQL it's practically free; in InnoDB and Oracle it can take longer than the operation itself. It's the reverse of what almost everybody expects.
  • Believing DELETE frees space, or that UPDATE rewrites the row. Neither: they leave dead versions, and the space is only reused after VACUUM.
  • Leaving a transaction open and then complaining about bloat. They're the same thing: the old snapshot prevents cleaning.
  • Putting an HTTP call inside a transaction. ACID ends at the database's edge; the card charge isn't undone by a ROLLBACK.
  • Tip: declare the rules twice, in the schema and in the statement. The first protects the data; the second, the user experience.
  • Tip: look at xmin, xmax and ctid once in your life. Physically seeing how an UPDATE creates a new row is worth ten explanations of MVCC.
  • Tip: if you care about your data, check today that you have backups and WAL archiving. PITR can't be improvised after the accident.

Exercises

Exercise 1

On the freshly reloaded database, demonstrate the MVCC model experimentally.

  1. Query xmin, xmax, ctid and stock of product 15 and note them down.
  2. Run UPDATE products SET stock = stock - 1 WHERE id = 15; three times in a row (under autocommit), querying the same columns after each one.
  3. Query n_live_tup and n_dead_tup in pg_stat_user_tables for products. Explain both numbers.
  4. Run VACUUM products; and look at them again. Has the table file's size (pg_relation_size('products')) gone down? Why?
  5. Now repeat step 2 inside BEGINROLLBACK. How many dead rows are left after the ROLLBACK? And why was the ROLLBACK instantaneous?

Exercise 2

GreenStore wants a new rule: a return's amount can't exceed its order's total billed. Nothing prevents it today, and in fact you can check it:

INSERT INTO returns (order_id, reason, date, amount)
VALUES (1, 'Consistency test', DATE '2026-03-05', 9999.00);
  1. Does it get inserted? Which ACID letter is at stake and why doesn't the engine protest?
  2. Can that rule be expressed with a CHECK? Reason it out by looking at what information the check needs.
  3. List three ways of enforcing it, saying in each case who guarantees it and what hole it leaves.
  4. Write the query that detects today whether any GreenStore return violates the rule.

Exercise 3

A colleague proposes this configuration for GreenStore's production server, "because it goes faster":

fsync = off
synchronous_commit = off
full_page_writes = off

And adds: "anyway, we have a replica and we back up every night".

  1. Explain, parameter by parameter, exactly what is lost in a power cut.
  2. Why does the replica not solve the fsync = off problem?
  3. In what specific scenario would setting synchronous_commit = off be reasonable, and in which would it be unacceptable? Give an example of each with GreenStore tables.
  4. What mechanism would allow recovering the database to the instant before the failure, and what has to have been configured beforehand to be able to use it?

Solutions

Solution 1

1 and 2. The xmin changes on every UPDATE (each is a different transaction creating a new version) and so does the ctid, because each version occupies a new physical position. The visible version's xmax is always 0; the one set to the new transaction's value is the previous version's, which you no longer see.

3. n_live_tup = 20 and n_dead_tup = 3: twenty live rows and three dead versions, one per UPDATE. The table now has 23 versions stored for 20 logical rows.

4. After VACUUM products;, n_dead_tup drops to 0, but pg_relation_size doesn't change. And that's the important part of the exercise: VACUUM marks the space as reusable by the table itself, it doesn't give it back to the operating system. For that you need VACUUM FULL, which rewrites the whole table with an exclusive lock (08-05). That's why bloat is prevented and not cured.

5. There are still three dead rows: the versions the transaction wrote before being undone are still physically there, only now they're invisible because their xmin corresponds to an aborted transaction. And the ROLLBACK was instantaneous precisely because of that: it restored nothing, it just noted "aborted" in pg_xact (section 2). Undoing doesn't erase the work done: it leaves it invisible and hands it over to VACUUM.

Solution 2

1. Yes, it gets inserted. INSERT 0 1, with no complaint at all. The letter at stake is the C: the database guarantees the declared rules, and that rule isn't declared anywhere. returns' constraints only require that the order_id exists and that the amount is >= 0. Nine thousand euros satisfies both.

2. No, it can't be done with a CHECK. A CHECK can only look at the columns of the row itself that's being inserted. To know whether €9,999.00 is too much you have to add up order 1's lines, that is, query another table, and a CHECK doesn't allow that (PostgreSQL rejects subqueries in a CHECK, precisely because the result could change afterwards and the constraint would stop being satisfied without anybody touching the row).

3. Three ways:

Way Who guarantees it Hole it leaves
A check in the application before the INSERT, inside the transaction Your code Any other write path (a script, psql, a second application) skips it. And between the check and the INSERT there's a race window if the order's lines can change
A conditional INSERT ... SELECT that only inserts if the sum allows it The statement itself, atomically It still doesn't protect against other write paths, but it eliminates the race condition: check and write are a single statement
A BEFORE INSERT OR UPDATE trigger on returns that queries order_lines and raises an error The database, for everybody It's the most robust; its cost is that the business logic moves into the database and has to be maintained there (10-05)

4. The query that audits the current state:

SELECT rt.id, rt.order_id, rt.amount,
       ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2)
         + MAX(o.shipping_cost)                                          AS order_total
FROM   returns     AS rt
JOIN   orders      AS o  ON o.id = rt.order_id
JOIN   order_lines AS ol ON ol.order_id = o.id
GROUP  BY rt.id, rt.order_id, rt.amount
HAVING rt.amount > ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2)
                   + MAX(o.shipping_cost);

On the freshly reloaded database it returns 0 rows: the three returns (€26.75, €34.02 and €19.80 on orders 6, 10 and 13) are all below their order's total. With the €9,999.00 row inserted, it returns one.

Solution 3

1. Parameter by parameter:

Parameter What's lost in a cut
synchronous_commit = off The last committed transactions (fractions of a second). The database stays intact and coherent: the last few things simply never came to exist
fsync = off Potentially the entire database. With no syncing, the writes reach disk in any order and recovery can find the WAL and the data in incompatible states. The result is silent corruption
full_page_writes = off Protection against partial page writes. If the system goes down writing an 8 KB page and only 4 get recorded, without this option the WAL can't rebuild it and that page is left broken

2. The replica saves nothing because it replicates what the primary says happened. If the primary corrupts its data, it propagates corrupt data; and if the failure is detected days later, the corruption is already in the replica and in the last few nights' backups. A replica protects against one server's hardware failure, not against logical corruption.

3. Reasonable on a bulk, re-runnable load: filling a metrics or analytics table with SET LOCAL synchronous_commit = off, where losing the last few seconds only means running the process again. Unacceptable on orders, order_lines and returns: losing a confirmed order means the customer paid and there's nothing in the system, and no latency saving makes up for that.

4. PITR. With a base backup and continuous WAL archiving the database can be restored to any instant before the failure. What has to be configured beforehand —and this is the whole moral— is wal_level = replica (or higher), archive_mode = on with its archive_command, a reliable archive destination and periodic, tested base backups. A backup that has never been restored isn't a backup: it's a hope.

Conclusion

You now know exactly what the four letters promise and what's underneath each one:

  • Atomicity: all or nothing. And in PostgreSQL the ROLLBACK restores nothing: it marks the transaction as aborted and its row versions stop being visible. That's why undoing is free here and expensive in InnoDB or Oracle.
  • Consistency: from one valid state to another according to the declared rules. CHECK, FK, UNIQUE and NOT NULL are the half the database signs; the other half is yours. And the stock case teaches you to use both routes: the CHECK (stock >= 0) as an inviolable net and the UPDATE ... WHERE stock >= 1 as the business flow, which is additionally safe against concurrency by being a single statement.
  • Isolation: concurrent transactions behave as if they were sequential. It's the only negotiable letter, and that negotiation is 09-04's isolation levels.
  • Durability: the WAL is written and synced before the data, which is why a COMMIT survives a power cut. synchronous_commit is the legitimate lever; fsync = off isn't. And from the WAL come replication and PITR too, which is the answer 05-04 left pending on how to recover a deletion.
  • MVCC: each row lives in several versions, marked with xmin and xmax, and each transaction sees the ones its snapshot allows. Hence readers don't block writers, and hence an UPDATE creates a new row with a different ctid and leaves the old one dead.
  • And hence the bloat: the dead versions pile up, VACUUM marks them as reusable, and an open transaction prevents that. The chain 08-05 left half-finished is closed, including the reason CREATE INDEX CONCURRENTLY exists.
  • The limits of ACID: it doesn't validate your logic, it doesn't guess your rules, it doesn't extend to the payment gateway and it doesn't cross a server's boundary for free — hence BASE and distributed systems.

With the theory in place, it's time for the full set of controls. In Transaction Control Statements you'll see all of BEGIN's options (ISOLATION LEVEL, READ ONLY, DEFERRABLE), the SAVEPOINTs —which let you undo only part of a transaction and, along the way, rescue you from 09-01's aborted state—, how to set the default level, how psycopg's, JDBC's and SQLAlchemy's autocommit behaves, why in PostgreSQL you can ROLLBACK a CREATE TABLE and in MySQL you can't, and the complete application pattern: try / except / rollback, idempotent retries and the ban on calling external services with a transaction open. All of it building, from start to finish, the confirmation of a GreenStore order.

SQL Course

Module 1: Introduction to SQL

Module 2: Basic SQL queries

Module 3: Working with multiple tables

Module 4: Advanced data filtering

Module 5: Data manipulation

Module 6: Advanced SQL functions

Module 7: Subqueries and nested queries

Module 8: Indexes and performance tuning

Module 9: Transactions and concurrency

Module 10: Advanced topics

Module 11: SQL in practice

Module 12: Final project

© Copyright 2026. All rights reserved