Schema Migrations: Versioning and Rollback Strategies
AI generated
SELECT
JOIN
SQL · Schema Migration · Deployment
Schema Migrations: Versioning and Rollback Strategies
from the naming convention to a safe NOT NULL column

A schema migration without clear versioning and a rollback plan quickly turns into a risk on a large live table. This article shows naming conventions for migration files, the difference between forward-only and reversible migrations, the expand-contract pattern, and safe approaches for NOT NULL columns on tables with millions of rows, with concrete SQL for every step.

17 min read Versioning · Rollback · Expand-Contract Standard SQL · MySQL · PostgreSQL

1. Why migrations need to be versioned

A schema migration describes a controlled, traceable change to the database schema that is versioned as code, managed in a repository, and applied to every environment in a fixed order. Without this versioning, development, staging and production environments inevitably drift apart: a column is added manually through a SQL client in production but never applied to the local development environment, an index exists on staging but not on production. This drift leads to bugs that only reproduce in exactly one environment.

Every schema migration therefore needs a unique, monotonically increasing identifier, usually a timestamp or a sequential number, that fixes the execution order across all developers and environments. A migration framework keeps track of which migrations have already been applied to a given database, typically in a dedicated table like schema_migrations, and on deployment only applies the migrations not yet recorded there. This simple bookkeeping is the foundation of any reliable schema migration strategy.

The following sections cover the practical structure of migration files, the choice between forward-only and reversible migrations, safe patterns for critical changes on large tables, and coordinating migrations with the application deployment.

2. Structure of a migration file and naming convention

A well-structured schema migration file follows a fixed naming convention that makes order and purpose recognizable at a glance: a timestamp in the format YYYYMMDDHHMMSS followed by a descriptive name, for example 20260724143000_add_status_to_orders.sql. The timestamp as the leading element guarantees a unique, chronological ordering even with several developers working in parallel on different migrations, without number conflicts caused by simultaneously created branches.

Every migration file should contain exactly one logical change, not several independent changes bundled into a single file. A migration that simultaneously adds a column, creates an index and transforms data is harder to debug when a single sub-step fails, and harder to revert in a targeted way. Comments at the top of the file documenting the business reason for the change pay off during every later troubleshooting session, especially once the original motivation is no longer top of mind months later.


-- File: 20260724143000_add_status_to_orders.sql
-- Purpose: introduce an explicit order status instead of
-- inferring it from a combination of nullable timestamp columns
ALTER TABLE orders
    ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending';

CREATE INDEX idx_orders_status ON orders (status);

-- Backfill existing rows based on current timestamp columns,
-- before removing the DEFAULT once all writers set status explicitly
UPDATE orders
SET status = CASE
    WHEN shipped_at IS NOT NULL THEN 'shipped'
    WHEN cancelled_at IS NOT NULL THEN 'cancelled'
    ELSE 'pending'
END;

3. Forward-only vs. reversible migrations

Reversible migrations define, alongside the actual up step, a down step that reverts the change exactly, for example DROP COLUMN as the counterpart to ADD COLUMN. This model initially sounds like extra safety, but quickly hits limits in practice: as soon as a migration transforms or deletes data, for instance when merging two columns into one, the down step often can no longer restore the original state without loss. A DROP COLUMN as the down step for an ADD COLUMN with backfill irrecoverably discards all data that was stored in that column after the migration.

Forward-only migrations deliberately forgo an automated down step and treat every schema migration as irreversible progress. A mistake is not fixed by migrating backward, but by a new, forward-facing migration that corrects the mistake. This approach mirrors how version control handles code: a faulty commit is rarely reset to the exact prior state with git revert, but corrected with a new commit that fixes the mistake while the history stays linear and forward-moving. For most teams running live systems, forward-only is the more robust and honest strategy.


-- Reversible migration style: up and down explicitly defined
-- Up:
ALTER TABLE customer ADD COLUMN loyalty_points INT NOT NULL DEFAULT 0;
-- Down:
ALTER TABLE customer DROP COLUMN loyalty_points;
-- Problem: any points accumulated after the up-migration
-- are permanently lost if the down-migration ever runs

-- Forward-only style: mistakes are fixed with a new migration,
-- not by reverting the old one
-- 20260724150000_add_loyalty_points.sql
ALTER TABLE customer ADD COLUMN loyalty_points INT NOT NULL DEFAULT 0;

-- 20260725090000_fix_loyalty_points_default.sql (a later, corrective migration)
UPDATE customer SET loyalty_points = 100 WHERE loyalty_points = 0 AND is_founding_member = TRUE;

4. Safe patterns for NOT NULL on large tables

