mdashikjs/blog
All posts
Database Migrations Without Downtime: A Practical Guide
Backend

Database Migrations Without Downtime: A Practical Guide

Backend5 min

Database Migrations Without Downtime: A Practical Guide

Every deployment that includes a migration is a risk. The difference between a smooth deploy and a 3am incident is how you structure the migration. Here are the rules I follow.

DatabaseMigrationsPostgreSQLDevOps
Share:

The golden rule: separate deploy from migrate

Never deploy code and run migrations in the same step. Deploy the code first, verify it works with the old schema, then run the migration. If the migration fails, the code is already running fine.

Additive changes only

In a zero-downtime environment, the old and new code run simultaneously during deploys. This means:

  • Safe: Add a column, add a table, add an index
  • Unsafe: Rename a column, drop a column, change a column type

How to rename a column safely

Three deploys instead of one:

  1. Deploy 1: Add the new column. Write to both old and new. Read from old.
  2. Deploy 2: Backfill the new column from the old. Switch reads to the new column.
  3. Deploy 3: Drop the old column.

Yes, it is more work. But each step is independently safe and reversible.

Index creation: CONCURRENTLY or nothing

-- This locks the table for the entire build time
CREATE INDEX idx_users_email ON users (email);

-- This does not
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);

On a table with 10M rows, the first statement locks writes for minutes. The second runs in the background. Always use CONCURRENTLY in production.

Migration testing checklist

  1. Run the migration against a copy of production data (not an empty test database)
  2. Check execution time — anything over 30 seconds needs a different approach
  3. Verify the rollback works — every migration should have a down function
  4. Test with the old version of the code running (simulate the overlap period)

The one thing I always forget

Default values on new columns. If you add a NOT NULL column without a default, every existing row fails the constraint. Add it as nullable first, backfill, then add the constraint. Or use a default value, but be aware that on large tables, Postgres rewrites every row when adding a column with a default (fixed in Postgres 11+ for non-volatile defaults).

MA

Written by Md Ashik

Senior Software Engineer building reliable backends. I write about the practical tradeoffs behind shipping software that holds up in production.