Schema change under load  / field guide
Practitioner field guide · Data platforms · 2026-09-21

The lock is brief. The queue is the outage.

How production teams change the schema of a hot relational table without stopping writes, reconstructed from the incident trackers, migration tools, design records and safety libraries where fourteen organisations wrote down what they learned. A reader leaves able to name the failure mode that takes migrated systems down, choose between the three architectures that survive it, and set the two timeouts that decide whether a bad migration hurts customers or only the deploy queue.

34 ledger rows, 30 primary documents 14 organisations 4 production incidents Evidence through September 2026 Read: 25 min
01

The territory

The problem, who has solved it in production, and the one mechanism that explains most of the failures.

State the problem without naming a database: a team must change the shape of data that a live system reads and writes thousands of times a second, without stopping those reads and writes, without corrupting what is stored, and without breaking the application processes that still remember the old shape. Every relational store makes this hard in the same place. A structural change needs exclusive access to the table's definition, however briefly, and exclusive access to a hot table is exactly what a live system cannot give.

The surprise in this record is where the danger actually sits. It is not the duration of the exclusive lock; most schema changes hold it for milliseconds. It is the queue that forms while the lock is waited for. GoCardless's engineers put the mechanism in one sentence in the README of the library they built after it bit them:

“Even if the lock is only held briefly, it will block all other access to the table while it is in the lock queue, as it conflicts with all other locks.” GoCardless, activerecord-safer_migrations README [source], checked 2026-09-21

A migration that waits behind one long-running query becomes a wall: every ordinary read and write queues behind the waiter, and a table that nobody has locked yet is effectively offline. The industry's defence against this mechanism is the same everywhere it has been encoded: give the migration a short lock acquisition timeout and retry, so it fails instead of queueing traffic behind it. GoCardless defaults the timeout to 750 ms. GitLab starts at 100 ms and retries on a schedule bounded at roughly 40 minutes. Doctolib ships 5 s. Three companies, three codebases, one rule, arrived at independently; this guide treats that convergence as the field's core finding.

750ms
Default lock acquisition timeout GoCardless ships for every migration, as of 2026
~40min
Worst-case bounded retry window of GitLab's with_lock_retries schedule, starting at a 100 ms lock_timeout, as of 2026
100GB
Table size above which GitLab.com refuses a new column outright; new indexes stop at 50 GB, as of 2026
4×Sev-3
GitLab.com schema-migration incidents Apr to Sep 2026: every one stalled deployments, none reported customer impact

The second finding follows from the first. Where the timeout-and-retry rule is fully institutionalised, the failure mode changes address. GitLab.com's production tracker records four schema-migration incidents between April and September 2026, and all four read the same way: the migration could not get its lock, aborted safely, blocked the deploy pipeline, and customers never noticed. The guardrail did not eliminate the failure; it moved it from the request path to the deploy path and downgraded it from an outage to an operational chore. That is what a good containment design looks like in an incident tracker: boring, recurring, severity 3.

Scope and evidence limits

This guide covers structural change to a single hot table in a relational store: Postgres and MySQL in-place DDL, shadow-table migration tooling, the schema-versioning protocol used by distributed SQL engines, and the application-side protocol that spans all three. It deliberately excludes resharding and splitting databases, engine swaps and cross-store migrations (covered by the 2026-08-29 resharding, 2026-09-19 engine-swap and 2026-09-20 GitLab digs), event and API schema evolution, and document stores.

Evidence constraint, stated plainly: this research environment could reach only github.com and gitlab.com. Every claim here traces to repository-hosted material fetched on 2026-09-21: incident trackers, design docs, tool source, release notes and safety libraries. Engineering blog posts, conference talks and the papers hosted at vldb.org were unreachable; the F1 schema-change paper (Rae et al., PVLDB 2013) is cited through the two fetched implementations that quote it. Where a category is absent, that absence is the environment's, not the field's.

Figure 1 · Three architectures, one shared application protocol

Change a hot table's shape
while it serves reads and writes

In-place DDL
with lock discipline

Shadow-table copy

Schema as
versioned state

Postgres lineage:
lock_timeout + retry,
CONCURRENTLY, NOT VALID
(GoCardless, Braintree,
Doctolib, GitLab)

MySQL lineage:
FB OSC, LHM, pt-osc,
gh-ost, Vitess VReplication

F1 protocol:
CockroachDB, TiDB

Application protocol: expand, migrate, contract
(safety libraries, multi-release column drop, versioned views)

Change a hot table's shape
while it serves reads and writes

In-place DDL
with lock discipline

Shadow-table copy

Schema as
versioned state

Postgres lineage:
lock_timeout + retry,
CONCURRENTLY, NOT VALID
(GoCardless, Braintree,
Doctolib, GitLab)

MySQL lineage:
FB OSC, LHM, pt-osc,
gh-ost, Vitess VReplication

F1 protocol:
CockroachDB, TiDB

Application protocol: expand, migrate, contract
(safety libraries, multi-release column drop, versioned views)

Every production answer to live schema change is one of three shapes, and all three still depend on the application-side expand-and-contract protocol at the bottom. Sources: gh-ost design docs, CockroachDB RFC, GitLab migration docs.
Diagram source
02

