Linting MySQL Migrations for Online DDL Compatibility
PostgreSQL teams have dedicated migration linters; MySQL teams mostly have code review. That gap matters because MySQL’s online DDL behaviour is subtle: the same ALTER TABLE ... MODIFY can be instant, in-place or a full table copy depending on the column, the character set and the version, and without explicit clauses MySQL silently picks the best algorithm it can — including a blocking copy. The most effective MySQL lint rule is therefore procedural rather than clever: every ALTER TABLE must state ALGORITHM and LOCK, so the server itself refuses anything that cannot run the way the author intended. This guide builds a small MySQL lint suite around that idea, adds the other rules that prevent common MySQL migration incidents, and connects it to a dry run that lets MySQL validate the clauses. It belongs to Migration Linting & Static Analysis.
Symptom / Error Signatures
MySQL migrations that would have benefited from linting cause:
- A “quick” column change that ran as
copy to tmp tablefor an hour, blocking writes. - A three-statement migration that failed on its third statement, leaving the first two committed and the migration tool marked dirty.
- A processlist full of
Waiting for table metadata lockbehind anALTERwaiting on a long transaction. - Replicas hours behind after an in-place index build on a very large table.
With the rules in place, the failures move to CI:
db/migrations/V61__widen_name.sql:1: error: explicit-algorithm-lock: ALTER TABLE customers lacks ALGORITHM= and LOCK=
ERROR 1846 (0A000): ALGORITHM=INPLACE is not supported. Reason: Cannot change column type INPLACE. Try ALGORITHM=COPY.
Root Cause Analysis
MySQL chooses the least restrictive algorithm it can for each ALTER TABLE unless told otherwise, and the choice depends on details a reviewer rarely checks: whether a VARCHAR change crosses the 255-byte length-prefix boundary, whether an ENUM member is appended or inserted, whether a column is indexed, the MySQL minor version. When you specify ALGORITHM=INSTANT or ALGORITHM=INPLACE, LOCK=NONE, the server validates the request and fails with a clear error if it cannot comply, instead of falling back. That makes the most reliable “linter” the server itself — provided every statement asks.
Two other properties of MySQL DDL shape the rules. Each DDL statement commits implicitly, so multi-statement migration files can be left half-applied, as discussed in avoiding implicit commits in MySQL DDL migrations. And every DDL needs an exclusive metadata lock briefly, with a default lock_wait_timeout of one year.
Immediate Mitigation
1. Add the explicit-clauses rule. A simple parser-free check is acceptable here because the rule is about the presence of clauses in ALTER TABLE statements; strip comments first.
# Python 3.11 · scripts/mysql_migration_lint.py · CI step over changed .sql files
# WARNING: heuristic statement splitting; keep migrations simple (one DDL per file) for accuracy.
import re, sys
def strip_comments(sql):
sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.S)
return re.sub(r"(--|#)[^\n]*", " ", sql)
failed = False
for path in sys.argv[1:]:
sql = strip_comments(open(path).read())
stmts = [s.strip() for s in sql.split(";") if s.strip()]
ddl = [s for s in stmts if re.match(r"(?i)(ALTER|CREATE|DROP|RENAME)\s", s)]
if len(ddl) > 1:
print(f"{path}: error: one-ddl-per-file: {len(ddl)} DDL statements"); failed = True
for s in ddl:
if re.match(r"(?i)ALTER\s+TABLE", s) and not (re.search(r"(?i)ALGORITHM\s*=", s) and re.search(r"(?i)LOCK\s*=", s)):
print(f"{path}: error: explicit-algorithm-lock: {s[:60]}…"); failed = True
if ddl and not re.search(r"(?i)SET\s+SESSION\s+lock_wait_timeout", sql):
print(f"{path}: error: lock-wait-timeout: set lock_wait_timeout before DDL"); failed = True
sys.exit(1 if failed else 0)
2. Dry-run against a scratch MySQL of the same version with production’s schema loaded (mysqldump --no-data), so the server validates every ALGORITHM/LOCK clause.
# Shell · CI job · MySQL service container matching production's version
mysql -h 127.0.0.1 -uroot -e "CREATE DATABASE shop"
mysql -h 127.0.0.1 -uroot shop < prod_schema.sql
for f in $CHANGED_MIGRATIONS; do mysql -h 127.0.0.1 -uroot shop < "$f" || exit 1; done
3. Add a large-table routing rule. Keep a list of tables above a size threshold (exported from information_schema.TABLES on production) and fail any native ALTER TABLE against them, requiring the change to be expressed as a gh-ost job instead.
Permanent Fix / Long-Term Pattern
Make the migration template satisfy the rules by default: every new MySQL migration file starts with SET SESSION lock_wait_timeout = 5; and contains one ALTER TABLE ... , ALGORITHM=..., LOCK=...;. Run the static rules and the dry run on every pull request, and route large-table changes through an online schema change tool, configured as described in throttling gh-ost on replica lag or, for Skeema users, via alter-wrapper-min-size in managing MySQL schemas with Skeema.
-- MySQL 8.0 · migration template · one DDL, explicit online clauses
-- WARNING: if MySQL rejects the clauses, redesign or route through gh-ost; do not just remove them.
SET SESSION lock_wait_timeout = 5;
ALTER TABLE customers ADD COLUMN loyalty_tier VARCHAR(16) NULL, ALGORITHM=INSTANT;
-- ROLLBACK PATH: ALTER TABLE customers DROP COLUMN loyalty_tier, ALGORITHM=INSTANT;
Atlas’s migrate lint also supports MySQL and can complement these rules with destructive-change and data-dependent analyzers, as noted in Migration Linting & Static Analysis. Review the large-table list quarterly as tables grow.
Keep the dry-run environment faithful. The scratch MySQL must match production’s major and minor version, because instant and in-place support has expanded across 8.0 releases — ALGORITHM=INSTANT for adding columns anywhere in the table and for dropping columns arrived in 8.0.29, for example — and its innodb_* defaults, SQL mode and character set should match too. Pin the container image to the exact production version and update it with production upgrades, so a change that the dry run accepts is one production will accept as well.
Verification Checklist
Frequently Asked Questions
Why require ALGORITHM and LOCK if MySQL picks the best algorithm anyway?
Because “best available” includes a blocking copy. Stating the algorithm you expect makes MySQL fail fast when it cannot deliver it, instead of silently locking the table.
Is a regex-based checker good enough for MySQL? For presence-of-clause rules on simple, one-statement migration files, usually yes. For more complex rules, use a proper MySQL parser or Atlas’s analyzers.
Why one DDL statement per file? MySQL commits each DDL statement implicitly, so a failure partway through a multi-statement file leaves it half-applied. One statement per file makes every migration all-or-nothing.
Does this replace review of MySQL migrations? No. The rules guarantee that each statement asks for online behaviour and that MySQL confirmed it can deliver it; reviewers still judge whether the change belongs in this release and whether running code tolerates it.
What threshold should the large-table rule use?
Base it on measured build times: tables whose in-place ALTER would take more than a few minutes, or whose replicas serve reads that cannot tolerate that much lag. Many teams start around 5–10 GB.