Migrations in a Rollout

During a rolling update two versions of your code share one database, so migrations must be backward compatible and run as a Job.

Syntaxphp bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing

A rolling update replaces pods a few at a time. For a minute or two the old image and the new image are both serving requests, against one database. Whatever the schema is during that window has to satisfy both. This is the single fact that makes production migrations different from migrations on your machine.

Expand and contract

Split a breaking change across releases. Expand first: add the new column as nullable, or add the new table, and change nothing that exists. Deploy code that writes both the old and the new shape and reads the old one. Then backfill the new column and deploy code that reads it. Contract last: once no running version mentions the old column, drop it. A rename is this pattern in disguise, because ALTER TABLE ... RENAME COLUMN breaks every old pod the instant it runs.

A Job, not an init container

An init container runs once per pod. Scale to six replicas and six migrations race each other; restart a pod next week and it runs again. A Job runs a pod to completion once, so the release becomes: apply the Job, wait for it to succeed, then update the Deployment image. If the migration fails, the old code is still serving and nothing has been rolled.

What trips people up

Adding a NOT NULL column with no default fails every insert from the old pods still running, which is an outage rather than an error you see in the migration log.

Example

Example · php
// migrations/Version20261003101500.php
declare(strict_types=1);

namespace DoctrineMigrations;

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

/**
 * Step 1 of 3 (expand). Deploys after this one may write email_normalised;
 * the pods still running the old image know nothing about it and keep working.
 */
final class Version20261003101500 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Expand: add nullable customer.email_normalised, leave email untouched.';
    }

    public function up(Schema $schema): void
    {
        // Nullable, no default, no backfill: an INSERT from an old pod that omits
        // this column still succeeds, so the rollout is safe while both run.
        $this->addSql('ALTER TABLE customer ADD email_normalised VARCHAR(255) DEFAULT NULL');

        // MySQL: adding the index now keeps the later backfill query fast.
        $this->addSql('CREATE INDEX IDX_customer_email_norm ON customer (email_normalised)');
    }

    public function down(Schema $schema): void
    {
        $this->addSql('DROP INDEX IDX_customer_email_norm ON customer');
        $this->addSql('ALTER TABLE customer DROP email_normalised');
    }
}

// php bin/console doctrine:migrations:migrate --no-interaction
//
//  [notice] Migrating up to DoctrineMigrations\Version20261003101500
//  [notice] finished in 61.4ms, used 20M memory, 1 migrations executed, 2 sql queries
//

When to use it

  • Add a column to a busy table without breaking the pods still running the previous image.
  • Rename a column across three releases instead of one, with no window where either version is broken.
  • Run migrations once per release rather than once per pod, and halt the deploy if they fail.
  • Review the exact SQL a release will execute before anything touches production.

More examples

The migration Job

The Job name carries the release, because a Job's pod template is immutable: reusing migrate as the name means the second release is rejected rather than run. backoffLimit: 0 means no retry, which is what you want for a half-applied migration that a human should look at. ttlSecondsAfterFinished cleans the Job up a day later, leaving the logs available until then. It uses the new image, so the migration files it runs are exactly the ones that release expects.

Example · yaml
# k8s/job-migrate-2026-10-03-1.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: migrate-2026-10-03-1
spec:
  backoffLimit: 0
  ttlSecondsAfterFinished: 86400
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: registry.example.com/shop/web:2026-10-03.1
          command:
            - php
            - bin/console
            - doctrine:migrations:migrate
            - --no-interaction
            - --all-or-nothing
          env:
            - name: APP_ENV
              value: prod
          envFrom:
            - secretRef:
                name: shop-db

The release sequence

This ordering is what makes the expand step safe: the schema change lands while only the old code is running, and the old code tolerates it by design. --dry-run prints the statements without touching the database, which is the moment to notice a table rewrite on a large table. kubectl wait is the step people skip, and skipping it means the rollout starts whether or not the migration finished. If the Job fails, nothing has changed except that no deploy happened.

Example · bash
# 1. see the SQL without running it
kubectl run migrate-dry --rm -it --restart=Never \
  --image=registry.example.com/shop/web:2026-10-03.1 -- \
  php bin/console doctrine:migrations:migrate --no-interaction --dry-run

# 2. run it once, and stop here if it fails
kubectl apply -f k8s/job-migrate-2026-10-03-1.yaml
kubectl wait --for=condition=complete --timeout=600s job/migrate-2026-10-03-1
kubectl logs job/migrate-2026-10-03-1

# 3. only now roll the code
kubectl set image deployment/web php=registry.example.com/shop/web:2026-10-03.1
kubectl rollout status deployment/web --timeout=300s

The contract migration, one release later

The pair to the primary example, and the one that is dangerous if run too early. abortIf stops the migration with a message instead of dropping a column the application still needs, which turns a possible outage into a failed Job. Note what down() admits: a contract step is not reversible in any useful sense, so the real recovery plan is a backup, not a rollback. Keep the backfill itself out of this migration and in a console command, so it can be run in batches without holding one long transaction.

Example · php
// migrations/Version20261017090000.php
declare(strict_types=1);

namespace DoctrineMigrations;

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

/**
 * Step 3 of 3 (contract). Only safe once no running pod reads or writes
 * customer.email, which means after the step 2 release is fully rolled out.
 */
final class Version20261017090000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Contract: drop customer.email, now that email_normalised is authoritative.';
    }

    public function up(Schema $schema): void
    {
        // Guard: refuse to drop the old column while rows are still unmigrated.
        $unmigrated = (int) $this->connection->fetchOne(
            'SELECT COUNT(*) FROM customer WHERE email_normalised IS NULL'
        );
        $this->abortIf($unmigrated > 0, sprintf('%d customers not backfilled.', $unmigrated));

        $this->addSql('ALTER TABLE customer DROP email');
    }

    public function down(Schema $schema): void
    {
        // The old values are gone; restoring the column is not restoring the data.
        $this->addSql('ALTER TABLE customer ADD email VARCHAR(255) DEFAULT NULL');
    }
}

Discussion

  • Be the first to comment on this lesson.