How it is actually built

Three architectures recur across every published system, and they map to what the underlying engine makes cheap.

Path one: change it in place, but never wait for a lock

Postgres-lineage teams alter the real table. The engine makes most structural changes metadata-only, and since Postgres 11 that includes adding a column with a default, so the dangerous part is not the work; it is the lock acquisition. The Postgres reference documentation states the default flatly: for ALTER TABLE, “an ACCESS EXCLUSIVE lock is acquired unless explicitly noted” (alter_table.sgml, checked 2026-09-21). So the discipline is entirely about how you wait. Set lock_timeout low, retry on failure, and decompose anything that must touch every row into a non-blocking variant: CREATE INDEX CONCURRENTLY instead of a blocking build, add a constraint NOT VALID and validate it in a second statement, backfill in batches outside the migration's transaction. Doctolib's library documents the split for foreign keys: “Adding the constraint itself is rather fast, the major part of the time is spent on validating this constraint” (safe-pg-migrations README). Validation scans rows under a lock that permits reads and writes; the two-step form buys the same end state without the outage.

Path two: copy the table sideways and swap

MySQL-lineage teams historically could not alter large tables in place, so the tooling builds a second table. SoundCloud's Large Hadron Migrator README preserves the original problem statement from the early 2010s: “the locking nature of ALTER TABLE may take your site down for an hour or more while critical tables are migrated” (LHM README). The shadow-table architecture that answers it has five components, stable across fifteen years of tools: a ghost table in the target shape; a chunked row copy; a capture channel that replays concurrent writes onto the ghost; a throttle that watches replica lag and load; and a cut-over that atomically renames the tables. The divergence point is the capture channel, and it is the most argued-over component in this record. Facebook's OSC, Percona's pt-online-schema-change and LHM used triggers on the original table. GitHub rejected triggers in 2016 with production evidence: “We have evidenced near or complete lock downs in production, to the effect of rendering the table or the entire database inaccessible due to lock contention,” and, decisively, a trigger cannot be paused: “even as the online operation throttles, the master is brought down by the load of the triggers” (why-triggerless.md). gh-ost reads the binary log instead, which makes the migration's write load genuinely detachable from the table's workload.

Figure 2 · The shadow-table reference architecture

Primary database

row ranges

chunked copy
1,000 rows / iteration

replay concurrent writes

ongoing DML

pause / resume

compare

atomic rename
behind sentry table

Original table
(live traffic)

Ghost table
(target schema)

Migration controller

Change capture:
triggers OR binlog OR
native replication

Throttle
(replica lag, load)

Verification
(checksum, replica test)

Cut-over

Primary database

row ranges

chunked copy
1,000 rows / iteration

replay concurrent writes

ongoing DML

pause / resume

compare

atomic rename
behind sentry table

Original table
(live traffic)

Ghost table
(target schema)

Migration controller

Change capture:
triggers OR binlog OR
native replication

Throttle
(replica lag, load)

Verification
(checksum, replica test)

Cut-over

The capture channel is the divergence point: triggers (FB OSC, pt-osc, LHM), binlog tailing (gh-ost), or the engine's own replication (Vitess). Everything else is common shape. Reconstructed from pt-osc source, gh-ost README and Vitess release notes.
Diagram source

Path three: make the schema itself versioned state

Distributed SQL engines cannot take a table-wide lock at all, so they adopt the protocol Google published for F1 in 2013: treat the schema as data that steps through intermediate states, with the invariant that no two servers are ever more than one version apart. CockroachDB's 2015 RFC implements it directly and states the safety argument: an index or column moves DELETE_ONLY → WRITE_ONLY → PUBLIC, “it is invalid to jump from the DELETE_ONLY to PUBLIC state or vice versa,” and with only two consecutive descriptor versions live at once, “a cluster will not create invalid data” (CockroachDB RFC). TiDB reimplemented the same machine independently in 2018 with the same invariant: “there are at most two different versions of the schema on the same table for all nodes of the system” (TiDB design doc). The backfill runs as many small transactions between state steps, which is the same chunked copy as path two wearing a different coat.

Figure 3 · The F1 state machine, as CockroachDB and TiDB run it

add begins

all nodes caught up

backfill complete

drop begins

all nodes caught up

data removed

DELETE_ONLY

WRITE_ONLY

PUBLIC

add begins

all nodes caught up

backfill complete

drop begins

all nodes caught up

data removed

DELETE_ONLY

WRITE_ONLY

PUBLIC

Each transition is safe next to its neighbour, so a cluster where two adjacent versions coexist cannot write invalid data; skipping a state is what corrupts. From the CockroachDB RFC (2015) and TiDB design doc (2018), both implementing Rae et al., PVLDB 2013.
Diagram source

The layer underneath all three: the application remembers

