Database Migrations Without Downtime: A Practical Guide for Small Teams

Most database migration horror stories share the same root cause: a migration that works perfectly on a small local database locks up or breaks a production table with millions of rows, because the two databases behave differently at scale in ways a small team doesn’t discover until it’s too late. Understanding why this happens is most of what’s needed to avoid it.

Why a Migration That Works Locally Can Break Production

Adding a column with a default value, for example, seems trivial — until that operation on a large table requires rewriting every existing row to fill in the new default, which on a table with tens of millions of rows can take minutes to hours and, on many database engines, holds a lock that blocks reads or writes to that table for the entire duration. Locally, with a few hundred test rows, the exact same migration completes instantly and gives no warning that it behaves completely differently at real scale.

This gap between “fast on a small table” and “table-locking on a large one” is the single most common cause of migration-related outages, and it’s specifically why migrations deserve more caution than most other kinds of code changes: a bug in application code usually fails loudly and gets rolled back quickly, while a slow migration can sit there blocking production traffic for the full duration it takes to run.

The Core Technique: Splitting Schema Changes From Data Changes

The most reliable pattern for avoiding migration outages is separating “changing the shape of the table” from “changing the data in the table,” and running them as distinct steps rather than one combined operation:

  • Adding a column: add it as nullable, with no default value computed for existing rows. This is typically fast regardless of table size, because it doesn’t require touching every existing row.
  • Backfilling the data: populate the new column’s values in small batches (a few thousand rows at a time, in a loop, with a short pause between batches) rather than in a single update statement that touches the entire table at once.
  • Enforcing constraints: only after the backfill is complete and verified, add a NOT NULL constraint or a default value going forward, as a separate, fast operation.

This turns one risky, table-locking operation into several small, safe ones — each individually fast, and each independently revertible if something goes wrong partway through.

Zero-Downtime Deploys Require the App and the Schema to Agree During the Transition

A subtlety that catches teams off guard: during a rolling deploy, old and new versions of your application code are often running simultaneously against the same database for some period of time. If a migration renames a column and the new application code expects the new name while old instances (still shutting down) expect the old name, requests hitting the old instances will fail during that overlap window.

The practical fix is to make schema changes backward-compatible for at least one deploy cycle: instead of renaming a column directly, add the new column alongside the old one, deploy application code that writes to both and reads from the new one, backfill existing data, then remove the old column in a later, separate deploy once you’re certain no running code still depends on it. This adds a couple of extra steps compared to a direct rename, but it’s what actually makes a rolling deploy safe.

What to Actually Check Before Running a Migration on Production

A short, practical pre-flight checklist catches most of the common failure modes: estimate the table’s row count and consider whether the operation touches every row (adding an indexed default, for instance) versus just the schema metadata (adding a nullable column); check whether the migration acquires a long-held lock on your specific database engine for this specific operation, since this varies significantly between Postgres, MySQL, and others, and even between versions of the same engine; and, for anything uncertain, test against a copy of production-scale data, not just your local development database with a handful of rows.

For genuinely large or risky migrations, running them during a low-traffic window, with monitoring open and a clear rollback plan already written down before starting, turns a stressful, improvised response into a boring, pre-planned one — which is exactly the goal.

Renaming or Dropping a Column Deserves the Same Caution

The expand-then-contract pattern described above for renames applies equally to dropping a column that’s no longer needed. Dropping it immediately, the moment you believe nothing uses it, risks breaking any code path you missed — a background job, a reporting query, an old mobile app version still in the wild that expects the old schema. A safer sequence: stop writing to the column first, monitor for any errors or unexpected behavior for a reasonable period, confirm through logs or query analysis that nothing is reading it either, and only then drop it in a separate, later migration.

Keeping a simple running list of columns that are mid-deprecation — stopped writing on this date, confirmed no reads by this date, safe to drop after this date — prevents this process from silently stalling forever, which is what tends to happen when there’s no explicit tracking and the eventual cleanup step quietly never gets prioritized.

Tooling Doesn’t Replace Understanding the Underlying Mechanics

Migration frameworks (in whatever language or ORM you use) make writing and versioning migrations easier, but none of them protect you from a migration that’s fundamentally unsafe for your table’s size — they’ll run the risky operation just as readily as a safe one unless you specifically design around the patterns above. Treating a migration framework as a safety net rather than a bookkeeping tool is a common and costly assumption; the safety has to be designed into the migration itself, not assumed from the tool that runs it.

Small teams that internalize the split-into-small-safe-steps pattern tend to stop dreading schema changes altogether, because the changes that used to require a maintenance window and a nervous on-call rotation become routine, low-drama deploys instead.

Leave a Reply

Your email address will not be published. Required fields are marked *