Changing a Schema in Production

Expand, migrate, contract: adding a column, backfilling in batches and dual-writing through a cutover with no downtime.

SyntaxALTER TABLE orders ADD COLUMN amount_minor BIGINT NULL, ALGORITHM=INSTANT

A schema change in production is not one step, because the old code and the new code are both running during a rollout. The pattern is three deploys: expand the schema so it supports both shapes, migrate the data and the readers, then contract by removing the old shape. Every individual step must be safe with both code versions live.

Expand

Only additive, and nullable with no backfilled default. In MySQL 8.4, ALGORITHM=INSTANT is the default for adding and dropping columns and can add one at any position. In PostgreSQL 11 and later, ADD COLUMN with a non-volatile default is a catalogue change rather than a table rewrite. Build indexes with CREATE INDEX CONCURRENTLY, and add constraints as NOT VALID first, then VALIDATE CONSTRAINT under a weaker lock.

Migrate

Deploy dual writes before the backfill, so the backfill never chases new rows. Backfill in bounded batches using keyset pagination, pausing between them to leave replication headroom — OFFSET rescans its prefix and degrades as it advances. Then move reads over, ideally behind a flag, comparing old and new for a while before trusting the new column.

Contract

Drop the old column only after a verification query returns zero mismatches and no deployed code reads it. For a change that InnoDB cannot do in place, gh-ost builds a shadow table from the binary log, while pt-online-schema-change uses triggers on the original.

Example

Example · javascript
// EXPAND. Additive, nullable, no default. In MySQL 8.4 this is the default
// algorithm for ADD COLUMN; stating it is how you make the migration fail loudly
// rather than quietly rebuilding a 200M-row table during a deploy.
//   ALTER TABLE orders ADD COLUMN amount_minor BIGINT NULL, ALGORITHM=INSTANT;
// PostgreSQL equivalent. A nullable column with no default has always been a
// catalogue-only change; what PostgreSQL 11 added is the same for a column with
// a non-volatile DEFAULT, which before 11 rewrote the whole table:
//   ALTER TABLE orders ADD COLUMN amount_minor BIGINT;

// MIGRATE, step 1: dual write. This ships BEFORE any backfill runs, so rows
// created during the backfill already have the new column.
async function insertOrder(db, order) {
  await db.none(
    `INSERT INTO orders (id, total, amount_minor, currency)
     VALUES ($1, $2, $3, $4)`,
    // Old column and new column, from one source value. Integer minor units are
    // the point of the migration: `total` was a float and lost half-cents.
    [order.id, order.total, Math.round(order.total * 100), order.currency],
  );
}

// MIGRATE, step 2: backfill in bounded batches, keyset-paginated. OFFSET would
// rescan the whole prefix on every batch and get slower as it goes.
async function backfillAmountMinor(db, { batchSize = 5_000, pauseMs = 50 } = {}) {
  let lastId = 0;
  let updated = 0;

  for (;;) {
    const rows = await db.manyOrNone(
      `UPDATE orders SET amount_minor = round(total * 100)
       WHERE id IN (
         SELECT id FROM orders
         WHERE id > $1 AND amount_minor IS NULL
         ORDER BY id
         LIMIT $2
       )
       RETURNING id`,
      [lastId, batchSize],
    );

    if (rows.length === 0) break;
    lastId = rows.reduce((max, r) => (r.id > max ? r.id : max), lastId);
    updated += rows.length;

    // Each batch is one transaction, so a crash loses at most one batch and the
    // resume point is just "the next NULL". The pause is what keeps replicas and
    // vacuum/purge able to keep up; without it the backfill becomes an outage.
    await new Promise((resolve) => setTimeout(resolve, pauseMs));
  }
  return updated;
}

// MIGRATE, step 3: read from the new column behind a flag, and keep reading the
// old one until the comparison has been clean for long enough to trust.
function orderAmountMinor(row, flags) {
  if (flags.readAmountMinor && row.amount_minor !== null) return row.amount_minor;
  return Math.round(row.total * 100); // old path, still correct
}

