A migration is a versioned schema change you can replay on any environment to get it to a specific state. The reason this matters: ad-hoc ALTER TABLE on production from a console gets your local dev box out of sync with prod within a month. Migrations make schema evolution boring and reviewable.
The reason migrations exist as a separate concept — instead of "just ALTER TABLE when you need to" — is that schemas drift. The dev database evolves freely while you build features. The staging database evolved differently last quarter. The production database has whatever was applied in the order it was applied. Without a system, "what's the actual current schema" becomes a question only an ops engineer with admin access can answer, and changes that work on dev fail on prod for non-obvious reasons. With migrations, the schema is a sequence of versioned text files in the repo; any environment can be brought to any version by replaying the right files; the question "what's the schema" is answered by reading the migrations folder.
The mental model is small: a migration is a file (usually two — an up step that applies the change and a down step that reverses it) tied to a unique version (a timestamp or a sequence number). A migration runner is the tool that tracks which migrations have been applied on each database (typically in a schema_migrations table) and applies the unrun ones in order. Different tools generate different file formats — raw SQL, ORM-specific DSL, declarative schema diffs — but the shape is the same. Pick one tool, commit the migration files, run the tool on every deploy, never edit a migration after it ships.
This tutorial covers the three tools you'll actually consider in 2026 — Prisma Migrate, Drizzle Kit, and raw SQL with a small runner — explains which to pick when, walks the first-migration flow for each, and ends with the operational rules that prevent the migration disasters teams keep rediscovering.
schema.prisma; Prisma diffs against the DB and generates the SQL migration. Best when you're already using Prisma as your ORM. Strong type generation; tight coupling between schema, types, and DB..ts files); Drizzle generates SQL migration files. Less coupling than Prisma — the migrations are plain SQL you can read, edit, and run elsewhere. Pairs with Drizzle ORM but works standalone too.Quick rule of thumb: using Prisma already? Prisma Migrate. Want type-safe DB code but plain-SQL migrations? Drizzle Kit. Want maximum portability or working in a non-TypeScript stack? Raw SQL.
You can switch later but it's painful. Pick deliberately.
Edit prisma/schema.prisma to describe the desired schema. Adding a new table:
model Post {
id Int @id @default(autoincrement())
title String
body String
authorId Int
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
}
Generate and apply a migration:
npx prisma migrate dev --name add_post_table
This creates prisma/migrations/<timestamp>_add_post_table/migration.sql — readable SQL Prisma derived from the diff — runs it against your dev DB, and regenerates the TypeScript types. Open the file and read what it generated. Prisma's diffs are usually correct but it has done surprising things (dropped + recreated columns instead of renaming, generated indexes you didn't expect) — catch them in review, not in production.
Commit the migration folder. On deploy: npx prisma migrate deploy applies all unrun migrations against the target DB. Never run migrate dev against production — it can prompt for resets.
Define the schema as TypeScript in db/schema.ts:
import { pgTable, serial, varchar, integer, timestamp } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
email: varchar("email", { length: 255 }).notNull().unique(),
createdAt: timestamp("created_at").defaultNow(),
});
export const posts = pgTable("posts", {
id: serial("id").primaryKey(),
title: varchar("title", { length: 255 }).notNull(),
body: varchar("body").notNull(),
authorId: integer("author_id").references(() => users.id).notNull(),
createdAt: timestamp("created_at").defaultNow(),
});
Configure Drizzle in drizzle.config.ts:
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: { url: process.env.DATABASE_URL! },
});
Generate the migration SQL:
npx drizzle-kit generate
# Creates drizzle/0001_add_posts.sql with the actual SQL.
# Open it. Read it. Commit it.
# Apply it:
npx drizzle-kit migrate
Drizzle's appeal: the generated SQL is plain. If you don't like the diff, hand-edit the .sql before committing. The migrate command tracks applied migrations in a __drizzle_migrations table.
For Node, node-pg-migrate is a small, well-maintained runner. Other ecosystems have equivalents: knex migrations, Flyway (JVM and standalone), sqitch (language-agnostic), Django/Rails/Laravel built-in migrations.
npm i node-pg-migrate
npx node-pg-migrate create add-posts-table
# Creates migrations/<timestamp>_add-posts-table.js with up() and down() exports.
Write the SQL:
// migrations/1715000000000_add-posts-table.js
exports.up = pgm => {
pgm.createTable("posts", {
id: "id", // shorthand for serial primary key
title: { type: "varchar(255)", notNull: true },
body: { type: "text", notNull: true },
author_id: { type: "integer", notNull: true, references: "users" },
created_at: { type: "timestamp", default: pgm.func("now()") },
});
};
exports.down = pgm => {
pgm.dropTable("posts");
};
Apply / revert:
npx node-pg-migrate up # apply all unrun
npx node-pg-migrate down # revert most recent
The migrations are tracked in a pgmigrations table. For more advanced needs (raw SQL only, no DSL), use pgm.sql("CREATE TABLE ...") inside up(). The runner doesn't care; it just executes whatever you put there.
.sql files; any tool in any language can apply them. Worth picking raw SQL if you have any reason to think the stack will move.
Most migrations are instant on small tables and slow-or-locking on big ones. The patterns to know:
NOT VALID first (fast, no validation), then VALIDATE CONSTRAINT in a separate migration (slow but doesn't block writes).CREATE INDEX CONCURRENTLY on Postgres for an online build. Cannot run inside a transaction — disable the wrapping transaction for that migration (each tool has a flag).UPDATE big_table SET col = ..." in a migration. It locks the table for hours. Backfill in batches from application code or a separate script; the migration only adds the column.For really big tables (50M+ rows on Postgres), look at pg_repack and online-schema-change tools. The migration runner can't help you avoid locks; the SQL itself has to be written carefully.
The down migration is mostly useful in dev — applying a migration, realizing it's wrong, reverting, regenerating. In production, "roll back the migration" is usually the wrong answer:
The production pattern instead: roll forward, not back. If a migration deployed and you regret it, write a new migration that fixes the regret. Drop the column you shouldn't have added; revert the constraint; whatever. This keeps the migration sequence linear and every environment converges to the same head.
"Seed data" — fake users for dev, lookup tables, demo content — is tempting to put in migrations because it lives near the schema. Don't. Migrations should be schema-only; data belongs in a separate script.
// scripts/seed.ts
import { db } from "./db";
await db.users.create({ email: "admin@local", role: "admin" });
await db.users.createMany([
{ email: "alice@local" },
{ email: "bob@local" },
]);
Run npm run seed after a fresh DB setup. The seed script is idempotent (use upsert or check-before-insert); it can run multiple times safely. This separation is what lets your migrations be pure schema and your seed data live next to the test fixtures it represents.