None of the database-side machinery saves you from the application's memory of the old schema. Rails caches the column list per process, so dropping a column crashes queries until every process restarts; strong_migrations states it as the first dangerous operation in its catalogue (README). GitLab's protocol for removing one column spans three releases: mark it ignored in release M, drop it in a post-deployment migration in M+1, remove the ignore rule in M+2 (avoiding-downtime doc). Xata's pgroll generalises the idea into infrastructure: it serves both schema versions at once through views, so old and new application code run against the same physical table and rollback is a search_path change (pgroll README). Note what that is: the F1 two-version invariant, rebuilt at the application boundary of a single-node Postgres. The distributed protocol and the deploy-safety folklore are the same idea at different layers, and neither source says so; that connection is this guide's inference.

03

The decisions that matter

Each fork with the reason recorded by the team that took it, and the condition that flips the answer.

How should concurrent writes reach the shadow table?

Chosen
  • Binlog tailing (GitHub gh-ost, 2016), later the engine's own replication (Vitess VReplication).
  • Capture load is decoupled from the table's workload; throttling becomes a “true pause” that ceases all writes on the master (gh-ost README).
Rejected
  • Triggers (FB OSC 2010, LHM, pt-osc), which run in the same transaction as every write.
  • gh-ost's design doc: triggers caused “near or complete lock downs in production” and cannot be cancelled mid-flight without losing changes (why-triggerless.md).
Flips when
  • You cannot read the binlog (no replication privileges, exotic topologies): pt-osc remains the fallback Percona maintains.
  • You run inside a sharding layer anyway: Vitess dropped gh-ost (v20) and then de-recognised it (v22) because replication-native migration needs no external process at all.

When the migration cannot get its lock, should it wait or fail?

Chosen
  • Fail fast and retry: GoCardless 750 ms default, GitLab a bounded schedule from 100 ms, Doctolib 5 s. Encoded independently in at least four companies' libraries.
  • The abort is the feature: it converts a customer outage into a failed deploy step.
Rejected
  • Waiting for the lock at default (unbounded) timeout, which parks every subsequent query behind the waiter.
  • GoCardless's README calls disabling the timeouts “extremely dangerous” for schema alterations (README).
Flips when
  • The change must complete now and the business accepts casualties: Vitess's forced cut-over “brutally kills any queries using the migrated table” and terminates lock-holding transactions (PR #14546). Killing the competition is the only alternative to joining the queue.

When a migration goes wrong, do you roll the schema back?

Chosen
  • Roll forward. Braintree's gem refuses ActiveRecord's automatic rollback entirely: “we roll forward, rather than rolling back, by having an engineer write a new schema change” (pg_ha_migrations README).
Rejected
  • Reverting the schema: dropping a just-added column loses data, re-adding a dropped constraint can fail against rows written in between.
Flips when
  • Both versions are still being served: pgroll keeps old and new schemas live simultaneously, so before the contract step, rollback is instant and safe (pgroll README). Reversibility exists only while you have not yet destroyed the old shape.
DecisionChosenRejectedBecauseEvidence
Try native instant DDL first?Yes, with fallbackAlways shadow-copyingMySQL 8.0 metadata-only changes skip the copy entirely; residual risk is the metadata lock during long transactionsgh-ost flags doc
Cut-over: atomic or two-step rename?Atomic swap behind a sentry tableTwo-step rename (FB OSC)Between the two renames “your table just does not exist, and queries will fail”cut-over.md, issue #82
Backfill inside the DDL transaction?Batched, throttled, outsideOne UPDATE in the migrationIn-transaction backfill holds the lock for the whole backfill; GitLab throttles on WAL backlog, autovacuum and apdexstrong_migrations, GitLab batched migrations
Who may change a big table at all?Hard size gates in CIReviewer judgementGitLab.com refuses new indexes above 50 GB and new columns above 100 GB via RuboCop rules, with a formal exception processlarge tables limitations
Safety by magic or by naming?Explicit safe_/unsafe_ prefixesAuto-rewriting migrationsBraintree: better that long-running operations “are not a surprise during your deploy cycle” than hidden by helperspg_ha_migrations
Lock ordering for FK removalLock referenced table first (reverse order)Natural statement orderPrevents deadlocks against concurrent application transactions that lock parent then childGitLab avoiding-downtime doc

Figure 4 · Choosing a migration mechanism for one table

yes

no

Postgres

no

yes

MySQL

no

yes

Is the change metadata-only
on your engine version?

Run it in place with a short
lock_timeout and bounded retries

Which engine family?

Must it scan or rewrite
every row?

Decompose: NOT VALID + validate,
CREATE INDEX CONCURRENTLY,
batched backfill with throttle

Table hot, large,
or replicated?

Shadow-table migration:
binlog capture, chunked copy,
atomic cut-over behind a sentry

In every branch: expand first,
contract only after all readers moved

yes

no

Postgres

no

yes

MySQL

no

yes

Is the change metadata-only
on your engine version?

Run it in place with a short
lock_timeout and bounded retries

Which engine family?

Must it scan or rewrite
every row?

Decompose: NOT VALID + validate,
CREATE INDEX CONCURRENTLY,
batched backfill with throttle

Table hot, large,
or replicated?

Shadow-table migration:
binlog capture, chunked copy,
atomic cut-over behind a sentry

In every branch: expand first,
contract only after all readers moved

Terminal nodes are actions. Distributed SQL engines (CockroachDB, TiDB, Vitess) remove the choice by running the state machine themselves. Derived from the decisions above; thresholds are GitLab.com's (50/100 GB gates).
Diagram source
04

