Online schema change: lock levels, CONCURRENTLY, gh-ost

What it is

Changing a table's structure on a live database without blocking reads or writes long enough to cause an outage. The difficulty is entirely about locks, and the answer differs by engine because their locking models differ.

POSTGRES     Most DDL takes ACCESS EXCLUSIVE, which blocks
             everything including SELECT. Many operations
             have been made fast or lock-free in recent
             versions; the rest need a workaround.

MYSQL        InnoDB online DDL has three algorithms
             (INSTANT, INPLACE, COPY) with different locking
             and different support per operation. Where it
             falls back to COPY, you use an external tool.

EXTERNAL     gh-ost and pt-online-schema-change build a
TOOLS        shadow table, backfill it, keep it in sync, and
             swap. Used when the native path is not online.

Commonly confused with the deploy sequencing. This page is about the DDL itself; the application-side sequencing is expand and contract, and you need both: a lock-free ALTER that ships alongside code using the new column still breaks the old pods during a rolling update.

Also commonly confused: "it took 200 milliseconds so it was safe". A short lock is not a safe lock if acquiring it queues behind a long-running transaction, which is the failure that takes production down.

The problem it solves

A schema change on a large table is one of the few operations that can take a healthy system down instantly, and the mechanism is not obvious.

THE LOCK QUEUE FAILURE, which is the one to know

  1. A long-running SELECT holds ACCESS SHARE on `orders`.
  2. Your ALTER TABLE requests ACCESS EXCLUSIVE.
     It cannot proceed, so it QUEUES.
  3. *** Every subsequent query on `orders` queues BEHIND
     the ALTER, because lock requests are ordered. ***
  4. The application's connection pool fills with waiters.
  5. Total outage on that table, and the ALTER itself has
     not started.

The ALTER was going to take 200 ms. The outage lasted as
long as the original SELECT, plus recovery.

That inversion is the whole reason lock_timeout matters: the danger is not the operation's duration, it is the queue that forms while it waits.

Mechanics

Postgres: know which operations are safe

SAFE (metadata only, brief ACCESS EXCLUSIVE)
  ADD COLUMN with no default, or with a non-volatile default
    (PG 11+ stores the default in catalog rather than
     rewriting the table)
  DROP COLUMN                    (marks it dropped; space
                                  reclaimed by VACUUM later)
  ADD CONSTRAINT ... NOT VALID   then VALIDATE separately
  RENAME COLUMN / TABLE
  SET STATISTICS

DANGEROUS (rewrites the table, holds the lock throughout)
  ALTER COLUMN TYPE              (most conversions)
  ADD COLUMN with a VOLATILE default
  SET NOT NULL                   (pre-PG 12: full scan)
  ADD PRIMARY KEY
  CLUSTER, VACUUM FULL

NEEDS THE SPECIAL FORM
  CREATE INDEX                -> CONCURRENTLY
  DROP INDEX                  -> CONCURRENTLY
  REINDEX                     -> CONCURRENTLY (PG 12+)

Two patterns that turn a dangerous operation into a safe sequence:

-- CONSTRAINT: validate without holding the lock during the scan.
ALTER TABLE orders
  ADD CONSTRAINT orders_total_positive CHECK (total_cents > 0)
  NOT VALID;                       -- brief lock, no scan

ALTER TABLE orders
  VALIDATE CONSTRAINT orders_total_positive;
                                   -- scans under SHARE UPDATE
                                   -- EXCLUSIVE: reads and writes
                                   -- continue
