On a 500 GB table, the wrong ALTER locks writes for hours or floods replicas with lag. The right one finishes in milliseconds. Choosing well starts with knowing what MySQL can do natively.
Native algorithms
| Algorithm | What happens | Typical operations (8.0+) |
|---|---|---|
INSTANT | Metadata change only, milliseconds | Add column (any position from 8.0.29), drop column (8.0.29+), rename column, set/drop default, modify ENUM/SET definition (append) |
INPLACE | Rebuild or index build inside InnoDB, concurrent DML allowed | Add secondary index, drop index, change row format, many column changes with LOCK=NONE |
COPY | New table, rows copied, DML blocked | Change column data type, change character set of a column, some PK changes |
Always state the algorithm so MySQL refuses instead of silently doing something slower:
ALTER TABLE orders ADD COLUMN channel VARCHAR(20) NULL, ALGORITHM=INSTANT;
ALTER TABLE orders ADD INDEX idx_orders_created (created_at), ALGORITHM=INPLACE, LOCK=NONE;
If the operation cannot use that algorithm, you get an error explaining why, and nothing changes.
Warning
INPLACE is online on the primary but replicas apply it as one long statement. A 2-hour index build means 2 hours of replica lag (unless the replica uses parallel replication cleverly, which still waits for the DDL). For big tables with lag-sensitive readers, use gh-ost or pt-osc.
There is a limit on the number of INSTANT column changes per table (row versions) before a rebuild is required; check INFORMATION_SCHEMA.INNODB_TABLES.TOTAL_ROW_VERSIONS.
The metadata lock trap
Every ALTER needs an exclusive metadata lock briefly at start and end. If a long transaction holds a shared lock on the table, the ALTER waits, and every new query on the table queues behind the ALTER. The app freezes.
Guard against it:
SET SESSION lock_wait_timeout = 5; -- give up after 5 s instead of blocking the world
ALTER TABLE orders ADD COLUMN channel VARCHAR(20) NULL, ALGORITHM=INSTANT;
Check for long transactions first:
SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_s, trx_mysql_thread_id
FROM information_schema.innodb_trx ORDER BY trx_started LIMIT 10;
gh-ost: triggerless, binlog-based
gh-ost creates a ghost table, copies rows in chunks, and applies ongoing changes by reading the binary log (row format required). Cut-over is a short atomic rename.
gh-ost \
--host=replica01.internal --database=shop --table=orders \
--alter="MODIFY COLUMN amount DECIMAL(14,2) NOT NULL" \
--user=ghost --ask-pass \
--chunk-size=1000 --max-load=Threads_running=40 --critical-load=Threads_running=200 \
--max-lag-millis=1500 --throttle-control-replicas=replica02.internal,replica03.internal \
--postpone-cut-over-flag-file=/tmp/ghost.postpone \
--exact-rowcount --concurrent-rowcount \
--verbose --execute
- Connecting to a replica (with
--assume-rbror row-based binlogs there) reduces primary load; gh-ost still writes to the primary. --max-lag-millisand--throttle-control-replicaspause copying when replicas fall behind.- The postpone flag file keeps gh-ost ready but not cutting over until you delete the file, so you choose the cut-over moment.
Interactive control while it runs:
echo throttle | nc -U /tmp/gh-ost.shop.orders.sock
echo "chunk-size=500" | nc -U /tmp/gh-ost.shop.orders.sock
echo status | nc -U /tmp/gh-ost.shop.orders.sock
Limitations: no foreign keys on the table (child or parent) by default, needs row-based binlog, and on RDS/Aurora some flags differ (--allow-on-master, --assume-rbr); read the gh-ost docs for your platform.
pt-online-schema-change: trigger-based
pt-online-schema-change \
--alter "ADD INDEX idx_orders_customer_created (customer_id, created_at)" \
D=shop,t=orders,h=primary01.internal,u=ptosc --ask-pass \
--chunk-size=1000 --max-lag=2 --check-interval=1 \
--max-load Threads_running=40 --critical-load Threads_running=200 \
--set-vars lock_wait_timeout=5 \
--dry-run
# then replace --dry-run with --execute
It uses triggers to keep the new table in sync, which adds write overhead on the primary and can conflict with existing triggers. It handles foreign keys with --alter-foreign-keys-method (read the trade-offs carefully).
Choosing
| Change | Use |
|---|---|
| Add/drop/rename column on 8.0.29+ | ALGORITHM=INSTANT |
| Add index, small/medium table or lag acceptable | INPLACE, LOCK=NONE |
| Add index on huge table with lag-sensitive replicas | gh-ost |
| Type change on huge table | gh-ost (or pt-osc if FKs present) |
| Table with foreign keys | pt-osc, or plan FK handling first |
Before and after
- Check free disk: online rebuilds need roughly the table's size again.
- Run on a staging copy with production-like size; time it.
- Schedule during lower traffic; monitor
Threads_running, replica lag, and disk. - After cut-over, keep the old table (gh-ost
_deltable, pt-osc--no-drop-old-table) until you are confident, then drop it in small steps if very large.
Dropping a multi-hundred-GB table can stall the server while the file is deleted. A common approach is to create a hard link to the .ibd file before the DROP (so the DROP returns quickly), then truncate the linked file in steps at OS level.
Prevent it next time
- Migrations specify
ALGORITHMandLOCKexplicitly; CI fails ALTERs without them. lock_wait_timeoutset low for migration sessions.- Large-table changes go through a reviewed gh-ost/pt-osc runbook, not an ORM migration at deploy time.