Cutting Over gh-ost Migrations Safely
The row copy finished overnight, and gh-ost has been sitting at State: postponing cut-over all morning, applying binlog events and keeping the ghost table current. Now someone has to decide when to swap the tables — and the last time a cut-over ran at peak traffic, it failed four times with Timeout while waiting for lock before succeeding, and the application logged a burst of stalled writes each time. The cut-over is the only moment in a gh-ost migration when the original table is locked, so it is the only moment that can hurt. This guide explains what gh-ost does during those seconds, how to schedule it, and how to tune it so it either succeeds quickly or backs off cleanly. It completes the gh-ost workflow in Online Schema Change Tools.
LOCK TABLES and the rename — normally well under a second — and the rename swaps both tables in one atomic step.Symptom / Error Signatures
Cut-over problems show up in gh-ost’s output and in the application:
- gh-ost logs
Timeout while waiting for lockorLock wait timeout exceededduring cut-over, followed by a retry. After--default-retriesfailures (60 by default) it gives up and exits. - The application logs a spike of write latency, or
ERROR 1205, at the moment of each cut-over attempt. SHOW PROCESSLISTduring the attempt shows application threadsWaiting for table metadata lockorWaiting for table level lockon the migrated table.- gh-ost reports
State: postponing cut-overindefinitely because a postpone flag file exists — intended, but easy to forget. - After a successful cut-over, the old table remains as
_tbl_deland consumes disk until dropped.
Root Cause Analysis
gh-ost’s default cut-over (--cut-over=default) is designed so the swap is atomic: there is no moment when the table name points at nothing. It achieves this with two connections. The first creates a sentinel table and takes LOCK TABLES on the original table and the sentinel, which stops application writes. gh-ost then applies the last binlog events so the ghost table is fully current. The second connection issues a combined RENAME TABLE that moves the original to the sentinel’s name and the ghost to the original’s name; it blocks because of the held lock. The first connection then drops the sentinel and unlocks, and MySQL executes the queued rename before any queued application statement, because renames get priority. Queued writes then land on the new table.
The fragile part is acquiring the initial lock. LOCK TABLES ... WRITE needs an exclusive metadata lock, so it waits behind any open transaction on the table — the same queueing problem described in diagnosing “Waiting for table metadata lock”. gh-ost bounds that wait with --cut-over-lock-timeout-seconds (default 3), and while it waits, application writes queue behind it. If the timeout fires, gh-ost releases everything, application traffic resumes, and it retries after a pause.
| Setting | Effect | Guidance |
|---|---|---|
--postpone-cut-over-flag-file |
cut-over waits while the file exists | always set; remove the file in a chosen window |
--cut-over-lock-timeout-seconds |
max wait for the initial lock and rename | 1–3; lower on very hot tables |
--default-retries |
attempts before giving up | default 60 is usually fine |
--cut-over=two-step |
non-atomic: rename original away, then ghost in | avoid; brief “table does not exist” window |
Immediate Mitigation
1. Always start with the cut-over postponed. If a migration is running without a postpone flag, create the file named by --postpone-cut-over-flag-file if one was configured; otherwise use the interactive socket to postpone.
# Shell · host running gh-ost · no database privileges needed
touch /var/run/migrations/postpone-orders
echo status | nc -U /tmp/gh-ost.shop.orders.sock # expect "postponing cut-over" once copy completes
2. Clear blockers just before cutting over. Check for long transactions on the table; they are the reason cut-over attempts time out.
-- MySQL 8.0 · read-only · run immediately before releasing the cut-over
SELECT trx_mysql_thread_id, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_s, trx_query
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 2
ORDER BY trx_started;
3. Release the cut-over in a low-traffic window. Remove the flag file (or send unpostpone to the socket) and watch the log.
# Shell · triggers the cut-over; gh-ost retries on lock timeout up to --default-retries
rm /var/run/migrations/postpone-orders
# or: echo unpostpone | nc -U /tmp/gh-ost.shop.orders.sock
4. If attempts keep failing, re-postpone and investigate. Repeated timeouts mean a workload holds the table continuously. Recreate the flag file to stop retrying, identify the workload with performance_schema.metadata_locks, and schedule around it.
Permanent Fix / Long-Term Pattern
Treat cut-over as a separate, scheduled deployment step. Start every gh-ost run with --postpone-cut-over-flag-file, let the copy run for as long as it needs under throttle (see throttling gh-ost on replica lag), and release the cut-over from your deploy tooling in a window when the table is quietest. Keep --cut-over-lock-timeout-seconds short — two or three seconds — so a failed attempt is barely visible, and rely on retries rather than a long wait. Make sure the application tolerates a brief write stall: pool checkout and request timeouts should exceed the cut-over lock timeout.
After the swap, keep the old table as _tbl_del for a defined period rather than dropping it immediately; it is your fastest rollback if the new schema causes problems. Drop it later during a quiet window, remembering that dropping a very large InnoDB table can itself cause a stall on some versions and file systems. Record the cut-over time in the deploy log so any error spike can be correlated, as described in monitoring long-running migrations in production.
-- MySQL 8.0 · rollback within the retention window · requires ALTER and DROP on the schema
-- WARNING: writes made to the new table since cut-over are NOT in _orders_del; reconcile before swapping back.
RENAME TABLE orders TO _orders_failed, _orders_del TO orders;
-- ROLLBACK PATH: RENAME TABLE orders TO _orders_del, _orders_failed TO orders;
Verification Checklist
Frequently Asked Questions
Is gh-ost’s cut-over really atomic?
With the default cut-over mode, yes: the combined RENAME TABLE swaps both names in one operation, so no query ever sees the table missing. Writers are blocked briefly while the lock is held, and queued writes land on the new table afterwards.
What happens to application writes during a failed attempt?
They wait for up to --cut-over-lock-timeout-seconds, then proceed against the original table when gh-ost releases its locks. Nothing is lost; the ghost table continues to receive those changes through the binlog, and gh-ost tries again later.
Should I use --cut-over=two-step?
Generally not. It renames the original table away and then renames the ghost into place as two operations, leaving a short window in which the table does not exist and queries fail. The default atomic mode avoids that.
When can I drop the _tbl_del table?
Once you are confident you will not need to swap back — typically after the application has run on the new schema through a normal traffic cycle. Before dropping, remember that any rollback would need to reconcile writes made to the new table since the cut-over.