Zero-Downtime Database Migration Guide
Alter tables without locking users out. Production-safe schema change strategies for PostgreSQL, MySQL, SQLite, SQL Server, and Oracle — with copy-paste ready scripts.
Why Standard Migrations Cause Downtime
Most ALTER TABLE statements need an exclusive lock on the table. While the lock is held, reads may be blocked and writes are definitely blocked. On large tables, the alteration can take minutes or hours — effectively taking your application offline.
Here is what typically locks a table:
- Adding a column with a
DEFAULTvalue (rewrites the entire table on some databases) - Changing a column type
- Adding an index the standard way
- Adding a
FOREIGN KEY(validates every row) - Dropping a column
- Renaming a column or table
The good news: many of these can be done online if you use the right syntax or the right tool.
PostgreSQL — The Friendliest for Online Changes
PostgreSQL has the best out-of-the-box support for online schema changes among open-source databases.
Add a Column (No Lock)
-- Adding a nullable column is instant and lock-free ALTER TABLE users ADD COLUMN preferences JSONB; -- Adding with DEFAULT rewrites the table in PG < 11. -- In PG 11+, this is also fast (metadata-only). ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
Create Index Without Locking
-- Standard CREATE INDEX locks writes. Use CONCURRENTLY instead. CREATE INDEX CONCURRENTLY idx_users_email ON users(email); -- Drop index concurrently too DROP INDEX CONCURRENTLY idx_users_email;
CONCURRENTLY takes longer and uses more CPU, but it never acquires an exclusive lock. If it fails (e.g., duplicate values on a unique index), you get an invalid index that you must drop and retry.
Add Foreign Key Without Long Locks
-- Step 1: Add the FK as NOT VALID (no row scan, minimal lock) ALTER TABLE orders ADD CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID; -- Step 2: Validate in a separate transaction (SHARE UPDATE EXCLUSIVE lock) ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_user_id;
VALIDATE CONSTRAINT still needs a lock, but it is much weaker than the lock required by a full ADD FOREIGN KEY. Run it during low traffic.
Change a Column Type (Requires Rewrite)
-- Changing column type usually rewrites the table. -- There is no built-in online tool, but you can use the expand/contract pattern: -- Step 1: Add new column ALTER TABLE products ADD COLUMN price_cents BIGINT; -- Step 2: Backfill in batches UPDATE products SET price_cents = (price * 100)::BIGINT WHERE id BETWEEN 1 AND 10000; -- ... repeat in batches -- Step 3: Add trigger to keep in sync CREATE OR REPLACE FUNCTION sync_price_cents() RETURNS TRIGGER AS $$ BEGIN NEW.price_cents = (NEW.price * 100)::BIGINT; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_sync_price_cents BEFORE INSERT OR UPDATE ON products FOR EACH ROW EXECUTE FUNCTION sync_price_cents(); -- Step 4: Switch application to use new column -- Step 5: Drop old column ALTER TABLE products DROP COLUMN price;
Third-Party Tools
- pg_repack — Rebuilds bloated tables online. Can also add columns/types by rebuilding.
- pg_squeeze — Similar to pg_repack, works via logical decoding (no triggers).
MySQL — Use pt-online-schema-change or gh-ost
MySQL's native ALTER TABLE is historically table-locking. InnoDB supports online DDL in 5.6+, but coverage is spotty and large-table rewrites still lock.
Native Online DDL (MySQL 5.6+ / MariaDB 10.0+)
-- Add column online ALTER TABLE users ADD COLUMN preferences JSON, ALGORITHM=INPLACE, LOCK=NONE; -- Create index online ALTER TABLE users ADD INDEX idx_email (email), ALGORITHM=INPLACE, LOCK=NONE; -- Add foreign key online (InnoDB only) ALTER TABLE orders ADD CONSTRAINT fk_user_id FOREIGN KEY (user_id) REFERENCES users(id), ALGORITHM=INPLACE, LOCK=NONE;
ALGORITHM=INPLACE. Changing a column type, dropping a primary key, or converting a character set still requires a table copy (locks). Check SHOW ENGINE INNODB STATUS during the migration.
pt-online-schema-change (Percona Toolkit)
# Install: apt-get install percona-toolkit (or download from percona.com) # Add a column to a large table pt-online-schema-change \ --alter "ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'active'" \ --execute \ --max-load Threads_running=25 \ --critical-load Threads_running=50 \ D=mydb,t=users # Add an index pt-online-schema-change \ --alter "ADD INDEX idx_email (email)" \ --execute \ D=mydb,t=users # Drop a column pt-online-schema-change \ --alter "DROP COLUMN old_field" \ --execute \ D=mydb,t=users
--dry-run first to validate the migration without executing it. Use --max-load and --critical-load to throttle or abort if the database is under heavy load.
gh-ost (GitHub's Triggerless Tool)
# Download from https://github.com/github/gh-ost/releases # Add a column gh-ost \ --database="mydb" \ --table="users" \ --alter="ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'active'" \ --user="root" \ --password="secret" \ --host="localhost" \ --execute # Throttle if replication lag > 3 seconds gh-ost \ --database="mydb" \ --table="users" \ --alter="ADD INDEX idx_email (email)" \ --user="root" \ --password="secret" \ --host="localhost" \ --max-lag-millis=3000 \ --execute
SQLite — Work Around Limited ALTER TABLE
SQLite supports only a tiny subset of ALTER TABLE: RENAME TABLE, ADD COLUMN (with restrictions), DROP COLUMN (3.35.0+), and RENAME COLUMN (3.25.0+). Everything else requires recreating the table.
Safe Table Recreation
-- SQLite: change a column type (requires recreation) -- Step 1: Create new table CREATE TABLE users_new ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL, preferences JSONB -- new/changed column ); -- Step 2: Copy data INSERT INTO users_new (id, name, email, preferences) SELECT id, name, email, NULL FROM users; -- Step 3: Drop old table DROP TABLE users; -- Step 4: Rename ALTER TABLE users_new RENAME TO users; -- Step 5: Recreate indexes and triggers CREATE INDEX idx_users_email ON users(email);
Add Column (Native, Limited)
-- SQLite can add columns, but with restrictions: -- - Cannot add PRIMARY KEY, UNIQUE, or FOREIGN KEY -- - Cannot add NOT NULL without DEFAULT ALTER TABLE users ADD COLUMN preferences TEXT;
SQL Server — ONLINE=ON and Resumable Operations
SQL Server Enterprise Edition supports online index operations and resumable index builds. Standard Edition is more limited.
Create Index Online
-- Online index build (Enterprise Edition) CREATE INDEX idx_users_email ON users(email) WITH (ONLINE = ON); -- Resumable index build (SQL Server 2017+) CREATE INDEX idx_users_email ON users(email) WITH (ONLINE = ON, RESUMABLE = ON, MAX_DURATION = 5 MINUTES); -- Resume later if interrupted ALTER INDEX idx_users_email ON users RESUME; ALTER INDEX idx_users_email ON users PAUSE; ALTER INDEX idx_users_email ON users ABORT;
Add Column Online
-- Adding a nullable column is metadata-only and instant ALTER TABLE users ADD COLUMN preferences NVARCHAR(MAX) NULL; -- Adding with DEFAULT may lock. Use this pattern for large tables: -- 1. Add column nullable (instant) ALTER TABLE users ADD COLUMN status NVARCHAR(20) NULL; -- 2. Backfill in small batches UPDATE TOP (10000) users SET status = 'active' WHERE status IS NULL; -- Repeat until complete -- 3. Add DEFAULT constraint (metadata-only) ALTER TABLE users ADD CONSTRAINT df_users_status DEFAULT 'active' FOR status; -- 4. Optionally make NOT NULL after backfill ALTER TABLE users ALTER COLUMN status NVARCHAR(20) NOT NULL;
Add Foreign Key with Minimal Locking
-- SQL Server checks all rows by default. -- For large tables, first create a trusted CHECK constraint, -- then add the FK WITH NOCHECK to skip validation. -- Step 1: Add a CHECK constraint that enforces the FK rule ALTER TABLE orders WITH CHECK ADD CONSTRAINT chk_orders_user_id CHECK (user_id IN (SELECT id FROM users)); -- Step 2: Add FK with NOCHECK (no row scan) ALTER TABLE orders WITH NOCHECK ADD CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) REFERENCES users(id); -- Step 3: Make the FK trusted (optional, for query optimizer) ALTER TABLE orders WITH CHECK CHECK CONSTRAINT fk_orders_user_id;
WITH NOCHECK skips validation. If orphan rows exist, your data integrity is compromised. Only use this if you have already verified referential integrity.
Oracle — DBMS_REDEFINITION and Editioning Views
Oracle has the most mature online redefinition toolkit, but it is also the most complex.
DBMS_REDEFINITION (Online Table Redefinition)
-- Step 1: Verify the table can be redefined
BEGIN
DBMS_REDEFINITION.CAN_REDEF_TABLE('SCOTT', 'EMPLOYEES');
END;
/
-- Step 2: Create an interim table with the new schema
CREATE TABLE employees_new (
id NUMBER PRIMARY KEY,
name VARCHAR2(100) NOT NULL,
email VARCHAR2(100),
status VARCHAR2(20) DEFAULT 'active' -- new column
);
-- Step 3: Start redefinition
BEGIN
DBMS_REDEFINITION.START_REDEF_TABLE(
uname => 'SCOTT',
orig_table => 'EMPLOYEES',
int_table => 'EMPLOYEES_NEW',
col_mapping => NULL,
options_flag => DBMS_REDEFINITION.CONS_USE_PK
);
END;
/
-- Step 4: Sync changes (run periodically during migration)
BEGIN
DBMS_REDEFINITION.SYNC_INTERIM_TABLE('SCOTT', 'EMPLOYEES', 'EMPLOYEES_NEW');
END;
/
-- Step 5: Finish redefinition (atomic swap)
BEGIN
DBMS_REDEFINITION.FINISH_REDEF_TABLE('SCOTT', 'EMPLOYEES', 'EMPLOYEES_NEW');
END;
/
-- Step 6: Drop interim table
DROP TABLE employees_new;
Online Index Rebuild
-- Rebuild an index online ALTER INDEX idx_employees_email REBUILD ONLINE; -- Create index online CREATE INDEX idx_employees_email ON employees(email) ONLINE;
Edition-Based Redefinition (Advanced)
For applications that must remain available during complex structural changes, Oracle's Edition-Based Redefinition (EBR) allows you to install a new version of your schema in a separate edition while the old edition continues to serve traffic. After testing, you switch sessions to the new edition.
-- Create a new edition CREATE EDITION edition_v2; -- Switch to it ALTER SESSION SET EDITION = edition_v2; -- Apply schema changes in this edition only ALTER TABLE employees ADD COLUMN status VARCHAR2(20); -- Hot-rollover: new sessions get edition_v2, existing sessions stay on old edition
The Expand/Contract Pattern
The most database-agnostic way to do zero-downtime migrations is the expand/contract pattern (also called blue/green or parallel change). It works on every database, including SQLite.
How It Works
- Expand: Add the new column/table/index. Old code still works.
- Dual-write: Update application code to write to both old and new structures.
- Backfill: Migrate existing data in small batches.
- Switch reads: Point read queries to the new structure.
- Remove old writes: Stop writing to the old structure.
- Contract: Drop the old column/table/index.
Example: Rename a Column
-- Step 1: Add new column (expand) ALTER TABLE users ADD COLUMN user_email VARCHAR(255); -- Step 2: Backfill in batches UPDATE users SET user_email = email WHERE user_email IS NULL LIMIT 10000; -- Repeat until done -- Step 3: Add trigger to keep in sync CREATE TRIGGER trg_sync_email BEFORE INSERT OR UPDATE ON users FOR EACH ROW BEGIN NEW.user_email = COALESCE(NEW.user_email, NEW.email); NEW.email = COALESCE(NEW.email, NEW.user_email); END; -- Step 4: Update application to read from user_email -- Step 5: Stop writing to old 'email' column -- Step 6: Drop old column (contract) ALTER TABLE users DROP COLUMN email;
Pre-Deploy Safety Checklist
- Run the migration on a staging database with production-like data size
- Measure lock time with
performance_schema(MySQL) orpg_stat_activity(PostgreSQL) - Test rollback script — know how to undo the change in < 5 minutes
- Schedule the migration during lowest traffic hours
- Notify the team; have a second engineer on standby
- Verify backups are current and restorable
- Use
pt-online-schema-change --dry-runorgh-ost --test-on-replicafirst - Monitor replication lag during and after the migration
- Have a runbook for aborting mid-migration
Common Pitfalls
- Assuming "online" means zero impact. Online migrations still use CPU, I/O, and disk space. Monitor resource usage.
- Running migrations directly on the primary. For MySQL, consider running
gh-oston a replica first with--test-on-replica. - Adding a DEFAULT to a large table on old PostgreSQL. Before PG 11, this rewrites the entire table. Upgrade or use the expand/contract pattern.
- Missing index recreation after SQLite table rebuild. Always diff the schema before and after to catch dropped objects.
- Foreign key validation on a busy table. Even
VALIDATE CONSTRAINTcan spike I/O. Run during low traffic or useNOT VALID+ application-level checks.
Verify Your Migrations with SchemaLens
Before you deploy, paste your old and new schemas into SchemaLens to catch:
- Breaking changes that could crash your app
- Missing indexes after a table recreation
- Column type changes that truncate data
- Rollback scripts for every forward change