-- NOT NULL on PG 12+: add a validated CHECK first, then the
-- NOT NULL can use it and skip the scan.
ALTER TABLE users ADD CONSTRAINT users_email_nn
  CHECK (email IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_email_nn;
ALTER TABLE users ALTER COLUMN email SET NOT NULL;  -- no scan
ALTER TABLE users DROP CONSTRAINT users_email_nn;

CREATE INDEX CONCURRENTLY, and what it costs

It builds in two table scans plus a wait, without blocking
writes.

  1. Take SHARE UPDATE EXCLUSIVE (blocks other DDL, not DML)
  2. Wait for all transactions that could see the pre-index
     state
  3. First scan: build the index
  4. Second scan: catch rows that changed during scan 1
  5. Wait again for concurrent transactions

COSTS
  Roughly 2 to 3x the duration of a normal build.
  Cannot run inside a transaction block, so it cannot be
  part of a migration transaction, which matters for
  migration tooling.
  On failure it leaves an INVALID index that must be
  dropped explicitly, and which still costs write
  maintenance while it sits there.
-- Always check afterwards. An invalid index is invisible to
-- the planner and expensive on every write.
SELECT indexrelid::regclass, indisvalid
FROM pg_index WHERE NOT indisvalid;

The invalid-index leftover is the operational trap, because the migration "failed" cleanly, someone re-ran it, and the invalid index sits there for months slowing every write.

The two settings that prevent the outage

-- Do not queue for more than 2 seconds. If the lock is not
-- available, fail rather than blocking everything behind us.
SET lock_timeout = '2s';

-- Do not let the operation itself run away.
SET statement_timeout = '30s';

ALTER TABLE orders ADD COLUMN referral_code text;

lock_timeout is the single most important line in any migration script, and its absence is the difference between a failed migration and an outage. The failure mode without it is exactly the lock queue above.

And the retry loop that makes it usable:

# A short lock_timeout means the migration often fails on a
# busy table. Retry with backoff rather than raising it.
for attempt in range(20):
    try:
        with conn.begin():
            conn.execute("SET LOCAL lock_timeout = '2s'")
            conn.execute(ddl)
        break
    except LockNotAvailable:
        time.sleep(2 ** min(attempt, 5))

Retrying a 2-second timeout twenty times is much safer than one attempt with a 60-second timeout, because each attempt's queue is short-lived.

MySQL: the three algorithms

ALGORITHM=INSTANT   Metadata only. Milliseconds regardless of
                    table size. Supported for: adding a column
                    (8.0.12+), renaming, changing a default,
                    and a growing list.

ALGORITHM=INPLACE   Rebuilds the table or index in place.
                    LOCK=NONE means DML continues. Duration
                    scales with table size.

ALGORITHM=COPY      Copies the whole table. Blocks writes
                    unless a tool manages it. This is the one
                    to avoid.
-- Assert what you expect rather than discovering it.
-- If INSTANT is not supported for this change, this FAILS
-- rather than silently falling back to COPY and blocking
-- writes for an hour.
ALTER TABLE orders
  ADD COLUMN referral_code VARCHAR(32),
  ALGORITHM=INSTANT;

Specifying the algorithm explicitly is the discipline, because the default is to pick the best available and silently fall back, and "silently falls back to COPY on a 400 GB table" is an outage discovered at runtime.

gh-ost and pt-online-schema-change

When the native path is COPY, an external tool does the same thing with control.

BOTH TOOLS
  1. Create a shadow table with the new schema.
  2. Copy rows in chunks, throttling on replica lag.
  3. Keep the shadow in sync with ongoing changes.
  4. Atomic rename swap.

THE DIFFERENCE, and it matters
  pt-online-schema-change uses TRIGGERS on the original
    table to propagate changes.
    -> Triggers run inside the original write transaction,
       so they add latency to every write and can amplify
       lock contention. On a hot table this is felt.

  gh-ost reads the BINLOG instead.
    -> No triggers, no added write latency, and it can run
       on a replica and swap on the primary.
    -> Also pausable and throttleable interactively, and it
       can be resumed.

gh-ost's binlog approach is why GitHub built it, and the interactive throttle is the operationally important part: you can pause a migration when a spike arrives and resume afterwards, which a trigger-based tool cannot do cleanly.

gh-ost \
  --alter="ADD COLUMN referral_code VARCHAR(32)" \
  --table=orders \
  --max-load=Threads_running=25 \
  --critical-load=Threads_running=100 \
  --chunk-size=1000 \
  --max-lag-millis=1500 \
  --allow-on-master \
  --postpone-cut-over-flag-file=/tmp/postpone \
  --execute

--postpone-cut-over-flag-file is the feature worth knowing: the copy runs to completion and then waits for you to remove the flag file, so the disruptive moment happens when a human is watching rather than at 3am when the copy happens to finish.

The backfill, which is where the outages actually happen

Adding a column is fast. Populating it is where the danger is.

-- CATASTROPHIC on a large table:
UPDATE orders SET referral_code = derive(source);
--   holds a long transaction
--   generates enormous WAL
--   blocks autovacuum from cleaning up dead tuples
--   lags replicas, possibly past their retention
--   and rolling it back is as expensive as running it
-- Batched, with a pause. The pause is not politeness: it is
-- what lets replicas catch up and autovacuum run.
DO $$
DECLARE last_id BIGINT := 0; n INT;
BEGIN
  LOOP
    WITH batch AS (
      SELECT id FROM orders
      WHERE id > last_id AND referral_code IS NULL
      ORDER BY id LIMIT 5000 FOR UPDATE SKIP LOCKED
    )
    UPDATE orders o SET referral_code = derive(o.source)
    FROM batch b WHERE o.id = b.id;
    GET DIAGNOSTICS n = ROW_COUNT;
    EXIT WHEN n = 0;
    SELECT max(id) INTO last_id FROM (
      SELECT id FROM orders WHERE id > last_id ORDER BY id LIMIT 5000) s;
    COMMIT;
    PERFORM pg_sleep(0.05);
  END LOOP;
END $$;

FOR UPDATE SKIP LOCKED in the batch selection means the backfill never blocks on a row a user transaction is holding; it skips it and picks it up on a later pass.

And the check that must gate the next step:

-- Before any code reads the new column, prove the backfill
-- is complete. This gate is not optional.
SELECT count(*) FROM orders WHERE referral_code IS NULL;

A worked example: a migration that caused an outage

THE CHANGE
  Add an index on orders(customer_id, created_at) to fix a
  slow query. 180M rows.

WHAT HAPPENED
  The migration ran `CREATE INDEX` (not CONCURRENTLY) inside
  the deploy pipeline's migration transaction, with no
  lock_timeout.

  1. A nightly analytics query held ACCESS SHARE on orders.
  2. CREATE INDEX requested ACCESS EXCLUSIVE and queued.
  3. All subsequent queries on orders queued behind it.
  4. The connection pool filled in ~40 seconds.
  5. Checkout, order history and fulfilment all failed.
  6. Duration: 22 minutes, until the analytics query
     finished and the index build then ran to completion,
     blocking throughout.

WHAT WOULD HAVE PREVENTED IT, in order of value
  a. lock_timeout = '2s'. The migration would have failed
     harmlessly and been retried. ONE LINE.
  b. CREATE INDEX CONCURRENTLY. No ACCESS EXCLUSIVE at all,
     and it cannot run in a transaction block, which would
     have forced (c).
  c. Running migrations OUTSIDE the deploy transaction, so a
     failed migration does not roll back a deploy and a
     long migration does not hold a transaction open.
  d. A pre-flight check for long-running transactions on the
     target table:

     SELECT pid, now() - xact_start AS age, query
     FROM pg_stat_activity
     WHERE state != 'idle' AND now() - xact_start > interval '1 min';

THE POLICY THAT CAME OUT OF IT
  Every migration script starts with SET lock_timeout, index
  creation is always CONCURRENTLY, migrations run outside
  the deploy transaction, and any migration touching a table
  over 10M rows requires a written plan naming the lock
  level it takes.

The finding worth stating: the index build was going to take 40 minutes and that was never the problem. The problem was 22 minutes of total unavailability caused by a queue, which a two-second timeout would have prevented entirely.

Production evidence

PostgreSQL's documentation on ALTER TABLE locking levels is the authoritative reference for which operations take which locks, and it is worth reading rather than trusting a blog post, because the list changes between versions as operations are optimised.

PostgreSQL 11's addition of non-volatile-default ADD COLUMN without a rewrite, and version 12's ability to use a validated CHECK to skip the SET NOT NULL scan, are examples of operations moving from dangerous to safe between versions, which is why the version matters.

GitHub's gh-ost announcement and design documentation explain the binlog-versus-trigger choice explicitly: triggers add latency inside the original write transaction, and the binlog approach also enables the interactive throttle and postponed cut-over.

Percona's pt-online-schema-change documentation is candid about the trigger overhead and about the foreign-key handling complications, which is the honest comparison.

MySQL's online DDL documentation lists per-operation algorithm support and is explicit that omitting ALGORITHM allows a silent fallback, which is the basis for specifying it.

Strong Migrations and similar linters exist as tooling that rejects dangerous DDL in code review, which is convergent evidence that this class of mistake is common enough to automate against.

The debate

The case for native online DDL: no extra tooling, no shadow table, no swap risk, and modern Postgres and MySQL handle most common operations without a rewrite. Adding gh-ost to a stack that does not need it is operational surface for nothing.

The case for always using a tool: predictable behaviour regardless of the operation, throttling on replica lag, pausability, and a postponed cut-over so the risky moment is supervised. On a large table the native path's behaviour varies by operation and version in ways that are easy to get wrong.

The case for a maintenance window: simplest, obviously safe, and for many organisations a Sunday morning window costs less than the engineering to avoid it. Increasingly unavailable as an option, and worth naming rather than dismissing.

My position: lock_timeout on every migration, CONCURRENTLY for every index, migrations outside the deploy transaction, and an external tool only when the native path would rewrite a large table.

lock_timeout is the one I would treat as non-negotiable, because the danger is the queue, not the operation. In the worked example a 40-minute index build caused 22 minutes of total unavailability before it even started, and one line would have turned that into a harmless failed migration. It is the highest ratio of protection to effort available anywhere in this topic.

Running migrations outside the deploy transaction matters for two reasons that compound: a migration inside a transaction cannot use CONCURRENTLY at all, and a long migration inside the deploy holds a transaction open across the whole deploy, which blocks vacuum and can lag replicas.

On tooling, I would not add gh-ost by default. The question is whether the specific operation on the specific table would rewrite it, and for ADD COLUMN on modern Postgres or INSTANT on modern MySQL the answer is no, so the tool adds risk rather than removing it. Where the native path is a copy on a 400 GB table, gh-ost's binlog approach, throttling and postponed cut-over are worth the operational surface, and I would prefer it over the trigger-based alternative because triggers add latency inside every write transaction on the original table.

The thing I would insist on beyond the DDL itself is the backfill discipline, because that is where the outages actually happen. Adding the column is milliseconds; the single UPDATE over 180 million rows holds a long transaction, generates enormous WAL, blocks autovacuum and lags replicas. Batched with SKIP LOCKED and a short sleep, with a completeness check gating the next step.

Where I would push back on a proposal: specify the lock level you expect and assert it. In MySQL that is ALGORITHM=INSTANT failing rather than falling back to COPY; in Postgres it is knowing which lock the operation takes and saying so in the migration plan. "It worked in staging" is not evidence, because staging has neither the row count nor the concurrent long-running transaction.

Follow-up Q&A

"How do you add a column to a 200 million row table safely?" The ADD COLUMN itself is usually the easy part: on Postgres 11 and later, adding a column with no default or a non-volatile default is a catalog change, so milliseconds regardless of size. What I would put first is SET lock_timeout = '2s' in the migration, because the danger is not the operation's duration, it is that it queues behind a long-running transaction and then every subsequent query queues behind it. Then the backfill, batched, and only then the code that reads it.

"Explain the lock queue failure." A long-running SELECT holds ACCESS SHARE. Your ALTER requests ACCESS EXCLUSIVE and cannot proceed, so it queues. And because lock requests are ordered, every subsequent query on that table now queues behind the ALTER. The connection pool fills, and you have a total outage on that table before the ALTER has even started. In a case I worked, a 40-minute index build produced 22 minutes of unavailability that way, and a two-second lock_timeout would have prevented all of it.

"What does CREATE INDEX CONCURRENTLY cost?" Roughly two to three times the duration, because it does two table scans plus two waits for concurrent transactions. It cannot run inside a transaction block, which means migrations have to run outside the deploy transaction. And on failure it leaves an INVALID index behind, which the planner ignores while it still costs write maintenance, so checking pg_index for NOT indisvalid afterwards is part of the procedure. That leftover sitting for months is the common operational trap.

"How do you make SET NOT NULL safe on a large table?" On Postgres 12 and later, add a CHECK (col IS NOT NULL) as NOT VALID, which takes a brief lock and no scan, then VALIDATE CONSTRAINT, which scans under SHARE UPDATE EXCLUSIVE so reads and writes continue. Then SET NOT NULL can use the validated constraint and skips its own scan, and you drop the check afterwards. The same NOT VALID then VALIDATE pattern is the general answer for constraints.

"When would you use gh-ost rather than native DDL?" When the native path would rewrite a large table, which on MySQL means the operation falls back to ALGORITHM=COPY. I would not add it by default, because for the common operations modern engines are already online and the tool is operational surface for nothing. When I do use it, gh-ost over pt-online-schema-change, because it reads the binlog rather than installing triggers, so it adds no latency inside the original write transaction, and because it can be throttled and paused interactively.

"What's the gh-ost feature you'd actually rely on?" The postponed cut-over flag file. The copy runs to completion and then waits for you to remove the file, so the disruptive rename happens when a human is watching rather than whenever the copy finishes, which might be 3am. Combined with --max-load and --max-lag-millis for throttling, that is what makes a multi-hour migration on a hot table supervisable.

"Where do these migrations actually go wrong?" The backfill, not the DDL. A single UPDATE over 180 million rows holds a long transaction, generates enormous WAL, blocks autovacuum from cleaning dead tuples, and lags replicas possibly past their retention. Batched in five thousand row chunks with FOR UPDATE SKIP LOCKED so it never blocks on a row a user holds, with a short sleep to let replicas catch up. And a completeness check, counting remaining nulls, gating the deploy that starts reading the column.

"What about MySQL specifically?" Specify ALGORITHM explicitly rather than letting it pick, because the default is to choose the best available and silently fall back, and "silently falls back to COPY on a 400 gigabyte table" is an outage discovered at runtime. Asserting ALGORITHM=INSTANT means the statement fails if instant is unavailable, which is exactly what you want in a migration script.

"How does this relate to expand-contract?" They are two halves and you need both. This is the DDL: which lock it takes, how to avoid the queue, how to backfill. Expand-contract is the application-side sequencing: write both, read old, then read new, then stop writing old, then drop. A perfectly lock-free ALTER shipped alongside code that uses the new column still breaks every pod that has not been replaced yet during a rolling update.

Common misconceptions

"It only takes 200 milliseconds, so it's safe." Duration is not the risk. Queueing behind a long-running transaction is, and every subsequent query queues behind you.

"CONCURRENTLY is free." It is two to three times slower, cannot run in a transaction, and leaves an invalid index on failure that costs write maintenance until dropped.

"Adding a column is the risky part." On modern engines it is a catalog change. The backfill is where the outages are.

"MySQL online DDL handles it." Only for operations that support INSTANT or INPLACE. Without an explicit ALGORITHM it silently falls back to COPY.

"It worked in staging." Staging has neither the row count nor the concurrent long-running analytics query, which are the two things that cause the failure.

Interview delivery note

Lead with the lock queue, because it is the mechanism people do not know and it reframes the whole question: "The danger isn't how long the operation takes. It's that if it can't get its lock immediately it queues, and because lock requests are ordered, every subsequent query on that table queues behind it. So a forty-minute index build can produce total unavailability before it has even started."

Then the one-line fix: "Which is why SET lock_timeout is the most important line in any migration script. Two seconds, and retry with backoff. A short timeout retried twenty times is much safer than one attempt with a sixty-second timeout, because each attempt's queue is short-lived."

Show engine-specific knowledge concretely: "On Postgres I'd know which operations rewrite the table: adding a column with a non-volatile default doesn't since version 11, SET NOT NULL can skip its scan since 12 if you add a validated CHECK first, and index creation always goes CONCURRENTLY, which costs two to three times the duration and can't run inside a transaction. On MySQL I'd specify ALGORITHM=INSTANT explicitly so it fails rather than silently falling back to COPY."

Point at where the outages actually are: "And I'd say that the DDL usually isn't where these go wrong, the backfill is. A single UPDATE over a hundred and eighty million rows holds a long transaction, generates enormous WAL, blocks autovacuum and lags replicas. Batched with SKIP LOCKED and a short sleep, with a completeness check gating the deploy that reads the column."

Close by connecting it to the other half: "and this is only the database side. The application sequencing is expand-contract, and you need both, because a perfectly lock-free ALTER shipped alongside code that uses the new column still breaks every pod that hasn't been replaced yet."

Further reading

  • The PostgreSQL documentation on ALTER TABLE, particularly the lock levels table and the version-specific notes on which operations avoid a rewrite.
  • The PostgreSQL documentation on CREATE INDEX CONCURRENTLY, including the invalid-index failure behaviour.
  • GitHub Engineering's gh-ost design documentation, for the binlog-versus-triggers argument and the throttling and postponed cut-over features.
  • Percona's pt-online-schema-change documentation, for the trigger-based alternative and its caveats.
  • MySQL's online DDL documentation, for per-operation algorithm support.