What broke in production

Five failure classes account for everything in this record: the queue behind the lock, the copy that diverges, the contested cut-over, the application that remembers, and the backfill that becomes a workload.

Figure 5 · The lock-queue mechanism, and where the timeout cuts it

App queriesALTER TABLELock managerLong analytics queryApp queriesALTER TABLELock managerLong analytics querywaits behind the analytics queryqueue behind the waiting ALTERtable effectivelyofflineholds ACCESS SHARE (minutes)request ACCESS EXCLUSIVESELECT and UPDATE arrivelock_timeout (750 ms) aborts thewaitqueue drains, service resumesretry later, or after killing theblocker
App queriesALTER TABLELock managerLong analytics queryApp queriesALTER TABLELock managerLong analytics querywaits behind the analytics queryqueue behind the waiting ALTERtable effectivelyofflineholds ACCESS SHARE (minutes)request ACCESS EXCLUSIVESELECT and UPDATE arrivelock_timeout (750 ms) aborts thewaitqueue drains, service resumesretry later, or after killing theblocker
The ALTER holds nothing yet; it is the act of waiting that walls off the table. The lock_timeout abort at the end is the entire safety mechanism this field converged on. Mechanism as described in the GoCardless README; the 750 ms figure is its shipped default.
Diagram source
Postmortem

The retries ran out: ACCESS EXCLUSIVE never acquired

AssumptionA bounded lock-retry schedule will eventually find a quiet moment on the projects and deployments tables.
What happenedGitLab's post-deploy migration “needed an ACCESS EXCLUSIVE lock on the projects and deployments tables, and it exhausted its lock-retry attempts twice.”
Blast radiusDeployments blocked until the migration was skipped and re-run; no customer impact identified. Severity 3.
FixSkip, then retry per runbook; the same class recurred on 2026-09-03 and resolved the same way.
Design ruleA guardrail that fails closed converts outages into chores, and the chore recurs; budget operator time for it rather than customer time.
Postmortem

Autovacuum held the table when the migration arrived

AssumptionThe only competition for a DDL lock is application queries, which are short.
What happened“An ongoing autovacuum on the notes table is causing lock contention during the migration”; creating a function and trigger exhausted its lock retries.
Blast radiusDeploys blocked during weekend patch-release pressure; migration skipped and rescheduled via change request. Severity 3.
FixSkip and reintroduce later. Doctolib's README documents the same blocker generically: an (auto)vacuum's ShareUpdateExclusiveLock means “you are most likely out of luck for the current migration.”
Design ruleThe database's own maintenance is a first-class lock competitor; check for vacuum activity before DDL on big tables, as GitLab's backfill throttler now does automatically.
Postmortem

The migration deadlocked against application traffic

AssumptionTaking locks in the migration's natural statement order is safe because each lock is brief.
What happenedAdding a foreign key to a partitioned CI table deadlocked: “the deadlock was triggered by locking conflicts with application queries during high traffic.”
Blast radiusPost-deploy migration failed and was skipped; no customer impact reported. Severity 3.
FixGitLab's helpers now default FK removal to reverse_lock_order: true, locking the referenced table before the source to match application ordering.
Design ruleDDL participates in the same deadlock graph as your busiest transactions; lock ordering is part of the migration's design, not an engine detail.
Bug report

INSERT IGNORE quietly discarded rows during the copy

AssumptionThe row copy is mechanical, so whatever survives it equals the original table.
What happenedgh-ost copies with insert ignore. A collation change made previously distinct values collide under a unique key, and the colliding rows were “ignored, then lose data,” per issue #1526 (2025). Issue #1039 (2021) shows a second variant: under semi-sync replication a transaction blocked in commit was invisible to both the binlog listener and the copy boundary query, so its row reached neither table.
Blast radiusSilent row loss discovered after cut-over; no duration published in either issue.
Fixgh-ost grew --panic-on-warnings; the deeper mitigation is GitHub's own practice of continuously migrating the production fleet on replicas and checksumming results.
Design ruleAny migration that changes key semantics (unique keys, collation, type narrowing) needs a row-count and checksum comparison as a gate, not an option.
Issue thread

The cut-over stalled, and retrying made the load worse

AssumptionIf the atomic swap times out, retrying immediately is free.
What happenedVitess found that failed cut-overs retried in a tight loop compound the problem: “the database, that is already under heavy load, needs to cope with frequently recurring cut-over attempts, which themselves put additional locks on tables.” Separately, a gh-ost cut-over timeout could deadlock the tool's own event pipeline (PR #1698, closed unmerged as a duplicate of the #1637 fix).
Blast radiusMigrations stuck at 99% on hot tables; repeated brief write-blocking on every attempt.
FixVitess added cut-over backoff (1, 5, 10, then 30-minute intervals) and, as the terminal escalation, a forced cut-over that kills queries and lock-holding transactions on the table.
Design ruleThe cut-over is a lock acquisition like any other and needs the same backoff discipline; and the end of every escalation ladder is choosing which side loses.
Encoded incident

The app kept querying a column that no longer existed

