Home Database SQL ALTER TABLE: 7 Safe Add Column Moves That Work
Beginner 3 min · September 07, 2026

SQL ALTER TABLE: 7 Safe Add Column Moves That Work

ADD COLUMN NOT NULL locked writes 22 minutes on 900M rows.

N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Lessons pulled from things that broke in production.

Follow
Production
production tested
September 22, 2026
last updated
1,799
articles · all by Naren
Before you start⏱ 12 min
  • CREATE TABLE and basic column types
  • NULL vs NOT NULL and DEFAULT concepts
  • Running migrations in staging before production
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • ALTER TABLE ADD COLUMN appends a column to every row — fast as metadata-only when nullable, expensive when backfilling defaults or constraints
  • Safe paths: nullable add + batched backfill + later NOT NULL (MySQL), or DEFAULT-added instant columns on Postgres 11+ with batched validation
  • Performance insight: Postgres 11+ adds DEFAULT columns in ~5ms regardless of size; MySQL 5.7 rebuilt a 900M-row table and locked writes for 22 minutes
  • Production insight: a single ADD COLUMN NOT NULL DEFAULT on MySQL 5.7 queued 40k writes and paged the on-call at 3 AM
  • Rule: add nullable, backfill in 10k batches, validate, then add constraints — never one-step NOT NULL on a big table
✦ Definition~90s read
What is SQL ALTER TABLE Add Column?

ALTER TABLE ... ADD COLUMN changes a table's schema by appending a column definition (name, type, nullability, default, constraints). Small tables apply it in milliseconds; huge tables may rewrite every row, validate constraints across all data, or hold locks blocking writes.

Adding a column is like adding a new field to every form in a filing cabinet with 900 million folders.

The classic Stack Overflow question asks for the ALTER syntax and the NOT NULL story. Behavior splits by engine and version: PostgreSQL 11+ treats ADD COLUMN with a constant DEFAULT as metadata-only (instant, no rewrite); MySQL 8.0 with ALGORITHM=INPLACE avoids full rebuilds for many adds but still validates; older MySQL copies the whole table.

The universal safe pattern — add nullable, backfill in batches, then constrain — works on all of them.

Plain-English First

Adding a column is like adding a new field to every form in a filing cabinet with 900 million folders. Writing 'optional: ___' on each form's template takes seconds — that's a nullable column. But demanding every old form be filled in immediately means opening all 900 million folders at once — that's NOT NULL with a default on an old database. Smart offices add the blank field first, fill folders in nightly batches, and only then stamp it 'required.'

You need one new column — a loyalty tier, a consent flag, a retry counter. ALTER TABLE ADD COLUMN looks like a one-liner, and on your laptop it is. Then you run it on the 900M-row orders table and writes freeze.

The gap between small and big tables is a cliff. Newer Postgres versions add defaults instantly, older MySQL rebuilds the entire table, and NOT NULL constraints force validation scans everywhere. Same statement, wildly different blast radius.

Don't fear it. You'll learn seven moves — the safe expand-migrate-contract sequence, batched backfills, and the per-dialect instant paths — that make schema changes boring again.

Syntax You'll Use Weekly (and the NOT NULL Catch)

The base form is ALTER TABLE orders ADD COLUMN loyalty_tier VARCHAR(20). Nullable by default, fast nearly everywhere, safe to run even on big tables. Variants add DEFAULT 'bronze', NOT NULL, CHECK constraints, and Postgres' IF NOT EXISTS guard.

The catch: NOT NULL on a populated table demands a value for every existing row. Without DEFAULT it errors; with DEFAULT old engines materialize it into all rows (the 22-minute rebuild). New Postgres shortcuts constant defaults as metadata — but validation scans for CHECK/NOT NULL still read the table.

Default habit: write the nullable add first, always. Constraints are a second migration after backfill, not part of the first statement. Two small deploys beat one heroic ALTER.

