Database Migrations for Zero Downtime: Expand/Contract, Compatibility, Risks
AI generated
CI/CD
.yml
GitLab · Magento · Database · Zero Downtime
Database Migrations for Zero Downtime
Expand/Contract, compatibility, and risks assessed realistically

No other part of a zero downtime deployment fails as often as database migrations. Renaming a column, dropping a table, adding a NOT NULL constraint: these are operations that are simply not compatible with code that is still running. This article explains the expand/contract pattern, when it works, and when a maintenance window is the more honest answer.

14 min read Expand/Contract · db_schema.xml · Online Schema Change Magento 2.4.x · MySQL 8.x · GitLab 16.x

1. Why database migrations put zero downtime at risk

Zero downtime deployment assumes that the database can be read and written by both the old and the new code version at the same time. That sounds obvious, but it is not: many ordinary schema changes are not compatible with the code version that is still in production while the migration runs. Take an example: the new code expects a column called customer_consent. The old code does not know it and ignores it, which is fine and compatible. But if that column is NOT NULL with no default, every INSERT from the old code fails because it never populates that column. That is downtime, even if no maintenance window was ever planned.

The problem is structural: setup:upgrade in Magento runs migrations before the new code is fully active. In a zero downtime model without a maintenance window, the old code version keeps running while the migration executes. After the migration, the database is already in the new state while the code is still in the old one. Any schema change that is incompatible with the old code produces errors during this window. The expand/contract pattern is the structural approach for making that window safe, but it requires deliberate design for every schema change, not just fast DDL writing.

2. The expand/contract pattern: theory and practice

The expand/contract pattern splits every breaking schema change into two phases: expand (a safe, backward compatible change) and contract (cleanup, only once the old code is no longer running). Renaming a column from street to address_line is not carried out as a single migration but split up: expand, add the new column address_line (the old code ignores it), and have the new code write to both columns. Contract, after one or more successful releases once no old code version is running anymore, drop the street column.

In practice this means some deployments need two separate releases. The expand release adds the new structure and runs alongside the old code. The contract release removes the old structure and runs only with the new code. Between the two releases you must ensure there is no rollback to a code version that still expects the old column. The expand/contract pattern raises the planning effort but systematically eliminates the whole class of "migration broke the running code" failures. For Magento teams that genuinely want zero downtime, it is not an optional pattern but a mindset to apply to every schema change.

3. Magento db_schema.xml and setup:upgrade in a zero downtime context

Magento 2.3+ uses the declarative schema system built around db_schema.xml. The system compares the desired schema state, described in XML, with the current database state and generates the necessary DDL statements. That is a step forward compared to the old InstallSchema classes, but it does not change the underlying problem: setup:upgrade runs the migrations and afterward the database is in the new state, regardless of whether the old code is still running.

In a zero downtime model there are two approaches for setup:upgrade: it runs before the symlink switch in the new release directory, while the old version is still active, or it runs after the symlink switch. The first approach is correct for backward compatible migrations: the database is in the new state and the old code can still work with it. The second approach minimizes the time window in which new code runs against an old database, but it is only safe for backward compatible changes. For incompatible changes there is no safe approach without a maintenance window, which is the honest statement that zero downtime promises often fail to make.

# Deployment pipeline with explicit migration step and compatibility check
deploy:production:
  stage: deploy
  environment: production
  script:
    - |
      ssh "$DEPLOY_USER@$DEPLOY_HOST" bash <<'REMOTE'
        set -euo pipefail
        RELEASE="$DEPLOY_PATH/releases/$(date +%Y%m%d-%H%M%S)"

        # Phase 1: Prepare new release directory
        mkdir -p "$RELEASE"
        rsync -az --delete /tmp/release/ "$RELEASE/"
        ln -sfn "$DEPLOY_PATH/shared/app/etc/env.php" "$RELEASE/app/etc/env.php"
        ln -sfn "$DEPLOY_PATH/shared/pub/media" "$RELEASE/pub/media"

        # Phase 2: Run migrations BEFORE switching symlink
        # Safe only for backward-compatible schema changes
        # If migration is breaking, enable maintenance mode here
        php "$RELEASE/bin/magento" setup:upgrade --no-interaction --keep-generated

        # Phase 3: Build static assets
        php "$RELEASE/bin/magento" setup:di:compile
        php "$RELEASE/bin/magento" setup:static-content:deploy en_US -f

        # Phase 4: Atomic symlink switch
        ln -sfn "$RELEASE" "$DEPLOY_PATH/current"
        php "$DEPLOY_PATH/current/bin/magento" cache:flush

        echo "Migration and deployment complete"
      REMOTE

4. Backward compatible schema changes: what is allowed

