Databases

Master Zero Downtime Schema Migrations with Expand-and-Contract

Database schema changes are a critical point of failure for high-availability applications. Master the expand-and-contract pattern to execute zero downtime schema migrations, ensuring continuous service and preventing costly outages during database evolution.

Krapton Engineering
Reviewed by a senior engineer9 min read
Share
Master Zero Downtime Schema Migrations with Expand-and-Contract

In today's continuously deployed environments, a database schema change can be the most dangerous operation you perform. A simple ALTER TABLE statement, if not carefully managed, can introduce significant downtime, leading to lost revenue, frustrated users, and damaged trust. Achieving truly evolutionary database design requires strategies that keep your application online, even as its data model evolves.

TL;DR: Zero downtime schema migrations are essential for high-availability applications. The expand-and-contract pattern phases database and application changes over time, preventing service interruptions by allowing old and new code to coexist during the transition. This robust strategy ensures continuous operation, even for complex schema evolutions.

Key takeaways

A flock of birds flying in a V formation against a clear blue sky with fluffy clouds.
Photo by Çiğdem Bilgin on Pexels
  • Traditional ALTER TABLE operations can cause unacceptable downtime due to database locks.
  • The expand-and-contract pattern is a multi-phase strategy to achieve zero downtime schema migrations.
  • It involves expanding the schema, migrating data, switching application logic, and then contracting the old schema.
  • Careful planning, dual-writes, and idempotent data backfills are crucial for success.
  • This pattern is ideal for critical, high-traffic applications but adds complexity.

The Peril of Database Schema Changes in 2026

A flock of cranes silhouetted against a vibrant sunset sky in Drebber, Germany.
Photo by Georg Wietschorke on Pexels

As applications scale and business requirements shift, your database schema must evolve. However, direct modifications like adding a non-nullable column or renaming a table can trigger exclusive locks that block reads and writes for the duration of the operation. For a Postgres database handling thousands of transactions per second, even a few seconds of lock contention can lead to cascading failures and a full-blown outage.

In a recent client engagement, we observed a seemingly innocuous ALTER TABLE ADD COLUMN new_status TEXT NOT NULL DEFAULT 'pending'; statement on a Postgres 16 table with over 500 million rows. Despite the DEFAULT clause, the operation required a full table rewrite to apply the default value, acquiring an ACCESS EXCLUSIVE lock. This blocked all application traffic for over 15 minutes, causing a critical service disruption that could have been avoided with a phased approach. Understanding these locking behaviors, as detailed in the PostgreSQL official documentation on ALTER TABLE, is fundamental to preventing such incidents.

Introducing the Expand-and-Contract Pattern for Zero Downtime Schema Migrations

The expand-and-contract pattern is a robust, multi-phase strategy designed to evolve your database schema without incurring downtime. It decouples database changes from application code changes, allowing you to deploy modifications incrementally. Think of it like renovating a house: you don't tear down a wall while people are still living there. Instead, you build a new structure, move things over, and only then remove the old parts.

This pattern is particularly vital for modern SaaS platforms and enterprise applications where 24/7 availability is non-negotiable. It ensures that your application can always read and write data, even during the transition period when both the old and new schema versions coexist. This approach mitigates the risk of downtime, data corruption, and application errors that often plague traditional, monolithic migration strategies.

Step-by-Step Guide: Implementing Expand-and-Contract

Let's illustrate the expand-and-contract pattern with a common scenario: adding a new, non-nullable column to an existing table. We'll assume a products table and want to add a sku column.

Phase 1: Expand (Database-only changes)

First, modify the database schema to introduce the new column, but make it nullable and without any immediate constraints that would cause a full table rewrite or acquire an exclusive lock. This step should be non-blocking.


-- Step 1.1: Add the new column as nullable
ALTER TABLE products
ADD COLUMN sku TEXT;

-- Step 1.2: Add a unique index concurrently (if needed)
CREATE UNIQUE INDEX CONCURRENTLY idx_products_sku ON products (sku) WHERE sku IS NOT NULL;

Note the use of CONCURRENTLY for index creation, which allows the table to remain accessible during index build. This phase is purely database-focused and does not require application changes.

Phase 2: Migrate (Application & Data Sync)

This is the most complex phase. Your application code is updated to write data to both the old schema (implicitly, by ignoring the new column) and the new column. Crucially, you'll also need to backfill existing data for the new column. This typically involves a separate, idempotent migration script or background job.

For our sku example, if SKUs are generated, your application would start generating and writing them. If they come from an external system, you'd integrate that. For existing rows, a script would populate the new sku column for all rows where it's currently NULL. On a production rollout for a critical payment system, our team measured the importance of idempotent backfill jobs. If a backfill fails midway, it must be resumable without duplicating or corrupting data. Monitoring tools were critical to track the progress and lag of these dual-writes and backfills, ensuring data consistency before proceeding.

This phase also involves updating any APIs or services that interact with the products table to handle the new sku column. For help with such complex custom API development, Krapton's engineers can provide expert guidance.

Phase 3: Switch (Application-only change)

Once the data migration is complete and you're confident that all new writes are populating the sku column correctly, you can update your application to read exclusively from the new column and enforce any new logic associated with it. This is often managed with feature flags, allowing for an immediate rollback if issues arise.

At this point, the application no longer relies on the old schema's implicit behavior regarding the missing sku column. All reads and writes are directed to the new structure.

Phase 4: Contract (Database cleanup)

Finally, after a sufficient soak period (e.g., a few days to weeks) to ensure stability, you can remove the old, now unused schema elements. This step cleans up your database and often involves adding the final constraints.