📊 Production Insight
The incident ALTER combined three operations (add + default + not-null) in one line. Split across three deploys, each step would have been trivially safe.
🎯 Key Takeaway
Nullable ADD first; constraints later. One-step NOT NULL is a small-table luxury.

Why Engines Differ: Instant vs Rebuild in 2 Minutes

PostgreSQL 11+ stores a constant DEFAULT as table metadata — ADD COLUMN ... DEFAULT 'bronze' completes in ~5ms on any size because existing rows read the default lazily. Nullable adds were always metadata-only. Only volatile defaults (now(), random()) or later constraint validation force real work.

MySQL 8.0 with ALGORITHM=INPLACE handles many nullable adds and some defaulted adds without rebuilds — but specify the algorithm explicitly, or the engine may silently pick COPY. MySQL 5.7 rebuilds for NOT NULL + DEFAULT every time: 900M rows copied, indexes rebuilt, writes locked.

SQL Server adds nullable columns quickly but backfills defaults row-by-row on older versions (with ONLINE options on Enterprise). Know your version's row in this table before scheduling — the engine, not the SQL, sets the blast radius.

add_column_safe.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- Step 1 (all engines): nullable add — fast, safe
ALTER TABLE orders ADD COLUMN loyalty_tier VARCHAR(20);
-- Postgres idempotent variant:
-- ALTER TABLE orders ADD COLUMN IF NOT EXISTS loyalty_tier VARCHAR(20);

-- Step 2: backfill in batches (repeat with rising id windows)
UPDATE orders SET loyalty_tier = 'bronze'
WHERE id BETWEEN 1 AND 10000 AND loyalty_tier IS NULL;

-- Step 3 (after backfill + deploy): constrain
-- Postgres instant-ish path (11+):
-- ALTER TABLE orders ALTER COLUMN loyalty_tier SET DEFAULT 'bronze';
-- ALTER TABLE orders ALTER COLUMN loyalty_tier SET NOT NULL;
-- MySQL explicit algorithm:
-- ALTER TABLE orders ADD COLUMN loyalty_tier VARCHAR(20),
--   ALGORITHM=INPLACE, LOCK=NONE;
📊 Production Insight
Same logical change: ~5ms on Postgres 15, 22 minutes on MySQL 5.7. Version awareness is the whole ballgame.
🎯 Key Takeaway
Postgres 11+ defaults are instant; MySQL needs INPLACE or online tools; old engines rebuild.

The Expand-Migrate-Contract Sequence (Zero Downtime)

Rolling deploys mean old and new code coexist. Expand: add the nullable column — both versions run fine (old ignores it, new tolerates NULL). Migrate: backfill data in batches and deploy code writing both old and new paths (dual-write). Contract: once all hosts run new code and NULLs hit zero, add NOT NULL/DEFAULT and drop the old path.

Skipping steps causes the classic breakage: requiring the column before old hosts deploy crashes them on INSERT (unknown column), and backfilling before code tolerates NULL corrupts reads.

Write the three steps as three tickets with three deploy windows. Schema changes are release trains, not single commits — each phase verified before the next departs.

📊 Production Insight
Post-incident, every column addition ships as expand/migrate/contract tickets. Zero column-related deploy failures in 14 months since.
🎯 Key Takeaway
Expand (nullable) → migrate (backfill + dual-write) → contract (constrain). Never skip phases.

Batched Backfills That Don't Lock the Table

Backfill with keyset windows, not one giant UPDATE: UPDATE orders SET loyalty_tier='bronze' WHERE id BETWEEN $a AND $b AND loyalty_tier IS NULL LIMIT 10000, committing per batch with sleeps between. Each batch holds locks briefly; progress survives interruption.

Size batches by replication lag, not just speed: watch replica lag after each batch and pause when it exceeds 5s. A backfill that outruns replicas trades a fast migration for stale reads and failover risk.

