Zero Downtime Database Migrations in Symfony with Doctrine
AI generated
SF
{ }
Symfony · Doctrine · Database · DevOps
Zero Downtime Database Migrations
with Doctrine, without table locks and without downtime

A migration that spontaneously renames a column or locks a table can bring a running Symfony application to its knees right in the middle of rush hour. Zero downtime database migrations with Doctrine instead follow the expand contract pattern: schema and application grow through small, always backward compatible steps. This article walks through every step in detail, from the first new column to safely removing the old one.

19 min read Expand contract · Batch backfill · Dual write · Doctrine migrations Symfony 7 · Doctrine ORM 3 · MySQL 8

1. Why classic migrations are dangerous during live operation

A classic migration that renames a column, changes its type, or locks a table within a single transaction implicitly assumes the application does not need to be reachable during the migration. In a setup aiming for zero downtime database migration, that assumption does not hold: the application keeps running, serving users while the schema changes, and any incompatibility between the running code and the new schema causes errors in real time.

The core problem is a matter of timing. Between the moment a migration changes the schema and the moment every application instance has switched to the new code, some time always passes, and during that time both the old and the new code version must be able to work with the same schema simultaneously. A migration that renames a column in a single, non backward compatible step is guaranteed to break the still running old version, no matter how fast the deployment completes.

Real zero downtime database migration therefore requires a fundamentally different approach than writing a single migration per feature as usual. Every schema change is broken down into multiple, independently deployable steps, each of which is backward compatible on its own. This principle, known as the expand contract pattern, is the common thread running through the rest of this article.

2. The expand contract pattern as the core principle

The expand contract pattern splits every schema change into two clearly separated phases. In the expand phase, only additions happen: new columns, new tables, new indexes, never is anything existing deleted or renamed in this phase. The old application version keeps working unchanged in the meantime, because it simply ignores the new structures. Only in the later contract phase, once the old version has been fully retired from operation, are the no longer needed old structures removed.

For a zero downtime database migration with Doctrine, this means concretely: a planned column rename is never implemented as an ALTER TABLE ... RENAME COLUMN in a single step. Instead, a new column with the desired name is created first, followed by a backfill of existing data, a transition period with parallel use of both columns, and only at the end a separate migration that removes the old column. Four to five individual deployments instead of one, but each of them risk free for live operation.

3. Safely adding new columns without a table lock

The first step of every zero downtime database migration following the expand contract pattern is adding a new column. In modern MySQL and PostgreSQL versions, adding a nullable column without a default value is usually a pure metadata operation that requires no full table lock and completes in milliseconds even on large tables with millions of rows. If, however, a default value is set for an existing column or a NOT NULL constraint is enforced immediately, the database, depending on version and storage engine, may need to rewrite the entire table, which on large tables can take minutes or hours while blocking the table the whole time.

The safe Doctrine migration for this step consistently adds the new column as nullable without a default and deliberately skips immediate constraints. NOT NULL constraints and foreign keys are added only in a later migration, after the backfill from the next section has finished and every row is guaranteed to carry a valid value.


<?php
// migrations/Version20260730120000.php
// Expand step: add the new column, nullable, no default, no lock risk
declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260730120000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Expand: add nullable shipping_status column, no default, no lock';
    }

    public function up(Schema $schema): void
    {
        // Nullable, no default — metadata-only operation on most engines
        $this->addSql(
            'ALTER TABLE orders ADD shipping_status VARCHAR(32) DEFAULT NULL'
        );
    }

    public function down(Schema $schema): void
    {
        $this->addSql('ALTER TABLE orders DROP shipping_status');
    }
}

4. Backfilling data in batches instead of one giant transaction

After adding the new column, existing rows need to be filled with meaningful values. A single UPDATE orders SET shipping_status = ... without restriction locks the table for the entire duration of the update on tables with millions of rows, making it the exact opposite of zero downtime database migration. The robust approach instead processes small batches, typically a few thousand rows per pass, with short pauses between batches, so other requests against the table are not starved.

This backfill runs as a standalone Symfony console command, not as part of the migration itself, because migrations usually run in a single, often long transaction, while a batch backfill needs many short, independent transactions. For zero downtime database migration, this separation is crucial: the migration only changes the structure, the backfill command fills in the data, and both steps are independently repeatable and observable.


<?php
// src/Command/BackfillShippingStatusCommand.php
declare(strict_types=1);

namespace App\Command;

use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(name: 'app:backfill:shipping-status')]
final class BackfillShippingStatusCommand extends Command
{
    private const int BATCH_SIZE = 2000;

