Warming up the neural circuits...
By the end of this chapter you will:
Imagine you have a working app with 50,000 orders. Your manager asks you to add a
refund_reasoncolumn. You can't just delete the table and recreate it — that loses all the data. A migration is how you evolve a live database safely.
A migration is a versioned, tracked script that evolves your database structure safely and reproducibly.
Think of migrations as Git commits for your database. Each one is:
Once a migration has been merged and run anywhere, never edit it. The moment a migration runs on one machine, that machine's schema diverges from any machine where it ran in its original . Write a new migration instead.
The tool is sequelize-cli. It reads from src/database/migrations/ and tracks which files have run in a table called schema_migrations.
# Run all pending migrations
npm run db:migrate
# Undo the last migration
npm run db:migrate:undo
# Undo all migrations (wipe schema)
npm run db:migrate:undo:allName it with a timestamp prefix so migrations run in order:
src/database/migrations/20260501120000_create_orders.jsFormat: YYYYMMDDHHMMSS_description.js
up function// src/database/migrations/20260501120000_create_orders.js
'use strict';
module.exports = {
async up(q, Sequelize) {
// Wrap everything in a transaction so partial failures roll back
await q.sequelize.transaction(async (t)
If the table creation succeeds but the constraint addition fails, you now have a table without the constraint. Next time you run the migration, it fails trying to create a table that already exists. Wrapping in a transaction means everything rolls back to a clean on any failure.
| Rule | What happens if you break it |
|---|---|
| One change per migration | Hard to review, impossible to partially revert |
| Never edit a merged migration | Schema diverges between environments silently |
| Always wrap in a transaction | Partial migrations leave the DB in a broken state |
| Add constraints in the migration, not just the model | Models lie; the DB cannot |
| Add indexes in the migration | Model decorators don't create indexes by default |
| Never backfill > 100k rows in a migration | Table locks up, your app goes down |
// Instant in PostgreSQL — no lock, no downtime
await q.addColumn('orders', 'refund_reason', {
type: Sequelize.TEXT,
allowNull: true,
});-- ❌ This locks the whole table for minutes on large tables
ALTER TABLE orders ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'api';Do it in 3 steps across 3 separate migrations:
Migration 1 — Add nullable:
await q.addColumn('orders', 'source', {
type: Sequelize.STRING(20),
allowNull: true,
});Between migrations — Deploy code that always sets source on new records.
Background job — Backfill existing rows in batches (not in a migration):
await Order.update({ source: 'api' }, {
where: { source: null },
limit: 10000,
});
// Repeat until no more nullsMigration 2 — Add the NOT NULL constraint after all rows are filled:
await q.changeColumn('orders', 'source', {
type: Sequelize.STRING(20),
allowNull: false,
});If you deploy code that removes the column read AND the migration that drops the column at the same time, old pods (still running during rolling deploy) crash because they try to read a column that no longer exists.
Step 1: Deploy code that stops reading/writing the column.
Wait for one full deploy cycle (all old pods gone).
Step 2: Migration that drops the column.
-- ❌ This locks the table for writes during index creation
CREATE INDEX idx_orders_user ON orders (user_id);
-- ✅ This builds the index without blocking writes (PostgreSQL only)
CREATE INDEX CONCURRENTLY idx_orders_user ON orders (user_id);In a migration:
await q.sequelize.query(
'CREATE INDEX CONCURRENTLY idx_orders_user ON orders (user_id)',
{ transaction: null } // CONCURRENTLY cannot run inside a transaction!
);Seeds are separate from migrations. They insert reference data — things like role names, currency codes — that the app needs to function.
Seeds must be — running twice should not create duplicates:
module.exports = {
async up(q) {
await q.bulkInsert('roles', [
{ id: 'r1', name: 'admin', created_at: new Date() },
| Mistake | Consequence |
|---|---|
| Editing a merged migration | Schema diverges between dev, staging, prod silently |
| Not wrapping in a transaction | Partial migration — table exists but missing constraints |
| Adding NOT NULL column in one step | Table lock for minutes. App unavailable. |
| Dropping column same day as code change | Old pods crash during rolling deploy |
| Large backfill in a migration | Table lock. errors. On-call emergency. |
CREATE INDEX without CONCURRENTLY | Table lock for writes during build |
Forgetting ignoreDuplicates in seeds | Running seeds twice errors. CI breaks. |
A migration is a git commit for your database. Once merged and run, it is permanent history. Never edit it — write a new one.