Backward compatible means: the old code can still correctly read and write after the migration. This includes the following operations. Adding a new column with a NULL default or a DEFAULT value that the old code does not need to populate explicitly. Adding a new table: the old code simply ignores it. Adding an index: this does not change any data structures, only query performance. Making a column nullable: this lets the old code write NULL if it does not know the column at all. These operations form the expand phase in the expand/contract context.

Some changes that are not obviously backward compatible turn out to be fine too: increasing the length of a VARCHAR column, for example from 100 to 255, does not break the old code because already stored data remains readable and the old code never writes data that exceeds the new length. Adding a new ENUM option to an existing ENUM column is compatible as long as the old code never writes that option. Removing an index is compatible too, since it only affects performance, not data structure. Anyone who knows these categories and applies them deliberately can make most release migrations zero downtime compatible.

5. Incompatible changes: when a maintenance window is required

Incompatible schema changes are ones where the old code fails after the migration. The "drop column" category is unambiguous: if the old code accesses a column that no longer exists, it errors out. The "rename column" category is equivalent, since from the database's perspective a rename is a drop of the old column and an add of the new one; the old code does not know the new name. Adding a NOT NULL constraint without a default breaks every INSERT from the old code that does not populate the column. Changing a data type incompatibly (for example VARCHAR to INT) can make stored data unreadable or cause conversion errors.

For these changes there is no zero downtime solution without reworking the approach: the expand/contract pattern defers the incompatible phase to a separate release, once no old code is running anymore. Anyone who cannot or does not want to do that, for instance because the release bundles a single large update with new features and breaking schema changes, needs a maintenance window. That is the honest answer. Maintenance mode in Magento (bin/magento maintenance:enable) makes sure no requests are processed while the migration runs. That is not a failure, it is a controlled trade-off that beats an uncontrolled failure in production.

6. Online schema change: pt-online-schema-change and gh-ost

For large tables in Magento projects, the real problem is often not compatibility but the runtime of the migration. An ALTER TABLE on a catalog_product_entity_varchar table with millions of rows can take minutes or hours, during which the table stays readable under MySQL 8.0 thanks to instant or inplace DDL, but writes can still be blocked. pt-online-schema-change and gh-ost are tools that perform schema changes on large tables as an online operation without locking the table.

These tools create a new table with the desired schema, copy the data in batches, and switch over atomically at the end. Writes that happen during the migration are synchronized through triggers (pt-osc) or row-based replication (gh-ost). This makes schema changes on tables with millions of rows possible with minimal impact on live operations. The downside: these tools are not integrated into Magento's db_schema.xml system. They have to be run separately, either in the deployment script or as a pre-migration step, before setup:upgrade runs. Anyone using them also has to make sure that db_schema.xml correctly describes the new state, so Magento does not try to run the migration again.

7. Anchoring migrations in the GitLab pipeline process

Migrations need to be anchored at the right point in the GitLab pipeline process. A common mistake: setup:upgrade runs at the end of the deployment, after the symlink switch, which means the new code runs briefly against the old database structure. For most backward compatible changes that is not a problem, but for schema changes the new code depends on, such as a new column or a new index, the new code can fail until the migration finishes.

The safer order is: setup:upgrade runs in the new release directory before the symlink switch. The database is then in the new state while the old code is still active, which requires backward compatibility. After the symlink switch, the new code runs against the already migrated database, with no further migration phase needed. This order turns migrations explicitly into a gate in the deployment process: if setup:upgrade fails, the symlink does not switch. That is the intended behavior, since a failed migration is not a deployment.

8. Comparison: compatible vs. incompatible schema changes

The most important decision for every schema change is: is this change compatible with the code version currently running? The answer determines whether zero downtime is possible or a maintenance window is needed.

Schema change Compatible? Expand/Contract? Maintenance window?
New column (nullable / default) Yes Expand phase No
New column (NOT NULL, no default) No Expand first (nullable), then NOT NULL Or expand/contract
Drop column No Contract phase (once rollback is no longer needed) Or maintenance window
Rename column No Expand (new column) then both write then contract (drop old) Or 2 releases
Add index Yes - No (pt-osc for large tables)

The table shows that zero downtime is not a binary property, it depends on every single schema change. Anyone bundling all changes into the same release, feature development, new columns, dropped columns, is almost guaranteed to have an incompatible migration in the mix. The solution: integrate a migration checklist into the review process. Every db_schema.xml change gets checked for compatibility before merge. Incompatible changes are split into separate releases or explicitly flagged with a maintenance window.

9. Typical failure patterns in database migrations during deployment

The most common failure pattern is a setup:upgrade timeout on large tables: the migration takes longer than the GitLab job timeout, the job aborts, but the migration keeps running on the server. Result: the deployment has failed from the pipeline's point of view, but the database is left in an intermediate state. Diagnosis: check the MySQL process list to see if an ALTER TABLE is still running. Prevention: set a generous job timeout, and use pt-osc or gh-ost for large tables.

