Lesson 06 / 21
Schema Migrations
Splitting a schema change into versioned steps: forward and backward migration scripts, the version table, the atomic rollback of a failed step, and changing a column without an outage through an expand-write-contract scheme.
Contents
The connection pool works on the assumption that every connection sees the same schema. The assumption holds until the schema changes. When a column is added, removed, or renamed, the application’s running instances and the database’s state pull apart: which instance sees which schema depends on when each instance was last refreshed.
The SQL Fundamentals course introduced a schema change as a command run once, on its own. At the application layer, the same command becomes a migration step: versioned, applied in sequence, reversible, and recorded so that it is known which step has been applied in which environment. This lesson builds a migration runner, runs it in both directions, and changes a column without an outage.
Steps and the Version Table
Migration steps are files. Each step’s forward and backward direction lives in a separate file; the name begins with a sequence number.
mkdir -p migrations cat > migrations/001-loan-table.up.sql <<'SQL' CREATE TABLE member (member_id INTEGER PRIMARY KEY, first_name TEXT NOT NULL, last_name TEXT NOT NULL, email TEXT, registered_at TEXT NOT NULL); CREATE TABLE loan (loan_id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL, member_id INTEGER NOT NULL REFERENCES member(member_id), pickup_date TEXT NOT NULL, return_date TEXT); SQL cat > migrations/001-loan-table.down.sql <<'SQL' DROP TABLE loan; DROP TABLE member; SQL cat > migrations/002-overdue-fine.up.sql <<'SQL' ALTER TABLE loan ADD COLUMN overdue_fine REAL NOT NULL DEFAULT 0; SQL cat > migrations/002-overdue-fine.down.sql <<'SQL' ALTER TABLE loan DROP COLUMN overdue_fine; SQL cat > migrations/003-open-loan-index.up.sql <<'SQL' CREATE INDEX loan_open_index ON loan (member_id) WHERE return_date IS NULL; SQL cat > migrations/003-open-loan-index.down.sql <<'SQL' DROP INDEX loan_open_index; SQL
The information about which step has been applied lives in the database itself. If it were kept on the file system or in a configuration file, it would not travel with the database when the database is copied.
// migrate.mjs — runs migration steps up and down by checking the version table import { DatabaseSync } from "node:sqlite"; import { readdirSync, readFileSync } from "node:fs"; const db = new DatabaseSync(process.env.DB_PATH ?? "library.db"); db.exec(`CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)`); const steps = [...new Set(readdirSync("migrations").map((d) => d.replace(/\.(up|down)\.sql$/, "")))] .sort() .map((name) => ({ version: Number(name.slice(0, 3)), name })); const appliedVersions = () => db.prepare("SELECT version FROM schema_version ORDER BY version") .all().map((r) => r.version); function runStep(step, direction) { const sql = readFileSync(`migrations/${step.name}.${direction}.sql`, "utf8"); db.exec("BEGIN"); try { db.exec(sql); if (direction === "up") { db.prepare("INSERT INTO schema_version VALUES (?,?,datetime('now'))").run(step.version, step.name); } else { db.prepare("DELETE FROM schema_version WHERE version = ?").run(step.version); } db.exec("COMMIT"); console.log(`${direction === "up" ? "up " : "down"} ${step.name}`); } catch (err) { db.exec("ROLLBACK"); console.log(`ERROR ${step.name}: ${err.message}`); process.exit(1); } } const [command, targetArg] = process.argv.slice(2); const target = targetArg === undefined ? null : Number(targetArg); if (command === "status") { const done = new Set(appliedVersions()); for (const step of steps) console.log(`${done.has(step.version) ? "[x]" : "[ ]"} ${step.name}`); console.log("current version:", Math.max(0, ...done)); } else if (command === "up") { const done = new Set(appliedVersions()); for (const step of steps) { if (!done.has(step.version) && (target === null || step.version <= target)) runStep(step, "up"); } } else if (command === "down") { const doneVersions = appliedVersions().sort((x, y) => y - x); for (const v of doneVersions) { if (v > target) runStep(steps.find((step) => step.version === v), "down"); } } else { console.log("usage: node migrate.mjs status | up [version] | down <version>"); }
The runner makes three decisions. The step and the version record are written in the same transaction; steps are ordered by file name, so the numbering determines the sequence; and the order reverses in the backward direction.
Forward Execution
node migrate.mjs status
node migrate.mjs up 3
node migrate.mjs status
[ ] 001-loan-table [ ] 002-overdue-fine [ ] 003-open-loan-index current version: 0 up 001-loan-table up 002-overdue-fine up 003-open-loan-index [x] 001-loan-table [x] 002-overdue-fine [x] 003-open-loan-index current version: 3
The three steps were applied in sequence and written to the version table. The second
status call reads the information from the database, not from the files; if the same
migration directory were run against a different database, every box there would show
empty.
Atomicity of a Failed Step
If an error occurs partway through a step, that step must not be left half-applied. The step below deliberately produces an error on its second line.
cat > migrations/004-broken-example.up.sql <<'SQL' ALTER TABLE loan ADD COLUMN branch_id INTEGER; ALTER TABLE loan ADD COLUMN branch_id INTEGER; SQL cat > migrations/004-broken-example.down.sql <<'SQL' ALTER TABLE loan DROP COLUMN branch_id; SQL node migrate.mjs up 4 echo "exit code: $?" node migrate.mjs status sqlite3 library.db "PRAGMA table_info(loan);" | grep -c branch_id
ERROR 004-broken-example: duplicate column name: branch_id exit code: 1 [x] 001-loan-table [x] 002-overdue-fine [x] 003-open-loan-index [ ] 004-broken-example current version: 3 0
Three results appear together: the runner returned a nonzero exit code, the step was not
marked in the version table, and the number on the last line shows that the branch_id
column is not in the table. The first ALTER TABLE command had succeeded; when the
transaction rolled back, it was rolled back too.
This behavior is engine-dependent. Not every relational database can roll back schema-changing commands inside a transaction; on engines that cannot, each step is shrunk to a single command, because being left half-applied requires manual repair.
Backward Execution
The backward direction runs the applied steps in reverse.
node migrate.mjs down 1
node migrate.mjs status
down 003-open-loan-index down 002-overdue-fine [x] 001-loan-table [ ] 002-overdue-fine [ ] 003-open-loan-index [ ] 004-broken-example current version: 1
The index was dropped first, then the column was removed. If the order were reversed, an index that used the column would be left dangling. If the backward direction is left unwritten, the migration becomes one-directional, and there is no way back from a faulty version.
Zero-Downtime Column Change
The real difficulty is a change made while the application keeps running. The email
column on the member relation is being renamed to email_address. Renaming it in a
single step is a breaking change: the moment the renaming command runs, application
instances that have not yet been refreshed start failing.
The solution is to split the change into three steps. The Relational Database Administration course covered this scheme under the name expand–contract migration; a writing phase sits between the two ends.
- Expand: the new column is added, and the existing values are copied over. The old column stays.
- Write: the new version of the application writes to both columns and reads from the new one. The writing behavior in this phase is called dual write. The old version keeps running too.
- Contract: once every instance has been refreshed, the old column is removed.
The steps are three migration files.
cat > migrations/005-email-expand.up.sql <<'SQL' ALTER TABLE member ADD COLUMN email_address TEXT; UPDATE member SET email_address = email WHERE email_address IS NULL; SQL cat > migrations/005-email-expand.down.sql <<'SQL' ALTER TABLE member DROP COLUMN email_address; SQL cat > migrations/006-email-backfill.up.sql <<'SQL' UPDATE member SET email_address = email WHERE email_address IS NULL AND email IS NOT NULL; SQL cat > migrations/006-email-backfill.down.sql <<'SQL' SELECT 1; SQL cat > migrations/007-email-contract.up.sql <<'SQL' ALTER TABLE member DROP COLUMN email; SQL cat > migrations/007-email-contract.down.sql <<'SQL' ALTER TABLE member ADD COLUMN email TEXT; UPDATE member SET email = email_address; SQL
Two versions of the code stand in for the application’s old and new instances.
// old-code.mjs — running old version: knows only the email column import { DatabaseSync } from "node:sqlite"; const db = new DatabaseSync("library.db"); try { db.prepare("INSERT INTO member (first_name, last_name, email, registered_at) VALUES (?,?,?,?)") .run("Old", "Record", "[email protected]", "2025-07-20"); const n = db.prepare("SELECT count(*) AS n FROM member WHERE email IS NOT NULL").get().n; console.log(`old code: ran, rows with email = ${n}`); } catch (err) { console.log(`old code: ERROR -> ${err.message}`); }
// new-code.mjs — new version: reads the email_address column; writes to the old one too when DUAL_WRITE=1 import { DatabaseSync } from "node:sqlite"; const db = new DatabaseSync("library.db"); const dual = process.env.DUAL_WRITE === "1"; try { if (dual) { db.prepare("INSERT INTO member (first_name, last_name, email_address, email, registered_at) VALUES (?,?,?,?,?)") .run("New", "Record", "[email protected]", "[email protected]", "2025-07-20"); } else { db.prepare("INSERT INTO member (first_name, last_name, email_address, registered_at) VALUES (?,?,?,?)") .run("New", "Record", "[email protected]", "2025-07-20"); } const n = db.prepare("SELECT count(*) AS n FROM member WHERE email_address IS NOT NULL").get().n; console.log(`new code: ran (dual write=${dual ? "on" : "off"}), rows with email_address = ${n}`); } catch (err) { console.log(`new code: ERROR -> ${err.message}`); }
The script below sets up the database from scratch and runs both code versions after every step. The broken example step is deleted so that it does not block the steps that follow.
# zero-downtime.sh — the state of the two code versions across a three-step column change rm -f library.db migrations/004-broken-example.up.sql migrations/004-broken-example.down.sql node migrate.mjs up 3 >/dev/null sqlite3 library.db "INSERT INTO member (first_name,last_name,email,registered_at) VALUES ('Alice','Kane','[email protected]','2023-02-14'), ('Clara','Diaz',NULL,'2024-01-09');" echo "== version 3: only the old column exists ==" node old-code.mjs node new-code.mjs echo "== version 5 (expand): new column added and backfilled ==" node migrate.mjs up 5 node old-code.mjs DUAL_WRITE=1 node new-code.mjs echo "== version 6 (backfill): what the old code wrote moved to the new column ==" node migrate.mjs up 6 node old-code.mjs DUAL_WRITE=1 node new-code.mjs echo "== version 7 (contract): old column removed ==" node migrate.mjs up 7 node old-code.mjs node new-code.mjs echo "-- rows with no address --" sqlite3 -header -column library.db "SELECT member_id, first_name, last_name FROM member WHERE email_address IS NULL;"
sh zero-downtime.sh
== version 3: only the old column exists == old code: ran, rows with email = 2 new code: ERROR -> table member has no column named email_address == version 5 (expand): new column added and backfilled == up 005-email-expand old code: ran, rows with email = 3 new code: ran (dual write=on), rows with email_address = 3 == version 6 (backfill): what the old code wrote moved to the new column == up 006-email-backfill old code: ran, rows with email = 5 new code: ran (dual write=on), rows with email_address = 5 == version 7 (contract): old column removed == up 007-email-contract old code: ERROR -> table member has no column named email new code: ran (dual write=off), rows with email_address = 6 -- rows with no address -- member_id first_name last_name --------- ---------- --------- 2 Clara Diaz 6 Old Record
The two middle phases are the proof of zero downtime: at versions 5 and 6, both the old and the new code ran without error. The only moment the change is breaking is version 7, and that moment can be deferred until every instance has been refreshed.
Condition of the Contract Step
The last part of the output carries a warning. Two rows are left with an empty
email_address column. Of these, Clara had no address from the start; Old Record is
the problematic one. The old code wrote that row after the backfill step: it wrote
to the old column, the new column stayed empty, and the value was lost when the contract
step removed the old column.
The condition for the contract step follows from this: no instance may still be writing to the old column. The condition is met not by repeating the backfill step, but by confirming that the old instances have been fully withdrawn. In practice, this confirmation is made in two ways: verifying that the rollout has finished, and checking, immediately before the contract step, that the number of rows left empty is zero.
The Limit of a Down Migration
Reversing the contract step is possible, but the reversal brings back the schema, not the data.
node migrate.mjs down 6 sqlite3 -header -column library.db "SELECT member_id, first_name, email, email_address FROM member WHERE member_id IN (1,2,6);"
down 007-email-contract member_id first_name email email_address --------- ---------- ------------------ ------------------ 1 Alice [email protected] [email protected] 2 Clara 6 Old
The email column came back and was filled from the email_address values. Row six’s
address is empty again: the information was lost for good when the contract step
deleted it. A down migration is a rollback plan, not a backup. The backward
direction of a data-losing step is meaningful only if the deleted data was saved
somewhere else; otherwise, the reversal only repairs the schema.
Summary
- A migration step is a versioned, two-directional unit applied in sequence; the information about which step has been applied lives in the version table inside the database itself.
- The step itself and the version record are written in the same transaction; on the step that failed, both were rolled back together, and the version table did not change.
- Changing a column without an outage takes three phases: expand, write with dual write, contract. In the two middle phases, the old and new code versions ran together.
- The contract step cannot be applied while an instance is still writing to the old column; when the rule was not followed, the value of a row written after the backfill was lost.
- A down migration undoes the schema, not deleted data; a rollback plan does not stand in for a backup.
Next Step
The migration runner opened every step with BEGIN, closed it with COMMIT, and called
ROLLBACK on error; that is what made the step and the version record get written
together. The same question applies to the application’s ordinary operation: which
writes does a loan request cover, must all of these writes become final together, and
which layer holds the code that opens and closes the transaction? The next topic opens
with this question, and its first lesson builds the transaction boundary on the
definition of a unit of work: a boundary drawn too narrow produces half-finished states,
and one drawn too wide produces locks held for a long time.
To keep your progress and take notes, Log in
My notes
Log in to take notes.