ACID, the log and durability
A transaction is all or nothing, and once COMMIT returns it survives a crash. Postgres keeps both promises with one mechanism: the write-ahead log. Here is the write path, what a commit costs, and a recorded crash.
- 1Postgres writes every change to the write-ahead log (WAL) before the data page. The log decides what committed.
- 2COMMIT returns after the commit record is flushed. synchronous_commit chooses which flush it waits for.
- 3One flush serves every commit that waits for it. Throughput grows with clients; latency stays near one flush.
- 4Durability is only as good as the drive. A flush that stops in a volatile cache can lose commits.
- A crash can stop the server between any two disk writes.
- The log says what the transaction planned and whether it committed.
A transfer changes two pages. Only a log written first can say, after a crash, whether both changes count or neither.
| letter | promise | implemented by |
|---|---|---|
| A | All of the transaction, or none of it. | The commit log marks each transaction committed or aborted. No COMMIT record: aborted, also after a crash. |
| C | Each commit leaves the data valid. | CHECK, UNIQUE, FOREIGN KEY and EXCLUDE constraints. Rules the schema cannot express stay in the application. |
| I | Transactions do not see each other's unfinished work. | Snapshots and locks. See T2 and T3. |
| D | After COMMIT returns, a crash loses nothing. | The WAL is flushed to disk before COMMIT returns. Recovery replays it. |
The C in ACID is the application's invariant. The database enforces only the part you declare as constraints.
Atomicity and durability come from the WAL and the commit log. Consistency comes from constraints. Isolation comes from snapshots and locks.
| write | when | pattern |
|---|---|---|
| WAL record | Each change, into WAL buffers | Append, in memory |
| WAL flush | At COMMIT, or by the WAL writer | Sequential, one flush for many commits |
| Data page | At a checkpoint, or when a buffer is reused | Random, many changes per page |
- A page in shared buffers may hold changes that exist only in the WAL on disk. Recovery puts them back.
- A checkpoint writes every dirty page, then records a redo point. Recovery never needs older WAL.
- checkpoint_timeout is 5 min and max_wal_size is 1 GB by default. Whichever comes first starts a checkpoint.
At commit, Postgres flushes only the WAL. The changed data pages stay in memory, and a checkpoint writes them later.
Session 1 credits grace first, then debits ada by more than she has.
| t | Session 1 | Session 2 |
|---|---|---|
| 1 | BEGINBEGIN | |
| 2 | UPDATE accounts SET balance = balance + 150 WHERE id = 2UPDATE 1 | |
| 3 | SELECT id, balance FROM accounts ORDER BY idid = 1, balance = 100; id = 2, balance = 100The credit is not visible to other sessions. | |
| 4 | UPDATE accounts SET balance = balance - 150 WHERE id = 123514 new row for relation "accounts" violates check constraint "accounts_balance_check"Failing row contains (1, ada, -50). | |
| 5 | COMMITROLLBACKThe transaction failed, so COMMIT rolls it back. | |
| 6 | SELECT id, balance FROM accounts ORDER BY idid = 1, balance = 100; id = 2, balance = 100The credit is gone too. Nothing changed. |
ada 100, grace 100Until COMMIT, other sessions see none of the transfer. A failed statement aborts the whole transaction, so COMMIT becomes ROLLBACK.
start():
read pg_control1
IF the last shutdown was clean: RETURN ready
FOR EACH WAL record from the redo point to the end2:
IF it carries a page image: copy it over the page3
ELSE IF page LSN < record LSN4: apply it to the page
a transaction with no COMMIT record stays aborted5
write an end-of-recovery checkpoint
RETURN ready- 1A small file that records the last checkpoint and whether the server shut down cleanly.
- 2Replay ends at the last valid record. A record cut off by the crash is ignored.
- 3A full-page image repairs a torn page. It needs no intact old copy.
- 4Each page stores the position of the last record applied to it, so replay never applies a record twice.
- 5No undo pass. The commit log says aborted, so no snapshot sees its rows.
Tested source Go: kill every server process
// Crash kills every server process with SIGKILL, as a power cut or an out-of-memory kill would.
// It first stops all of them with SIGSTOP, so no process can write anything between the kills.
// Shared memory, and the WAL buffers in it, is gone.
func (c *Cluster) Crash() error {
pm, err := c.postmasterPID()
if err != nil {
return err
}
out, err := exec.Command("pgrep", "-P", strconv.Itoa(pm)).Output()
if err != nil {
return fmt.Errorf("list server processes: %w", err)
}
pids := []int{pm}
for f := range strings.FieldsSeq(string(out)) {
pid, err := strconv.Atoi(f)
if err != nil {
return fmt.Errorf("read pid %q: %w", f, err)
}
pids = append(pids, pid)
}
for _, sig := range []syscall.Signal{syscall.SIGSTOP, syscall.SIGKILL} {
for _, pid := range pids {
if err := syscall.Kill(pid, sig); err != nil && !errors.Is(err, syscall.ESRCH) {
return fmt.Errorf("signal %v to %d: %w", sig, pid, err)
}
}
}
deadline := time.Now().Add(10 * time.Second)
for c.running() {
if time.Now().After(deadline) {
return fmt.Errorf("postmaster %d still runs 10 s after SIGKILL", pm)
}
time.Sleep(10 * time.Millisecond)
}
return nil
}
On restart Postgres replays the WAL from the last checkpoint. Anything not flushed before the crash never happened.
CHECKPOINT| order | level | on disk when COMMIT returned | after restart |
|---|---|---|---|
| 101 | on | ||
| 102 | on | ||
| 103 | off | ||
| 104 | off |
The test kills every server process at once, as an out-of-memory kill or a power cut would. The WAL writer was set to wake every 10 s, so the unflushed records stay in memory.
With synchronous_commit off, COMMIT returns before the flush. A crash in that window loses the transaction as a whole.
- first order
- 8,632 bytes of WAL: a 7,588-byte heap page image plus an index page image.
- next order
- 168 bytes: the row, the index entry and a 34-byte commit record.
- page
- 8 kB. A drive can write it partly before a crash.
- Frequent checkpoints mean more page images and more WAL.
- Turn full_page_writes off only on storage that writes 8 kB atomically.
- wal_compression makes the images smaller.
The first change to each page after a checkpoint logs the whole page. That costs WAL, and it lets recovery repair a page that a crash tore.
| tool | capability | what it gives you | also used for |
|---|---|---|---|
| Postgres | Write-ahead log (WAL) | Every change is on disk in the log before its data page. Recovery replays it. | Streaming replication, point-in-time recovery, change data capture |
| Postgres | Commit log (pg_xact) | Two bits per transaction: committed or aborted. A crash mid-transaction leaves it aborted. | Row visibility in every snapshot |
| Postgres | synchronous_commit, per transaction with SET LOCAL | Pick the flush each transaction waits for: none, local, or a standby. | Cheap writes for logs and metrics |
| Postgres | Group commit | One flush makes every waiting commit durable. | High commit rates on slow drives |
| Postgres | Checkpoints: checkpoint_timeout, max_wal_size | Bound the WAL that recovery must replay. | Recycling old WAL files |
| Postgres | full_page_writes | A page image after each checkpoint repairs torn pages. | Base backups of a running server |
| Postgres | Constraints: CHECK, UNIQUE, FOREIGN KEY | The database refuses an invalid state, so a failed transfer rolls back. | Every invariant you can declare |
| Postgres | synchronous_standby_names | COMMIT waits for 1 or more standbys, so losing the primary loses nothing. | Failover with no data loss |
| Postgres | pg_current_wal_lsn, pg_walinspect | Read WAL positions and the records themselves. | Replication lag, read-your-writes tokens |
| Drive | A cache flush command | Limit A drive with a volatile write cache may report a write before it is durable. |
Postgres gives me a write-ahead log, a commit log, a per-transaction durability setting and group commit. The drive must give me an honest flush.
commit(tx):
append the COMMIT record1 to the WAL buffers
IF synchronous_commit = off:
RETURN ok // the WAL writer flushes it soon2
flush the WAL up to the COMMIT record // one flush serves every waiter3
IF local, or no synchronous standby4:
RETURN ok
wait until a standby has the record:
remote_write: written to its OS
on: flushed to its disk
remote_apply5: replayed, visible to reads
RETURN ok- 1The record is 34 bytes in our run. It is the moment of commit for recovery.
- 2Within 3 × wal_writer_delay: 600 ms at the default of 200 ms.
- 3Group commit. A commit that arrives during a flush waits for the next one, with the others.
- 4With synchronous_standby_names empty, remote_write, on and remote_apply all act as local.
- 5Replayed on the standby, so a read there sees the change.
Tested source Go: commit at a level and read the WAL positions
// InsertOrder inserts one order in its own transaction at a synchronous_commit level, and reads
// the WAL positions right after COMMIT returns. With "on", COMMIT waits until the commit record
// is flushed. With "off", it returns at once and the WAL writer flushes the record later.
func InsertOrder(ctx context.Context, conn *pgx.Conn, id int, level string) (Commit, error) {
var c Commit
if _, err := conn.Exec(ctx, "SET synchronous_commit = "+pgx.Identifier{level}.Sanitize()); err != nil {
return c, fmt.Errorf("set synchronous_commit %s: %w", level, err)
}
var flush string
if err := conn.QueryRow(ctx, schema["positions"]).Scan(&c.Start, &flush); err != nil {
return c, fmt.Errorf("read wal positions: %w", err)
}
err := pgx.BeginFunc(ctx, conn, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, schema["insert_order"], id); err != nil {
return fmt.Errorf("insert order %d: %w", id, err)
}
return tx.QueryRow(ctx, "SELECT pg_current_xact_id()::text").Scan(&c.XID)
})
if err != nil {
return c, fmt.Errorf("order %d: %w", id, err)
}
if err := conn.QueryRow(ctx, schema["positions"]).Scan(&c.Insert, &c.Flush); err != nil {
return c, fmt.Errorf("read wal positions: %w", err)
}
if err := conn.QueryRow(ctx, "SELECT $1::pg_lsn >= $2::pg_lsn, $2::pg_lsn - $3::pg_lsn",
c.Flush, c.Insert, c.Start).Scan(&c.OnDisk, &c.Bytes); err != nil {
return c, fmt.Errorf("compare wal positions: %w", err)
}
return c, nil
}
COMMIT appends a commit record, then waits for the flush that synchronous_commit names. Off waits for nothing; remote_apply waits for a standby to replay.
| level | COMMIT waits for | primary crashes | primary lost | median |
|---|---|---|---|---|
| off | nothing | Last 600 ms lost | Lost | 0.074 ms |
| local | local flush | Safe | Lost | 0.145 ms |
| remote_write | standby wrote it to its OS | Safe | Standby OS up | 0.358 ms |
| on | standby flushed it | Safe | Safe | 0.363 ms |
| remote_apply | standby replayed it | Safe | Safe | 0.393 ms |
- "Primary lost" assumes a failover to the synchronous standby. With no standby, on means local.
- Median commit time for 1 client, on one laptop with the standby on the same machine. A real network adds its round trip.
- Off never corrupts data. A lost transaction is lost as a whole.
I keep on for money. I use off per transaction for data I can lose, and a synchronous standby when losing the machine must lose nothing.
- Measured with a full drive-cache flush: 1 commit per flush with 1 client, 11 with 16 clients, 35 with 64.
- Latency for each client stays near 1 to 2 flushes: 20 ms alone, 40 ms with 64 clients.
Only one WAL flush runs at a time. Commits that arrive during it share the next flush, so throughput grows with clients.
- Full flush: wal_sync_method = fsync_writethrough, which empties the drive cache on macOS.
- Drive cache: the macOS default. The write reaches the drive's cache, not durable storage.
- commit_delay = 1 ms cut 16 clients from 24,604 to 9,325 commits a second. On the full flush it changed little.
On an honest flush, one client gets about 50 commits a second. Group commit takes 64 clients to about 1,500. A flush that stops at the drive cache is 100 times faster and not durable.
| event | result | what stops it | saved by |
|---|---|---|---|
| Power fails with synchronous_commit = on | No committed transaction is lost. | Each commit record was flushed before COMMIT returned. Recovery replays it. | WAL |
| The server is killed with synchronous_commit = off | The last commits are lost: 2 of 4 in our run. | Use off only for data you can lose. The rest of the data stays consistent. | Per transaction |
| The drive acknowledges a flush from a volatile cache, then loses power | Committed transactions are lost, or pages are corrupt. | Use drives with power-loss protection, or turn the write cache off. On macOS set fsync_writethrough. | Drive |
| fsync returns an I/O error | The kernel may have dropped the dirty pages. A retry can report success. | Postgres stops with PANIC by default (data_sync_retry = off) and recovers from the WAL. | PANIC, redo |
| A crash tears an 8 kB page write | The page is part old, part new. | The page image from full_page_writes replaces it during recovery. | Page images |
| The only synchronous standby goes down | Every commit waits. | Name two standbys with a quorum: ANY 1 (s1, s2). Commits wait for the faster one. | Quorum |
| The WAL disk fills | Postgres stops with PANIC. No commits. | Alert on WAL size. A failing archive command or an unused replication slot is the usual cause. | Monitoring |
| A long time since the last checkpoint | Recovery takes long, because it replays more WAL. | checkpoint_timeout and max_wal_size bound the WAL after the redo point. | Checkpoints |
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | One Postgres, synchronous_commit = on, a drive that flushes honestly. | About 50 commits a second per client on our laptop SSD, 1,500 with 64 clients. | Commit time dominates request time, or commits queue for the flush. |
| 2 | Fewer, larger commits: many rows per transaction, multi-row INSERT. Keep enough clients for group commit. | Each flush carries more work: 35 commits per flush with 64 clients. | Some writes can be lost without harm, and they still wait for the flush. |
| 3 | SET LOCAL synchronous_commit = off for logs, metrics and similar data. | Those commits skip the flush wait: 69,635 a second against 24,604 on the same laptop. | The flush itself limits the important writes. |
| 4 | A drive with power-loss protection (PLP). It acknowledges from a cache that survives a power cut. | The flush returns from the protected cache. On our laptop a write to the cache took 0.15 ms; a full flush took 20 ms. | Losing the whole machine must not lose commits. |
| 5 | A synchronous standby, synchronous_commit = on. | Losing the primary loses no commit. Each commit adds a network round trip. | A standby failure stops all commits. |
| 6 | A quorum of standbys: ANY 1 (s1, s2). | One standby can fail, and commits go on. | Top of the ladder. |
Drive cache: the macOS default flush, which stops at the drive's cache. Here it stands in for a PLP drive. Do not turn fsync off to go faster. A crash then corrupts the database, not only the last commits. synchronous_commit = off gives most of the speed with a bounded loss.
I start with synchronous_commit on and an honest flush. I batch and add concurrency before I trade durability, and I trade it per transaction, never for the whole database.
0 of 9 known
COMMIT returned with synchronous_commit = on. The power fails 1 ms later. Is the transaction safe?
Why does Postgres flush the WAL at commit and not the changed data pages?
With synchronous_commit = off, what can a crash cost you, and what can it not?
One client commits 51 times a second. 64 clients commit 1,523 times a second on the same drive. Why?
What does full_page_writes protect against?
What decides how long crash recovery takes?
You set synchronous_commit = remote_apply. What do you get that on does not give?
When does commit_delay help?
A transaction updated 1 million rows and then rolls back. Is the ROLLBACK slow?
- full flush
- About 20 ms on our laptop SSD: 51 commits a second for 1 client.
- group commit
- 64 clients: 1,523 commits a second, 35 per flush.
- drive cache
- 0.15 ms a commit, about 6,000 a second per client. Not durable.
- off
- 2 times the commits for 1 client, 2.8 times for 16. Loss window: 600 ms.
- WAL
- 168 bytes for a small insert; 8,632 with page images.
- checkpoint
- Every 5 min or 1 GB of WAL by default.
Postgres 16 on an 8-core laptop with an NVMe SSD under macOS, 3-second runs. Standby on the same laptop. Use these as orders of magnitude.