How zero-downtime database migrations work

Binadit Tech Team 31 July 2026 9 min lees
How zero-downtime database migrations work

What a zero-downtime migration actually means

A database migration changes the structure of your data: adding a column, renaming a table, changing a data type, splitting a monolithic table into two. The problem is not the change itself. The problem is that production databases serve live traffic while you make it.

Zero-downtime migration does not mean the migration runs instantly. It means the application keeps serving reads and writes correctly while the schema transitions from old to new. That distinction matters for infrastructure performance optimization work, because most migration failures are not caused by bad SQL. They are caused by lock contention, application code that assumes a schema state that no longer exists, or a cutover step that briefly takes the primary offline.

Engineers who understand this well treat a migration as a sequence of small, reversible steps rather than one large deployment. That mental model is the foundation for everything below.

How it actually works under the hood

Picture a production PostgreSQL or MySQL instance handling a few thousand queries per second through a connection pool. Your application servers hold a schema assumption baked into the ORM models and prepared statements. The database holds the actual schema. A migration is the process of moving both, in sync, without a moment where they disagree in a way that breaks a query.

Most schema changes fall into one of three categories, and each behaves differently under load:

  • Additive changes (new column, new table, new index): generally safe, but can still lock a table depending on the engine and whether a default value forces a full table rewrite.
  • Destructive changes (dropping a column, renaming a table): dangerous if any running application code still references the old structure.
  • Transformative changes (changing a column type, splitting a table, moving data to a new shape): require a temporary state where both old and new structures exist simultaneously.

The pattern that handles all three safely is known as expand-contract (sometimes called parallel change):

  1. Expand: add the new schema element alongside the old one. New column, new table, new index. Nothing is removed yet.
  2. Migrate: backfill data into the new structure and update application code to write to both old and new locations (dual-write) or read from the new one with a fallback.
  3. Verify: confirm data consistency between old and new structures under real traffic, not just in staging.
  4. Contract: once all application instances are confirmed to use the new structure, remove the old column, table, or index.

The reason this avoids downtime is simple: at every point in the sequence, both the currently deployed application code and the previous version can run against the schema without erroring. That matters because deployments are rolling, not instant. During a rolling deploy, you might have old and new application code running against the same database for 30-90 seconds. If your migration does not tolerate that overlap, you get sporadic 500 errors that look random but are entirely deterministic.

Underneath this, the database itself is doing locking work you need to understand. In PostgreSQL, adding a column with a constant default is metadata-only since version 11 and takes milliseconds regardless of table size. Adding a column with a non-constant default, or adding a NOT NULL constraint without a default, historically forced a full table rewrite that holds an ACCESS EXCLUSIVE lock, blocking all reads and writes for the duration. MySQL's InnoDB has similar distinctions: some ALTER TABLE operations use INSTANT or INPLACE algorithms that avoid locking, others fall back to COPY, which rebuilds the entire table.

Concrete examples with real numbers or configs

Here is a real scenario: a SaaS platform with a 40 million row orders table needs to rename status to order_status because a new fulfillment module conflicts with the old naming.

The naive approach (what breaks):

ALTER TABLE orders RENAME COLUMN status TO order_status;

This is fast in PostgreSQL, metadata-only. But every application server still running the previous deploy references status in raw queries or ORM mappings. The moment this statement commits, every one of those queries starts failing with "column does not exist." On a rolling deploy across 12 application pods, that is a guaranteed multi-minute error spike.

The expand-contract approach:

-- Step 1: expand
ALTER TABLE orders ADD COLUMN order_status varchar(20);

-- Step 2: backfill in batches to avoid long-running transactions
UPDATE orders SET order_status = status
WHERE id BETWEEN 1 AND 50000 AND order_status IS NULL;
-- repeat in batches of 50k until complete

-- Step 3: add a trigger or dual-write in application code
-- so new rows populate both columns during the transition

-- Step 4: deploy application code that reads order_status,
-- falls back to status if null, for one full release cycle

-- Step 5: after confirming zero fallback reads in logs for 7 days
ALTER TABLE orders DROP COLUMN status;

