Marcio Cunha

Zero-Downtime Database Migrations Using the Expand and Contract Pattern

Learn how to modify relational database schemas in production without interrupting service by applying the step-by-step column and table expansion and contraction technique.

Marcio Cunha12 min
Also available in:EspañolPortuguês
Summary
  • Direct structural alterations on large databases cause locks that take down entire systems for hours.
  • The expand and contract method decouples database changes into isolated steps that coexist peacefully with old and new code.
  • Creating intermediary columns allows old and updated application versions to read and write data simultaneously without corruption.
  • The definitive removal of legacy columns occurs only after complete publication and validation cycles in the production environment.
  • Rigorous backward compatibility discipline eliminates the risk of downtime and restores predictability to software delivery cycles.

The invisible challenge of structural changes in databases

When we need to alter data formats in a large-scale system, the first instinct is usually simple: alter the table and deploy the new code. In practice, production systems process thousands of requests per second, and any command that blocks access to the main table causes cascading failures, frustrating users and overloading support teams.

In relational databases like PostgreSQL or MySQL, altering a column or adding complex constraints often requires the system to entirely rewrite the data file on disk. During this rewrite, the database locks the table to prevent inconsistencies, turning a simple routine update into a widespread outage.

To solve this impasse without interrupting user traffic, software engineering adopts the pattern known as Expand and Contract. In practical terms, this approach breaks a risky change into several incremental and safe steps, allowing the application to evolve continuously without anyone noticing the transition behind the scenes.

Understanding the expansion and contraction lifecycle

The core concept behind Expand and Contract is ensuring that database changes always happen in an additive manner before becoming destructive. Instead of modifying what already exists, we create new structures alongside the old ones, temporarily populating both until the entire system depends exclusively on the new feature.

This lifecycle is usually divided into three distinct phases: expansion, migration, and contraction. In expansion, we prepare the database to accept the new format without breaking the legacy code still running on production servers. In migration, we move old data to the new location and gradually update the application code.

In the final phase, contraction, we safely remove everything that has become obsolete, such as old columns or duplicated tables. This surgical care avoids the dreaded planned downtime moment, ensuring the service remains accessible twenty-four hours a day.

Practical scenario: splitting full name into first and last name

Imagine we have a table called customers with a single column called full_name, containing both first and last names together. The new business rule requires the system to store this data separately in two distinct columns: first_name and last_name.

If we altered the table all at once to drop the old column and create the two new ones, all ongoing queries and registrations would fail instantly. The current application code would try to write data to the old column that no longer exists, generating critical execution errors that crash the payment or registration flow.

To avoid this collapse, we apply the first step of the pattern: we add the new columns first_name and last_name to the table, leaving the old full_name column intact. At this exact moment, the database has both the old and new structures coexisting in harmony, paving the way for code transition.

Updating application code with dual write support

With the new columns created in the database, the next step is updating the application to support a transition state known as dual writing. During this period, application code is adjusted to continue writing the full name to the old column while also populating the newly created columns.

For queries and reads, the system can continue using the old column while the background copy process is not yet completed. This momentary redundancy is the secret to avoiding any disruption, ensuring that if an error occurs in the new logic, the main flow continues operating without interruption.

Below is an illustrative code example demonstrating how the application handles this dual write during the structural transition period:

def update_customer(connection, customer_id, full_name):
parts = full_name.split(' ', 1)
first = parts[0]
last = parts[1] if len(parts) > 1 else ''

# Dual write: keep the old column and populate the new ones
cursor = connection.cursor()
cursor.execute(
"UPDATE customers SET full_name = %s, first_name = %s, last_name = %s WHERE id = %s",
(full_name, first, last, customer_id)
)
connection.commit()

This simple snippet illustrates perfectly how the system acts as a bridge between the past and the future, ensuring no data is lost or incorrectly interpreted by business routines.

Synchronizing existing historical data

Adding columns and updating code for new records solves only half the problem, because old records already in the database still have the new columns empty. To fill this gap, we need to run a background batch process known as historical data migration.

This process reads old records in small blocks, for example, one thousand rows at a time, and updates the corresponding new columns without overloading the database server memory. Doing this in controlled batches prevents sudden spikes in CPU and disk usage that could slow down the system for end users.

After completing this sweep, all table rows have their data correctly duplicated between the old and new structures. At this point, the application is ready to switch its primary read source to the newly created columns.

Adjusting queries and preparing for contraction

With historical data fully synchronized and code adapted to write in both places, the next step is altering application queries to read exclusively from the new columns. This ensures business logic is validated and operating with the new structural scheme.

After deploying this change and monitoring the system for a few days to ensure there are no hidden errors, we enter the final phase of the pattern known as contraction. In this stage, we remove the code that wrote to the old column and finally drop the full_name column from the database table.

Dropping an old column in modern systems must be done with caution, but since the application has completely stopped using it, this operation occurs without any risk of breakage or data loss in production.

Final considerations on safe schema evolution

Implementing structural changes without interrupting system operation requires discipline, planning, and breaking complex tasks into smaller, reversible steps. The Expand and Contract pattern transforms a stressful database operation into a predictable and safe engineering routine.

Although this approach requires more lines of temporary code and greater initial planning effort, return on investment is invaluable. Ensuring high availability and operational confidence solidifies the team's technical maturity and preserves the best possible experience for those using the system every day.