AssumptionOnce the database accepts the DDL, the change is done.
What happenedORMs cache the column list per process. strong_migrations: dropping a column “can cause exceptions until your app reboots.” GitLab's backfill guidance adds the mirror image for heavy updates: “there have been incidents due to the heavy load from these migrations while the database was underperforming.”
Blast radiusErrors on every query touching the cached column list until restart; for backfills, database-wide degradation.
FixThe three-release drop protocol (ignore, drop, clean up) and a backfill throttler that pauses 10 minutes on WAL backlog, autovacuum activity or apdex breach.
Design ruleA schema change is complete when the last process that remembers the old shape exits, not when the DDL commits; and a backfill is a production workload that needs its own SLO-aware throttle.

One reading across all six cards: no incident in this record was caused by the schema change itself being wrong. The failures live in the machinery around it: acquiring the lock, copying the rows, swapping the tables, and the application's memory. That matches what the resharding dig found about cutovers a month ago, and it says where review effort belongs: spend it on the migration's interaction with live traffic, not on the DDL statement in the diff.

05

Numbers you can plan against

Every figure is a shipped default or a published limit, which is what makes them planning numbers: they encode what their operators measured and survived.

MetricValueAtContextAs ofSource
Migration lock_timeout default750 msGoCardlessEvery Rails migration, statement_timeout 1500 ms2026README
First-attempt lock_timeout100 msGitLabwith_lock_retries schedule; escalates to 2 s2026with_lock_retries.rb
Bounded retry window, worst case~40 minGitLabComment in the timing configuration2026with_lock_retries.rb
Migration timeouts default5 sDoctoliblock_timeout and statement_timeout, retried on failure2026README
Row-copy chunk size1,000 rowsgh-ostDefault; allowed range 10 to 100,0002026main.go
Replication-lag throttle threshold1,500 msgh-ostmax-lag-millis default; copy pauses above it2026main.go
Cut-over lock timeout3 sgh-ostMax lock hold during swap attempt, then retry (60 retries default)2026main.go
Copy chunk target time0.5 sPercona pt-oscChunk size auto-tuned to hit this; max-lag default 1 s2026tool docs
Cut-over retry backoff1/5/10/30 minVitessEscalating intervals after failed cut-over attempts2023PR #14546
New index size gate50 GBGitLab.comLarger tables need a formal exception2026limits doc
New column size gate100 GBGitLab.comAlso the target ceiling for any table's total size2026limits doc
Backfill batch / sub-batch10,000 / 100 rowsGitLabExample shipped defaults; jobs spaced 2 min apart, 10 min pause on a stop signal2026batched migrations doc
Read these carefully

All values are defaults or policy gates read from source on 2026-09-21, not benchmark results; they tell you what each operator considers survivable, not what your hardware will do. Two derived observations: the spread of lock timeouts (100 ms to 5 s) tracks how much each team's traffic can queue without user-visible errors, so derive yours from your p99 request budget rather than copying any of them. And nobody publishes end-to-end migration durations for named large tables; treat any such number you hear as anecdote. The 40-minute GitLab figure is a retry budget, not a migration duration.

06

The evidence wall

Every source behind this page, graded. All are repository-hosted artifacts on github.com or gitlab.com, the only hosts reachable from this research environment; the full ledger with quotes ships alongside as sources.md.

Postmortem GitLab2026-08-13

gprd post-deploy migration failing with AttemptsExhaustedError

ACCESS EXCLUSIVE on projects and deployments exhausted its retry budget twice; deploys blocked, customers unaffected.

Carry forwardFail-closed lock retries turn outages into deploy delays; plan the runbook for the delay.
gitlab.com/gitlab-com/gl-infra/production #22699
Postmortem GitLab2026-09-03

Post-deploy migration failed adding a foreign key

Same class three weeks later on deployment_merge_requests; the runbook retry succeeded in minutes. “No customer-facing service impact has been identified.”

Carry forwardA recurring Sev-3 is the visible cost of not paying recurring Sev-1s.
gitlab.com/gitlab-com/gl-infra/production #22849
Postmortem GitLab2026-09-07

Autovacuum on the notes table blocked a trigger-creating migration

Lock contention from database maintenance, not application queries; migration skipped under weekend deploy pressure and rescheduled by change request.

Carry forwardVacuum and other maintenance hold real locks; big-table DDL should check for them first.
gitlab.com/gitlab-com/gl-infra/production #22873
Postmortem GitLab2026-04-06

Deadlock adding a foreign key to a partitioned CI table

The migration and high-traffic application transactions locked the same tables in opposite orders; PostgreSQL chose a victim.

Carry forwardMigrations join the application's deadlock graph; lock order is a design input (see reverse_lock_order).
gitlab.com/gitlab-com/gl-infra/production #21712
Decision record GitHub2016

gh-ost: why triggerless, and how cut-over stays atomic

