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.
Table of contents
- 1. Why migrations need to be versioned
- 2. Structure of a migration file and naming convention
- 3. Forward-only vs. reversible migrations
- 4. Safe patterns for NOT NULL on large tables
- 5. Backward-compatible migrations: expand-contract
- 6. Rollback strategies in practice
- 7. Coordinating migrations and deployments
- 8. Testing migrations
- 9. Tools and ecosystem for schema migrations
- 10. Summary
- 11. FAQ
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.