9 ACID, honestly
We committed three rows, got COMMIT back three times, killed the server, started it again, and the rows were gone. Postgres was working exactly as configured. One session setting, synchronous_commit = off, had traded those rows for speed, and the trade was in the documentation all along.
ACID gets treated like a badge: the database “is ACID”, so your data is safe. It’s four separate promises, each kept by one specific mechanism, each with a price, a switch that turns it off, and an edge where it stops. Most data loss in Postgres-backed systems happens at those edges, not inside them.
Four promises, four mechanisms
Here’s the whole chapter on one table. The rest of it proves each row.
| Letter | Kept by | Costs | Switched off by | Ends at |
|---|---|---|---|---|
| Atomicity | row versions, pg_xact status | dead tuples per abort | autocommit | your transaction boundary |
| Consistency | your constraints | a check per write | not declaring them | rules across rows |
| Isolation | snapshots, locks, SSI | waits, retries | read committed | the level you chose |
| Durability | WAL flushed at commit | a flush per commit | synchronous_commit, fsync, lying disks | this machine’s disk |
They fail independently, so we’ll take them one at a time.
Atomicity: nothing is ever undone
Atomicity means a transaction happens completely or not at all. You probably picture an undo log: on rollback, the database puts the old values back.
Postgres has no undo. Rollback doesn’t touch the table at all.
A rollback leaves its rows on the page
Every row version carries the ID of the transaction that created it (xmin), and whether it counts depends on what the commit log, pg_xact, says about that transaction (Updates are inserts). It works like a checkbook register: a cancelled check isn’t erased, it’s stamped VOID.
Insert three rows into a small credits table of account balances and roll back. pageinspect still finds all three tuples on the page, byte for byte what the insert wrote, and pg_xact_status() says their transaction aborted. Two bits in pg_xact are the only thing hiding them. Every query that reads them checks that status and skips them, and they stay until vacuum removes them.
That’s the whole of rollback: flip a status, leave the mess for later. It’s why a rollback takes the same time for one row or ten million.
A failed statement changes nothing, and still costs
Atomicity applies to every statement too. Charge every account 10 credits, where account 1000, the last row, only has 5:
Nothing changed: 999 balances are still 100. But the update wrote 999 new row versions before it hit row 1000, and they’re all still in the table, dead:
The table grew from 6 pages to 11 for a statement that changed nothing, and wrote over 100,000 bytes of WAL. A failed bulk update costs about as much I/O as a successful one and leaves a table’s worth of dead tuples for vacuum. A batch job that fails on its last row and retries every five minutes is a bloat machine.
Validate before you write when failure is likely (where balance >= 10, or a pre-check query), and run big changes in batches so a failure wastes one batch, not the table. Inside a transaction, a savepoint gives the same all-or-nothing at a finer grain (Savepoints contain the damage).
Deep dive: What a rollback and a failed update leave on the page
Prove it · lab 08-acid
Run the failed update by hand and the dead count can land a little under 999: a later read of a nearly full page can prune some aborted versions early (Pruning waits for a nearly full page). The lab holds an older snapshot open so the count is exact. The first query to read an aborted tuple also caches the verdict on it as a hint bit, HEAP_XMIN_INVALID, so later readers skip the pg_xact lookup (A read can write).
Prove it · lab 08-acid
Commit is one small record
So what makes a transaction committed? The 34-byte COMMIT record from A WAL record is small; a page image is not: write it, flush the WAL through it, mark the transaction committed in pg_xact, then make it visible. The moment the flush returns is the moment of commit. A crash a microsecond later replays the record and the commit stands; a microsecond earlier, the transaction never happened (Recovery only redoes). A rollback’s ABORT record isn’t even flushed: a transaction with no commit record counts as aborted anyway.
Consistency is the part you write
The C is the odd letter out. Atomicity, isolation, and durability are things the database does. Consistency means “the transaction moves the data from one valid state to another”, and “valid” is defined by you: not null, check, unique, foreign keys, and exclusion constraints (Constraints, chosen on purpose is their home). Postgres enforces exactly the rules you declare, and not one more.
Constraints fire per statement, or at commit
Postgres checks most constraints as it writes each row, and foreign keys at the end of each statement. A unique, primary key, foreign key, or exclusion constraint declared deferrable initially deferred waits until COMMIT instead, so a transaction can insert invoice lines before their invoice. If the invoice never arrives, the COMMIT fails. (check and not null can’t be deferred.)
So COMMIT can fail. Deferred constraints, serialization failures, and lost connections all surface there, and after any earlier error COMMIT quietly comes back as ROLLBACK (One error aborts the whole transaction). Code that ignores the result of commit will tell users their data is saved when it isn’t.
Deep dive: A COMMIT that fails
The insert succeeded; the COMMIT failed, and nothing was kept. Insert invoice 777 before committing and the same transaction commits cleanly.
Rules across rows are invisible to constraints
Most real business rules span rows: “a free account has at most 3 seats”, “debits equal credits”. A check constraint sees only the row being written, so the usual next move is a trigger that counts the account’s seats and raises an exception at 3.
It works, one request at a time. Now two requests add a seat to an account that has 2, at the same moment. Each trigger counts 2, because the other insert hasn’t committed. Both commit. The account has 4 seats, and the trigger never raised.
Nothing was wrong with the code. A rule that reads other rows is only as strong as the isolation it runs under. Three fixes, strongest first:
- Make it a constraint. “No two rows conflict” is an exclusion constraint (An exclusion constraint makes overlaps impossible), and a count limit can become one: a
seat_numberwithcheck (seat_number between 1 and 3)andunique (account_id, seat_number)caps seats at three. - Run the writers at serializable, which aborts one of them with a retryable error.
- Serialize on a parent row:
perform 1 from accounts where id = new.account_id for no key updateat the top of the trigger. Usefor no key update, notfor update:for updatealso blocks the foreign key checks of every insert, into any table, that references that account, until the trigger’s transaction ends.
Deep dive: The racing trigger, and why a function in a check constraint lies
The trigger, and the race the lab runs against it:
Prove it · lab 08-acid
A subquery in a check is refused outright (cannot use subquery in check constraint). Wrapping it in a function gets past the parser, and then lies to you. Postgres assumes a check’s result for a row never changes, so it won’t re-check when the other rows do, and a later dump and restore can fail on rows that no longer pass.
“Consistency” also means something else in distributed systems. In CAP and in replication talk, it’s whether every copy returns the latest write; ACID’s C is whether your rules hold. A replica can be perfectly ACID-consistent and still seconds behind (After a write, a replica can show the old value).
Isolation is a dial, not a guarantee
Isolation decides which effects of concurrent transactions yours can see. The ideal is that transactions behave as if they ran one at a time. Postgres offers that, at a price, and defaults to something weaker. Here’s the model; Pick an isolation level on purpose is the practical side.
Only serializable rules out every anomaly
An anomaly is a result no serial order of the transactions could produce. The SQL standard defines its levels by three of them; Berenson et al. showed in 1995 that the list was incomplete. What Postgres does at each level:
| Anomaly | Read committed | Repeatable read | Serializable |
|---|---|---|---|
| Dirty read: see uncommitted data | never | never | never |
| Non-repeatable read: a row changes between reads | yes | never | never |
| Phantom: new matching rows appear | yes | never | never |
| Lost update: read, compute, overwrite | yes, silently | error (40001) | error (40001) |
| Write skew: two read, each writes a different row | yes | yes | error (40001) |
| Read-only anomaly: a reader sees an impossible state | yes | yes | error (40001) |
- There’s no dirty read anywhere. Postgres accepts
read uncommittedand gives you read committed. - Read committed takes a snapshot per statement, repeatable read one per transaction (A snapshot picks one version). Request handlers want the first. A report that runs five queries and adds them up wants the second, or its totals won’t reconcile.
- Only serializable catches the bottom two rows, by making you retry. Lost update and write skew are demonstrated in Read committed loses updates computed in the app and Serializable catches the write skew repeatable read misses. The last row is here.
Even a read-only transaction can see the impossible
Here’s the anomaly that surprises people most, because the victim never writes anything. Billing works in periods. A control row holds the current period; charges are recorded against it; at month end someone closes the period by bumping the number, and a report totals the closed period. Three transactions:
At repeatable read, everything succeeds. T3 reported period 1 final at 3 charges totalling 600. Then T1 committed its charge into period 1, which now holds 4 charges and 700. No serial order produces that: if T1 ran before T2, the report would include its charge; if after, the charge would land in period 2.
At serializable, the report gets the same 3 charges, but T1’s commit fails with could not serialize access due to read/write dependencies among transactions. T1 retries, reads period 2, and its charge lands where it belongs. No two transactions wrote the same row, so only a level that tracks reads can catch it.
Deep dive: How serializable catches it without blocking anyone
Prove it · lab 08-acid
Postgres implements serializable with serializable snapshot isolation (Ports and Grittner, 2012). Every transaction runs on a snapshot, exactly as in repeatable read, and also records what it read as non-blocking SIReadLock entries. When one transaction writes something a concurrent one read, that’s a read-write dependency. Every serialization anomaly needs a “pivot”: a transaction with a dependency coming in and one going out. When Postgres sees one, it aborts a transaction with SQLSTATE 40001. Nobody blocks; somebody retries. Here, T1 is the pivot: the report read the period-1 charges before T1 added one (a dependency into T1), and T1 read the period that T2 then changed (a dependency out of T1). T1 is the one cancelled, and the error says so: Reason code: Canceled on identification as a pivot, during commit attempt.
The bookkeeping is coarse (it can track whole pages or relations instead of rows), so there are false positives, and the cost is real but usually modest. For long reports, begin isolation level serializable read only deferrable waits until it can take a snapshot that can’t be part of any anomaly, then runs without tracking and can never fail.
Durability is an fsync
Durability means a committed transaction survives a crash: its commit record reached disk before COMMIT returned. Commit means flush measured what that costs: one fsync per commit, and about 130 commits per second for one client on a disk with 7.7 ms flushes. Here we break it on purpose.
synchronous_commit = off loses a window
With synchronous_commit = off, COMMIT returns before its record is flushed, so a crash can take up to about 600 ms of acknowledged commits (synchronous_commit = off can lose about 600 ms of commits). The risk is loss, never corruption. The subtle part: the transaction is visible before it’s durable. Other sessions can read its rows, act on them, send an email about them, and then the crash erases them.
A real crash took three acknowledged commits
This chapter’s lab runs its own server so it can kill it. It widens the window (wal_writer_delay at 10 seconds, the maximum), commits three rows with synchronous_commit = off, and checks their WAL is still only in shared memory. Then it sends SIGKILL to the container: no shutdown checkpoint, no final flush. On restart, recovery replays to the end of valid WAL, and select count(*) from crash_test returns 0.
Three acknowledged commits, gone. The same inserts with the default synchronous_commit = on, and the same kill, come back with all three rows. And with wal_writer_delay at 1 second, rows committed more than 3 seconds before the kill survive. The window is real, and exactly as wide as the docs say.
So use it per transaction, only for writes you could lose.
Deep dive: A killed container is not a power cut
The restart log tells the story:
“got 0” is the end of valid WAL, not an error. The lab knew the commits were still in memory because pg_current_wal_lsn() (how far WAL has been written out) was behind pg_current_wal_insert_lsn() (how far it’s been generated).
Killing the container killed Postgres, and the host’s kernel kept running. Anything Postgres had already handed to the operating system with write(), but not forced to disk with fsync(), sat in the kernel’s page cache and survived. That’s why the lab checks that the WAL hadn’t even been written before the kill.
A real power loss also takes the page cache, and then the disk’s own cache. Testing that needs pulled plugs or a VM you can hard-reset, and tools built for it (the docs point to diskchecker.pl). Our test shows the window between COMMIT returning and WAL leaving Postgres’s memory. Below that line is the operating system’s and the hardware’s job, and they can fail at it.
Prove it · lab 08-acid.sh
fsync = off and full_page_writes = off belong only in CI
Two settings remove durability for the whole server. fsync = off stops Postgres forcing anything to disk, and a power failure can leave data files and WAL out of step: “unrecoverable data corruption”, in the docs’ words. full_page_writes = off drops the page images that repair torn pages (The first change after a checkpoint logs the whole page), safe only on storage that can’t tear an 8 KB write, such as ZFS. Both make benchmarks look great. Use them on a throwaway CI database, and nowhere else.
fsync is only as honest as the drive
fsync only works if the drive tells the truth when it says “done”. Many consumer drives acknowledge a write once it’s in their own volatile cache, which a power cut erases. Use storage with power-loss protection, run pg_test_fsync on the volume, and distrust flushes faster than the hardware could physically manage (Where the time goes).
Keep data checksums on, too (the initdb default since PostgreSQL 18). A flipped bit from a disk or controller becomes an error instead of silently wrong rows. They detect damage; backups repair it (With checksums, even a read writes WAL has their WAL cost).
Deep dive: When the operating system lied, and the checksum upgrade trap
In 2018, Postgres developers found that on Linux a failed fsync() could report the error once and then mark the dirty pages clean, so retrying “succeeded” without the data reaching disk. Since then, Postgres treats an fsync failure on a data file as a crash (PANIC) and recovers from WAL rather than trusting a retry; the change shipped in PostgreSQL 12 and was backported to the versions supported at the time (Fsync Errors).
pg_upgrade requires matching checksum settings, so moving an old cluster without checksums onto a fresh 18 cluster needs initdb --no-data-checksums, or pg_checksums to enable them on the old cluster first, offline. Checksums protect data pages only, not memory or internal structures.
Durable here is not durable everywhere
Everything so far is about one machine’s disk. With default asynchronous replication, a commit returns once it’s flushed on the primary, and replicas get it a moment later. Fail over inside that moment and commits your users saw succeed are gone, with the new primary perfectly consistent (Asynchronous is the default, and it can lose commits). Synchronous replication closes the window for a network round trip per commit (Synchronous commit costs a round trip).
Decide how many acknowledged commits you can lose, as a number, before the incident decides for you.
ACID stops at one transaction in one database
Every guarantee above has the same boundary: one transaction, in one database. Your business operations rarely respect it.
Autocommit splits one operation into two
A transfer of 30 credits from account 1 to account 2 is one business operation. Written as two statements with no begin, it’s two transactions, and many ORMs work this way by default: each save() is its own commit. Crash the app between the two:
sum(balance) dropped by exactly 30. Thirty credits vanished, and no database guarantee was broken: each statement was atomic, consistent, isolated, and durable. The operation wasn’t, because nobody told the database it was one. Wrapped in begin and commit, the same crash rolls everything back.
Put every business operation in one explicit transaction, and find out what your ORM does by default. Keep it short: no HTTP calls or waiting on a user inside it, because an open transaction holds locks and holds back vacuum.
Prove it · lab 08-acid
Two-phase commit is rarely the answer
When one operation spans two databases, the textbook answer is two-phase commit. Postgres supports its side (prepare transaction, then commit prepared), but a prepared transaction belongs to no session: if the coordinator dies, it sits there through restarts, holding locks and holding back vacuum. That’s why max_prepared_transactions defaults to 0. And a message broker, a payment API, or an email service doesn’t speak two-phase commit at all.
Deep dive: A prepared transaction survives a crash, and so do its locks
After a SIGKILL and restart, transfer-42 is still in pg_prepared_xacts, its insert is still invisible, and an update of row 1 still waits on its lock. If you turn the feature on, alert on any row in pg_prepared_xacts older than a minute (Vacuum can’t remove what an old snapshot might need).
Prove it · lab 08-acid.sh
Put the message in the same transaction as the order
Here’s the case that bites almost every application. Placing an order writes the order and publishes an “order placed” message for other services. Two systems, so whichever you do first, a crash between them leaves them disagreeing: an order nobody hears about, or a message about an order that doesn’t exist.
The fix is to stop writing to two systems. Write the message into a table, in the same transaction as the order, and let a separate process deliver it:
Now atomicity covers both: the order and its message commit together or not at all. A relay process claims a batch with for update skip locked, publishes it, and deletes what it sent, in one transaction. That transaction is the one deliberate exception to “no network calls inside a transaction”: keep its batches small and give the relay’s role an idle_in_transaction_session_timeout, so a stuck broker can’t hold it open.
If the relay dies after publishing and before its delete commits, the next relay sends those messages again. That’s at-least-once delivery, and the deal is that consumers deduplicate by message id. It beats “sometimes the message is lost”. The same shape works for emails and charges: record the intent in the transaction, act after commit, and make the action safe to repeat (Retry the whole transaction).
Deep dive: Two relays never claim the same message
skip locked lets several relays run side by side without double-claiming (A Postgres queue commits with the business write). In the lab, two relays asking for 5 messages each at once get 10 different ones. At very high volume, logical decoding can read outbox inserts straight from the WAL instead of polling, but a polling relay carries most applications a long way.
Prove it · lab 08-acid
The short version
- Rollback undoes nothing: aborted row versions stay on the page until vacuum. A failed statement still writes its rows, its WAL, and its bloat.
- A transaction is committed the moment its commit record is flushed. Check the result of every
COMMIT. - Consistency is only the rules you declare. A check that reads other rows is a race; use a constraint, serializable, or a lock on a parent row.
- Read uncommitted is read committed. Only serializable catches write skew and the read-only anomaly, and it needs retries.
synchronous_commit = offcan lose about 600 ms of acknowledged, already-visible commits. Use it per transaction, for data you can lose.- Never run production with
fsync = offorfull_page_writes = off. Use storage with power-loss protection and keep checksums on. - A commit is durable on one machine. With asynchronous replicas, a failover can lose acknowledged commits; pick your number before an incident does.
- Put each business operation in one explicit transaction. To notify another system, write an outbox row in the same transaction and relay it.
Further reading
- Transaction isolation, PostgreSQL documentation. The exact guarantees of each level, including serializable’s read-only deferrable mode.
- Reliability, PostgreSQL documentation. What a durable commit requires from disks, caches, and controllers, and how to test yours.
- Asynchronous commit, PostgreSQL documentation. The risk window of
synchronous_commit = off, in the developers’ own words. - A Critique of ANSI SQL Isolation Levels, Berenson, Bernstein, Gray, Melton, O’Neil, and O’Neil, SIGMOD 1995. The paper that showed the standard’s anomaly list was incomplete and defined snapshot isolation.
- Serializable Snapshot Isolation in PostgreSQL, Ports and Grittner, PVLDB 2012. How Postgres’s serializable level works, from the people who built it. The SSI wiki page has the batch-closing example this chapter adapts.