Verify continuously: SELECT count(*) WHERE loyalty_tier IS NULL trending to zero, plus spot-checks on value distribution. The last 1% (new rows inserted mid-backfill) gets caught by the DEFAULT or a final sweep before the NOT NULL step.

backfill_batches.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- Batched backfill: 10k rows per statement, committed per batch
UPDATE orders
SET loyalty_tier = 'bronze'
WHERE loyalty_tier IS NULL
ORDER BY id
LIMIT 10000;
-- Repeat (script loop) until affected-rows = 0, sleeping 0.5s between.
-- Monitor: SHOW SLAVE STATUS (lag) / pg_stat_replication.

-- Progress check between batches
SELECT count(*) AS remaining_nulls
FROM orders WHERE loyalty_tier IS NULL;

-- Catch concurrent inserts: ensure default before constraining
ALTER TABLE orders ALTER COLUMN loyalty_tier SET DEFAULT 'bronze';
💡Throttle by replica lag, not gut feel
After each batch, check replica lag. Over 5 seconds? Pause. Backfills are marathons — finishing an hour later beats lagging replicas and stale checkout reads.
📊 Production Insight
The safe rerun backfilled 900M rows over 3 nights in 10k batches. Longest lock held: 300ms. Checkout never noticed.
🎯 Key Takeaway
10k keyset batches + sleep + lag watch + NULL-count trending to zero.

Defaults, Checks, and Types That Bite

DEFAULT 'bronze' on the ADD is instant on modern Postgres but a rebuild trigger on old MySQL — prefer SET DEFAULT as a separate step after backfill so each engine takes its fast path. Volatile defaults (DEFAULT now()) always cost more; add them last with a maintenance window.

CHECK constraints (CHECK (tier IN (...))) validate every existing row on creation — a full scan even where the add was instant. Add the column first, backfill, then ADD CONSTRAINT with NOT VALID (Postgres) followed by VALIDATE CONSTRAINT to split the scan from the lock.

Type choice matters up front: VARCHAR(20) vs TEXT vs ENUM changes future ALTER costs. Prefer right-sized types now — widening later on 900M rows is its own migration saga.

📊 Production Insight
A CHECK added with the column scanned 900M rows under lock. NOT VALID + separate VALIDATE would have kept the lock under a second.
🎯 Key Takeaway
Defaults and checks are separate steps; NOT VALID splits scans from locks; size types right first time.

Online Tools and Lock Clauses for Big MySQL Tables

For MySQL tables over ~10M rows, raw ALTER is disallowed by policy on serious teams. pt-online-schema-change and gh-ost apply changes via triggers/ghosts with near-zero locking: reads and writes continue while the shadow table syncs, then a brief atomic swap.

When INPLACE suffices, still declare intent: ALTER TABLE ... ALGORITHM=INPLACE, LOCK=NONE fails fast if the engine can't comply, instead of silently rebuilding. LOCK=SHARED (reads ok, writes blocked) is the middle ground for changes that truly need it.

Postgres users aren't exempt: set statement_timeout and lock_timeout around DDL (SET lock_timeout = '5s') so a stuck ALTER aborts instead of queueing behind a report query and blocking everything behind it.

📊 Production Insight
The rollout guide now mandates pt-osc/gh-ost above 10M rows and explicit ALGORITHM/LOCK clauses always. Three big-table ALTERs since: zero lock incidents.
🎯 Key Takeaway
Big MySQL: online tools. Always: explicit ALGORITHM/LOCK. Postgres: lock_timeout around DDL.

Rollback Plan Before You Run Anything

Every ALTER ticket states three things: the reverse statement (ALTER TABLE orders DROP COLUMN loyalty_tier), the snapshot/backup ID it can restore from, and the backfill resume point if killed mid-batch. No plan, no run.

Prefer additive-only changes: a new nullable column rolls back with a DROP; a renamed or dropped column doesn't roll back at all. Renames are two changes (add new, dual-write, drop old) — never one.

