Skip to main content

🛠️ Using Migration Tools

You know what a migration is — now let's actually drive one. In this lesson you'll configure Knex.js end to end, run migrations from the command line, generate them the Sequelize way, and meet Prisma Migrate's declarative approach where you describe the schema and the tool writes the SQL for you.

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

🎯 Learning Objectives

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

  • Set up Knex.js with a per-environment knexfile.js
  • Create, run, roll back, and check the status of migrations from the CLI
  • Write real Knex migrations for a multi-table schema with foreign keys
  • Compare the imperative (Knex, Sequelize) and declarative (Prisma) approaches
  • Run migrations programmatically and safely on application startup
  • Wire migrations into a CI/CD pipeline with the right command per environment

Estimated Time: 75 minutes

Practice: Build a three-table blog schema with Knex migrations, then roll it back cleanly.

In This Lesson

The Tool Families

Every migration tool solves the same problem — an ordered, tracked set of schema changes — but they split into two philosophies. Imperative tools have you write the steps ("create this table, add that column"). Declarative tools have you describe the desired end state and generate the steps for you.

graph TD A[Node Migration Tools] --> B[Imperative
you write up and down] A --> C[Declarative
you describe the schema] B --> B1[Knex.js] B --> B2[node-pg-migrate] B --> B3[Sequelize CLI] C --> C1[Prisma Migrate]

💡 Which should you learn first?

We lead with Knex.js because its migrations are just JavaScript functions calling a query builder — nothing hidden. Once you can read a Knex migration you can read them all, because Sequelize and node-pg-migrate follow the same up/down shape. We finish with Prisma so you can see how the declarative model differs.

Setting Up Knex

Knex is a SQL query builder for Node that ships with a full migration system. It supports PostgreSQL, MySQL, SQLite, and MSSQL. Setup is three commands.

# 1. Install Knex and a database driver (pg for PostgreSQL)
npm install knex pg

# 2. Generate a knexfile.js in your project root
npx knex init

# 3. Create your first migration
npx knex migrate:make create_users_table

The knexfile.js

The knexfile.js is the control center. It defines a connection and a migrations folder per environment, so the same commands behave correctly in development, staging, and production. Notice how production reads secrets from environment variables rather than hard-coding them.

// knexfile.js
require('dotenv').config();

/** @type {import('knex').Knex.Config} */
module.exports = {
  development: {
    client: 'pg',
    connection: {
      host: 'localhost',
      database: 'blog_dev',
      user: 'postgres',
      password: 'password',
    },
    migrations: { directory: './db/migrations', tableName: 'knex_migrations' },
    seeds: { directory: './db/seeds' },
  },

  production: {
    client: 'pg',
    connection: {
      host: process.env.DB_HOST,
      database: process.env.DB_NAME,
      user: process.env.DB_USER,
      password: process.env.DB_PASSWORD,
      ssl: { rejectUnauthorized: false },   // most hosted Postgres requires SSL
    },
    pool: { min: 2, max: 10 },
    migrations: { directory: './db/migrations', tableName: 'knex_migrations' },
  },
};

⚠️ Never commit real credentials

The development block shows a password inline only for a local throwaway database. Production credentials come from environment variables (process.env) loaded from a .env file that is git-ignored. Committing a production password is one of the most common — and most costly — beginner mistakes.

The Knex CLI Workflow

Day-to-day, you'll live in a handful of commands. Here they are with what each one does.

CommandWhat it does
knex migrate:make <name>Create a new timestamped migration file
knex migrate:latestRun all pending migrations (the everyday command)
knex migrate:upRun just the next pending migration
knex migrate:downReverse just the last applied migration
knex migrate:rollbackUndo the last batch of migrations
knex migrate:rollback --allUndo every migration (fresh start)
knex migrate:statusShow which migrations are applied vs. pending
knex migrate:latest --env productionRun against a specific environment

🍳 Analogy: a recipe book with a bookmark