Adding a NOT NULL column directly via ALTER TABLE ADD COLUMN with NOT NULL and no DEFAULT on a table with existing rows fails immediately, because every existing row has no value for the new column. The safe approach for this schema migration proceeds in several steps: first the column is added as NULLABLE with a sensible DEFAULT, which is possible in modern PostgreSQL and MySQL versions without a full table lock. Existing NULL values are then backfilled in batches, to avoid long transactions and excessive lock time on large tables.

Only after all existing rows carry a valid value is the column switched to NOT NULL in a separate, final step. In PostgreSQL, this final switch can additionally be prepared through a CHECK constraint with NOT VALID, which is added without a full table scan and then validated afterward with VALIDATE CONSTRAINT, before the actual NOT NULL property is set. This multi-step approach turns a potentially blocking operation on a table with hundreds of millions of rows into a series of short, uncritical steps.


-- Step 1: add the column as NULLABLE with a sensible default,
-- fast on modern PostgreSQL and MySQL without a full table rewrite
ALTER TABLE orders ADD COLUMN status VARCHAR(20) DEFAULT 'pending';

-- Step 2: backfill existing rows in batches to avoid long locks
UPDATE orders SET status = 'pending'
WHERE status IS NULL
LIMIT 10000;
-- Repeat step 2 until zero rows are affected