The design docs record the rejection of triggers with production evidence (lock downs, unthrottleable load, untestable on replicas) and the sentry-table cut-over that fails back to the original table (issue #82).

Carry forwardA capture mechanism you cannot pause is a capture mechanism that can take the primary down.
github.com/github/gh-ost doc/why-triggerless.md
Decision record GitHub2016-06

Issue #82: safe, blocking, atomic, pure-MySQL cut-over

The recorded argument for the two-connection swap: a sentry table blocks premature rename, and “no matter what happens” a failed attempt leaves the original table serving.

Carry forwardDesign the cut-over so every failure path lands on the old table, never on no table.
github.com/github/gh-ost/issues/82
Source GitHub2026

gh-ost README and main.go: the operational surface

True pause, dynamic reconfiguration, postponed cut-over, and continuous production testing by migrating the fleet on replicas with checksums. Defaults: 1,000-row chunks, 1,500 ms lag throttle, 3 s cut-over lock timeout.

Carry forwardThe tool's flags are a checklist of everything that has gone wrong for its authors.
github.com/github/gh-ost README
Source GitHub community2021–2025

gh-ost issues #1526 and #1039: how the copy loses rows

INSERT IGNORE swallows unique-key collisions after a collation change; semi-sync commit stalls hide a row from both the binlog listener and the copy boundary.

Carry forwardChecksum-gate any migration that changes key semantics; the copy path fails silently.
github.com/github/gh-ost/issues/1526
Source GitHub community2026-06

gh-ost PR #1698, closed unmerged: cut-over timeout deadlock

A cut-over timing out mid-event-processing could hang the tool's own pipeline via an unreceived channel send; closed as already fixed by #1637. The failure mode is real either way.

Carry forwardThe migration tool is itself a concurrent system; its abort paths need the same review as its happy path.
github.com/github/gh-ost/pull/1698
Source GoCardless2016+

activerecord-safer_migrations: the lock queue, encoded

The README explains the queue mechanism in two numbered points and ships the defaults (750/1500 ms) that every migration inherits. Built after roughly 15 seconds of unplanned API downtime from a routine migration, per the blog post it links.

Carry forwardSet lock_timeout and statement_timeout on every migration connection, library-enforced, not by convention.
github.com/gocardless/activerecord-safer_migrations
Source Instacart (ankane)2026

strong_migrations: the catalogue of dangerous operations

Each unsafe operation with mechanism and safe recipe: column drops against ORM caches, in-transaction backfills, non-concurrent indexes. Blocks them in CI by default.

Carry forwardMake the unsafe path fail in development; incident lessons only persist as enforced checks.
github.com/ankane/strong_migrations
Decision record Braintree / PayPal2026

pg_ha_migrations: explicit safety, no schema rollback

safe_/unsafe_/raw_ prefixes make every trade-off visible in the diff; automatic rollback is rejected in favour of rolling forward with a new change.

Carry forwardTreat “undo” as a new forward migration; schema reverts lose data or fail validation.
github.com/braintree/pg_ha_migrations
Source Doctolib2026

safe-pg-migrations: decomposition patterns for Postgres DDL

FKs and check constraints added NOT VALID then validated; indexes always concurrent; documents autovacuum's ShareUpdateExclusiveLock as a blocker you cannot beat.

Carry forwardSplit every row-scanning DDL into a fast metadata step and a lock-light validation step.
github.com/doctolib/safe-pg-migrations
Decision record GitLab2026

Avoiding downtime in migrations, and the large-table gates

The three-release column-drop protocol, with_lock_retries as the standard wrapper, reverse lock ordering for FK work, and hard 50/100 GB size gates enforced by RuboCop with a formal exception process.

Carry forwardAbove a size threshold, the answer to “how do we migrate this table” becomes “we do not; partition or shrink it first.”
gitlab.com/gitlab-org/gitlab avoiding_downtime_in_migrations.md
Source GitLab2026

with_lock_retries.rb and the batched-migration throttler

The full retry schedule in code, bounded at about 40 minutes, and a backfill framework that pauses 10 minutes on WAL backlog, autovacuum activity or apdex breach, added “because there have been incidents.”

Carry forwardThrottle backfills on the database's health signals, not on a fixed sleep.
gitlab.com/gitlab-org/gitlab with_lock_retries.rb
Vendor PostgreSQL2026

ALTER TABLE reference: the lock table

“An ACCESS EXCLUSIVE lock is acquired unless explicitly noted”; the subforms that take weaker locks (FK adds at SHARE ROW EXCLUSIVE, options at SHARE UPDATE EXCLUSIVE) are exactly the ones the safety libraries route through.

Carry forwardMemorise which of your common DDL forms escape ACCESS EXCLUSIVE; everything else needs the timeout discipline.
github.com/postgres/postgres alter_table.sgml
Decision record CockroachDB2015-10

RFC: online schema change (the F1 protocol, implemented)

DELETE_ONLY, WRITE_ONLY, PUBLIC with a two-version lease invariant and backfill as small transactions; links and follows the F1 paper's state diagram.

Carry forwardCorrectness under mixed versions comes from making every adjacent pair of states compatible, then never skipping a state.
github.com/cockroachdb/cockroach RFC 20151014
Decision record PingCAP2018-10

TiDB online DDL design: the same machine, independently

One elected owner steps the cluster through none, delete only, write only, write reorganization, public; at most two schema versions live at once, enforced by lease.

Carry forwardTwo independent reimplementations of one 2013 paper is as close to a proven pattern as this field gets.
github.com/pingcap/tidb design doc 2018-10-08
Source Percona2026

pt-online-schema-change: the trigger-based survivor

Chunked copy tuned to 0.5 s per chunk, 1 s lag ceiling, triggers for capture, and an honest RISKS section; foreign keys break the atomic rename and get their own workaround flag.

Carry forwardFKs are the shadow-table pattern's structural enemy; audit for them before choosing a tool.
github.com/percona/percona-toolkit pt-online-schema-change
Source Meta / SoundCloud2010s; archived

The first generation: FB OnlineSchemaChange and LHM, both archived

LHM's README preserves the original hour-long ALTER TABLE problem; Facebook's OSC repo (copy mode, table swap) went read-only on 2026-08-04. The lineage that invented the pattern has exited maintenance.

Carry forwardMigration tooling has a lifecycle; check the archive banner before betting a runbook on a tool.
github.com/facebookincubator/OnlineSchemaChange
Source Vitess2024–2025

Release notes v20 and v22: gh-ost unbundled, then de-recognised

v20 stops embedding the gh-ost binary; v22: “Vitess no longer recognizes the gh-ost and pt-osc Online DDL strategies. The vitess strategy is the recommended way to make schema changes at scale.”

Carry forwardMigration is migrating into the database layer itself; external copy tools are becoming a legacy interface.
github.com/vitessio/vitess v22 release notes
Source Vitess2023-12

PR #14546: cut-over backoff and forced cut-over

Failed cut-overs stop retrying in a tight loop (1/5/10/30 min backoff); the terminal option kills queries and lock-holding transactions on the migrated table. MySQL 8.0 only, since it needs performance_schema.data_locks.

Carry forwardDecide before the migration which side gets killed if the cut-over cannot win politely.
github.com/vitessio/vitess/pull/14546
Source Xata2023+

pgroll: both schema versions served at once

Views expose old and new shapes over one physical table; clients pin a version via search_path; rollback before contract is instant. The two-version invariant, rebuilt at the application boundary.

Carry forwardIf both versions must work anyway (they must, during deploys), consider making that explicit and queryable.
github.com/xataio/pgroll
Vendor GitHub2026

gh-ost flags doc: instant DDL first, with a fallback

--attempt-instant-ddl covers MySQL 8.0 metadata-only changes; the residual risk is the metadata lock when long transactions are running, and the tool falls back to the copy automatically.

Carry forwardAlways try the metadata-only path first; the copy machinery is the expensive fallback, not the default.
github.com/github/gh-ost command-line-flags.md
07

Build a miniature, then productionise it

Each rung is buildable against a local Postgres or MySQL in hours; the line from toy to production crosses at rung four.

Reproduce the lock queue

Three sessions on one Postgres table: session A runs a long SELECT (pg_sleep join), session B runs ALTER TABLE, session C runs one-row SELECTs. Watch C hang the moment B starts waiting.

Done when: pg_locks shows B waiting and C queued behind it while A holds only ACCESS SHARE.  Teaches: the outage needs no lock to be held by the migration at all.

Add the seatbelt

Wrap the ALTER in SET lock_timeout = '750ms' and a retry loop with backoff, GoCardless-style. Re-run rung one and watch the queue drain instead of growing.

Done when: the ALTER fails and retries while C's latency stays flat.  Teaches: the timeout protects the traffic, not the migration.

Rename a column without breaking either app version

Run two versions of a small app against one table. Do the rename as expand (add column, dual-write), migrate readers, contract (drop old), following GitLab's multi-release protocol; try the naive RENAME first and watch the old version break.

Done when: both app versions serve correctly at every intermediate step.  Teaches: the schema change is a deployment protocol, not a statement.

Backfill a million rows without hurting anyone

Add a column, then backfill in batches of 10,000 with a sub-batch sleep, outside any transaction, while pgbench runs. Add one health check (replication lag or p99) that pauses the backfill.

Done when: pgbench p99 stays within budget for the whole backfill and the pause demonstrably triggers.  Teaches: a backfill is a workload with its own SLO interaction.

Build a toy shadow-table migration, then corrupt it

On MySQL: create the ghost table, copy in chunks, capture concurrent writes (a trigger is fine for the toy), swap with RENAME TABLE. Then add a unique key that collides under the copy and watch INSERT IGNORE eat rows, reproducing gh-ost #1526.

Done when: your checksum step catches the row loss your swap step missed.  Teaches: verification is a component, not a nicety.

Run the real tool the way its authors do

Run gh-ost with --test-on-replica against a replica of a seeded database, then inspect the two tables it leaves for comparison. Throttle it mid-copy via the interactive socket and watch replication lag recover.

Done when: you have compared the tables, forced a throttle, and postponed then completed a cut-over.  Teaches: the operational surface (pause, audit, postpone) is why these tools exist.

Institutionalise it

Add a migration linter (strong_migrations or a squawk-style checker) to CI so the unsafe forms fail review, plus a size gate that refuses DDL on tables above your threshold without an exception label, copying GitLab's model.

Done when: a teammate's naive remove_column is blocked with a printed safe recipe.  Teaches: incident lessons persist only as enforced checks, which is how every library in this guide came to exist.

08

Keep hunting

The queries that found this material, adapted for a normal network rather than this environment's repository-only view.

Incident trackers and postmortems

  • site:gitlab.com gl-infra/production migration lock
  • "WithLockRetries::AttemptsExhaustedError"
  • "schema migration" postmortem "lock_timeout" OR "metadata lock"
  • github availability report "schema migration"

Design arguments in repositories

  • repo:github/gh-ost is:issue "data loss" OR "lose data"
  • repo:github/gh-ost is:pr is:closed is:unmerged cut-over
  • path:docs/RFCS "schema change" repo:cockroachdb/cockroach
  • "online DDL" design doc site:github.com

The safety-library layer

  • "lock_timeout" migration gem README site:github.com
  • "safe migrations" postgres "ACCESS EXCLUSIVE" "lock queue"
  • strong_migrations OR pg_ha_migrations OR safe-pg-migrations compare

Tool lifecycle signals

  • vitess release notes gh-ost deprecated
  • "pt-online-schema-change" "foreign keys" risk
  • "ALGORITHM=INSTANT" metadata lock long transaction

Two vocabulary keys unlock most of this field: search the failure (“lock queue”, “metadata lock”, “AttemptsExhausted”) rather than the practice (“zero-downtime migration”, which returns tutorials), and read the READMEs of safety libraries as what they are: postmortems compiled into code.

09

References

  1. GitHub, gh-ost: Why triggerless? github/gh-ost repository design doc, 2016, maintained. Checked 2026-09-21.
  2. GitHub, gh-ost: Cut-over step github/gh-ost repository design doc, 2016, maintained. Checked 2026-09-21.
  3. Shlomi Noach, Describing safe, blocking, atomic, pure-mysql cut-over phase github/gh-ost issue #82, 2016-06-26. Checked 2026-09-21.
  4. GitHub, gh-ost README github/gh-ost repository. Checked 2026-09-21.
  5. GitHub, gh-ost command-line flags github/gh-ost repository doc. Checked 2026-09-21.
  6. GitHub, gh-ost main.go (defaults) github/gh-ost source. Checked 2026-09-21.
  7. gh-ost issue #1526: lose data with UNIQUE KEY github/gh-ost, opened 2025-04-07. Checked 2026-09-21.
  8. gh-ost issue #1039: data loss under semi-sync replication github/gh-ost, opened 2021-10-26. Checked 2026-09-21.
  9. gh-ost PR #1698: fix cutover retries (closed unmerged) github/gh-ost, 2026-06-04. Checked 2026-09-21.
  10. GoCardless, activerecord-safer_migrations README gocardless repository. Checked 2026-09-21.
  11. Andrew Kane / Instacart, strong_migrations README ankane repository. Checked 2026-09-21.
  12. Braintree, pg_ha_migrations README braintree repository. Checked 2026-09-21.
  13. Doctolib, safe-pg-migrations README doctolib repository. Checked 2026-09-21.
  14. GitLab, Avoiding downtime in migrations gitlab-org/gitlab documentation, maintained. Checked 2026-09-21.
  15. GitLab, WithLockRetries source gitlab-org/gitlab source. Checked 2026-09-21.
  16. GitLab, Large tables limitations gitlab-org/gitlab documentation. Checked 2026-09-21.
  17. GitLab, Batched background migrations gitlab-org/gitlab documentation. Checked 2026-09-21.
  18. GitLab production incident #22699 gl-infra/production tracker, 2026-08-13. Checked 2026-09-21.
  19. GitLab production incident #22849 gl-infra/production tracker, 2026-09-03. Checked 2026-09-21.
  20. GitLab production incident #22873 gl-infra/production tracker, 2026-09-07. Checked 2026-09-21.
  21. GitLab production incident #21712 gl-infra/production tracker, 2026-04-06. Checked 2026-09-21.
  22. PostgreSQL, ALTER TABLE reference source (alter_table.sgml) postgres/postgres mirror. Checked 2026-09-21.
  23. CockroachDB, RFC: online schema change cockroachdb/cockroach, 2015-10-14. Cites Rae et al., “Online, Asynchronous Schema Change in F1”, PVLDB 6(11), 2013 (paper host unreachable from this environment). Checked 2026-09-21.
  24. PingCAP, TiDB DDL architecture design doc pingcap/tidb, 2018-10-08. Checked 2026-09-21.
  25. Percona, pt-online-schema-change (source with embedded docs) percona/percona-toolkit. Checked 2026-09-21.
  26. Meta, OnlineSchemaChange repository facebookincubator, archived 2026-08-04. Checked 2026-09-21.
  27. SoundCloud, Large Hadron Migrator README soundcloud/lhm, archived. Checked 2026-09-21.
  28. Vitess v20.0.0 release notes vitessio/vitess, 2024. Checked 2026-09-21.
  29. Vitess v22.0.0 release notes vitessio/vitess, 2025. Checked 2026-09-21.
  30. Vitess PR #14546: cut-over backoff and forced cut-over vitessio/vitess, merged 2023-12-13. Checked 2026-09-21.
  31. Xata, pgroll README xataio/pgroll. Checked 2026-09-21.