Skip to main content

🗂️ Database Migrations Concepts

Your app's code lives in Git — every change is versioned, ordered, reviewable, and reversible. Your database schema deserves the exact same discipline. Migrations are how you give it that: small, timestamped scripts that evolve your schema one deliberate step at a time, tracked so every environment ends up identical.

Week 8 · Day 5 (Friday: Database Migrations and Seeding) · Lecture 1

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what a migration is and why schema changes need version control
  • Describe the anatomy of a migration: up, down, a version/timestamp, and the migrations tracking table
  • State the golden rule — never edit a migration that has already been applied — and explain why
  • Distinguish safe from unsafe schema changes and sequence a zero-downtime migration
  • Separate schema migrations from data migrations and know when each is needed
  • Recognize the major migration tools across the JS/SQL/NoSQL ecosystem

Estimated Time: 70 minutes

Practice: Plan a multi-step migration to add a required column to a live table without downtime.

In This Lesson

What Is a Migration?

A database migration is a versioned script that describes one specific change to your database — creating a table, adding a column, building an index — together with instructions for undoing it. Run your migrations in order, and any empty database transforms into your current, correct schema. Run them on a teammate's laptop, on the CI server, and on production, and all three end up byte-for-byte identical.

🏠 Analogy: Renovation blueprints

Migrations are like the numbered blueprints for renovating a house. Each blueprint states exactly what to change (add a room, remove a wall, rewire the kitchen), the order to do it in, and how to reverse it if something goes wrong. Anyone — a new contractor joining mid-project — can read the stack of blueprints and know precisely what the house looks like at every stage. Without them, everyone's renovating from memory, and no two houses come out the same.

Put simply: migrations are version control for your schema. Just as Git records every code change so you can replay history or roll back, a migration system records every schema change in ordered, immutable files that live right beside your application code.

