Hello! Welcome to your next lesson on designing high-load distributed systems.
In our previous lesson, we explored PostgreSQL partitioning as a powerful technique for managing very large tables. Now, we address the next logical challenge: how do you modify the schema of such a table—one that might be serving tens of thousands of requests per second—without taking your service offline?
Your work on high-availability payment platforms and trading systems has undoubtedly highlighted the business-critical need for continuous operation. A simple ALTER TABLE that locks a core table for even a few minutes is not an option. This lesson introduces a foundational pattern to solve this exact problem.
Our learning outcome is to analyze the stages of the expand-contract pattern, detailing the state of application code and database schema at each step to ensure zero-downtime deployment. This methodical approach is a cornerstone of modern database management and continuous delivery in high-load environments.
1. The Challenge: Why Naive Schema Changes Fail
In a development environment, changing a table schema seems simple. For instance, to rename a column, you might run:
ALTER TABLE users RENAME COLUMN username TO user_login;
However, on a production database with a high-traffic table, this operation typically acquires an ACCESS EXCLUSIVE lock. This is the most restrictive lock level in PostgreSQL, blocking all operations, including SELECT queries. For a system like a payment gateway, this translates directly to downtime, failed transactions, and a poor user experience.
To evolve our systems safely, we need a strategy that decouples the schema change from the application deployment and avoids these disruptive locks.
2. Introducing the Expand-Contract Pattern
The expand-contract pattern (also known as parallel change) is a systematic, multi-stage process for making backward-compatible changes to a system's interface—in our case, the database schema. It ensures that at every step, the system remains fully functional.
To get a high-level overview, please start with this introductory reading.
Using the expand and contract pattern | Prisma's Data Guide
This guide from Prisma's Data Guide provides a clear definition of the expand-contract pattern and its purpose in achieving zero-downtime migrations.
Please read the section 'What is the expand and contract pattern?'. Focus on its definition as a multi-step process and its key benefits: avoiding downtime and enabling rollbacks.
The core idea is to break the migration into three main phases:
- Expand: Additively introduce the new schema elements (columns, tables) alongside the old ones. The system "expands" to support both structures simultaneously.
- Migrate: Transition the application and data to use the new structure. This phase is often the most complex, involving data backfilling and careful changes to application logic.
- Contract: Once the old structure is no longer used, it is safely removed. The system "contracts" back to a single, consistent state.
This visual diagram provides an excellent summary of the entire flow, which we will now break down in detail.