    public function __construct(private readonly Connection $connection)
    {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $processed = 0;

        do {
            // Small, independent transactions — never one giant UPDATE
            $affected = $this->connection->executeStatement(
                'UPDATE orders SET shipping_status = \'unknown\'
                 WHERE shipping_status IS NULL
                 LIMIT :limit',
                ['limit' => self::BATCH_SIZE],
                ['limit' => \PDO::PARAM_INT]
            );

            $processed += $affected;
            $output->writeln(sprintf('Backfilled %d rows so far', $processed));

            usleep(200_000); // 200ms pause between batches to avoid saturation
        } while ($affected > 0);

        $output->writeln(sprintf('<info>Backfill complete: %d rows</info>', $processed));
        return Command::SUCCESS;
    }
}

5. Dual write phase: the application writes to old and new structure

During the transition, both the old and the new version of the application must be able to run in parallel, which means writes must happen into both structures at once. For zero downtime database migration, this dual write phase is the trickiest part, because a mistake here can lead to silently inconsistent data that only surfaces much later.

In Symfony, dual write can be elegantly implemented through a Doctrine event listener on prePersist and preUpdate, which automatically keeps both fields in sync on every write without the actual application code noticing anything. Once every application instance has been updated to the new code version that consistently uses the new column, the dual write listener can be removed in another, risk free deployment.


<?php
// src/EventListener/OrderDualWriteListener.php
declare(strict_types=1);

namespace App\EventListener;

use App\Entity\Order;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Events;
use Doctrine\Persistence\Event\LifecycleEventArgs;

#[AsDoctrineListener(event: Events::prePersist)]
#[AsDoctrineListener(event: Events::preUpdate)]
final class OrderDualWriteListener
{
    /**
     * Keeps the legacy status column and the new shipping_status
     * column in sync during the transition window. Remove this
     * listener only after every application instance reads
     * exclusively from shipping_status.
     */
    public function prePersist(LifecycleEventArgs $args): void
    {
        $this->syncColumns($args);
    }

    public function preUpdate(LifecycleEventArgs $args): void
    {
        $this->syncColumns($args);
    }

    private function syncColumns(LifecycleEventArgs $args): void
    {
        $entity = $args->getObject();
        if (!$entity instanceof Order) {
            return;
        }

        // Mirror the legacy field into the new column on every write
        $entity->setShippingStatus($entity->getLegacyStatus());
    }
}

6. Switching reads and verifying consistency

Before the old column can be removed, it must be established beyond doubt that not a single application instance still reads from it. For zero downtime database migration, a two step approach is recommended: first the read path in the code is switched to the new column, while dual write keeps filling both columns. Only after this code version has fully rolled out does a consistency check follow, comparing, either by sampling or exhaustively, whether the old and new columns actually match.

This consistency check is best run as a standalone batch command, similar to the backfill command, and logs every discrepancy found instead of automatically correcting it. Discrepancies usually indicate a bug in the dual write logic that must be fixed before the contract phase is even allowed to begin. A zero downtime database migration process that skips this check risks silent data loss when the old column is finally removed.

7. Safely removing old columns, only after full migration

Only once it is certain that no code version still reads or writes the old column, and the consistency check has run without discrepancies, may the contract phase begin. This final migration removes the old column and all associated indexes, typically also a fast, largely metadata based operation, provided no foreign keys with cascading effects are involved.

An important safety step before this final migration: the dual write listener from section five must be completely removed before the column disappears, otherwise the application would attempt to write to a column that no longer exists. The order is therefore always: first the code deployment without dual write, then verification in production, only then the contract migration. This strict ordering is the core of what distinguishes zero downtime database migration from a risky ad hoc schema change.


<?php
// migrations/Version20260815090000.php
// Contract step: remove the legacy column, only after dual-write
// has been fully removed from the application code
declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260815090000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Contract: drop legacy status column after full cutover to shipping_status';
    }

    public function preUp(Schema $schema): void
    {
        // Safety guard: refuse to run if any row still lacks the new column
        $count = $this->connection->fetchOne(
            'SELECT COUNT(*) FROM orders WHERE shipping_status IS NULL'
        );

        if ((int) $count > 0) {
            throw new \RuntimeException(
                sprintf('Refusing to drop legacy column: %d rows not backfilled yet', $count)
            );
        }
    }

    public function up(Schema $schema): void
    {
        $this->addSql('ALTER TABLE orders DROP status');
    }
}