Knex migrations are like a recipe book where each page is one dish. The up function is the recipe; the down function is how to un-cook it (imagine un-cracking an egg). The knex_migrations table is the bookmark — it always knows which page you're on, so migrate:latest just cooks forward from wherever you left off.

The batch concept

When migrate:latest runs several pending migrations at once, it groups them into a single batch (see the batch column in the tracking table). A single migrate:rollback reverses the whole most-recent batch together — handy when a deploy's worth of changes needs to come back out as a unit.

A Real Multi-Table Migration

Let's build the core of a blog: users, posts, and comments, with foreign keys tying them together. First generate the files:

npx knex migrate:make create_users_table
npx knex migrate:make create_posts_table
npx knex migrate:make create_comments_table

Then fill them in. Each is a standalone, reversible change:

// db/migrations/20260731100000_create_users_table.js
exports.up = function (knex) {
  return knex.schema.createTable('users', (table) => {
    table.increments('id').primary();
    table.string('username', 50).notNullable().unique();
    table.string('email', 100).notNullable().unique();
    table.string('password_hash', 255).notNullable();
    table.boolean('is_admin').defaultTo(false);
    table.timestamps(true, true); // created_at & updated_at, defaulting to now()
  });
};
exports.down = function (knex) {
  return knex.schema.dropTable('users');
};
// db/migrations/20260731100100_create_posts_table.js
exports.up = function (knex) {
  return knex.schema.createTable('posts', (table) => {
    table.increments('id').primary();
    table.string('title', 255).notNullable();
    table.text('content').notNullable();
    table.string('slug', 255).notNullable().unique();
    // Foreign key: a post belongs to a user. CASCADE removes posts if the user is deleted.
    table.integer('author_id').unsigned().notNullable()
      .references('id').inTable('users').onDelete('CASCADE');
    table.string('status', 20).defaultTo('draft'); // draft | published | archived
    table.timestamp('published_at');
    table.timestamps(true, true);
  });
};
exports.down = function (knex) {
  return knex.schema.dropTable('posts');
};
// db/migrations/20260731100200_create_comments_table.js
exports.up = function (knex) {
  return knex.schema.createTable('comments', (table) => {
    table.increments('id').primary();
    table.text('content').notNullable();
    table.integer('user_id').unsigned().notNullable()
      .references('id').inTable('users').onDelete('CASCADE');
    table.integer('post_id').unsigned().notNullable()
      .references('id').inTable('posts').onDelete('CASCADE');
    table.boolean('is_approved').defaultTo(true);
    table.timestamps(true, true);
  });
};
exports.down = function (knex) {
  return knex.schema.dropTable('comments');
};

Run them and check the result:

npx knex migrate:latest
npx knex migrate:status

Output

Batch 1 run: 3 migrations
Found 3 Completed Migration file/files.
20260731100000_create_users_table.js
20260731100100_create_posts_table.js
20260731100200_create_comments_table.js
No Pending Migration files Found.

⚠️ Order is not optional here

The timestamps ensure users is created before posts references it. If you generated the posts migration first, its foreign key would point at a table that doesn't exist yet and the migration would fail. This is why you never rename or reorder existing migration files.

Sequelize Migrations

Sequelize is a full ORM whose CLI generates migrations alongside models. The style is imperative like Knex, but the API is queryInterface and it can scaffold a model and its migration together.

# Install Sequelize, the CLI, and the driver
npm install sequelize sequelize-cli pg pg-hstore

# Scaffold config/, models/, migrations/, seeders/
npx sequelize-cli init

# Generate a model AND its migration in one step
npx sequelize-cli model:generate --name User \
  --attributes username:string,email:string,password:string

The generated migration uses the same up/down pair — just with Sequelize's own methods:

