Writing Idempotent MySQL Migrations Without IF NOT EXISTS
A MySQL migration with three ALTER TABLE statements failed on the third because of a lock-wait timeout. The runner retried it, and the retry failed on the first statement: ERROR 1060 (42S21): Duplicate column name 'region'. The first two statements had already committed — MySQL DDL commits implicitly — so the migration is now half-applied and cannot be rerun. On PostgreSQL you would reach for ADD COLUMN IF NOT EXISTS; on MariaDB too. MySQL 8.0 supports neither ADD COLUMN IF NOT EXISTS nor CREATE INDEX IF NOT EXISTS, so idempotency has to be built by hand. This guide shows the guard patterns that make MySQL migrations safe to rerun, applying the principles of Idempotent Script Design to an engine that gives you fewer tools.
Symptom / Error Signatures
A MySQL migration that is not idempotent fails on rerun with one of these:
ERROR 1060 (42S21): Duplicate column name 'region'
ERROR 1061 (42000): Duplicate key name 'idx_orders_region'
ERROR 1091 (42000): Can't DROP 'legacy_code'; check that column/key exists
ERROR 1826 (HY000): Duplicate foreign key constraint name 'fk_orders_customer'
ERROR 3822 (HY000): Duplicate check constraint name 'chk_orders_amount'
In a migration tool the same failure appears as a failed version that the tool will not move past — Flyway marks it failed in flyway_schema_history, golang-migrate sets the dirty flag — and an operator must repair it by hand. That manual repair, under incident pressure, is where most migration mistakes happen.
Root Cause Analysis
MySQL executes each DDL statement in its own implicit transaction: it commits any open transaction before the statement and commits the statement’s effect immediately after. A multi-statement migration is therefore a sequence of independently committed steps, and a failure on step N leaves steps 1 to N−1 applied, as described in avoiding implicit commits in MySQL DDL migrations. Retrying from the top then collides with what already exists.
Idempotency fixes this without transactions: if every statement checks whether its effect is already present and does nothing if so, rerunning the whole migration converges on the same end state regardless of where the previous attempt stopped. MySQL’s catalog in information_schema has everything needed for those checks — COLUMNS, STATISTICS for indexes, TABLE_CONSTRAINTS and REFERENTIAL_CONSTRAINTS for constraints. The awkward part is that plain SQL has no conditional DDL, so the check must decide which statement to run via a prepared statement or a stored procedure.
Immediate Mitigation
1. Repair the half-applied migration by finishing it, not reverting it. Compare what the migration intended with what exists, and run only the missing steps by hand. Then mark the version as applied in the tool’s history (for Flyway, flyway repair removes the failed entry so the fixed file can run again; for golang-migrate, migrate force <version> clears the dirty flag).
2. Guard column changes with a catalog check and a prepared statement. The pattern chooses between the real DDL and a harmless no-op:
-- MySQL 8.0 · migration session · requires ALTER on shop.orders and SELECT on information_schema
-- WARNING: DDL still commits implicitly; the guard only makes a rerun safe, not the step atomic.
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'orders' AND COLUMN_NAME = 'region') = 0,
'ALTER TABLE orders ADD COLUMN region VARCHAR(32) NULL, ALGORITHM=INSTANT',
'DO 0');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ROLLBACK PATH: same guard with COUNT(*) = 1 and 'ALTER TABLE orders DROP COLUMN region'.
3. Guard indexes and constraints the same way. Index names live in information_schema.STATISTICS; foreign keys and check constraints in TABLE_CONSTRAINTS.
-- MySQL 8.0 · migration session · online index build, guarded by name
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'orders' AND INDEX_NAME = 'idx_orders_region') = 0,
'ALTER TABLE orders ADD INDEX idx_orders_region (region), ALGORITHM=INPLACE, LOCK=NONE',
'DO 0');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ROLLBACK PATH: guarded 'ALTER TABLE orders DROP INDEX idx_orders_region, ALGORITHM=INPLACE, LOCK=NONE'.
4. Keep one DDL statement per migration where the tool allows it. Even with guards, a migration that contains one DDL statement has only two states — applied or not — which makes failures trivial to reason about.
Permanent Fix / Long-Term Pattern
Wrap the guard in a small set of stored procedures created once by the migration tooling, so individual migrations read cleanly and cannot get the catalog query subtly wrong. A procedure such as add_column_if_missing(table, column, definition) builds and executes the statement only when the column is absent. Install the helpers in a schema the migration role owns, and version them like any other migration.
-- MySQL 8.0 · one-time helper, installed by an early migration · requires CREATE ROUTINE
-- WARNING: the definition string is concatenated into DDL; call it only from reviewed migrations.
DELIMITER //
CREATE PROCEDURE add_column_if_missing(IN p_table VARCHAR(64), IN p_column VARCHAR(64), IN p_def TEXT)
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table AND COLUMN_NAME = p_column) THEN
SET @ddl = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_def);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
END IF;
END //
DELIMITER ;
CALL add_column_if_missing('orders', 'region', 'VARCHAR(32) NULL, ALGORITHM=INSTANT');
-- ROLLBACK PATH: DROP PROCEDURE IF EXISTS add_column_if_missing;
Name-based guards check that something with the name exists, not that it matches the intended definition. Pair them with a post-migration verification — compare the resulting schema against the expected one, as in detecting production schema drift against a desired state — so an index with the right name and wrong columns does not slip through. Test the whole approach by running every migration twice in CI, as described in how to write idempotent SQL scripts for safe deploys.
Verification Checklist
Frequently Asked Questions
Does MySQL 8.0 support ADD COLUMN IF NOT EXISTS?
No. MySQL 8.0 supports IF NOT EXISTS and IF EXISTS for creating and dropping tables, databases, views and some other objects, but not for adding or dropping columns and indexes. MariaDB does support them, which is a common source of confusion.
Why use DO 0 as the no-op?
PREPARE needs a valid statement in both branches. DO 0 evaluates an expression and returns nothing, takes no locks and causes no implicit commit, so it is the cheapest possible placeholder.
Can I wrap the MySQL migration in a transaction instead?
No. DDL statements cause an implicit commit before and after they run, so a surrounding START TRANSACTION does not make them atomic. Idempotent guards are the reliable substitute.
Do these guards affect online DDL?
No. The guard runs a quick catalog query and then executes the same ALTER TABLE you would have written, including its ALGORITHM and LOCK clauses. Metadata-lock behaviour and the need for a short lock_wait_timeout are unchanged.