graph LR A[Empty Database] --> B["Migration 001
create users"] B --> C["Migration 002
add email"] C --> D["Migration 003
create posts"] D --> E[Current Schema]

Each arrow is a migration applied in sequence. The database's state at any point is simply "every migration up to here has run." That single idea — an ordered, replayable log of schema changes — is the whole concept.

Why Migrations Matter

Early in a project it is tempting to just open a database GUI and click "add column." It works — once. The problems arrive the moment a second person, a second environment, or a second month gets involved.

Life without migrations

⚠️ The ad-hoc trap

  • Environment drift: your dev database, staging, and production all diverge because nobody applied the same changes in the same order.
  • Lost history: a column appears and nobody remembers who added it, when, or why.
  • Broken deploys: new code expects a column that was never created in production.
  • No way back: a bad change hits production and there's no defined path to reverse it.

What migrations give you

BenefitWhat it means in practice
VersioningEvery schema change is a file with a timestamp, committed to Git alongside the code that needs it.
ReproducibilityOne command builds an identical schema on any machine, every time.
CollaborationTeammates pull your migration and run it — no Slack message describing SQL to paste.
ReversibilityA down function defines exactly how to undo the change.
AutomationMigrations run as a step in your CI/CD pipeline, not by hand at 2 a.m.
DocumentationThe migration folder is a readable, chronological history of how the schema grew.

💡 Real-world scenario: the growing startup

A startup ships an MVP with five tables. Six months and twenty features later the schema is unrecognizable. If every developer had been editing their local database by hand, deploying to production would mean remembering every change and replaying it flawlessly. With migrations, the schema's entire evolution is a folder of ordered files — production catches up with a single migrate:latest, and nobody has to remember anything.

Anatomy of a Migration

Whatever tool you use, every migration shares the same three parts.

1. Up and down

The up function applies the change. The down function reverses it. They are mirror images: whatever up builds, down tears down.

// db/migrations/20260731143227_create_users_table.js
// Knex migration — 'up' applies, 'down' reverses.

/** @param {import('knex').Knex} knex */
exports.up = function (knex) {
  return knex.schema.createTable('users', (table) => {
    table.increments('id').primary();              // auto-incrementing PK
    table.string('username').notNullable().unique();
    table.string('email').notNullable().unique();
    table.string('password_hash').notNullable();
    table.boolean('is_active').defaultTo(true);
    table.timestamps(true, true);                  // created_at + updated_at
  });
};

/** @param {import('knex').Knex} knex */
exports.down = function (knex) {
  return knex.schema.dropTable('users');           // the exact inverse
};

2. A version / timestamp

The filename starts with a timestamp (20260731143227) or a sequence number (001). This is what makes migrations ordered — the tool always runs them oldest-first so dependencies (a table before the foreign key that references it) resolve correctly.

3. The migrations tracking table

Here is the piece beginners overlook. Your migration tool creates a small bookkeeping table in your own database — Knex calls it knex_migrations, Prisma uses _prisma_migrations. It records which migrations have already run:

knex_migrations table

id | name                                  | batch | migration_time
---+---------------------------------------+-------+---------------------
 1 | 20260731143227_create_users_table.js |     1 | 2026-07-31 14:33:01
 2 | 20260731150000_create_posts_table.js |     1 | 2026-07-31 14:33:01
 3 | 20260801090000_add_user_bio.js        |     2 | 2026-08-01 09:15:22

When you run migrate:latest, the tool compares this table against the files on disk and runs only the ones not yet recorded. That is why you can run the command a hundred times and nothing happens after the first — the tracking table already knows those migrations are applied. This is the mechanism behind everything migrations do.

The tool compares migration files on disk against the tracking table and runs only the pending ones Files on disk 001 create_users 002 create_posts 003 add_bio 004 add_index (new) Tracking table ✓ 001 ✓ 002 ✓ 003 — 004 pending — compare run only 004
The tracking table is the source of truth for "which migrations have run." Pending files are applied in order; applied ones are skipped.

The Golden Rule: Never Edit an Applied Migration

This is the single most important rule in this lesson. Once a migration has been committed and applied anywhere — a teammate's machine, staging, production — treat it as frozen. Never change it.

Why? Because the tracking table records a migration as "done" by its name. If you edit the file's contents after it ran, the tool still sees the name in the table and won't run it again. Your machine now silently has a different schema from everyone who ran the original version. Some tools also store a checksum and will hard-error the moment a file's contents drift from what was applied.

⚠️ What to do instead

Made a mistake in a migration, or need to change the schema again? Write a new migration. Need to drop the column you just added? Add a migration that drops it. The history is append-only, exactly like Git commits — you don't rewrite the past, you add to it.

✅ The exception

The one time it's fine to edit a migration is before you have committed or shared it and it has only ever run on your own machine — where you can simply roll it back, edit, and re-run. The instant it touches version control or another environment, that window closes.

Safe vs. Unsafe Changes & Zero-Downtime

On an empty dev database every change is instant and harmless. On a live production table with millions of rows and active traffic, some changes are safe and some can lock the table or break the running app. Knowing the difference is what separates a junior from a senior.

✅ Generally safe⚠️ Needs care (can lock or break)
Adding a new tableDropping a table or column
Adding a nullable column (or one with a default)Adding a NOT NULL column with no default
Adding an index concurrentlyRenaming a table or column
Adding a constraint existing data already satisfiesChanging a column's type

The zero-downtime pattern

The trick for the risky changes is to break one dangerous change into several safe ones, deployed as a sequence so old and new code both keep working at every step. Renaming last_name to family_name without downtime looks like this:

sequenceDiagram participant App as Application participant DB as Database Note over App,DB: Step 1 — add the new column (nullable, safe) App->>DB: Migration: add family_name Note over App,DB: Step 2 — deploy code writing to BOTH columns App->>DB: write last_name AND family_name Note over App,DB: Step 3 — backfill existing rows App->>DB: Migration: copy last_name into family_name Note over App,DB: Step 4 — deploy code reading only family_name App->>DB: read family_name Note over App,DB: Step 5 — drop the old column App->>DB: Migration: drop last_name

At no single moment does the schema and the deployed code disagree. This "expand, migrate, contract" sequence is how companies apply schema changes to systems serving millions of users without a maintenance window.

💡 Real-world: schema changes at scale

Large applications with huge tables use online-schema-change tooling (for example, GitHub's open-source gh-ost for MySQL) to alter tables without locking them, combined with the expand/contract pattern and feature flags to switch code paths. The concept is identical to what you just saw — only the tooling scales it up.

Schema Migrations vs. Data Migrations

There are two things a migration can change, and it's worth naming them clearly:

  • Schema migration — changes the structure: create a table, add a column, build an index.
  • Data migration — changes the contents: transform, move, or backfill the rows themselves.

📦 Analogy: moving house

Building the new house (creating tables and columns) is the schema migration. Carrying your belongings into it (moving and reshaping the data) is the data migration. And you never demolish the old house before your things are safely inside — you don't drop the old column until the data has been copied across.

A classic data migration: someone stored a full name in one name column and now needs separate first_name and last_name. That takes both kinds of change in one careful sequence.

// db/migrations/20260801090000_split_user_name.js

exports.up = async function (knex) {
  // 1) SCHEMA change: add the two new columns (nullable for now)
  await knex.schema.table('users', (table) => {
    table.string('first_name');
    table.string('last_name');
  });

  // 2) DATA change: reshape every existing row
  const users = await knex('users').select('id', 'name');
  for (const user of users) {
    const parts = (user.name || '').trim().split(/\s+/);
    await knex('users').where('id', user.id).update({
      first_name: parts[0] || '',
      last_name: parts.slice(1).join(' ') || '', // everything after the first word
    });
  }
};

exports.down = async function (knex) {
  // Rebuild the original 'name' from the split parts, then drop the columns.
  const users = await knex('users').select('id', 'first_name', 'last_name');
  for (const user of users) {
    const fullName = `${user.first_name || ''} ${user.last_name || ''}`.trim();
    await knex('users').where('id', user.id).update({ name: fullName });
  }
  await knex.schema.table('users', (table) => {
    table.dropColumn('first_name');
    table.dropColumn('last_name');
  });
};

Notice the down function carefully reverses both the data and the structure, in the opposite order. A reversible data migration is a hallmark of professional work.

The Tool Landscape

You rarely hand-write the tracking table or the run-in-order logic — a migration tool does it. In the JavaScript/Node world, four names dominate, and you'll see each up close in the next lesson.

ToolStyleGood to know
Knex.jsImperative up/down query builderLightweight, database-agnostic, great first tool — we use it throughout.
node-pg-migrateImperative, PostgreSQL-focusedClean API when you're all-in on Postgres.
SequelizeORM with CLI-generated migrationsMigrations pair with model definitions.
Prisma MigrateDeclarative schema fileYou describe the desired schema; Prisma generates the SQL migration.

Beyond Node there's Flyway and Liquibase (Java), Alembic (Python), and Rails migrations (Ruby) — all built on the same concepts. Even schema-less MongoDB benefits from migrations (via migrate-mongo) whenever the shape of documents needs to change across an existing collection. The tool changes; the mental model you learned here does not.

Practice & Quiz

🏋️ Exercise 1: Sequence a zero-downtime migration

Goal: Your live users table needs a required phone_number column, but it holds millions of rows and the app is serving traffic. Adding a NOT NULL column with no default in one shot is unsafe. Write out the ordered steps — which are migrations, which are code deploys — to get there without downtime.

💡 Hint

You cannot make a column NOT NULL while existing rows have no value for it. Add it nullable first, get every row populated, and only then tighten the constraint. Remember that application code also has to start writing the new column at some point.

✅ Solution
  1. Migration: add phone_number as a nullable column (safe, instant).
  2. Deploy code that populates phone_number for all new and updated rows.
  3. Migration (data): backfill existing rows with a value (real data or a placeholder), ideally in batches.
  4. Migration: alter the column to NOT NULL now that every row has a value.
// Step 1 — add nullable column
exports.up = (knex) =>
  knex.schema.table('users', (t) => t.string('phone_number'));

// (Step 2 happens in application code, deployed between migrations.)

// Step 3 — backfill existing rows
exports.up = (knex) =>
  knex('users').whereNull('phone_number').update({ phone_number: 'unknown' });

// Step 4 — enforce NOT NULL
exports.up = (knex) =>
  knex.schema.alterTable('users', (t) =>
    t.string('phone_number').notNullable().alter()
  );

🏋️ Exercise 2: Write the down

Goal: Here is the up for a migration that adds a categories table and a category_id foreign key on posts. Write the matching down that reverses it cleanly.

exports.up = async function (knex) {
  await knex.schema.createTable('categories', (t) => {
    t.increments('id').primary();
    t.string('name').notNullable().unique();
  });
  await knex.schema.table('posts', (t) => {
    t.integer('category_id').unsigned().references('categories.id');
  });
};
💡 Hint

Reverse in the opposite order. You must remove the foreign key and column from posts before you can drop the categories table it points to.

✅ Solution
exports.down = async function (knex) {
  // Undo in reverse: drop the FK column first, then the table it referenced.
  await knex.schema.table('posts', (t) => {
    t.dropColumn('category_id');
  });
  await knex.schema.dropTable('categories');
};

🎯 Quick Quiz

Question 1: How does a migration tool know which migrations still need to run?

Question 2: You spot a typo in a migration that already ran on production. What should you do?

Question 3: Which change is safe to apply to a large live table in a single migration?

Best Practices & Pitfalls

✅ Do

  • Keep migrations in version control, right next to the code that depends on them
  • Make one logical change per migration and give it a descriptive name
  • Always write a working down so every change is reversible
  • Test migrations against a copy of production data before running them for real
  • Break risky production changes into safe, sequenced steps (expand → migrate → contract)
  • Back up the database before running migrations in production

❌ Don't

  • Never edit or delete a migration that has already been applied or shared
  • Don't make schema changes by hand in a GUI outside the migration system
  • Don't drop a column or table before its data has been safely migrated
  • Don't add a NOT NULL column with no default to a populated table in one step
  • Don't let two deploys run migrations against the same database at the same time

⚠️ Ordering matters

Because migrations run oldest-first, a migration that adds a foreign key must come after the migration that creates the table it points to. Timestamps handle this automatically — which is exactly why you never renumber or reorder existing migration files.

Summary

🎉 Key Takeaways

  • A migration is a versioned, ordered schema change with an up and a down
  • A tracking table in your database records which migrations have run — that's how pending ones are detected
  • Never edit an applied migration; fix mistakes by adding a new one (history is append-only)
  • Split risky production changes into safe sequenced steps for zero downtime
  • Schema migrations change structure; data migrations reshape the rows — and both belong in the down

📚 Additional Resources

🚀 What's Next?

Now that you understand the concepts, the next lesson gets hands-on: installing and driving real migration tools — Knex, Sequelize, and Prisma Migrate — from the command line and inside your Node app: Using Migration Tools.

🎉 Well done!

You now think about your database the way you think about your code — versioned, reviewable, and reversible. That mindset is what keeps production databases safe.