// migrations/20260731-create-user.js
'use strict';
module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.createTable('Users', {
      id: { allowNull: false, autoIncrement: true, primaryKey: true, type: Sequelize.INTEGER },
      username: { type: Sequelize.STRING, allowNull: false, unique: true },
      email: { type: Sequelize.STRING, allowNull: false, unique: true },
      password: { type: Sequelize.STRING, allowNull: false },
      createdAt: { allowNull: false, type: Sequelize.DATE },
      updatedAt: { allowNull: false, type: Sequelize.DATE },
    });
  },
  async down(queryInterface, Sequelize) {
    await queryInterface.dropTable('Users');
  },
};
Sequelize commandKnex equivalent
db:migratemigrate:latest
db:migrate:undomigrate:rollback
db:migrate:undo:allmigrate:rollback --all
db:migrate:statusmigrate:status

Same concepts, different words. If Knex made sense, Sequelize will feel familiar.

Prisma Migrate

Prisma flips the model. Instead of writing migration steps, you edit one declarative schema.prisma file that describes the schema you want, and Prisma diffs it against the database to generate the SQL migration automatically.

🏗️ Analogy: blueprint vs. build instructions

Knex and Sequelize are step-by-step build instructions: "pour the foundation, then raise the walls." Prisma is handing over a finished blueprint and saying "make it look like this" — it figures out the steps to get from the current state to the drawing. Both build the same house; they just put your attention in different places.

npm install prisma --save-dev
npx prisma init
// prisma/schema.prisma
generator client { provider = "prisma-client-js" }
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now())
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int
  createdAt DateTime @default(now())
}

Create and apply a migration in development with one command:

# Diffs the schema, writes an SQL migration, applies it, regenerates the client
npx prisma migrate dev --name init

# Later, after editing schema.prisma (e.g. adding a bio field):
npx prisma migrate dev --name add_user_bio

# In production, apply already-created migrations without generating new ones:
npx prisma migrate deploy

Prisma still produces real, reviewable SQL migration files under prisma/migrations/ and tracks them in a _prisma_migrations table — the same underlying mechanism, generated for you:

