Your orders table has grown to 900 million rows and 480 GB on disk, and the product team needs a new VARCHAR(64) column added before the quarter-end reporting run — three days from now. A naive ALTER TABLE would acquire a metadata lock, block all writes for the better part of six hours, and almost certainly trigger a P0 incident before it finishes. You reach for pt-online-schema-change, run the pre-flight check, and immediately hit an error: the table already has three application triggers, and pt-osc refuses to proceed. This scenario plays out in production engineering teams every week, and navigating it requires understanding exactly how each tool — pt-osc, gh-ost, and MySQL's own native DDL — handles the constraints you cannot remove.
- pt-online-schema-change works by creating a shadow table, attaching AFTER triggers to sync changes, and copying rows in chunks — but it cannot operate on tables that already have triggers, because MySQL only allows one trigger per event per timing point per table.
- gh-ost is trigger-free: it reads the binary log stream for ongoing DML changes and applies them to a ghost table in parallel with a chunk-based row copy, making it the correct tool for tables that already carry triggers.
- MySQL 8.0 INSTANT DDL (
ALGORITHM=INSTANT) can add columns, change column defaults, and rename columns without touching a single data row — truly zero-downtime for qualifying operations. - Foreign key constraints complicate online schema changes for both tools; understand the specific limitations before designing your migration plan.
- Monitor long-running
ALTER TABLEprogress viaperformance_schema.events_stages_currentand thesys.session_progressview. - The cutover lock window — the brief exclusive lock at the end of the migration — is the highest-risk moment; gh-ost gives you explicit control over it, pt-osc does not.
Why Online Schema Changes Exist
MySQL's ALTER TABLE — even with ALGORITHM=INPLACE — holds a brief exclusive metadata lock (MDL) at the start of the operation to drain active transactions, and again at the end to swap the rebuilt table into place. For most operations that require a full table rebuild, the engine must read every row of the original table, write it into a new table with the updated structure, and then rename the tables atomically. On a 10 GB table this takes seconds. On a 500 GB table it takes hours. During that entire period, any write that arrives is queued behind the MDL, and on a busy application server with a saturated connection pool, those queued connections back up instantly and exhaust all available connections within minutes.
Online schema change tools avoid this by moving the long-running work outside the critical path of your application's MDL. They build a new table in the background, copy rows incrementally, and defer the final table swap to the shortest possible window. The difference between a six-hour outage and a two-second cutover lock is the difference between a scheduled maintenance window and an unscheduled P0.
pt-online-schema-change: Trigger-Based Copy
pt-online-schema-change (pt-osc), part of Percona Toolkit, was the industry standard for MySQL online schema changes for over a decade. Its mechanism is straightforward and auditable:
- Create a new empty table (
_tablename_new) with the target schema. - Create three AFTER triggers on the original table —
AFTER INSERT,AFTER UPDATE, andAFTER DELETE— that replicate any DML on the original to the new table in real time. - Copy rows from the original to the new table in small chunks, ordered by primary key, with configurable chunk size and sleep intervals to throttle I/O.
- When the row copy is complete, acquire a brief lock, rename
_tablename_newto the original name (and the original to_tablename_old), and release the lock. - Drop the old table.
# Basic pt-osc invocation — add a column to a large table
pt-online-schema-change \
--host=db-primary.internal \
--user=dba \
--ask-pass \
--alter="ADD COLUMN last_status_change DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" \
--execute \
--chunk-size=1000 \
--max-load="Threads_running=50" \
--critical-load="Threads_running=100" \
--check-interval=5 \
D=myapp,t=orderspt-osc will refuse to run if the target table already has any trigger. MySQL enforces a hard constraint: only one trigger can exist per event per timing point (BEFORE/AFTER) per table. Since pt-osc needs AFTER INSERT, AFTER UPDATE, and AFTER DELETE triggers, any pre-existing trigger occupying one of those three slots causes a fatal conflict. The error message is explicit: pt-online-schema-change: Cannot add triggers to a table that already has triggers. This is not a bug in pt-osc — it is a MySQL limitation that pt-osc cannot work around.
The trigger approach has one additional consequence: triggers fire synchronously inside the original transaction. Every write to the source table pays the cost of an additional write to the new table before the transaction can commit. On a high-write table this adds latency to your application's critical path during the entire migration window.
gh-ost: Trigger-Free Binlog Streaming
gh-ost (GitHub's Online Schema Transmogrifier) was built specifically to address the trigger problem. It is completely trigger-free. Instead of attaching triggers to the source table, gh-ost connects to a MySQL replica as a fake replica using the replication protocol and streams the binary log. Every row-format DML event on the source table appears in the binlog, gh-ost reads it, and applies it to the ghost table. The ghost table catches up to the source table through this binlog streaming rather than through triggers.
# gh-ost running against a replica for binlog streaming
# (never connects to primary for reads — reduces primary load)
gh-ost \
--host=db-replica.internal \
--port=3306 \
--user=gh-ost \
--password="$(cat /etc/gh-ost-password)" \
--database=myapp \
--table=orders \
--alter="ADD COLUMN last_status_change DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" \
--assume-rbr \
--chunk-size=1000 \
--max-load="Threads_running=50" \
--critical-load="Threads_running=100" \
--switch-to-rbr \
--allow-on-master=false \
--executeBecause gh-ost reads from the binlog rather than installing triggers, it works correctly on tables with existing triggers — those triggers fire for the application's DML as usual, and gh-ost's binlog reader sees the resulting row changes independently. The two mechanisms do not interact.
gh-ost also provides a control socket during migration. You can pause and resume the row copy, change chunk size and throttle parameters on the fly, and — crucially — control when the cutover happens:
# Interact with a running gh-ost migration via the unix socket
echo "status" | socat - /tmp/gh-ost.myapp.orders.sock
echo "throttle" | socat - /tmp/gh-ost.myapp.orders.sock
echo "unthrottle" | socat - /tmp/gh-ost.myapp.orders.sock
echo "chunk-size=500" | socat - /tmp/gh-ost.myapp.orders.sock
# Initiate the cutover manually when you are ready
echo "unpostpone" | socat - /tmp/gh-ost.myapp.orders.sockUse gh-ost's --postpone-cut-over-flag-file option to block the cutover until you are ready. gh-ost will complete the row copy and keep streaming binlog events into the ghost table indefinitely — keeping it perfectly in sync — until you remove the flag file. This lets you run the migration during business hours and schedule the actual table swap for off-peak, with the ghost table already fully warmed and caught up.
MySQL 8.0 INSTANT DDL: The Zero-Cost Alternative
Before reaching for an external tool, always check whether your change qualifies for ALGORITHM=INSTANT. Operations that are truly instant — meaning MySQL modifies only the table's metadata in the data dictionary with no row reads or writes — include:
- Adding a column at the end of the table (MySQL 8.0.12+; any position in MySQL 8.0.29+)
- Dropping a column (MySQL 8.0.29+, using a new "instant" drop mechanism)
- Changing a column's default value
- Modifying an
ENUMorSETcolumn by appending new values at the end of the value list - Renaming a column (since MySQL 8.0)
- Changing the number of virtual generated columns
- Changing index visibility (
ALTER TABLE t ALTER INDEX idx INVISIBLE)
-- Instant: add a column at the end (8.0.12+)
ALTER TABLE orders
ADD COLUMN last_status_change DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
ALGORITHM=INSTANT;
-- Instant: change a default value
ALTER TABLE orders
ALTER COLUMN status SET DEFAULT 'pending',
ALGORITHM=INSTANT;
-- Instant: rename a column
ALTER TABLE orders
RENAME COLUMN user_id TO customer_id,
ALGORITHM=INSTANT;
-- Non-instant: adding an index always requires INPLACE at minimum
ALTER TABLE orders
ADD INDEX idx_customer_created (customer_id, created_at),
ALGORITHM=INPLACE,
LOCK=NONE;Be aware of "instant column" row version limits. MySQL 8.0 stores instant-added columns in a row format that embeds a "row version" identifier in the row header. Starting from MySQL 8.0.29, the maximum number of instant column add/drop operations on a single table is 64. After that threshold, the next instant operation will silently fall back to a full rebuild. Run SELECT * FROM information_schema.INNODB_TABLES WHERE NAME = 'mydb/mytable'\G and check the INSTANT_COLS field to see your current instant column count before assuming INSTANT will apply.
ALGORITHM=INPLACE vs ALGORITHM=COPY vs ALGORITHM=INSTANT
When you omit the ALGORITHM clause, MySQL chooses the best available algorithm automatically. Understanding what each algorithm does — and when MySQL falls back — is essential for predicting the impact of any ALTER TABLE statement:
| Algorithm | Table Rebuild | DML Concurrency | Lock Window | Typical Use |
|---|---|---|---|---|
INSTANT |
None — metadata only | Full concurrency | Sub-millisecond MDL | ADD/DROP column (qualifying ops only) |
INPLACE |
Sometimes (operation-dependent) | Full concurrency during rebuild; MDL at start/end | Brief MDL at start and end | ADD INDEX, some column changes |
COPY |
Always — full table read and write | Reads allowed; writes blocked for duration | Exclusive lock for entire duration | Operations not supported by INPLACE |
Always specify the algorithm explicitly in production scripts and let MySQL return an error if the operation cannot meet your algorithm requirement — rather than silently falling back to a more impactful algorithm:
-- This will fail with an error if INSTANT is not available
-- Better to know early than to discover COPY is running on a 400 GB table
ALTER TABLE orders
ADD COLUMN risk_score TINYINT UNSIGNED DEFAULT NULL,
ALGORITHM=INSTANT;
-- If INSTANT fails, try INPLACE with LOCK=NONE
-- This will fail if the operation requires a table lock
ALTER TABLE orders
ADD COLUMN risk_score TINYINT UNSIGNED DEFAULT NULL,
ALGORITHM=INPLACE,
LOCK=NONE;Tables with Existing Triggers: Choosing Between pt-osc and gh-ost
The decision tree for tables that carry application triggers is short: use gh-ost. pt-osc simply cannot run. But the answer deserves more nuance for teams considering which tool to standardize on.
pt-osc is simpler to set up and understand. It requires no replica access, works directly against the primary, and has fewer moving parts. For a small engineering team running infrequent migrations on tables without existing triggers, it is the lower-friction choice. Its throttle controls are coarser — it pauses the chunk copy based on server load metrics — but they are adequate for most workloads.
gh-ost is more complex to operate but provides capabilities that matter at scale: the postponable cutover, the control socket, replica-lag-aware throttling, and complete trigger independence. For any table that already has triggers, gh-ost is not a preference — it is the only viable external tool.
gh-ost requires binary logging in row format (binlog_format=ROW) and that GTIDs or binary log coordinates be accessible from the replica it connects to. Verify these prerequisites before starting a migration: SHOW VARIABLES LIKE 'binlog_format' and SHOW REPLICA STATUS\G (or SHOW SLAVE STATUS\G on older versions). If your replica has binlog_format=STATEMENT, gh-ost can switch it for the migration session with --switch-to-rbr.
Foreign Key Constraints and Online Schema Changes
Foreign keys introduce a significant complication for both tools. pt-osc's default behavior is to drop all foreign keys from the original table, recreate them on the new table (with renamed constraints to avoid conflicts), and then attempt to rename the constraints back after cutover. This works for simple single-level FK relationships but becomes unreliable — and sometimes flat-out fails — with multi-level cascades or FK constraints that reference the table being altered from other tables.
gh-ost takes a different stance: it does not manage foreign keys at all by default. If you attempt a gh-ost migration on a table that is referenced by a FK in another table, gh-ost will detect this and refuse to proceed unless you explicitly pass --skip-foreign-key-checks. When you use that flag, gh-ost proceeds with the migration but does not move or recreate the foreign key relationships — leaving you responsible for rebuilding them afterward.
-- After a gh-ost migration on a table referenced by FKs,
-- you may need to rebuild the FK from the child table
-- Check for dangling FK references post-migration:
SELECT
rc.CONSTRAINT_NAME,
rc.TABLE_NAME AS child_table,
rc.REFERENCED_TABLE_NAME AS parent_table,
rc.DELETE_RULE
FROM information_schema.REFERENTIAL_CONSTRAINTS rc
WHERE rc.CONSTRAINT_SCHEMA = DATABASE()
ORDER BY rc.TABLE_NAME;For tables sitting at the center of a foreign key graph — referenced by many child tables — consider whether the schema change can be restructured to avoid the FK complication entirely. Adding a new column to a heavily-referenced parent table is often better handled by adding the column via ALGORITHM=INSTANT if it qualifies, or by temporarily disabling FK checks, running a native ALTER TABLE with ALGORITHM=INPLACE during a low-traffic window, and re-enabling checks — a trade-off that may be safer than running an external tool against a deeply connected table.
Monitoring Progress of Long-Running ALTER TABLE
For native ALTER TABLE operations running under ALGORITHM=INPLACE, MySQL exposes detailed progress through Performance Schema:
-- Monitor an in-progress ALTER TABLE in real time
SELECT
event_name,
work_completed,
work_estimated,
ROUND(work_completed / NULLIF(work_estimated, 0) * 100, 1) AS pct_done,
TIMEDIFF(NOW(), date_started) AS elapsed
FROM performance_schema.events_stages_current
WHERE event_name LIKE 'stage/innodb/alter%'
AND work_estimated > 0;
-- Alternative: sys schema view (MySQL 8.0+)
SELECT
thd_id,
conn_id,
state,
current_statement,
statement_latency,
progress
FROM sys.session
WHERE command = 'Query'
AND current_statement LIKE 'ALTER TABLE%';For gh-ost migrations, progress is available directly from the control socket and from the log output:
# Real-time gh-ost status including ETA
echo "status" | socat - /tmp/gh-ost.myapp.orders.sock
# Or tail the gh-ost log for periodic progress lines
# gh-ost logs progress every 60 seconds by default:
# -- Copy: 48,231,000/900,000,000 (5.4%); State: migrating;
# ETA: 2025-03-15 04:27:00 UTCCutover Strategy: Minimizing the Lock Window
The cutover — when the ghost table is renamed to replace the original — is the most dangerous moment in any online schema change. Both tools acquire an exclusive lock on the original table, rename the tables, and release the lock. Any write that arrives during this window is queued. The goal is to make this window as short as possible.
pt-osc's cutover is automatic and uncontrolled: when the chunk copy is complete, it immediately attempts the rename. The typical lock duration is under two seconds, but on a loaded server with many active transactions, it can be longer as MySQL waits for active transactions to commit before granting the exclusive lock.
gh-ost's cutover is far more controllable. When the row copy finishes, gh-ost enters a "ready to cut over" state and waits for your signal (or for the postpone flag file to be removed). It continues streaming binlog events the entire time, keeping the ghost table perfectly current. When you trigger the cutover, gh-ost uses an atomic two-step rename: it creates a temporary _del table, acquires the lock, renames the original to _del and the ghost to the original name simultaneously (using MySQL's multi-table rename atomicity guarantee), and then drops the _del table. The lock window is typically under one second.
# Controlled gh-ost cutover workflow:
# 1. Start migration with postpone flag
touch /tmp/gh-ost.postpone.myapp.orders
gh-ost ... --postpone-cut-over-flag-file=/tmp/gh-ost.postpone.myapp.orders --execute
# 2. Monitor until row copy is complete and gh-ost is waiting
echo "status" | socat - /tmp/gh-ost.myapp.orders.sock
# Look for: "Waiting for cut-over signal..."
# 3. At your chosen moment (off-peak, after application deploy, etc.):
rm /tmp/gh-ost.postpone.myapp.orders
# gh-ost proceeds with the cutover immediatelyPractical Decision Guide: pt-osc vs gh-ost vs Native ALTER TABLE
| Scenario | Recommended Approach | Reason |
|---|---|---|
| Add column at end, no FK involved | Native ALGORITHM=INSTANT |
Zero downtime, zero row copy, fastest possible |
| Add index on a large table, no existing triggers | Native ALGORITHM=INPLACE, LOCK=NONE or pt-osc |
INPLACE is fine if MDL at start/end is tolerable; pt-osc for extra safety |
| Table has existing application triggers | gh-ost | pt-osc cannot run; gh-ost is trigger-free by design |
| Need controlled cutover timing (off-peak swap) | gh-ost with postpone flag | pt-osc cutover is automatic; gh-ost cutover is operator-controlled |
| Table is a parent in a complex FK graph | Native ALGORITHM=INSTANT if eligible; otherwise low-traffic window with INPLACE |
Both external tools have significant FK handling limitations |
| No replica available, simple schema change | pt-osc | gh-ost requires binlog access, ideally from a replica; pt-osc runs against primary |
| High-write table, want zero added write latency during migration | gh-ost | pt-osc triggers add latency to every write during migration; gh-ost's binlog reads are asynchronous |
Before running any external tool on a production table, always run a dry-run first. Both tools support this: pt-osc with --dry-run instead of --execute, and gh-ost by omitting the --execute flag. The dry run validates connectivity, checks for trigger conflicts, verifies FK detection, and confirms the generated ALTER TABLE statement without touching any data. Make dry-run output part of your migration PR review process.
- pt-osc works by installing AFTER triggers on the source table — it cannot run on tables that already have triggers due to MySQL's one-trigger-per-event-per-timing-point constraint; gh-ost is the only viable external tool for those tables.
- gh-ost's trigger-free approach (binlog streaming as a fake replica) adds zero latency to production writes during migration and gives you explicit, operator-controlled cutover timing via a flag file and control socket.
- MySQL 8.0
ALGORITHM=INSTANTis the first tool you should reach for: adding columns, changing defaults, renaming columns, and appendingENUMvalues are all zero-downtime with no row copy whatsoever. - Always specify the algorithm explicitly in production DDL scripts (
ALGORITHM=INSTANT, thenINPLACE, then fail) so that unexpected fallback toALGORITHM=COPYcauses an error instead of a silent multi-hour table lock. - Foreign key constraints are the hardest part of online schema changes: pt-osc drops and recreates them (fragile for complex FK graphs), gh-ost skips them by default (requires manual post-migration rebuild), and native
INSTANTDDL is unaffected by FKs for qualifying operations. - Monitor native
ALTER TABLEprogress throughperformance_schema.events_stages_currentand gh-ost progress through its control socket; never run a large migration without a progress-monitoring session open in parallel. - The cutover lock is the highest-risk moment: plan it explicitly — use gh-ost's postpone flag to time the cutover for off-peak, and always have a rollback plan (pt-osc's
_tablename_oldtable, gh-ost's ghost table) available before triggering the rename.
Online schema changes on large MySQL tables — especially tables with triggers, complex foreign key graphs, or aggressive write throughput — are among the highest-stakes operational tasks a DBA team performs. Getting the tool selection wrong, misunderstanding the lock model, or misjudging the cutover window can turn a routine schema migration into a customer-facing incident. JusDB works with engineering teams to design and execute safe schema migrations at scale — from audit and tool selection through execution, cutover coordination, and post-migration validation. If your team is planning a large DDL operation and wants expert eyes on the migration plan before it runs, reach out to JusDB.