System Design
T1

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.

Not startedSaved in this browser only.
  1. 1Postgres writes every change to the write-ahead log (WAL) before the data page. The log decides what committed.
  2. 2COMMIT returns after the commit record is flushed. synchronous_commit chooses which flush it waits for.
  3. 3One flush serves every commit that waits for it. Throughput grows with clients; latency stays near one flush.
  4. 4Durability is only as good as the drive. A flush that stops in a volatile cache can lose commits.
T1
    A

    The problem

    a crash between two writes
    ✕ Write the pages in placepage A: ada − 30written✕crashpage B: grace + 30never written30 is gone.No record sayswhat was planned.✓ Write the log firstdebit Acredit BCOMMITWAL, flushed before COMMIT returns✕Restart: replay the log.Both pages get both changes.No COMMIT record: neither counts.The log, not the data pages, decides what committed.
    • 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.

    B

    What each letter promises

    and what implements it in Postgres
    letterpromiseimplemented by
    AAll of the transaction, or none of it.The commit log marks each transaction committed or aborted. No COMMIT record: aborted, also after a crash.
    CEach commit leaves the data valid.CHECK, UNIQUE, FOREIGN KEY and EXCLUDE constraints. Rules the schema cannot express stay in the application.
    ITransactions do not see each other's unfinished work.Snapshots and locks. See T2 and T3.
    DAfter 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.

    C

    The write path

    log first, pages later
    Clientsends COMMITBackendone per connectionshared memory: lost in a crashshared bufferspage 7: row changeddirty, not on diskWAL buffersHeap INSERTTransaction COMMITdisk: survives a crashdata filespage 7: old copyrandom writes, laterWAL filesappend onlyflushed at COMMITfsynccheckpoint123451Change the row in its page, in memory.2Append a WAL record for the change.3COMMIT: flush the WAL to disk.4COMMIT returns. The change is durable.5Later, a checkpoint writes the page.
    writewhenpattern
    WAL recordEach change, into WAL buffersAppend, in memory
    WAL flushAt COMMIT, or by the WAL writerSequential, one flush for many commits
    Data pageAt a checkpoint, or when a buffer is reusedRandom, 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.

    D

    Try it: one transfer, two sessions

    recorded from Postgres

    Session 1 credits grace first, then debits ada by more than she has.

    tSession 1Session 2
    1BEGINBEGIN
    2UPDATE accounts SET balance = balance + 150 WHERE id = 2UPDATE 1
    3SELECT id, balance FROM accounts ORDER BY idid = 1, balance = 100; id = 2, balance = 100The credit is not visible to other sessions.
    4UPDATE 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).
    5COMMITROLLBACKThe transaction failed, so COMMIT rolls it back.
    6SELECT id, balance FROM accounts ORDER BY idid = 1, balance = 100; id = 2, balance = 100The credit is gone too. Nothing changed.
    statement 6 of 6
    The CHECK constraint fails, and the whole transaction rolls back. The credit is gone too. After both sessions: ada 100, grace 100

    Until COMMIT, other sessions see none of the transfer. A failed statement aborts the whole transaction, so COMMIT becomes ROLLBACK.

    E

    Crash recovery

    replay from the redo point
    the WAL, oldest on the leftolder WALcheckpointredo point1018,632 B102168 Bpage imagesflushed to here103memory104memory✕SIGKILLOn restart: replay each record from the redo point.A page image replaces a page that a crash tore.103 and 104 returned COMMIT but are gone.
    start after a crashpseudo code
    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
    1. 1A small file that records the last checkpoint and whether the server shut down cleanly.
    2. 2Replay ends at the last valid record. A record cut off by the crash is ignored.
    3. 3A full-page image repairs a torn page. It needs no intact old copy.
    4. 4Each page stores the position of the last record applied to it, so replay never applies a record twice.
    5. 5No undo pass. The commit log says aborted, so no snapshot sees its rows.
    Tested source Go: kill every server process
    Go: kill every server processgo
    // 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.

    F

    Try it: SIGKILL after four commits

    recorded from a throwaway Postgres
    CHECKPOINT
    checkpointflushed to hereWAL →
    orderlevelon disk when COMMIT returnedafter restart
    101on
    102on
    103off
    104off
    step 1 of 8
    The table has 40 rows. The checkpoint wrote every dirty page to disk, so recovery starts here.

    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.

    G

    Torn pages and full-page writes

    recorded WAL sizes
    one 8 kB page as sixteen 512-byte sectorstornnewold: the crash came hereWALfull-page image, logged after the checkpointfixedRecovery copies the image, then replays later records.
    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.

    H

    Capabilities used

    what Postgres gives you
    toolcapabilitywhat it gives youalso used for
    PostgresWrite-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
    PostgresCommit log (pg_xact)Two bits per transaction: committed or aborted. A crash mid-transaction leaves it aborted.Row visibility in every snapshot
    Postgressynchronous_commit, per transaction with SET LOCALPick the flush each transaction waits for: none, local, or a standby.Cheap writes for logs and metrics
    PostgresGroup commitOne flush makes every waiting commit durable.High commit rates on slow drives
    PostgresCheckpoints: checkpoint_timeout, max_wal_sizeBound the WAL that recovery must replay.Recycling old WAL files
    Postgresfull_page_writesA page image after each checkpoint repairs torn pages.Base backups of a running server
    PostgresConstraints: CHECK, UNIQUE, FOREIGN KEYThe database refuses an invalid state, so a failed transfer rolls back.Every invariant you can declare
    Postgressynchronous_standby_namesCOMMIT waits for 1 or more standbys, so losing the primary loses nothing.Failover with no data loss
    Postgrespg_current_wal_lsn, pg_walinspectRead WAL positions and the records themselves.Replication lag, read-your-writes tokens
    DriveA cache flush commandLimit 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.

    I

    The commit path

    pseudo code
    commitpseudo code
    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
    1. 1The record is 34 bytes in our run. It is the moment of commit for recovery.
    2. 2Within 3 × wal_writer_delay: 600 ms at the default of 200 ms.
    3. 3Group commit. A commit that arrives during a flush waits for the next one, with the others.
    4. 4With synchronous_standby_names empty, remote_write, on and remote_apply all act as local.
    5. 5Replayed on the standby, so a read there sees the change.
    Tested source Go: commit at a level and read the WAL positions
    Go: commit at a level and read the WAL positionsgo
    // 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.

    J

    synchronous_commit levels

    what each waits for, what it risks
    levelCOMMIT waits forprimary crashesprimary lostmedian
    offnothingLast 600 ms lostLost0.074 ms
    locallocal flushSafeLost0.145 ms
    remote_writestandby wrote it to its OSSafeStandby OS up0.358 ms
    onstandby flushed itSafeSafe0.363 ms
    remote_applystandby replayed itSafeSafe0.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.

    K

    Group commit

    one flush, many commits
    time →, one flush ≈ 20 msWAL flushflush 1: 1 commitflush 2: 4 commitsT1T2T3T4T5COMMIT sentCOMMIT returnswaits for the flush in progress4 commits paid for one flush. Each one waited up to 2 flushes.
    • 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.

    L

    What a commit costs

    measured on one laptop
    Commits per second, laptop SSD101001k10k100kFull flush, 1 client: 51 commits per secondFull flush, 1 client51Full flush, 16 clients: 410 commits per secondFull flush, 16 clients410Full flush, 64 clients: 1,523 commits per secondFull flush, 64 clients1,523Drive cache, 1 client: 5,942 commits per secondDrive cache, 1 client5,942Drive cache, 16 clients: 24,604 commits per secondDrive cache, 16 clients24,604off, 16 clients: 69,635 commits per secondoff, 16 clients69,635commits per second, log scale
    • 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.

    M

    Failure cases

    what breaks, and what stops it
    eventresultwhat stops itsaved by
    Power fails with synchronous_commit = onNo committed transaction is lost.Each commit record was flushed before COMMIT returned. Recovery replays it.WAL
    The server is killed with synchronous_commit = offThe 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 powerCommitted 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 errorThe 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 writeThe page is part old, part new.The page image from full_page_writes replaces it during recovery.Page images
    The only synchronous standby goes downEvery commit waits.Name two standbys with a quorum: ANY 1 (s1, s2). Commits wait for the faster one.Quorum
    The WAL disk fillsPostgres 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 checkpointRecovery takes long, because it replays more WAL.checkpoint_timeout and max_wal_size bound the WAL after the redo point.Checkpoints
    N

    Scale ladder

    start durable; climb on a signal
    Each step adds one tool1sync commit2+ batches3+ off for logs4+ PLP drive5+ sync standby6+ quorummore load →
    Commits per second, 16 clients101001k10k100kFull flush: 410 commits per secondFull flush410Drive cache: 24,604 commits per secondDrive cache24,604Drive cache + standby: 20,867 commits per secondDrive cache + standby20,867off: 69,635 commits per secondoff69,635commits per second, log scale
    stepaddit handlesmove up when you see
    1One 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.
    2Fewer, 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.
    3SET 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.
    4A 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.
    5A synchronous standby, synchronous_commit = on.Losing the primary loses no commit. Each commit adds a network round trip.A standby failure stops all commits.
    6A 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.

    O

    Drill

    predict, then reveal

    0 of 9 known

    1. COMMIT returned with synchronous_commit = on. The power fails 1 ms later. Is the transaction safe?

    2. Why does Postgres flush the WAL at commit and not the changed data pages?

    3. With synchronous_commit = off, what can a crash cost you, and what can it not?

    4. One client commits 51 times a second. 64 clients commit 1,523 times a second on the same drive. Why?

    5. What does full_page_writes protect against?

    6. What decides how long crash recovery takes?

    7. You set synchronous_commit = remote_apply. What do you get that on does not give?

    8. When does commit_delay help?

    9. A transaction updated 1 million rows and then rolls back. Is the ROLLBACK slow?

    P

    Numbers to say

    measured in the lab
    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.