-- prisma/migrations/20260731_init/migration.sql
CREATE TABLE "User" (
  "id"        SERIAL      NOT NULL,
  "email"     TEXT        NOT NULL,
  "name"      TEXT,
  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");

💡 The key distinction

Note the two production-relevant commands: migrate dev generates migrations from schema changes (development only), while migrate deploy only applies existing ones (safe for production, never invents new SQL on a live database). Mixing them up is a classic Prisma gotcha.

Running Migrations in Your App

The CLI is great for local work, but real projects also run migrations programmatically — in npm scripts and inside CI/CD.

npm scripts

// package.json
{
  "scripts": {
    "migrate": "knex migrate:latest",
    "migrate:prod": "knex migrate:latest --env production",
    "rollback": "knex migrate:rollback",
    "reset-db": "knex migrate:rollback --all && knex migrate:latest",
    "make-migration": "knex migrate:make"
  }
}

Programmatic execution

Knex exposes its migration API in code, which lets you run migrations as part of a deploy script and react to the result:

// db/run-migrations.js
const knex = require('knex');
const config = require('../knexfile');

const env = process.env.NODE_ENV || 'development';
const db = knex(config[env]);

async function migrateToLatest() {
  try {
    const [batch, log] = await db.migrate.latest();
    if (log.length === 0) {
      console.log('Database already up to date.');
    } else {
      console.log(`Batch ${batch} ran ${log.length} migration(s): ${log.join(', ')}`);
    }
  } catch (err) {
    console.error('Migration failed:', err);
    process.exitCode = 1;   // signal failure to the pipeline
  } finally {
    await db.destroy();     // always close the pool
  }
}

migrateToLatest();

⚠️ Running migrations on server startup

It's tempting to call db.migrate.latest() when your Express server boots. It's convenient in development, but risky in production: if several server instances start at once they can race to migrate the same database. Prefer a dedicated migration step in your deploy pipeline that runs once, before the new app instances start.

In CI/CD

A typical pipeline runs migrations as an explicit, ordered step. Note it uses migrate deploy / migrate:latest — never a command that could generate new migrations against a live database.

sequenceDiagram participant CI as CI Pipeline participant Test as Test DB participant Prod as Production DB CI->>Test: migrate:latest (fresh DB) Test-->>CI: schema ready CI->>CI: run test suite Note over CI,Prod: only if tests pass CI->>Prod: backup, then migrate:latest Prod-->>CI: migrations applied CI->>Prod: deploy new app version

Practice & Quiz

🏋️ Exercise 1: Add a categories feature with Knex

Goal: Extend the blog schema with a many-to-many relationship between posts and categories. Write one migration that creates a categories table and a post_categories join table, plus its down.

💡 Hint

A join table holds two foreign keys (post_id, category_id) and a composite unique constraint to prevent duplicate pairs. In down, drop the join table before the categories table it references.

✅ Solution
exports.up = async function (knex) {
  await knex.schema.createTable('categories', (t) => {
    t.increments('id').primary();
    t.string('name', 50).notNullable().unique();
    t.string('slug', 50).notNullable().unique();
  });
  await knex.schema.createTable('post_categories', (t) => {
    t.integer('post_id').unsigned().notNullable()
      .references('id').inTable('posts').onDelete('CASCADE');
    t.integer('category_id').unsigned().notNullable()
      .references('id').inTable('categories').onDelete('CASCADE');
    t.unique(['post_id', 'category_id']); // no duplicate pairings
  });
};

exports.down = async function (knex) {
  await knex.schema.dropTable('post_categories'); // join table first
  await knex.schema.dropTable('categories');
};

🏋️ Exercise 2: Add a column, then roll it back

Goal: Write a migration adding a nullable bio text column to users, then give the exact CLI commands to apply it and immediately undo it.

✅ Solution
// db/migrations/20260801_add_user_bio.js
exports.up = (knex) =>
  knex.schema.table('users', (t) => t.text('bio'));

exports.down = (knex) =>
  knex.schema.table('users', (t) => t.dropColumn('bio'));
npx knex migrate:latest     # applies add_user_bio
npx knex migrate:rollback   # reverses the last batch (drops bio)

🎯 Quick Quiz

Question 1: In knexfile.js, why do the production credentials come from process.env instead of being written inline?

Question 2: Which Prisma command is safe to run against a production database?

Question 3: Why is auto-running migrations on every server-instance startup risky in production?

Best Practices & Pitfalls

✅ Do

  • Keep one knexfile.js (or equivalent config) with a block per environment
  • Load production secrets from environment variables, never from committed files
  • Run migrations as a dedicated, one-time step in your deploy pipeline
  • Use migrate:status before and after to confirm what changed
  • Prefer prisma migrate deploy (apply-only) on production
  • Always close the connection pool (db.destroy()) in programmatic scripts

❌ Don't

  • Don't run prisma migrate dev or rollback --all against production
  • Don't auto-migrate from every booting server instance
  • Don't forget the database driver (pg, mysql2) — Knex needs it separately
  • Don't generate migrations in an order where a foreign key precedes its target table
  • Don't skip a backup before a production migration

💡 Real-world perspective

Migration tools aren't academic — they're standard infrastructure. Teams reach for Knex or Prisma on new Node projects, Sequelize where an ORM is already in play, and Flyway or Liquibase in JVM shops. Being fluent in at least one is an expected, marketable skill.

Summary

🎉 Key Takeaways

  • Knex setup is install driver → knex initmigrate:make, driven by a per-environment knexfile.js
  • migrate:latest applies pending migrations; migrate:rollback reverses the last batch
  • Sequelize is the same imperative up/down shape with different command names
  • Prisma is declarative: edit schema.prisma, and migrate dev generates the SQL for you
  • In production, run migrations once in the pipeline and use apply-only commands (migrate deploy)

📚 Additional Resources

🚀 What's Next?

Your schema is now built and versioned — but an empty schema isn't much use. The next lesson fills it with data the right way: reference data, realistic development data, and repeatable, environment-aware seeds: Data Seeding Strategies.

🎉 Great work!

You can now stand up a real, versioned schema from scratch and evolve it safely with three different tools. That's a genuinely employable skill.