Schema Versioning & Migrations

Reviewed & published by Brayan K

By the end of this lesson you'll be able to evolve a production database the way professional teams do — with numbered, forward-only migration files that any environment can replay, a history table that records what ran, and the expand/contract pattern that lets you rename or reshape columns with zero downtime while a live app keeps serving traffic.

Part of the free SQL course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Learn

1. What Is a Migration?

A migration is a single SQL file that makes one change to your schema and carries a version number in its name. You run them in order — V1, then V2, then V3 — and a tool records which ones it has already applied. That ordered, recorded sequence is your schema's history.

Think of migrations as the numbered blueprints for a building. You don't rub out blueprint #1 to move a wall — you issue blueprint #2 with the change. Anyone can rebuild the whole structure by following the blueprints in order, and you always know exactly what was done and when.

The opposite — logging into production and typing ad-hoc ALTER TABLE by hand — leaves dev, staging, and production subtly different, with no record of what changed. Migrations fix that: the schema is now code in Git.

-- A migration is one numbered, forward-only SQL file.
-- File: V1__create_users.sql  (the "V1" is its version number)

CREATE TABLE users (
    id            SERIAL PRIMARY KEY,
    email         VARCHAR(255) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    created_at    TIMESTAMPTZ DEFAULT NOW()
);

-- Rules of a migration:
--   • It is checked into Git, like any other code.
--   • Once it has run on a shared database, you NEVER edit it.
--   • The next change goes in a NEW file (V2, V3, ...), never back in V1.

The single most important rule is highlighted below — break it and your environments drift apart.

2. Adding the Next Change

Need another change? Create the next-numbered file. Here V2 adds a column. A fresh database becomes correct by replaying every file in order, which is why every environment ends up identical.

-- The next change is a brand-new file with the next number.
-- File: V2__add_phone_to_users.sql

-- Add a nullable column — this is a fast, safe change.
ALTER TABLE users ADD COLUMN phone VARCHAR(20);

-- Flyway/Liquibase run V1 then V2, in order, exactly once each.
-- A fresh database can rebuild itself by replaying V1, V2, V3...
-- so every environment (dev, staging, prod) ends up identical.

Your Turn: write V3 safely

Fill in the blanks to write the next migration that adds a bio column. The expected file and contents are in the comments so you can check yourself.

-- 🎯 YOUR TURN — write the NEXT numbered migration.
-- V1 and V2 already exist. Add a "bio" column to users SAFELY.

-- 👉 1) Name the file with the next version number:
-- File: ___   (e.g. V3__add_bio_to_users.sql)

-- 👉 2) Add a NULLABLE text column (nullable = safe, no table lock):
ALTER TABLE users ADD COLUMN ___ ___;   -- column name, then the type

-- ✅ Expected: a new file V3__add_bio_to_users.sql containing
--    ALTER TABLE users ADD COLUMN bio TEXT;
--    After deploy, flyway_schema_history gains a row for version 3.

3. The Migration-History Table

How does a tool know V1 and V2 already ran? It keeps a history table inside your database with one row per applied migration. On each deploy it compares the files on disk against this table and runs only the new ones.

It also stores a checksum — a hash of each file's contents. If you edit an already-applied migration, the checksum no longer matches and the tool refuses to run, which is exactly how it enforces the forward-only rule for you.

-- Migration tools keep a HISTORY TABLE so they know what ran.
-- This is what Flyway creates (Liquibase calls it DATABASECHANGELOG).

CREATE TABLE flyway_schema_history (
    installed_rank INT PRIMARY KEY,        -- order applied
    version        VARCHAR(50),            -- "1", "2", ...
    description    VARCHAR(200),
    script         VARCHAR(1000),          -- the file name
    checksum       INT,                    -- hash of the file's contents
    installed_on   TIMESTAMPTZ DEFAULT NOW(),
    execution_time INT,                    -- milliseconds
    success        BOOLEAN
);

-- After running V1 and V2 it holds one row each:
SELECT version, description, success
FROM flyway_schema_history
ORDER BY installed_rank;

-- ✅ Result:
--   1 | create users     | true
--   2 | add phone to users | true
-- On the next deploy the tool sees these rows and skips V1 and V2.

4. Versioned vs. Repeatable Migrations

Versioned migrations (named V…) run once, in order — perfect for tables and columns, where running the same CREATE TABLE twice would error. Repeatable migrations (named R…) re-run automatically whenever their file changes — perfect for things you redefine wholesale, like views, functions, and seed data, using CREATE OR REPLACE.

-- VERSIONED migrations (V__) run ONCE, in order. Good for tables.
-- REPEATABLE migrations (R__) re-run whenever their file CHANGES.
-- Use them for things you redefine wholesale: views, functions, seed data.