Test the rollback on staging at scale, not just the rollforward. The incident's 9-minute rollback-of-copy delay was discovered during the fire; it should have been a line in the ticket.

⚠ DROP/RENAME have no undo button
Additive columns are reversible; destructive changes aren't. Renames and drops go through the full expand-migrate-contract cycle with dual-writes — or they don't go at all.
📊 Production Insight
Rollback rehearsal on staging revealed the 9-minute copy-rollback cost. The ticket now lists it, and maintenance windows are sized accordingly.
🎯 Key Takeaway
Reverse SQL + snapshot ID + resume point on every ticket. Rehearse rollback, not just rollout.
● Production incidentPOST-MORTEMseverity: high

The One-Line ALTER That Locked Orders for 22 Minutes

Symptom
At 03:04, order writes began timing out; the queue hit 40k pending inserts within 8 minutes. Checkout error rate climbed to 61%. The migration — a single ALTER adding loyalty_tier NOT NULL DEFAULT 'bronze' — held an exclusive metadata lock while copying 900M rows into a rebuilt table. Reads limped on; every write waited.
Assumption
The author tested the ALTER on a 50k-row staging copy (800ms) and assumed linear scaling — 'a second per million rows, maybe 15 minutes of background work.' The team assumed MySQL 5.7 would add the default lazily like Postgres 11, and that the deploy window's low traffic made any lock harmless.
Root cause
MySQL 5.7 executes this ADD COLUMN as a full table rebuild (ALGORITHM=COPY): 900M rows copied, secondary indexes rebuilt, exclusive lock held for 22 minutes. The NOT NULL + DEFAULT forced materializing the value into every row instead of a metadata change. No ALGORITHM/LOCK clause, no pt-online-schema-change, no replica-first rollout — just a raw ALTER at 3 AM with traffic still flowing.
Fix
Killed the ALTER at 03:26 (rollback of the copy took 9 more minutes), failed over writes to the replica, and replayed the 40k queued orders. Re-ran the change safely: ADD COLUMN nullable first (fast), backfilled in 10k batches over 3 nights, then ALTER to SET DEFAULT + validate NOT NULL off-peak. Upgraded the rollout guide to require pt-osc or 8.0 INPLACE with explicit LOCK=NONE for tables over 10M rows.
Key lesson
  • Never one-step NOT NULL+DEFAULT on big tables: add nullable, backfill in batches, constrain last — on every engine.
  • Stage at scale or gate by size: any ALTER on 10M+ rows needs an online-schema-change tool and explicit lock clauses.
Production debug guideFive schema-change failures and the exact recovery for each.5 entries
Symptom · 01
ALTER hangs with 'Waiting for table metadata lock' and writes pile up
Fix
A long transaction holds the lock. Find it (performance_schema.metadata_locks / pg_stat_activity), kill the blocker or the ALTER, then rerun off-peak with LOCK=NONE (MySQL) or short lock_timeout (Postgres). Never let ALTER queue behind analytics queries.
Symptom · 02
MySQL rebuilds the whole table for a simple add
Fix
Specify ALGORITHM=INPLACE, LOCK=NONE explicitly so it fails fast instead of silently copying. For 5.7 or huge tables, use pt-online-schema-change or gh-ost for non-blocking rollout.
Symptom · 03
ADD COLUMN .. NOT NULL fails on existing rows
Fix
Existing rows have no value — add nullable first, backfill (UPDATE in 10k batches), then ALTER to SET NOT NULL with a validation scan. One-step NOT NULL only works with a DEFAULT and still rewrites on old engines.
Symptom · 04
Deploys fail with 'column already exists' after a retried migration
Fix
Migrations aren't idempotent by default. Add IF NOT EXISTS (Postgres) or a pre-check against information_schema, and make the migration runner record applied versions so retries skip cleanly.
Symptom · 05
New column breaks old app servers during rolling deploy
Fix
Expand-migrate-contract violation: old code can't see the column. Add nullable (expand), deploy code that tolerates NULL, backfill, then constrain (contract). Never require the column before all hosts run the new code.
ADD COLUMN Costs by Engine
OperationPostgres 11+MySQL 8.0MySQL 5.7
Nullable addInstant (metadata)Fast (INPLACE)Fast-ish (may copy)
ADD + constant DEFAULT~5ms (metadata)Often INPLACEFull rebuild + lock
ADD + NOT NULLScan to validateScan + possible rebuildRebuild + 22-min style lock
ADD + CHECKScan (use NOT VALID)Validates per rowValidates per row
Big-table safetylock_timeout + batchespt-osc / gh-ostpt-osc / gh-ost required
⚙ Quick Reference
2 commands from this guide
FileCommand / CodePurpose
add_column_safe.sqlALTER TABLE orders ADD COLUMN loyalty_tier VARCHAR(20);Why Engines Differ
backfill_batches.sqlUPDATE ordersBatched Backfills That Don't Lock the Table

