Quick Answer

A database migration is a small script that changes your schema — adding a table, a column, an index — recorded as a versioned file alongside your application code. A migration runner tracks which ones have already been applied in a table inside the database itself, so re-running the full set is always safe and every environment ends up with an identical schema. The two things that actually matter are ordering, since migrations must run in a fixed sequence, and idempotency, since re-running an already-applied migration should change nothing.

Why you can't just run ALTER TABLE by hand

The tempting shortcut is to connect to the production database and run ALTER TABLE directly whenever you need a schema change. It works, once. The problem shows up the second time: your local database, your staging database, and production have now all diverged, and nothing records what changed, when, or why. Six months later nobody can say with confidence what production's schema actually looks like without connecting and checking table by table.

It gets worse with a team. If schema changes live only as commands someone typed into a terminal, every other developer has to be told to run the same command on their own machine, and inevitably someone doesn't, or runs it slightly differently. A fresh developer laptop, or a new staging environment, has no way to reconstruct the current schema from scratch.

Migrations fix this by making schema changes a normal part of your codebase: a file, committed to version control, reviewed the same way as any other code change. "What does the schema look like" becomes "run the migrations," the same answer for every environment, every time.

Anatomy of a migration file

A migration file is usually named with a timestamp prefix, so files sort into the order they need to run in without anyone having to manage a number by hand:

migrations/
  20260101_create_users.sql
  20260115_add_users_created_at.sql
  20260201_create_orders.sql

Each one pairs an up step (apply the change) with a down step (reverse it), whether that's expressed as two SQL files, two functions, or two blocks in one file depends on the tool. In code form, a migration is just an object with an id and a function that runs some schema-changing SQL:

const migrations = [
  {
    id: '20260101_create_users',
    up: (db) => db.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL);`),
  },
  {
    id: '20260115_add_users_created_at',
    up: (db) => db.exec(`ALTER TABLE users ADD COLUMN created_at TEXT;`),
  },
  {
    id: '20260201_create_orders',
    up: (db) => db.exec(`CREATE TABLE orders (
      id INTEGER PRIMARY KEY, user_id INTEGER, total INTEGER,
      FOREIGN KEY (user_id) REFERENCES users(id));`),
  },
];

The timestamp prefix matters more than it looks. If two developers each add a migration on the same day named 001_x and 002_y by hand, merging their branches creates a numbering collision. A timestamp like 20260115143022 is effectively unique per developer per moment, so two people's migrations merge cleanly and still sort into a sensible order.

How a migration runner tracks what's already applied

The core trick of any migration tool is a table, usually called something like schema_migrations, that lives inside the database itself and records which migration ids have already run. Before applying anything, the runner checks this table and skips whatever it already sees:

function runMigrations() {
  db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
    id TEXT PRIMARY KEY, applied_at TEXT NOT NULL
  );`);

  const applied = new Set(
    db.prepare('SELECT id FROM schema_migrations').all().map((r) => r.id)
  );

  let ranCount = 0;
  for (const migration of migrations) {
    if (applied.has(migration.id)) continue;
    migration.up(db);
    db.prepare('INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)')
      .run(migration.id, new Date().toISOString());
    ranCount++;
  }
  console.log(`${ranCount} migration(s) applied this run.`);
}

runMigrations(); // first run
runMigrations(); // second run, right after

Running this twice in a row against a fresh in-memory database produces:

3 migration(s) applied this run.
0 migration(s) applied this run.

The first run applies all three migrations and records their ids. The second run finds all three ids already present and applies nothing — which is exactly the property you want. Deploying your app re-runs the migration step every time, and that has to be safe whether zero migrations are pending or five are. This is what people mean when they say migrations should be idempotent: running the same set twice produces the same end state as running it once.

The gotcha: "down" doesn't mean "undo"

A down migration reverses the schema change. It does not restore data that the up migration destroyed, and dropping a column is the clearest example of the gap between the two:

db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, phone TEXT)');
db.prepare('INSERT INTO t (id, name, phone) VALUES (1, ?, ?)').run('Asha', '9876543210');
console.log(db.prepare('SELECT * FROM t').all());
// [ { id: 1, name: 'Asha', phone: '9876543210' } ]

db.exec('ALTER TABLE t DROP COLUMN phone'); // the "down" migration
console.log(db.prepare('SELECT * FROM t').all());
// [ { id: 1, name: 'Asha' } ]  -- phone value is gone, not just the column

db.exec('ALTER TABLE t ADD COLUMN phone TEXT'); // running "up" again
console.log(db.prepare('SELECT * FROM t').all());
// [ { id: 1, name: 'Asha', phone: null } ]  -- the column is back, the DATA is not

The column comes back exactly as it was defined. Asha's phone number does not — it's simply gone, replaced by null, because dropping a column deletes its stored values along with the column definition, and nothing recorded what those values used to be. A down migration for DROP COLUMN phone can only ever be ADD COLUMN phone; it has no way to know what the data was.

The practical takeaway: treat any migration involving DROP COLUMN, DROP TABLE, or a destructive data transformation as one-way in practice, whatever the down script claims to do. Take a database backup before running one against real data, and for anything valuable, migrate in two steps — stop writing to the column first, verify nothing reads it, and only drop it in a later, separate migration once you're certain.

Migrations in a team, and how to not break production

Once a migration has run against production, treat the file as frozen. Editing it and expecting the change to apply again does nothing on environments where it already ran — the runner sees the same id in schema_migrations and skips it, silently leaving those environments on the old version while anyone running migrations fresh gets the edited one. If a migration needs correcting, write a new migration that fixes it forward; never edit history.

Locking is the other production-specific risk that never shows up locally. An ALTER TABLE on a small local dev database finishes instantly. The same statement against a production table with tens of millions of rows can hold a lock for minutes, blocking every read and write to that table for the whole time — effectively an outage triggered by a routine deploy. PostgreSQL's CREATE INDEX CONCURRENTLY exists specifically to build an index without taking that lock, at the cost of taking longer to complete.

For a rename or type change on a live table, the safe pattern is several small migrations instead of one big one: add the new column, backfill it in batches, write to both old and new columns during a transition period, switch reads over, and only then drop the old column in its own later migration. Slower, but nothing ever has to fully stop for it.

Frequently Asked Questions

What's the difference between a migration and just editing the schema directly? A migration is a committed, ordered file that every environment runs the same way, so the schema's history is recorded and reproducible. Editing the schema directly leaves no record and causes environments to drift apart.
What does the "down" part of a migration actually do? It reverses the schema structure the "up" part created, such as dropping a column that was added. It does not restore data that was deleted by the up migration — a dropped column's values are gone even if you re-add the column.
Can I edit a migration file after it's been run in production? No. Environments that already ran it will skip it again since the runner sees its id as applied, so they'd never see your edit. Write a new migration to make the correction instead.
Do migrations run automatically when I deploy? Only if your deploy pipeline is configured to run them as a step. Many teams run migrations as an explicit, separate step before deploying new application code, specifically so a failed migration doesn't leave half-deployed code pointed at half-migrated data.
What happens if two developers create migrations at the same time? If filenames use a timestamp prefix, both migrations merge in cleanly and sort by creation time. If migrations use hand-assigned sequence numbers instead, two developers can collide on the same number and one will need renaming after the merge.