Database Migrations¶
Schema changes without migrations are chaos One developer adds a column in their local DB , another runs a different migration , and suddenly production can't deploy because the schemas don't match - version control your database like you version control your code
Why Migrations Matter¶
Without migrations , database schema is oral history "Hey did you run that ALTER TABLE script from last week's meeting?" is not a deployment strategy
// Without migrations - nightmare scenario
// Dev 1: adds column locally
ALTER TABLE users ADD COLUMN phone VARCHAR(20)
// Dev 2: never got the memo
// Production: has no phone column
// App crashes: "column users.phone does not exist"
// With migrations - everything in version control
// 001_add_phone_to_users.sql is committed , reviewed , deployed
Prisma Migrate¶
Prisma generates migrations from schema changes automatically
// schema.prisma - change schema
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
phone String? // new field
role Role @default(USER)
createdAt DateTime @default(now())
}
// Generate migration
npx prisma migrate dev --name add_phone_field
// Apply to production
npx prisma migrate deploy
// Reset for development
npx prisma migrate reset
// Generated SQL migration - review before applying
-- CreateMigration
ALTER TABLE "public"."User" ADD COLUMN "phone" TEXT;
/*
Warnings:
- A unique constraint covering the columns `[phone]` on table `User`
will be added. If there are existing duplicate values, this will fail.
*/
-- AlterIndex
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
Always review generated SQL before applying to production Prisma sometimes makes assumptions that don't match your data - duplicate values break unique constraints
Knex Migrations¶
Knex has the most flexible migration system
// Create a migration
npx knex migrate:make create_users_table
// File: migrations/20250101000000_create_users_table.js
exports.up = function(knex) {
return knex.schema.createTable('users', (table) => {
table.increments('id').primary()
table.string('email').notNull().unique()
table.string('name')
table.enu('role', ['user', 'admin']).defaultTo('user')
table.timestamp('created_at').defaultTo(knex.fn.now())
})
}
exports.down = function(knex) {
return knex.schema.dropTableIfExists('users')
}
// Adding a column
npx knex migrate:make add_phone_to_users
exports.up = function(knex) {
return knex.schema.table('users', (table) => {
table.string('phone')
table.string('address')
})
}
exports.down = function(knex) {
return knex.schema.table('users', (table) => {
table.dropColumn('phone')
table.dropColumn('address')
})
}
// Run migrations
npx knex migrate:latest
// Rollback
npx knex migrate:rollback
// Check status
npx knex migrate:status
Knex migrations are JavaScript files - you can add conditional logic Need to check if a column exists before adding it? Knex supports that with knex.schema.hasColumn()
node-pg-migrate¶
Lightweight migration tool for PostgreSQL - no ORM required
// Install
npm install node-pg-migrate pg
// package.json script
"migrate": "node-pg-migrate up",
"migrate:down": "node-pg-migrate down"
// Run with DATABASE_URL
DATABASE_URL=postgres://localhost/mydb npm run migrate
// migrations/001_add-users-table.js
exports.up = (pgm) => {
pgm.createTable('users', {
id: { type: 'serial', primaryKey: true },
email: { type: 'varchar(255)', notNull: true, unique: true },
name: { type: 'varchar(255)' },
role: { type: 'varchar(20)', default: 'user' },
created_at: { type: 'timestamp', default: pgm.func('now()') }
})
}
exports.down = (pgm) => {
pgm.dropTable('users')
}
// migrations/002_add-phone-column.js
exports.up = (pgm) => {
pgm.addColumn('users', {
phone: { type: 'varchar(20)' }
})
}
exports.down = (pgm) => {
pgm.dropColumn('users', 'phone')
}
node-pg-migrate runs against a raw PostgreSQL connection - no ORM overhead Perfect for projects where you want migrations without committing to an ORM
Seeding Data¶
Seed data fills your database with development/test data
// Prisma seed
// prisma/seed.js
const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()
async function main() {
// Create admin user
const admin = await prisma.user.upsert({
where: { email: 'admin@example.com' },
update: {},
create: {
email: 'admin@example.com',
name: 'Admin',
role: 'ADMIN'
}
})
// Create sample posts
const posts = await Promise.all([
prisma.post.create({
data: { title: 'Welcome', content: 'First post', authorId: admin.id }
}),
prisma.post.create({
data: { title: 'Tutorial', content: 'How to use this app', authorId: admin.id }
})
])
console.log('Seeded:', { admin, posts })
}
main()
.catch(e => { console.error(e); process.exit(1) })
.finally(() => prisma.$disconnect())
// Knex seed
// seeds/01_users.js
exports.seed = async function(knex) {
// Delete existing entries
await knex('users').del()
// Insert seed data
await knex('users').insert([
{ email: 'admin@example.com', name: 'Admin', role: 'admin' },
{ email: 'user1@example.com', name: 'Mahmoud', role: 'user' },
{ email: 'user2@example.com', name: 'Ali', role: 'user' }
])
}
// Run seeds
npx knex seed:run
Seeds should be idempotent - running them twice shouldn't crash or create duplicates Use upsert or del() before insert to guarantee clean state
Migrations in CI/CD¶
# .github/workflows/deploy.yml
jobs:
migrate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- name: Install dependencies
run: npm ci
- name: Run database migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Run seed (dev only)
if: github.ref == 'refs/heads/develop'
run: npx prisma db seed
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
# Docker entrypoint - run migrations before starting app
#!/bin/sh
# entrypoint.sh
echo "Running database migrations..."
npx prisma migrate deploy
echo "Starting application..."
exec node dist/index.js
Run migrations before the app starts - never during app initialization If migrations fail , the container crashes without serving traffic with a half-migrated schema
Rollback Strategies¶
// Strategy 1: Sequential rollback (Knex)
npx knex migrate:down # rollback last migration
npx knex migrate:down # rollback one more
npx knex migrate:down # keep rolling back until stable
// Strategy 2: Rollback to specific point
npx knex migrate:down 20250101000000 # rollback to this timestamp
// Strategy 3: Create a fix migration instead
// 003_fix_broken_column.js
exports.up = (pgm) => {
// Add the column we forgot
pgm.addColumn('users', {
phone: { type: 'varchar(20)' }
})
}
// Strategy 4: Blue-green deploy with backward-compatible schema
// Always make migrations backward-compatible for 1 deploy cycle
// Step 1: Add column (app still works without it)
// Step 2: Deploy new code that uses column
// Step 3: Remove old column (next deploy)
Rolling back migrations in production can lose data If you added a NOT NULL column with a default , rolling back is safe - if you deleted a column , rollback restores it but data is gone
Migration Best Practices¶
// 1. One change per migration
// BAD: 003_add_phone_and_address_and_role.sql
// GOOD: 003_add_phone.sql
// GOOD: 004_add_address.sql
// GOOD: 005_add_role.sql
// 2. Never edit existing migrations - create new ones
// BAD: Editing 001_create_users.sql after it was applied
// GOOD: 006_fix_users_email_constraint.sql
// 3. Test down migrations before deploying up
exports.down = (pgm) => {
// If this throws , you can't rollback
pgm.dropColumn('users', 'phone')
}
// 4. Back up production before running migrations
// pg_dump mydb > backup_$(date +%Y%m%d).sql
// 5. Run migrations during maintenance windows for large datasets
// ALTER TABLE on a 10M-row table locks the table for minutes
Never edit a migration that's been applied to production Create a new migration - it's like version control for your database schema , and rewriting history breaks everything
Prerequisites¶
- db_05_redis.md - understand caching before managing schema changes
next -> deploy_01_env_setup.md