System Design
O4

Change and migrations

Most outages start with a change. Ship code and schema in small steps that the old and the new version both accept, and measure what each step locks.

Not startedSaved in this browser only.
  1. 1During every deploy, old and new code run together. Each schema state must work for both.
  2. 2Rename in place breaks the running version. Expand, migrate, then contract, over several deploys.
  3. 3A DDL statement that waits for its lock blocks every query behind it. Set lock_timeout and retry.
  4. 4Prefer weak locks and catalog-only changes: CONCURRENTLY, NOT VALID then VALIDATE, and batched backfills.
O4
    A

    Two versions at once

    a rolling deploy
    time: one instance at a time →node 1node 2node 3node 4v1v1v1v1v2v1v1v1v2v2v1v1v2v2v2v1v2v2v2v2v1 and v2 serve togetherevery schema state must fit bothrollback: the same overlap, in reverse
    • A deploy replaces instances a few at a time. For minutes, v1 and v2 share one database.
    • A rollback runs the same overlap in reverse.
    • So a schema change and the code that needs it never ship as one step.

    During a rolling deploy, both versions serve traffic, so every schema change must be compatible with the old and the new code.

    B

    Deploy strategies

    how traffic moves to new code
    1%✓ gate5%✓ gate25%✓ gate50%✓ gate100%share of requests on v2, step by step (minutes to hours each)gate fails: send all traffic back to v1gate: canary error rate and p99 latency against the baseline, with a statistical test
    strategyrollbackstatus
    Stop all, deploy, start allAnother full deployNot approved
    Rolling, a few instances at a timeRoll the old version back outApproved
    Blue-green: a second fleet, then one switchSwitch back in seconds2× capacity
    Canary with automated analysisSet the canary weight to 0Approved
    Feature flag around new behaviourTurn the flag off, no deployApproved
    Dark launch: run new code on copied traffic, discard resultsNothing reaches usersReads only

    I use a canary with automated analysis for code, and feature flags to turn on behaviour separately from the deploy.

    C

    What each schema change locks

    recorded from Postgres 16 · 1,000,000 rows · 4 readers, 4 writers
    changelock takenrewriteranlongest read waitlongest write waitstatus
    ADD COLUMN, nullableACCESS EXCLUSIVEno19 ms4 ms4 msApproved
    ADD COLUMN with a constant defaultACCESS EXCLUSIVEno19 ms4 ms4 msApproved
    ADD COLUMN with a volatile defaultACCESS EXCLUSIVEYes3,177 ms3,145 ms3,146 msNot approved
    ALTER COLUMN TYPE int to bigintACCESS EXCLUSIVEYes2,680 ms2,659 ms2,656 msNot approved
    CREATE INDEXSHAREno898 ms8 ms878 msNot approved
    CREATE INDEX CONCURRENTLYSHARE UPDATE EXCLUSIVEno1,268 ms13 ms38 msApproved
    SET NOT NULLACCESS EXCLUSIVEno267 ms245 ms246 msNot approved
    CHECK NOT VALID, VALIDATE, then SET NOT NULLACCESS EXCLUSIVE, then SHARE UPDATE EXCLUSIVE, then ACCESS EXCLUSIVE, then ACCESS EXCLUSIVEno319 ms7 ms7 msApproved
    ADD FOREIGN KEYSHARE ROW EXCLUSIVE (customers) + SHARE ROW EXCLUSIVE (orders)no984 ms2 ms963 msNot approved
    ADD FOREIGN KEY NOT VALID, then VALIDATESHARE ROW EXCLUSIVE (customers) + SHARE ROW EXCLUSIVE (orders), then ROW SHARE (customers) + SHARE UPDATE EXCLUSIVE (orders)no803 ms1 ms6 msApproved
    DROP COLUMNACCESS EXCLUSIVEno30 ms7 ms7 msApproved
    • Locks come from pg_locks inside each statement's transaction. Waits come from 8 sessions that ran during the change.
    • A rewrite copies every row into a new file under ACCESS EXCLUSIVE. Reads and writes stop for the whole copy.
    • After the type change, 7 reads failed with 0A000: cached plan must not change result type. Running sessions held prepared statements for the old type.
    • Since Postgres 11, ADD COLUMN with a constant default changes only the catalog: 4 ms here.

    ACCESS EXCLUSIVE blocks reads and writes, SHARE blocks writes, and SHARE UPDATE EXCLUSIVE blocks neither. I pick the form that takes the weakest lock for the shortest time.

    D

    Unsafe and safe, measured

    longest wait of a writer, log scale
    Longest write wait while the change ran1101001k10kVolatile default (rewrite): 3,146 millisecondsVolatile default (rewrite)3,146CREATE INDEX: 878 millisecondsCREATE INDEX878CONCURRENTLY: 38 millisecondsCONCURRENTLY38ADD FOREIGN KEY: 963 millisecondsADD FOREIGN KEY963NOT VALID, VALIDATE: 6 millisecondsNOT VALID, VALIDATE6SET NOT NULL: 246 millisecondsSET NOT NULL246CHECK, VALIDATE, SET: 7 millisecondsCHECK, VALIDATE, SET7milliseconds, log scale
    • CONCURRENTLY builds the index in two passes and waits for older transactions. It runs longer: 1.3 s against 0.9 s.
    • NOT VALID adds the rule for new rows at once. VALIDATE checks old rows later under a weak lock.

    The safe forms do the same work, but writers wait milliseconds instead of seconds.

    E

    The lock queue

    recorded from Postgres
    0 ms1,500 msreportACCESS SHAREALTER TABLEwants ACCESS EXCL.reads, writesevery new queryholds the table, then commitswaits in the lock queuequeued behind the ALTER: up to 1,492 mslock_timeout100 ms, 6 triesEach failed attempt blocks queries for 100 ms at most, then gets out of the queue.Longest query wait: 103 ms.
    • A report holds the table for 1.5 s. An ALTER that needs 4 ms queues behind it.
    • Without a timeout, reads and writes stalled for 1,492 ms.
    • With lock_timeout of 100 ms, the ALTER needed 6 attempts. No query waited more than 103 ms.

    A waiting ALTER blocks every query behind it. I run DDL with a 100 ms lock_timeout and retry, so the worst stall is 100 ms, not the length of the longest transaction.

    F

    Run DDL with lock_timeout

    pseudo code
    apply one DDL statementpseudo code
    apply_ddl(statement):
      REPEAT up to 50 times:
        BEGIN
          SET LOCAL lock_timeout = '100ms'1    // give up fast
          run statement
        COMMIT; RETURN done
        ON lock_not_available (55P03)2:
          ROLLBACK                           // leave the queue3
          sleep 200 ms, then try again4
      RETURN failed: tell the owner
    1. 1SET LOCAL lasts for this transaction only. Other sessions keep their own settings.
    2. 2The lock was not granted in time. The statement did nothing, so a retry is safe.
    3. 3The queries that waited behind the ALTER now run.
    4. 4Long transactions end between attempts. Kill a transaction that never ends.
    Tested source Go: apply DDL with retries
    Go: apply DDL with retriesgo
    
    // ApplyDDL runs one DDL statement with a short lock_timeout, and retries when the lock is not
    // available (55P03). A DDL request that waits for its lock also blocks every later query on the
    // table, so it must give up quickly and try again later. lockTimeout 0 means wait forever.
    func ApplyDDL(ctx context.Context, db *pgxpool.Pool, sql string, lockTimeout time.Duration, attempts int, pause time.Duration) (int, error) {
      for try := 1; ; try++ {
        err := pgx.BeginFunc(ctx, db, func(tx pgx.Tx) error {
          set := fmt.Sprintf("SET LOCAL lock_timeout = '%dms'", lockTimeout.Milliseconds())
          if _, err := tx.Exec(ctx, set); err != nil {
            return fmt.Errorf("set lock_timeout: %w", err)
          }
          _, err := tx.Exec(ctx, sql)
          return err
        })
        var pgErr *pgconn.PgError
        switch {
        case err == nil:
          return try, nil
        case !errors.As(err, &pgErr) || pgErr.Code != "55P03":
          return try, fmt.Errorf("%s: %w", sql, err)
        case try == attempts:
          return try, fmt.Errorf("%s: lock not available after %d attempts: %w", sql, try, err)
        }
        select {
        case <-time.After(pause):
        case <-ctx.Done():
          return try, ctx.Err()
        }
      }
    }
    
    • Tested: with a 50 ms timeout and 3 attempts against an open transaction, it fails with 55P03 after 3 tries.
    • Also set statement_timeout, so a slow statement cannot run for hours.

    Every migration statement runs with SET LOCAL lock_timeout and a retry loop. A lock it cannot get fails fast and harms nobody.

    G

    Safe recipes

    Postgres 12 and later
    goalsafe sequence
    Add an indexCREATE INDEX CONCURRENTLY. If it fails, drop the invalid index and build again.
    Add a foreign keyADD CONSTRAINT ... NOT VALID, then VALIDATE CONSTRAINT.
    Make a column NOT NULLADD CHECK (col IS NOT NULL) NOT VALID, VALIDATE, SET NOT NULL, drop the CHECK.
    Add a unique constraintCREATE UNIQUE INDEX CONCURRENTLY, then ADD CONSTRAINT ... USING INDEX.
    Add a column with a defaultA constant default is safe. For a volatile one, add it nullable and backfill.
    Change a column typeAdd a new column, dual write, backfill, switch reads, drop the old column.
    Rename a column or tableExpand and contract (panel J). Never rename in place under live code.

    Source: the Postgres 16 ALTER TABLE and CREATE INDEX documentation. The first three rows are measured in panel C.

    I build indexes concurrently and add constraints NOT VALID, then validate them. A type change becomes a new column.

    H

    The deploy path

    click a step; its path lights up
    CI pipelinebuild, testMigration joblock_timeout, retryLoad balancerweight per versionFleet v1old codeFleet v2canary, then allPostgresshared by v1, v2Metrics, analysiserrors, p99

    Step 1: Expand

    • Run additive schema changes only: new nullable columns, new tables, indexes built CONCURRENTLY.
    • Each statement runs with lock_timeout and retries.
    • The old code still works, because it never reads the new parts.

    If it fails

    The lock is not available after all retries: stop the deploy. Nothing changed, so nothing needs a rollback.

    I expand the schema first, then canary the code with automated analysis and roll out. I contract only when the old version can never come back.

    I

    Capabilities used

    what each tool gives you
    toolcapabilitywhat it gives this designalso used for
    PostgresTransactional DDLA multi-statement migration commits all or nothing.Any schema change
    PostgresCatalog-only ADD COLUMN and DROP COLUMNMilliseconds on any table size, no rewrite.Expand and contract
    PostgresCREATE INDEX CONCURRENTLYBuild an index while writes continue.Unique constraints, partial indexes
    PostgresNOT VALID, then VALIDATE CONSTRAINTCheck new rows now, old rows later under a weak lock.Foreign keys, CHECK constraints
    PostgresA valid CHECK proves NOT NULLSET NOT NULL skips its full scan.Tightening old columns
    Postgreslock_timeout, statement_timeoutA waiting DDL request gives up before it stalls traffic.User requests, batch jobs
    Postgrespg_locks, pg_stat_activitySee which transaction blocks the migration.Incident debugging
    ServiceFeature flagsTurn behaviour on and off without a deploy.Gradual launches, kill switches
    ServiceTolerant reader: ignore unknown fieldsOld clients accept new responses.Events, message schemas
    Load balancerWeighted routing per versionCanary at 1%, then more, and back to 0 in seconds.Blue-green switch, dark launch
    OrchestratorRolling update with surge and unavailable limitsReplace instances a few at a time, with health checks.Node maintenance

    Postgres gives me catalog-only changes, concurrent index builds, NOT VALID constraints and lock_timeout. The load balancer and feature flags let me move traffic and behaviour separately.

    J

    Try it: rename a column while traffic runs

    recorded from Postgres; step through

    Step 1: Start

    schema after this stepcustomersidbigintprimary keynametextNOT NULLfull_nametextabsenteach version, run against this schemav1servingreadsnamewritesname✓ worksv2not livereadsnamewritesnamefull_name✕ failsv3not livereadsfull_namewritesnamefull_name✕ failsv4not livereadsfull_namewritesfull_name✕ fails✓ Every version that serves now (v1) works.Solid cards serve traffic at this step. Dashed cards show what happens if you deploy that version now.

    Schema change at this step

    None. Only the application changes.

    What goes wrong, version by version

    • v2: 42703 column "full_name" of relation "customers" does not exist
    • v3: 42703 column "full_name" of relation "customers" does not exist
    • v4: 42703 column "full_name" of relation "customers" does not exist
    step 1 of 7

    At every step of expand and contract, the versions that serve traffic both work. A rename in place breaks the old version the moment it runs.

    K

    Backfill in batches

    pseudo code · recorded on 500,000 rows
    copy name into full_namepseudo code
    backfill(batch = 5000, pause = 10 ms):
      top = max(id)
      FOR lo FROM 1 TO top STEP batch:
        UPDATE customers SET full_name = name   // one short transaction1
        WHERE id >= lo AND id < lo + batch2
          AND full_name IS NULL                 // skip done rows3
        sleep pause                             // replicas, vacuum keep up4
    1. 1Each batch commits on its own. Its row locks end at its commit.
    2. 2A primary key range is an index range scan. No OFFSET, so every batch costs the same.
    3. 3A stopped job can start again from the top. It changes only rows still missing.
    4. 4In production, also pause while replica lag is above a limit.
    Tested source Go: the batch loop · SQL: one batch
    Go: the batch loopgo
    
    // Backfill copies name into full_name for every customer, in batches of size ids. Each batch is
    // its own short transaction, so a row stays locked for one batch only, and the pause between
    // batches lets replicas and vacuum keep up. The WHERE clause skips rows already done, so a
    // stopped backfill can restart from the beginning.
    func Backfill(ctx context.Context, db *pgxpool.Pool, size int, pause time.Duration) (BackfillStats, error) {
      var top int64
      if err := db.QueryRow(ctx, "SELECT coalesce(max(id), 0) FROM customers").Scan(&top); err != nil {
        return BackfillStats{}, fmt.Errorf("read max id: %w", err)
      }
      var st BackfillStats
      start := time.Now()
      for lo := int64(1); lo <= top; lo += int64(size) {
        t := time.Now()
        tag, err := db.Exec(ctx, schema["backfill_batch"], lo, lo+int64(size))
        if err != nil {
          return st, fmt.Errorf("batch from id %d: %w", lo, err)
        }
        st.LongestMs = max(st.LongestMs, float64(time.Since(t).Microseconds())/1000)
        st.Rows += tag.RowsAffected()
        st.Batches++
        select {
        case <-time.After(pause):
        case <-ctx.Done():
          return st, ctx.Err()
        }
      }
      st.Ms = float64(time.Since(start).Microseconds()) / 1000
      st.PerSec = float64(st.Rows) / time.Since(start).Seconds()
      return st, nil
    }
    
    SQL: one batchsql
    UPDATE customers SET full_name = name
    WHERE id >= $1 AND id < $2 AND full_name IS NULL;
    methodtotalrows a secondrows locked forlongest write wait
    One UPDATE of every row5.8 s86,4395,784 ms5,755 ms
    100 batches of 5,000, 10 ms pause9.9 s50,512233 ms142 ms

    I backfill by primary key range in batches of a few thousand rows, each its own transaction, with a pause. Rows stay locked for one batch, not for the whole job.

    L

    Rollback, roll forward, API versions

    compatibility rules
    rulestatus
    Schema change and code that needs it in one deployNot approved
    Migrate first; the new schema accepts the old codeApproved
    Roll back code before the contract stepApproved
    Down migration that drops new dataNot approved
    Add optional fields to an APIApproved
    Rename or remove a field in placeNot approved
    Breaking change as a new version, old one deprecatedApproved
    Protobuf: reuse a field numberNot approved
    • Services talk to version N-1 and N+1 during a rollout, so APIs follow the same expand-and-contract rule.

    Every deploy must be safe to roll back until the contract step. After that, I roll forward.

    M

    Failure cases

    what breaks, and what stops it
    eventresultwhat stops itsaved by
    A migration waits behind a long transactionEvery query on the table queues: 1,492 ms in our run.lock_timeout of 100 ms and retries.lock_timeout
    CREATE INDEX on a large tableWrites stop for the build: 878 ms on 1M rows.CREATE INDEX CONCURRENTLY.CONCURRENTLY
    A concurrent index build failsAn invalid index stays and slows writes.Check indisvalid in pg_index, drop the index, build again.pg_index
    ALTER COLUMN TYPE on a live tableA full rewrite under ACCESS EXCLUSIVE, then 0A000 errors from cached plans.A new column, dual write and backfill.Expand, contract
    New code ships before its migration42703: column does not exist.Run the expand migration first.Order
    The backfill starts while v1 still writesRows v1 changes later keep a stale copy. The lab found 2 such rows.Start the backfill after the last v1 instance stops.Order
    A backfill in one UPDATERows stay locked 5.8 s; replicas fall behind.Batches by key range, with pauses.Batches
    A canary raises the error rateA small share of users sees errors.Automated analysis sets the canary weight to 0.Load balancer
    N

    Scale ladder

    start simple; climb on a signal
    Each step adds one practice or tool1migrate at deploy2+ lock_timeout3+ expand, contract4+ backfill jobs5+ copy and swapmore load →
    Backfill speed against a deadline1k10k100k1M100M rows in 1 hour: 27,778 rows per second100M rows in 1 hour27,7781B rows in 1 day: 11,574 rows per second1B rows in 1 day11,574One batched job: 50,512 rows per secondOne batched job50,512One UPDATE, all rows: 86,439 rows per secondOne UPDATE, all rows86,439rows per second, log scale
    stepaddit handlesmove up when you see
    1Migrations at deploy time, in one transaction each.Tables up to about 1M rows. Even a rewrite took 3.2 s here.More than one instance, so old and new code overlap. Or a lock stall in the latency graphs.
    2lock_timeout and retries on every DDL statement.Catalog-only changes on any table size, with stalls capped at 100 ms.Changes that need old and new code to agree: renames, type changes, NOT NULL.
    3Expand and contract, CONCURRENTLY and NOT VALID.Every change in panel G, with no read or write stall above tens of milliseconds.Backfills of tens of millions of rows; replica lag during the copy.
    4Backfill jobs: key-range batches, a pause on replica lag, restartable.About 50,512 rows a second per job here: 100M rows in about 33 minutes.A change Postgres cannot do online, such as a new primary key type on a huge table.
    5Copy and swap: build a new table, sync it with triggers or logical replication, then swap names.Any change, at the cost of 2× storage during the copy.Top of the ladder.

    The deadlines are derived: 100,000,000 rows ÷ 3,600 s ≈ 27,778 rows a second. The 100M estimate assumes the batched rate stays linear.

    On small tables, any migration at deploy time works. As tables grow, I add lock_timeout, then expand and contract with concurrent builds, then throttled backfill jobs, then copy-and-swap for changes Postgres cannot do online.

    O

    Drill

    predict, then reveal

    0 of 9 known

    1. Rename the column name to full_name with no downtime. List the steps.

    2. ALTER TABLE ADD COLUMN takes a few milliseconds. Why did it stop the site for a minute?

    3. Which of these rewrites the table: DEFAULT now(), DEFAULT gen_random_uuid()?

    4. CREATE INDEX CONCURRENTLY fails half way. What is left, and what do you do?

    5. Why add CHECK (col IS NOT NULL) NOT VALID before SET NOT NULL?

    6. When must the backfill start?

    7. You dropped the old column, and the new version has a bug. Roll back?

    8. Canary or blue-green?

    9. A mobile client from last year still calls your API. How do you remove a field?

    P

    Numbers to say

    measured in the lab
    add column
    19 ms with a constant default, on any table size.
    rewrite
    3.2 s per 1M rows, with reads and writes stopped.
    index
    CREATE INDEX blocked writes 878 ms on 1M rows. CONCURRENTLY: 38 ms.
    lock queue
    A 4 ms ALTER behind a 1.5 s report stalled queries 1,492 ms.
    lock_timeout
    100 ms caps that stall at about 103 ms.
    backfill
    About 50,512 rows a second in batches of 5,000.
    canary
    1%, 5%, 25%, 50%, 100%, with an analysis gate at each step.

    Postgres 16 on an 8-core laptop, 1M orders and 500,000 customers, 4 readers and 4 writers. Timings vary with load. Use them as orders of magnitude.