Tutorials Search / Shipping & infrastructure / Manage database migrations
📝 Written ● Intermediate Updated 2026-05-13

Manage database migrations

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.

What you'll learn

Prerequisites: A Postgres database your app talks to (local Docker, self-hosted, or managed at Supabase/Neon/RDS), an app that connects to it, and a clear idea of one schema change you want to make (e.g., add a column, add a table, add an index). You'll need to choose one of three tool flavors below; you can't easily mix them.

Step 1: Pick the tool flavor

1

The decision determines everything that follows

  • Prisma Migrate — you describe the schema in 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.
  • Drizzle Kit — you define the schema as TypeScript code (table definitions in .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.
  • Raw SQL + a runner (node-pg-migrate, knex, sqitch, Flyway) — you write the SQL by hand; the runner tracks what's applied. No coupling to any ORM. Most portable; the schema lives only in the SQL files and the DB itself, not in any other source of truth.

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.

Step 2 (Prisma): Write a schema, generate a migration

2

For projects already on Prisma

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.

Step 3 (Drizzle): Define schema, generate migration

3

SQL migrations from TypeScript schema definitions

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.

Step 4 (Raw SQL): node-pg-migrate or equivalent

4

SQL you wrote, runner that tracks it

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.

The "schema as TypeScript" tools (Prisma, Drizzle) couple your schema to a specific language stack. If you might switch languages later (Node → Python, say), the raw-SQL pattern is the only one that survives unchanged. The schema lives in plain .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.

Step 5: The rules that prevent migration disasters

5

Three non-negotiables

  • Never edit a deployed migration. Once a migration has run on any shared environment (staging, production), it is frozen. The next change is a new migration. Editing a deployed one means the file no longer matches what's already applied — every environment now has a different schema and the runner can't reconcile.
  • Migrations are always additive on the way up. Add columns as nullable. Add tables. Add indexes. Reversing a deploy by reverting the migration usually doesn't work in production (data has been written that depends on the new schema). Plan for forward-only.
  • Test the migration end-to-end before deploying. Spin up a clean DB, replay all migrations, verify the schema matches expectations. This is what CI does for you — see CI/CD with GitHub Actions Step 6 for the service-container pattern that runs migrations in CI.

Step 6: Production gotchas on big tables

6

What works on 1,000 rows breaks on 100 million

Most migrations are instant on small tables and slow-or-locking on big ones. The patterns to know:

  • Adding a NOT NULL column with a default value — on Postgres 11+, fast (metadata-only). On older versions, full table rewrite. Always check your Postgres version.
  • Adding a foreign key constraint — locks both tables while validating. On big tables: add the constraint as NOT VALID first (fast, no validation), then VALIDATE CONSTRAINT in a separate migration (slow but doesn't block writes).
  • Creating an index — locks writes on the table by default. Use 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).
  • Backfilling data — never put a "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.

Step 7: Rolling back

7

When you can, when you can't, what to do instead

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:

  • Data has been written that uses the new schema. Reverting drops that data.
  • Long-running migrations that you canceled mid-flight may have left the DB in a partial state. The down migration doesn't know which steps completed.
  • Rolling back a deployed migration on prod creates a divergence: prod is now on schema vN-1 while staging is on vN. Your next migration written against vN won't apply on prod.

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.

Step 8: Seeding test data

8

Keep it out of migrations

"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.

What's next