// CONTRACT, only after this returns 0 and no deployed build reads `total`:
//   SELECT count(*) FROM orders
//   WHERE amount_minor IS DISTINCT FROM round(total * 100);
//   ALTER TABLE orders DROP COLUMN total;

When to use it

  • Adding a NOT NULL column to a large table without taking a write lock on it.
  • Backfilling a derived column across hundreds of millions of rows while the service stays up.
  • Renaming or retyping a column that both the old and new deploy are reading during a rollout.
  • Moving a table to a different database or store and cutting over reads with evidence rather than hope.

More examples

PostgreSQL: the lock-avoiding forms of each step

The specific PostgreSQL forms that turn a table-locking migration into a safe one, including the invalid-index cleanup that a failed concurrent build leaves behind. Reach for this checklist on any table big enough that a full-table lock would be visible to users — and set lock_timeout on every migration regardless of size.

Example · sql
-- Index without blocking writes. CONCURRENTLY cannot run inside a transaction
-- block, so most migration frameworks need this marked as non-transactional.
CREATE INDEX CONCURRENTLY orders_amount_minor_idx ON orders (amount_minor);

-- A failed CONCURRENTLY build leaves an INVALID index behind that still costs
-- writes. Check for it, then drop and retry.
SELECT c.relname FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE NOT i.indisvalid;
DROP INDEX CONCURRENTLY IF EXISTS orders_amount_minor_idx;

-- Constraints in two steps. NOT VALID checks new and changed rows only, and
-- takes a brief lock; VALIDATE then scans existing rows without blocking writes.
ALTER TABLE orders ADD CONSTRAINT orders_amount_minor_nonneg
  CHECK (amount_minor >= 0) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_amount_minor_nonneg;

-- NOT NULL the same way: a validated CHECK lets PostgreSQL 12+ set the column
-- NOT NULL without a full table scan.
ALTER TABLE orders ADD CONSTRAINT orders_amount_minor_present
  CHECK (amount_minor IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_amount_minor_present;
ALTER TABLE orders ALTER COLUMN amount_minor SET NOT NULL;

-- Always bound how long a DDL statement may wait for its lock. Without this, a
-- migration queues behind one long transaction and every later query queues
-- behind the migration.
SET lock_timeout = '3s';
SET statement_timeout = '30s';

Dual-writing to a second store through a cutover

A store migration driven by one environment variable moved through four values, with shadow reads producing the mismatch metric that decides whether to proceed. Reach for it when moving data between systems that cannot share a transaction, and keep dual-verify running long enough to cover your slowest periodic job, not just a quiet afternoon.

Example · javascript
// Moving a table to a different store is the same three phases, except the two
// writes cannot share a transaction. Order matters, and so does which failure
// you allow.
const MODE = process.env.SESSION_STORE_MODE; // old | dual | dual-verify | new

async function putSession(oldStore, newStore, session) {
  if (MODE === 'old') return oldStore.put(session);

  // MODE === 'new': the old store is gone from the write path, so return before
  // touching it. Reaching this means the mismatch rate below has been zero for
  // an agreed period.
  if (MODE === 'new') return newStore.put(session);

  // dual and dual-verify: source of truth first. If it fails, nothing is
  // written anywhere.
  await oldStore.put(session);

  try {
    await newStore.put(session);
  } catch (err) {
    // The new store is not authoritative yet, so its failure must not fail
    // the request. Count it: a rising rate here means do not cut over.
    metrics.increment('session.shadow_write_failed', { reason: err.code });
  }
}

async function getSession(oldStore, newStore, id) {
  if (MODE === 'new') return newStore.get(id);

  const authoritative = await oldStore.get(id);

  if (MODE === 'dual-verify') {
    // Shadow read: compare, report, and still return the old answer. This is
    // the evidence that justifies the cutover, and it is cheap to leave on.
    newStore
      .get(id)
      .then((shadow) => {
        const same = JSON.stringify(shadow) === JSON.stringify(authoritative);
        metrics.increment(same ? 'session.shadow_match' : 'session.shadow_mismatch');
      })
      .catch(() => metrics.increment('session.shadow_read_failed'));
  }
  return authoritative;
}

Discussion

  • Be the first to comment on this lesson.