-- Step 3 (PostgreSQL): prepare NOT NULL via a CHECK constraint
-- that does not require a full table scan up front
ALTER TABLE orders ADD CONSTRAINT chk_status_not_null
    CHECK (status IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT chk_status_not_null;

-- Step 4: only now apply the actual NOT NULL constraint
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;
ALTER TABLE orders DROP CONSTRAINT chk_status_not_null;

5. Backward-compatible migrations: expand-contract

The expand-contract pattern, also called parallel change, solves the problem that a schema migration and an application deployment rarely take effect on every server at exactly the same moment. During a rolling deployment, old and new application versions run in parallel against the same database for a few minutes. If a column is renamed or removed before every application instance has been updated, queries from the old version fail, causing errors for end users during the deployment window.

The expand phase adds only additive, backward-compatible changes, for instance a new column alongside the old one, while both application versions continue to work because the old column still exists. Only after every application instance has been updated to the new version and exclusively uses the new column does the contract phase follow, which removes the old column. This separation into two schema migration steps split by the deployment is the core of any zero-downtime strategy for schema changes.

Phase Migration Deployment Compatibility
1. Expand Add new column, keep the old one Not yet rolled out Old and new version both run
2. Migrate Copy data from old to new Not yet rolled out Both columns kept in sync
3. Rollout No schema change New version writes new column Rolling deployment is safe
4. Contract Remove old column Fully rolled out Only new version active

6. Rollback strategies in practice

A true rollback of a schema migration in production, understood as an immediate reset to the previous schema state, is rarely the realistic answer to a problem in practice once the migration has already written or transformed data. The more robust approach is an application rollback with an unchanged, backward-compatible schema, which is exactly why additive, backward-compatible migrations in the expand-contract pattern are so valuable: an application rollback only works smoothly if the schema stays compatible with both application versions.

For cases where a genuine schema rollback is unavoidable, for instance a faulty migration that has not yet been used in production, every critical schema migration should be backed by a backup or a point-in-time recovery point before running on production. This backstop is the last line of defense when neither an application rollback nor a corrective forward migration can resolve the situation, for instance in the case of irreversible data loss caused by a faulty UPDATE or DELETE statement within the migration.

7. Coordinating migrations and deployments

The order between schema migration and application deployment determines whether a rolling deployment goes smoothly. The general rule is: migrations that add new columns or tables run before the application deployment, so the new application version encounters the expected structure right at startup. Migrations that remove columns or tables, on the other hand, only run after the deployment is fully complete, so no still-running old application instance accesses a structure that has already been removed.

In CI/CD pipelines, this order is usually modeled as an explicit step before the actual application rollout, with an automated gate that stops the deployment if the migration fails. Migrations should also be idempotent or at least safely repeatable, for instance with CREATE TABLE IF NOT EXISTS or a check of the current schema state before every change, so that a retried deployment attempt after a partial failure does not itself cause an error.


-- Idempotent migration: safe to re-run after a partial deployment failure
CREATE TABLE IF NOT EXISTS order_note (
    order_note_id  BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    order_id         BIGINT UNSIGNED NOT NULL,
    note              TEXT NOT NULL,
    created_at        TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Guard an index creation the same way (PostgreSQL)
CREATE INDEX IF NOT EXISTS idx_order_note_order_id
    ON order_note (order_id);

8. Testing migrations

A schema migration should be tested against a realistic copy of the production database, not just against an empty test database with a handful of synthetic rows. Many migration mistakes only show up at realistic data volumes: a CHECK constraint that fails on unexpected existing data, an index whose creation takes seconds on an empty table but hours on the real data volume, or a batch migration whose runtime is unremarkable at ten thousand test rows but unacceptable at a hundred million production rows.

A staging system with a current, anonymized copy of the production data is the most reliable way to realistically estimate the runtime and locking behavior of a migration before it runs on production. In addition, every migration should run automatically against a fresh schema in the CI pipeline, to ensure the migration chain as a whole runs error-free from an empty state to the current state, which is especially relevant for new development environments and test databases.

9. Tools and ecosystem for schema migrations

A broad ecosystem of specialized tools exists for the practical implementation of schema migration strategies. Flyway and Liquibase are language-agnostic, widely used solutions that manage migrations as SQL files or declarative XML/YAML definitions and keep the version history in a dedicated metadata table. Framework-native solutions like Django Migrations, Rails Migrations or Alembic for SQLAlchemy integrate more closely with the respective application layer and often auto-generate migration scaffolding from model changes.

Regardless of the chosen tool, the same basic principles apply: a unique, chronological order, a metadata table to track already applied migrations, and a deliberate choice between forward-only and reversible migrations. For very large tables, it is additionally worth using specialized online schema change tools like gh-ost or pt-online-schema-change for MySQL, which perform structural changes through a shadow table with minimal lock time instead of altering the target table directly and blocking.


-- The core bookkeeping table almost every migration tool relies on,
-- simplified to show the underlying principle
CREATE TABLE schema_migrations (
    version      VARCHAR(20) PRIMARY KEY,
    applied_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Before applying a migration, the tool checks whether its version
-- is already recorded, and skips it if so
SELECT version FROM schema_migrations
WHERE version = '20260724143000';

Mironsoft

Data modeling, schema design and database consulting

Schema changes without downtime on large live tables?

We plan critical schema migrations using the expand-contract pattern, coordinate them with your deployment process and minimize lock time on tables with millions of rows.

Migration review

Review of planned migrations for lock time and rollback risks

Zero-downtime strategy

Expand-contract migrations for rolling deployments without outages

Tooling setup

Production-ready setup of Flyway, Liquibase or online schema change tools

10. Summary

A reliable schema migration strategy starts with clear naming conventions and a metadata table that makes the applied state of every environment traceable. Forward-only migrations are more robust than automated down scripts for most live systems, because they fix mistakes with new, corrective migrations instead of potentially lossy backward steps. Safe patterns for NOT NULL columns, additive across several steps instead of a single blocking operation, prevent downtime on large tables.

The expand-contract pattern solves the fundamental problem that schema migration and application deployment rarely take effect at exactly the same moment, by separating additive and destructive changes in time. Combined with realistic testing against production-like data volumes and clear coordination with the deployment process, this results in a strategy that keeps schema changes safe even on critical, heavily used systems.

Schema migrations, the essentials at a glance

Versioning

Timestamp-based naming convention and a metadata table for the applied state.

Forward-only

Fix mistakes with new, corrective migrations instead of risky backward steps.

Adding NOT NULL safely

Add nullable with a default, backfill in batches, only then switch to NOT NULL.

Expand-contract

Additive change before the deployment, destructive change only afterward.

11. FAQ: Schema Migrations: Versioning and Rollback Strategies

1Why does a migration need a fixed naming convention?
A timestamp guarantees a unique, chronological order across all developers and environments, even with parallel migrations.
2Forward-only vs. reversible migrations?
Reversible migrations with a down step often lose data during transformations. Forward-only fixes mistakes with a new migration.
3How do I add NOT NULL to a large table?
Add nullable with a default first, backfill in batches, optionally secure with a NOT VALID constraint, then set NOT NULL.
4What is the expand-contract pattern?
Additive changes first, then deployment, only afterward remove destructive changes. Safely separates migration and application rollout.
5Is a genuine schema rollback realistic?
Rarely after a data transformation. An application rollback with a compatible schema is the more robust practice.
6What order for migration and deployment?
Additive migrations before deployment, destructive ones only afterward, so both application versions stay compatible.
7How do I realistically test migrations?
Against an anonymized copy of production data on staging, complemented by automated tests against a fresh schema in CI.
8Which tools are typically used?
Flyway, Liquibase, framework-native solutions like Rails or Alembic, and gh-ost or pt-online-schema-change for very large tables.
9How should a migration file be structured?
Exactly one logical change per file, with a comment on the business reason, so the motivation stays clear later.
10What is a metadata table for migrations?
A table like schema_migrations logging already applied migrations, so a deployment only applies the ones still outstanding.