Migrations
What a Migration Is
A migration is a small, versioned script that changes your database schema: create a table, add a column, add an index. Each migration runs once, in order, on every environment (your laptop, staging, production).
Think of Git for your database structure. Instead of someone typing ALTER TABLE by hand on the production server and hoping everyone else remembers to do the same, every change is a file in the repo, reviewed and applied the same way everywhere.
1migrations/2├── V1__create_orders.sql3├── V2__add_index_orders_user_id.sql4├── V3__add_is_veg_to_menu_items.sql5└── V4__create_coupons.sqlHow Migration Tools Work
The tool keeps a history table in the database listing which migrations already ran. On every deploy, it runs only the new ones:
1History table (flyway_schema_history / knex_migrations)2 V1 ✓ V2 ✓ V3 ✓3 4Deploy with V4 added5 → tool sees V4 is new → runs V4 → records V4 ✓6 7Fresh laptop database8 → runs V1, V2, V3, V4 in order → same schema as productionCommon tools:
| Stack | Tools |
|---|---|
| Java / Spring | Flyway, Liquibase |
| Node.js + SQL | Knex, node-pg-migrate, Prisma Migrate, Sequelize migrations |
| MongoDB | migrate-mongo (for data changes, since there's no fixed schema) |
Writing a Migration
Our menu needs an is_veg flag on every dish:
-- file: src/main/resources/db/migration/V3__add_is_veg_to_menu_items.sql-- Flyway runs it automatically on Spring Boot startupALTER TABLE menu_itemsADD COLUMN is_veg BOOLEAN NOT NULL DEFAULT false;
An up step applies the change; a down step undoes it. Flyway's free version runs forward only, so teams often fix mistakes with a new migration instead of rolling back.
Rules:
- Never edit a migration that already ran in production. Write a new one.
- Commit migrations together with the code that needs them.
- Run them automatically in the deploy pipeline, not by hand.
The Hard Part: Changing a Live Database
During a deploy, old and new versions of the app run at the same time for a few minutes. A migration must work with both. Some changes also lock a big table and freeze writes.
Say we want to rename orders.amount to orders.total_amount. A direct rename breaks the old app instantly:
1Migration: RENAME amount → total_amount2Old app still running: SELECT amount FROM orders → error: column does not exist3→ orders fail until every server is on the new versionThe safe way is expand and contract: add the new thing first, remove the old thing last, across several deploys.
1Deploy 1 (expand) Add column total_amount2 App writes to BOTH amount and total_amount3 4Backfill Copy old values: total_amount = amount (in batches)5 6Deploy 2 App reads from total_amount only7 8Deploy 3 (contract) Drop column amountAt every step, both the running app versions work.
Risky Operations on Big Tables
| Operation | Risk | Safer way (PostgreSQL) |
|---|---|---|
CREATE INDEX | Blocks writes until done, could be minutes on millions of rows | CREATE INDEX CONCURRENTLY (slower, no write lock; can't run inside a transaction) |
Add NOT NULL column without default | Fails on existing rows | Add with a constant DEFAULT, or add as nullable, backfill, then set NOT NULL |
| Change a column's type | Rewrites the whole table, locks it | New column + backfill + switch (expand and contract) |
| Rename or drop a column | Breaks the app version still running | Expand and contract |
Big UPDATE backfill | Long locks, heavy load | Update in batches (e.g. 10,000 rows at a time) |
-- don't wait forever for a lock; fail fast and retry laterSET lock_timeout = '5s';-- build an index without blocking new ordersCREATE INDEX CONCURRENTLY idx_orders_restaurant_id ON orders (restaurant_id);-- backfill in batches instead of one huge UPDATEUPDATE orders SET total_amount = amountWHERE id IN (SELECT id FROM orders WHERE total_amount IS NULL LIMIT 10000);-- repeat until no rows are updated
Interview Angle
How to Explain it in an Interview
Common mistakes:
- Renaming or dropping a column in the same deploy as the code change.
- Running
CREATE INDEXwithoutCONCURRENTLYon a busy production table. - Thinking MongoDB needs no migrations. Old documents still need updating when the shape changes.
Summary
1Migration → versioned script for a schema change, run once, in order2Tool → history table tracks what ran (Flyway, Liquibase, Knex, migrate-mongo)3Rules → never edit applied migrations, commit with code, run in the pipeline4Live databases → old + new app run together during deploy5Expand/contract → add new → write both → backfill → switch → drop old6Big tables → CREATE INDEX CONCURRENTLY, batch backfills, lock_timeout7MongoDB → no ALTER, but data migrations still neededChanging data safely includes removing it safely. Next, soft delete vs hard delete compares marking rows as deleted with actually removing them.