3. A Detailed Analysis of the Stages
The power of this pattern lies in its deliberate, step-by-step nature, which requires careful coordination between database changes and application deployments. Let's analyze the state of the system at each stage for a common use case: renaming column_A to column_B.
Stage 1: Expand the Schema and Start Dual Writing
The first phase involves preparing the database and the application to handle both the old and new data structures.
-
Database Change:
- Add the new column (
column_B) to the table. - Crucially, the new column must be
NULL-able or have aDEFAULTvalue. This allows PostgreSQL to add the column without rewriting the entire table, making theALTER TABLEoperation a near-instantaneous metadata change that doesn't require a heavy lock.
ALTER TABLE my_table ADD COLUMN column_B <datatype> NULL; - Add the new column (
-
Application Change (Deployment 1):
- Writes: Modify the application code so that any
INSERTorUPDATEoperation writes the relevant data to bothcolumn_Aandcolumn_B. - Reads: The application continues to read exclusively from
column_A.
- Writes: Modify the application code so that any
At the end of this stage, column_A remains the source of truth for reads, but all new and updated data is being mirrored to column_B, ensuring it stays current going forward.
Using the expand and contract pattern | Prisma's Data Guide
This resource details the initial phases of the pattern: adding the new schema and updating the application to perform dual writes.
Please read 'Step 1: Build and deploy the new schema' and 'Step 2: Expand the interface'. Note the importance of making new columns nullable and the logic of dual writing.
Stage 2: Backfill Existing Data
Now that new data is being dual-written, we need to handle the historical data.
- Data Migration (Background Process):
- Run a script to copy data from
column_Atocolumn_Bfor all existing rows wherecolumn_BisNULL. - For large tables, this must be done in small, manageable batches to avoid long-running transactions, excessive I/O, and replication lag. A typical approach is to update a few thousand rows at a time in a loop with a small delay.
-- Run this repeatedly in a script until it updates 0 rows UPDATE my_table SET column_B = column_A WHERE id IN ( SELECT id FROM my_table WHERE column_B IS NULL AND column_A IS NOT NULL LIMIT 1000 ); - Run a script to copy data from
- Application State: The application continues to run as before (reading from A, writing to A and B). This ensures that any records updated by users during the backfill process are correctly handled by the application's dual-write logic.
Using the expand and contract pattern | Prisma's Data Guide
This part of the guide covers the critical data migration step, which runs in the background while the application is live.
Now, read 'Step 3: Migrate existing data to the new schema'. Given your experience, consider the operational challenges of running such a backfill on a multi-terabyte table in a live production environment.
At the end of this stage, both column_A and column_B contain identical data. You can verify this with queries that check for discrepancies.
Stage 3: Switch Reads to the New Column
With the data fully migrated and consistent, we can now switch the application to use the new column as its source of truth.
- Application Change (Deployment 2):
- Reads: Modify the application code to read from
column_Binstead ofcolumn_A. - Writes: The application continues to write to both columns. This is a safety measure; if a critical bug is found after deployment, you can quickly roll back to the previous application version without data loss, as
column_Ais still being maintained.
- Reads: Modify the application code to read from
After this deployment is verified as stable in production, you can proceed to the next step. This is often controlled by a feature flag for maximum safety.
Stage 4: Stop Writing to the Old Column (Contract Phase Begins)
Now we begin to "contract" the system by phasing out the old structure.
- Application Change (Deployment 3 or Feature Flag Flip):
- Writes: Modify the application to stop writing to
column_A. It now reads from and writes to onlycolumn_B. - Reads: Continue reading from
column_B.
- Writes: Modify the application to stop writing to
At this point, column_A is no longer being updated and its data becomes stale. The migration is functionally complete from the application's perspective.
Using the expand and contract pattern | Prisma's Data Guide
These steps describe the cutover, where the application logic is switched to use the new schema. The example in the full text also mentions using feature flags, a common practice for this kind of controlled rollout.
Read 'Step 4: Test the new interface', 'Step 5: Cut reads over to the new interface', and 'Step 6: Discontinue writing to the original structure'.
Stage 5: Clean Up
The final step is to remove the old, unused elements from the system.
- Application Change (Optional, good practice):
- Remove any code that references
column_A. This can be done in a follow-up cleanup task.
- Remove any code that references
- Database Change:
- Drop the old column. This is another fast, non-blocking metadata operation.
ALTER TABLE my_table DROP COLUMN column_A;
This GitHub discussion provides a concise, practical summary of the pattern framed in terms of deployments, which should resonate with your role as an Engineering Manager.
Implementation Guidance for Expand and Contract Pattern ...
This discussion frames the pattern in terms of 'Change Sets', each corresponding to a deployment, which is a very practical way to think about execution.
Skim through the initial post. Notice how the author breaks the process down into three distinct deployments. This reinforces the phased nature of the pattern and the tight coupling between code and schema changes.
Activity: Mapping the System States
To solidify your understanding, let's map the state of the system at each major step.
| Stage | DB Schema State | Application Read Target | Application Write Target(s) |
|---|---|---|---|
| Initial | column_A exists |
column_A |
column_A |
| 1. Expand | column_A, column_B (nullable) |
column_A |
column_A, column_B |
| 2. Backfill | column_A, column_B (data being copied) |
column_A |
column_A, column_B |
| 3. Switch Read | column_A, column_B (fully populated) |
column_B |
column_A, column_B |
| 4. Stop Old Write | column_A (stale), column_B |
column_B |
column_B |
| 5. Contract | column_B only |
column_B |
column_B |
This table clearly shows how the source of truth for reads and the targets for writes evolve, ensuring a valid data source is always available to the application.
Conclusion
In this lesson, we have dissected the expand-contract pattern, a critical strategy for zero-downtime schema evolution in high-load systems.
Key Takeaways:
- Avoids Downtime: The pattern's primary goal is to avoid taking
ACCESS EXCLUSIVElocks on live tables, ensuring continuous availability. - Coordinated Changes: Successful execution requires careful coordination across multiple deployments, involving both database schema alterations and application code updates.
- Phased & Reversible: The process is broken into distinct phases (Expand, Migrate, Contract). Most steps are reversible, providing a safety net if issues arise.
- Key Enablers: The process relies on backward-compatible changes, such as adding nullable columns, dual writing, and using feature flags for controlled cutovers.
Preview of the Next Lesson
Theory is essential, but practice is where mastery is built. In our next lesson, "Implement a zero-downtime column addition using the expand-contract pattern," we will move from analysis to implementation. You will apply the stages we've discussed today to a practical exercise in PostgreSQL, solidifying your understanding of the mechanics at each step.