The second failure pattern is the foreign key deadlock: Magento's setup:upgrade temporarily disables foreign key checks during migrations (SET FOREIGN_KEY_CHECKS=0). If queue consumers or cron jobs write at the same time, deadlocks can occur because MySQL lock queues interact with the foreign key mechanism. Prevention: stop consumers and cron before running setup:upgrade (see the previous article). The third failure pattern is the inconsistent rollback situation: rolling back to the old release works for the code but not for the database, because migrations are not reversible. Anyone rolling back after an incompatible migration has to restore the database to the old state separately or accept the incompatibility as a known risk.

# Pre-migration compatibility check job
validate:migration:
  stage: build
  image: php:8.4-cli
  needs: ["build:composer"]
  script:
    # Check for potentially breaking schema changes
    - |
      php -r "
        // Parse db_schema.xml changes from git diff
        \$diff = shell_exec('git diff HEAD~1 HEAD -- */db_schema.xml 2>/dev/null');
        if (empty(\$diff)) {
          echo 'No schema changes detected' . PHP_EOL;
          exit(0);
        }

        // Warn about potentially breaking patterns
        \$breakingPatterns = [
          'nullable=\"false\"' => 'NOT NULL column: check backward compatibility',
          'xsi:type=\"drop\"'   => 'DROP column/table: requires prior code removal',
        ];

        \$warnings = [];
        foreach (\$breakingPatterns as \$pattern => \$message) {
          if (str_contains(\$diff, \$pattern)) {
            \$warnings[] = 'WARNING: ' . \$message;
          }
        }

        if (!empty(\$warnings)) {
          echo implode(PHP_EOL, \$warnings) . PHP_EOL;
          echo 'Review these changes for Zero-Downtime compatibility.' . PHP_EOL;
          // Exit 0, warning only, not blocking; team decides
          exit(0);
        }
        echo 'Schema changes appear backward compatible' . PHP_EOL;
      "
  allow_failure: true
  when: on_success

10. Summary

Database migrations in zero downtime deployments require deliberate schema design, not just good pipeline design. The expand/contract pattern is the structural approach: first expand (backward compatible changes the old code tolerates), then contract (cleanup once no old code is running anymore). Backward compatible changes, such as new nullable columns, new tables, and new indexes, can be deployed without a maintenance window. Incompatible changes, such as dropping columns, renaming them, or adding NOT NULL without a default, require two separate releases or a maintenance window. That is not a weakness, it is an honest assessment of the situation.

setup:upgrade should run before the symlink switch so the new code goes straight against the migrated database. For large tables, pt-osc or gh-ost is the right way to avoid migration timeouts and lock issues. A migration checklist in the review process, checking every db_schema.xml change for compatibility, prevents breaking changes from slipping unnoticed into a release that was planned as zero downtime. That is the standard that Magento teams genuinely aiming for zero downtime need to meet.

Database Migrations for Zero Downtime: The essentials at a glance

Expand/Contract

Split every breaking change into two releases. Expand means backward compatible. Contract only happens once no old code is running anymore.

setup:upgrade timing

Run it before the symlink switch, for backward compatible migrations. If it fails, the symlink does not switch.

Large tables

pt-online-schema-change or gh-ost for tables with millions of rows. Prevents timeouts and lock issues with DDL statements.

Maintenance window

The honest answer for incompatible changes. maintenance:enable, migrate, maintenance:disable beats a silent failure.

11. FAQ: Database Migrations for Zero Downtime

1What is the expand/contract pattern?
A breaking schema change split into two phases: expand (backward compatible) and contract (cleanup after the next release, once no old code is running).
2Which schema changes are backward compatible?
New nullable columns, new tables, new indexes, and increasing VARCHAR length. These do not break the old code.
3When is a maintenance window required?
For incompatible changes: dropping a column, renaming it, adding NOT NULL without a default, or changing a data type incompatibly.
4setup:upgrade timing in the pipeline?
Before the symlink switch, for backward compatible migrations. If it fails, the symlink does not switch.
5What is pt-online-schema-change?
DDL on large tables without locking. Shadow table, batch copy, atomic switch. Prevents timeouts on tables with millions of rows.
6Rollback after a breaking migration?
Code rollback works, the database stays in the new state. For incompatible changes, the database has to be reset separately.
7pt-osc vs. gh-ost?
pt-osc uses triggers, gh-ost uses row-based replication. Both enable online schema changes without table locks.
8Checking backward compatibility?
Ask: can the old code still correctly read/write after the migration? New nullable column: yes. Dropping a column: no.
9Migration checklist in GitLab reviews?
A validate job in the build stage: check db_schema.xml diffs for breaking patterns, output warnings. Teams decide whether to proceed.
10Cron issues with setup:upgrade?
Yes, cron jobs can cause deadlocks during setup:upgrade. Running cron:remove before setup:upgrade prevents this.