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:

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;
Warning: 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;
Tip: 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

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;
Warning: Not all operations support 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
Tip: Always run with --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
Why gh-ost over pt-online-schema-change? gh-ost does not use triggers. It reads the binary log to catch ongoing changes, which is safer on very busy tables and avoids trigger-related performance degradation.

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);
Critical: SQLite table recreation drops indexes, triggers, and views that reference the old table. You must script their recreation. SchemaLens can diff your before/after schemas to verify nothing was lost.

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;
Warning: 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

  1. Expand: Add the new column/table/index. Old code still works.
  2. Dual-write: Update application code to write to both old and new structures.
  3. Backfill: Migrate existing data in small batches.
  4. Switch reads: Point read queries to the new structure.
  5. Remove old writes: Stop writing to the old structure.
  6. 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;
Tip: Never drop the old structure in the same deploy that adds the new one. Always keep at least one deploy cycle where both exist. This lets you roll back instantly if something breaks.

Pre-Deploy Safety Checklist

Common Pitfalls

Verify Your Migrations with SchemaLens

Before you deploy, paste your old and new schemas into SchemaLens to catch:

Compare Schemas Free Check Migration Safety

Related Migration Guides