-- File: R__active_users_view.sql   (note the R and the double underscore)
CREATE OR REPLACE VIEW active_users AS
SELECT id, email
FROM users
WHERE status = 'active';

-- Edit this file and the tool re-applies it (the checksum changed).
-- That is allowed here precisely because CREATE OR REPLACE is idempotent —
-- running it twice leaves the same result.

5. Rolling Back — and Why Pros Roll Forward

A rollback is the reverse of a migration: the undo of ADD COLUMN bio is DROP COLUMN bio. Some tools let you write a paired "down" script for this. It sounds tidy, but there's a catch most teams learn the hard way.

Keep down-scripts for fast feedback in dev and CI, but in production, prefer a new forward migration over an in-place rollback.

6. Expand / Contract — Zero-Downtime Changes

Here's the hardest problem in this lesson. You can't rename a column in one step on a live system, because for a moment the running app still expects the old name — and a request hitting it during the change would crash. The fix is the expand/contract pattern (also called parallel change): change the schema and the app in small, separately-deployed steps where every step is compatible with the code already running.

Replacing a water pipe in a busy building: you fit the new pipe alongside the old one (expand), connect everything to it, then remove the old pipe (contract). The water never stops flowing because both pipes work during the overlap.

The sequence is always the same four phases: add the new column (nullable), backfill existing rows into it, switch the app to use it, then drop the old column once nothing references it.

Backward compatibility means a new schema still works with the old app; forward compatibility means the old schema still works with the new app. Expand/contract works precisely because the overlap period keeps both true at once.

-- ZERO-DOWNTIME rename of users.email -> users.email_address
-- using the EXPAND / CONTRACT (parallel change) pattern.
-- Each migration ships separately, with the app updated in between.

-- V4 (EXPAND): add the new column, nullable. Old app still works.
ALTER TABLE users ADD COLUMN email_address VARCHAR(255);

-- V5 (BACKFILL): copy existing data into the new column.
UPDATE users SET email_address = email WHERE email_address IS NULL;

-- (Deploy app version that READS/WRITES BOTH columns. Both are kept in sync.)

-- V6 (SWITCH): now the app reads only email_address. The old column is dead.

-- V7 (CONTRACT): once NOTHING uses "email" any more, drop it.
ALTER TABLE users DROP COLUMN email;

-- The golden rule: every step is backward-compatible with the app version
-- currently running, so you can deploy (or roll back) without downtime.

Your Turn: order the steps

The four expand/contract steps for a rename are shuffled below. Number them 1–4 in the comments. The correct order is in the comments so you can check yourself.

-- 🎯 YOUR TURN — put the expand/contract steps in the RIGHT order.
-- Goal: rename "phone" to "phone_number" with zero downtime.
-- Below are the four steps SHUFFLED. Number them 1-4 in the comments.

-- 👉 Step ___ : DROP COLUMN phone;                          (contract)
-- 👉 Step ___ : ADD COLUMN phone_number VARCHAR(20);        (expand)
-- 👉 Step ___ : deploy app that reads only phone_number;    (switch)
-- 👉 Step ___ : UPDATE users SET phone_number = phone;      (backfill)

-- ✅ Expected order:
--   1 = ADD COLUMN (expand)   2 = UPDATE backfill
--   3 = switch app reads      4 = DROP COLUMN (contract)

7. CI/CD for Databases

Because migrations live in Git, your deployment pipeline can run them automatically. A typical flow: on every pull request, CI spins up a throwaway database and applies all migrations from scratch to prove they replay cleanly; on merge to production, the deploy step runs flyway migrate (or liquibase update) before the new app starts.

A migration step in a CI/CD pipeline

1. Build — package the app and its migration files together.

2. Test — apply every migration to a fresh disposable DB, then run the test suite.

3. Migrate — run flyway migrate against production (it skips already-applied files via the history table).

4. Release — start the new app version, which now matches the migrated schema.

Because expand/contract keeps each step backward-compatible, you can run the migration before the new app rolls out and still serve traffic the whole time — that's what makes the deploy zero-downtime.

Common Errors (and the fix)

Frequently Asked Questions

Q: Why can't I just edit an old migration to fix a typo?

Because it has already run on other people's databases and on production. The tool's checksum would mismatch and refuse to run. Add a new migration that corrects it instead — that keeps every environment reproducible.

Q: Flyway vs. Liquibase — which should I pick?

If your team is comfortable writing plain SQL, Flyway is simpler. If you need database-agnostic changes (the same change description running on Postgres, MySQL, and Oracle) or richer rollback support, Liquibase's XML/YAML format is built for that.

Q: Do I really need expand/contract for a tiny app?

If you can tolerate a few seconds of downtime during a deploy, a direct RENAME COLUMN is fine. Expand/contract matters once you have live traffic that must not see errors while the schema and app are mid-change.