8. Doctrine migrations in practice: hooks and locking

Doctrine Migrations provides preUp, postUp, preDown and postDown hooks, which enable safety checks essential for zero downtime database migration, as shown in the previous section. Equally important is that migrations are never executed by multiple deployment pipelines at once, which can lead to race conditions when different services deploy in parallel against the same database. Doctrine Migrations comes with a built in locking mechanism for this, using a metadata table to ensure only one migration run is ever active at a time.

For production grade zero downtime database migration, it is also recommended to give every migration an explicit statement timeout. A migration that unexpectedly runs long, for example because another transaction is holding a lock, should abort and raise an alarm after a defined time instead of blocking indefinitely and, in the worst case, stalling the entire deployment process.


#!/usr/bin/env bash
# run-migration.sh — apply a single Doctrine migration with a hard
# statement timeout so a stuck migration cannot block the deployment
set -euo pipefail

MIGRATION_VERSION="${1:?Usage: run-migration.sh <migration-version>}"

php bin/console doctrine:migrations:execute \
  --up "${MIGRATION_VERSION}" \
  --no-interaction \
  --query-time-limit=30

echo "[OK] Migration ${MIGRATION_VERSION} applied within timeout"

9. Migration strategies compared directly

The table below compares three common approaches to schema changes during live operation.

Strategy Lock time Complexity Compatibility during rollout
Single giant transaction high, whole table low none, old version breaks immediately
Online schema change tool low, short final lock medium, requires external tool good for pure structural changes
Expand contract pattern minimal, mostly metadata based high, multiple deployments complete throughout the entire rollout

Online schema change tools such as gh-ost or pt-online-schema-change solve the locking problem at the database level very well, but they do not answer the question of application compatibility during the rollout. For genuine zero downtime database migration in Symfony applications, the expand contract pattern is therefore usually indispensable, while online schema change tools remain a valuable complementary technique for the actual structural change on very large tables.

Mironsoft

Symfony DevOps, Doctrine migrations and database operations without downtime

Zero downtime database migrations for your Symfony operations?

We build expand contract migrations for Symfony and Doctrine: batch backfill, dual write phases, consistency checks and safely removing old columns without downtime.

Migration audit

Reviewing existing migrations for risky locking and rewrite operations

Expand contract migration

Migrating critical schema changes to safe, multi step migrations

Backfill automation

Implementing batch commands with monitoring and consistency checks

10. Summary

Zero downtime database migration with Doctrine requires a fundamental shift away from the single, everything changing migration toward the expand contract pattern. New columns are added nullable without a default, existing data is backfilled in small batches instead of one giant transaction, and a dual write phase keeps old and new structure in sync while both code versions run in parallel.

Only after full verification that no instance still uses the old structure does the contract phase follow, with the safe removal of old columns, guarded by explicit checks in preUp hooks. This multi step approach requires more deployments than a classic migration, but in return guarantees genuine zero downtime database migration without table locks, without incompatibilities between code versions, and without the risk of silent data loss.

Zero Downtime Database Migrations with Doctrine — The Essentials at a Glance

Expand contract pattern

Add first, never rename or delete immediately. Remove only after the full rollout completes.

Batch backfill

Small, independent transactions instead of a single giant UPDATE across millions of rows.

Dual write phase

A Doctrine event listener keeps old and new columns in sync until the full cutover is complete.

Safe contract phase

preUp guards prevent removing old columns as long as not every row has been migrated.

11. FAQ: Zero Downtime Database Migrations with Doctrine

1What is the expand contract pattern?
Splitting schema changes into an additive expand phase and a later contract phase that removes old structures.
2Why nullable without a default?
Pure metadata operation without a table lock. A default value or NOT NULL can trigger a full rewrite.
3Why not backfill inside the migration?
Migrations run in one long transaction, backfill needs many short ones. A console command separates both cleanly.
4What is a dual write phase?
Every write fills both old and new structure at once, usually through an event listener.
5When to remove the old column?
Only after full verification and a technical guard that refuses without a clean migration.
6Preventing parallel migration runs?
Doctrine Migrations' built in locking mechanism through a metadata table.
7Why a statement timeout?
Prevents a stuck migration from blocking the entire deployment process indefinitely.
8Online schema change tools as alternative?
Solve only the locking problem, not application compatibility. Expand contract remains additionally needed.
9How many deployments for a rename?
Typically four to five steps, each risk free on its own.
10What if the consistency check finds discrepancies?
Log instead of auto correct. Usually points to a bug in the dual write logic.