-- Step 4.1: Add the NOT NULL constraint concurrently
-- This is crucial for large tables to avoid blocking locks.
ALTER TABLE products
ADD CONSTRAINT products_sku_not_null CHECK (sku IS NOT NULL) NOT VALID;

ALTER TABLE products
VALIDATE CONSTRAINT products_sku_not_null;

ALTER TABLE products
ALTER COLUMN sku SET NOT NULL;

-- Step 4.2: Drop the old column (if applicable, not in this specific example)
-- ALTER TABLE products DROP COLUMN old_column_name;

The `ADD CONSTRAINT ... NOT VALID` followed by `VALIDATE CONSTRAINT` pattern is a Postgres-specific trick to add `NOT NULL` constraints to large tables without acquiring an `ACCESS EXCLUSIVE` lock for the entire validation period. This is a critical optimization for zero downtime schema migrations. Integrating such complex migration steps into your CI/CD pipeline benefits greatly from robust DevOps services.

Common Pitfalls and Advanced Considerations

  • Lock Contention: Always be aware of the lock levels your DDL statements acquire. Prefer `CREATE INDEX CONCURRENTLY` and phased `ALTER TABLE` operations for adding `NOT NULL` constraints as shown above.
  • Long-Running Transactions: Ensure your data backfill scripts are idempotent and run in batches to avoid creating excessively long transactions that can block VACUUM or cause replication lag.
  • Foreign Keys: Adding or modifying foreign key constraints requires careful planning, often involving temporary removal, phased updates, and re-addition to prevent downtime.
  • Migration Tools: Tools like Flyway or Liquibase manage schema versioning but do not inherently implement the expand-and-contract pattern. You must design the individual migration scripts to follow the pattern. However, they provide a framework for executing the phased non-blocking migrations.

When NOT to use this approach

While powerful, the expand-and-contract pattern introduces significant complexity. It is overkill for:

  • Small, non-critical applications where a brief maintenance window is acceptable.
  • Development or staging environments where downtime has no production impact.
  • Simple, non-blocking changes, such as adding a nullable column without a default, or creating an index concurrently, which can often be done directly without application code changes.

Always weigh the cost of complexity against the business impact of potential downtime.

Example: Renaming a Column with Zero Downtime

Renaming a column, say `email` to `user_email` in a `users` table, is a classic use case for expand-and-contract:

  1. Expand: Add the new `user_email` column to `users` as nullable.
    ALTER TABLE users ADD COLUMN user_email TEXT;
  2. Migrate:
    • Update application code to write to both `email` and `user_email` (dual-write).
    • Run a backfill script to copy `email` values to `user_email` for all existing rows.
    • Update application code to read from `user_email` if present, falling back to `email`.
  3. Switch: Update application code to write and read *only* from `user_email`. Remove all references to `email` from the application. This can be a separate deployment, potentially using a feature flag.
  4. Contract: After a safe period, drop the old `email` column.
    ALTER TABLE users DROP COLUMN email;

This phased approach ensures that at no point is the application unable to access user email data.

Migration Strategy Downtime Risk Complexity Best For
Direct ALTER TABLE High (locks on large tables) Low Development, staging, small non-critical tables, non-blocking changes (e.g., adding nullable column without default).
Expand-and-Contract Very Low (near-zero) High (multi-phase application and DB changes) High-availability production systems, large tables, critical services where any downtime is unacceptable.

FAQ

What is the expand-and-contract pattern in database migrations?

The expand-and-contract pattern is a strategy for performing database schema changes incrementally without downtime. It involves adding new schema elements (expand), migrating data and dual-writing (migrate), switching application logic to the new schema (switch), and finally removing the old elements (contract).

Why are traditional ALTER TABLE statements risky for production databases?

Traditional ALTER TABLE statements can acquire exclusive locks on tables, preventing reads and writes during the operation. On large tables or high-traffic systems, this can lead to significant downtime and service disruption, impacting user experience and business operations.

Can ORMs like Prisma or Drizzle handle zero downtime migrations automatically?

While ORMs simplify schema definition and generation of migration scripts, they don't inherently automate the multi-phase expand-and-contract pattern for zero downtime. You still need to orchestrate the application code changes, data backfills, and phased deployments manually, even when using an ORM's migration capabilities.

How does this strategy prevent data loss during schema changes?

By implementing dual-writes and idempotent backfill scripts, the expand-and-contract pattern ensures that both old and new data representations are maintained and synchronized during the transition. If an issue arises, the application can temporarily revert to using the old schema, preventing data loss and ensuring consistency.

Scale Your Database Changes with Confidence

Mastering zero downtime schema migrations with the expand-and-contract pattern is a cornerstone of building highly available, resilient applications. It's a complex undertaking that demands deep database expertise and careful coordination between development and operations teams. If your team is grappling with database performance issues, or you need to implement advanced zero downtime schema migrations at scale, our principal-level backend engineers at Krapton are ready to help. Book a free consultation with Krapton to discuss your specific challenges and architect a robust solution.

About the author

Krapton Engineering is a team of principal-level software engineers with extensive hands-on experience designing, building, and scaling high-performance database systems for startups and enterprises globally. We specialize in Postgres performance, complex schema evolution, and architecting resilient backend solutions that ensure continuous application uptime.

postgresqldatabase performancesqlschema designmigrationsbackend engineeringzero downtimeapplication uptimeexpand-and-contract
About the author

Krapton Engineering

Krapton Engineering is a team of principal-level software engineers with extensive hands-on experience designing, building, and scaling high-performance database systems for startups and enterprises globally. We specialize in Postgres performance, complex schema evolution, and architecting resilient backend solutions that ensure continuous application uptime.