Q: How do migrations stay in sync with the app deploy?

Run the migration step in CI/CD before starting the new app. As long as each migration is backward-compatible (the old app still works against the new schema), the order is safe and you avoid downtime.

Mini-Challenge: Make a NOT NULL Column Safely

Put it all together — a brief, a blank canvas, and the expected outcome in the comments. Write the migrations, then copy them into a playground to confirm.

-- 🎯 MINI-CHALLENGE — make a risky change safe.
-- The schema already has: V1 (users), V2 (phone), V3 (bio).
-- A teammate wants users to have a NOT NULL "status" column.
--
-- Adding a NOT NULL column with no default LOCKS and can fail on a big table.
-- Split it into safe, separately-numbered migrations:
--   1. V4: add status as a NULLABLE column with a sensible default
--   2. V5: backfill existing rows so none are NULL
--   3. V6: tighten the column to NOT NULL
--
-- ✅ Expected: three files V4/V5/V6, each runnable on a live table
--    without a long lock, ending with status NOT NULL DEFAULT 'active'.

-- your migrations here

🎉 Lesson Complete

Practice quiz

What is a database migration?

  • A backup file
  • A way to move servers
  • A single, version-numbered SQL file making one change to the schema
  • A type of index

Answer: A single, version-numbered SQL file making one change to the schema. A migration is one numbered SQL file (V1, V2, ...) that makes a change and is checked into Git like any code.

What is the 'forward-only' rule of migrations?

  • Once a migration has run on a shared database, never edit it; add a new higher-numbered file
  • Always edit the latest file
  • Run migrations in reverse
  • Delete old migrations regularly

Answer: Once a migration has run on a shared database, never edit it; add a new higher-numbered file. An applied migration is frozen; the next change always goes in a new, higher-numbered file so environments don't drift.

How does a migration tool know which migrations have already run?

  • It guesses by file date
  • It re-runs everything every time
  • It asks the developer
  • It keeps a history table inside the database with one row per applied migration

Answer: It keeps a history table inside the database with one row per applied migration. Tools like Flyway keep a history table (flyway_schema_history) recording each applied migration so they run only new ones.

What does the checksum stored for each migration enforce?

  • Faster queries
  • That you cannot edit an already-applied migration without the tool refusing to run
  • Automatic rollbacks
  • Encryption of the file

Answer: That you cannot edit an already-applied migration without the tool refusing to run. The checksum hashes the file's contents; editing an applied migration changes it, so the tool detects the mismatch and refuses.

What is the difference between versioned (V__) and repeatable (R__) migrations?

  • Versioned run once in order; repeatable re-run whenever their file changes
  • No difference
  • Repeatable run once; versioned re-run
  • Versioned are for views only

Answer: Versioned run once in order; repeatable re-run whenever their file changes. Versioned migrations run once for tables/columns; repeatable migrations re-apply when changed, ideal for views/functions via CREATE OR REPLACE.

Why must repeatable migrations be idempotent?

  • So they run faster
  • So they can be deleted
  • Because running them twice must give the same result as running once
  • To avoid using checksums

Answer: Because running them twice must give the same result as running once. Idempotent means running it twice equals running it once, which is why repeatable migrations use CREATE OR REPLACE.

In production, why do teams prefer rolling forward over an in-place rollback?

  • Rollbacks are illegal
  • A rollback like DROP COLUMN can silently destroy data users stored
  • Rolling forward is slower
  • Rollbacks need no testing

Answer: A rollback like DROP COLUMN can silently destroy data users stored. Rollbacks lose data; a new corrective forward migration keeps history append-only and nothing is silently destroyed.

What is the correct order of the expand/contract pattern for renaming a column?

  • Drop, add, switch, backfill
  • Switch, drop, add, backfill
  • Backfill, drop, add, switch
  • Add (expand), backfill, switch app, drop old (contract)

Answer: Add (expand), backfill, switch app, drop old (contract). Expand/contract is: add the new column nullable, backfill, switch the app to it, then drop the old column once nothing uses it.

Why split adding a NOT NULL column into separate migrations on a big live table?

  • To use more disk
  • Adding NOT NULL with no default can lock the table and time out; add nullable, backfill, then tighten
  • It is required by Git
  • To create more checksums

Answer: Adding NOT NULL with no default can lock the table and time out; add nullable, backfill, then tighten. Add the column nullable (with a default), backfill existing rows, then add NOT NULL in a later migration to avoid a long lock.

Why does expand/contract enable zero-downtime deploys?

  • It skips the migration step
  • It stops all traffic briefly
  • Every step is backward-compatible with the app version currently running
  • It only works on empty tables

Answer: Every step is backward-compatible with the app version currently running. Because each step keeps the old app working against the new schema, you can migrate before the new app rolls out with no downtime.

Continue this course

Related lessons