Key takeaways

1
Nullable ADD first on all engines; constraints come after backfill, never with it.
2
Postgres 11+ defaults are metadata-instant; MySQL needs INPLACE clauses or online tools.
3
Backfill in 10k batches throttled by replica lag, NULL-count trending to zero.
4
Split validation from locking (NOT VALID + VALIDATE); set lock_timeout around DDL.
5
Every ticket carries reverse SQL, snapshot ID, and resume point
rehearse rollback.

Common mistakes to avoid

4 patterns
×

One-step ADD COLUMN NOT NULL DEFAULT on a huge table

Symptom
Full rebuild, 22-minute write lock, 40k queued orders.
Fix
Nullable add → batched backfill → SET DEFAULT → SET NOT NULL across separate deploys.
×

Running ALTER without ALGORITHM/LOCK clauses

Symptom
Engine silently picks COPY; lock scope discovered via outage.
Fix
Declare ALGORITHM=INPLACE, LOCK=NONE (fail fast) or route through pt-osc/gh-ost above 10M rows.
×

Adding CHECK/NOT NULL validation in the same lock

Symptom
900M-row scan under exclusive lock; writes stall for the whole validation.
Fix
Postgres: ADD CONSTRAINT ... NOT VALID then VALIDATE separately; others: backfill first, constrain off-peak.
×

Requiring the column before all app hosts deploy

Symptom
Old servers crash on INSERT with unknown-column errors mid-rollout.
Fix
Expand-migrate-contract: nullable first, dual-write, constrain only after full rollout.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Why is ADD COLUMN instant on Postgres 11+ but slow on MySQL 5.7?
Q02SENIOR
How do you add a NOT NULL column to a 900M-row table without downtime?
Q03SENIOR
What is expand-migrate-contract and why does column work need it?
Q01 of 03SENIOR

Why is ADD COLUMN instant on Postgres 11+ but slow on MySQL 5.7?

ANSWER
Postgres 11+ stores constant defaults as metadata read lazily per row — no rewrite. MySQL 5.7 materializes NOT NULL+DEFAULT into every row via table copy with an exclusive lock. Same SQL, different storage mechanics.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the basic ALTER TABLE ADD COLUMN syntax?
02
How do I add a column only if it doesn't exist?
03
Will ADD COLUMN with DEFAULT lock my table?
04
How do I backfill 900M rows safely?
05
Should I use pt-online-schema-change?
N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Lessons pulled from things that broke in production.

Follow
Verified
production tested
September 22, 2026
last updated
1,799
articles · all by Naren
🔥

That's SQL. Mark it forged?

3 min read · try the examples if you haven't

Previous
SQL UPDATE from SELECT Statement
2 / 3 · SQL
Next
PostgreSQL psql Introspection and Top N per Group