Batching the backfill matters at this scale. A single UPDATE across 40 million rows can hold row locks and generate WAL/binlog volume that causes replication lag, which then cascades into read-replica staleness for any service relying on eventual consistency. Batches of 10k-50k rows with short pauses between them keep replication lag under a second and avoid long-running transactions that block autovacuum in PostgreSQL.

For larger transformative changes, like splitting a users table into users and user_profiles for a SaaS platform with 200 million rows, the timeline looks different:

PhaseDurationRisk level
Expand: create new table, add foreign keysMinutesLow
Backfill via batched job4-10 hoursLow, if throttled
Dual-write period in application1-2 weeksMedium, requires monitoring
Read cutover, verify consistency3-5 daysMedium
Contract: drop old columnsMinutesLow, if verification passed

The total calendar time is measured in weeks, but the actual risk window, the moments where a mistake causes visible errors, is compressed into a handful of short, controlled deploy events. That is the trade being made: more elapsed time for a dramatically smaller blast radius.

Trade-offs and design decisions

Zero-downtime migration is not free. It costs engineering time, adds temporary complexity, and requires discipline that is easy to skip under deadline pressure. A few honest trade-offs:

  • Dual-write complexity: maintaining two write paths temporarily doubles the surface area for bugs. A missed dual-write path is the most common cause of silent data drift during migrations.
  • Longer total migration time: a change that could technically run in one blocking transaction over 10 minutes now takes days or weeks when done incrementally. For low-traffic internal tools, this overhead often is not worth it.
  • Read replica lag under batched backfills: even throttled backfills generate write volume. On a busy primary, this can measurably increase replication lag, which matters if your application routes any reads to replicas.
  • Rollback complexity: a single ALTER TABLE is trivial to roll back. A multi-phase migration with dual writes requires a rollback plan for each phase, not just the final state.

There is also a decision about tooling. Online schema change tools like gh-ost or pt-online-schema-change for MySQL work by creating a shadow copy of the table, replaying changes via triggers or binlog, and swapping the table atomically at the end. They automate much of the expand-contract pattern for a single ALTER, but they add operational overhead: monitoring the shadow table copy, watching for lag, and handling failures mid-copy on a multi-terabyte table.

For PostgreSQL, native support for concurrent index creation (CREATE INDEX CONCURRENTLY) and instant column additions have reduced the need for third-party tooling in many cases, but large table rewrites (changing a column type, for example) still often require the expand-contract pattern manually or via extensions like pg_repack.

When to use it, when not to

Zero-downtime migration patterns are worth the overhead when:

  • The table is large enough that a blocking ALTER would take longer than your acceptable maintenance window (a rough rule of thumb: anything over a few million rows on a busy table).
  • The service has an SLA that makes any planned downtime a contractual or reputational problem, common in high availability infrastructure for fintech or payment platforms.
  • Deploys are rolling and asynchronous, meaning old and new application code will run concurrently against the database regardless of what you do about the schema.
  • The change is destructive or transformative, not purely additive.

It is reasonable to skip the full pattern when:

  • The table is small (under a few hundred thousand rows) and the change is a simple additive one with a metadata-only cost.
  • You control a genuine maintenance window and the business impact of a 5-minute planned pause is acceptable and communicated.
  • The environment is staging or internal tooling where a short blip has no customer-facing consequence.

The judgment call is really about blast radius versus overhead. A 3-person internal tool team doing a full expand-contract cycle for a 50,000-row table is spending more engineering time on process than the risk justifies. A payments platform doing a blocking ALTER on a live orders table during business hours is taking on risk that has no upside.

This same blast-radius thinking applies broadly across zero-downtime migration work generally, not just schema changes. Infrastructure cutovers, DNS changes, and application deploys all benefit from the same incremental, reversible mindset.

Further reading and how we approach this

A few things worth understanding before you plan your next schema change: how your database engine's locking behavior differs between versions (PostgreSQL 11+ changed several ALTER TABLE cost profiles significantly), how your ORM handles schema drift between deploy versions, and whether your monitoring can actually detect a fallback read path silently failing.

If you want to go deeper on the operational side of migrations beyond schema changes, our guide on identifying database warning signals covers how to know when a migration is becoming urgent versus routine maintenance.

We design and run this kind of infrastructure for European businesses every day. Explore our managed cloud platform.