Migrations
A schema migration (also called a database migration) is a set of incremental, reversible changes to a database schema. According to Wikipedia, schema migrations "allow the database schema to evolve as the application's requirements change, while preserving existing data."
Why migrations matter
As your application evolves, you'll need to change your data model—adding new fields, renaming tables, changing data types, or restructuring relationships. Without a disciplined approach, these changes can lead to:
- Data loss or corruption
- Downtime during deployments
- Inconsistencies between environments (dev, staging, production)
- Difficulty rolling back failed changes
Migrations solve these problems by treating schema changes as versioned, tested, and reversible operations.
How MigrationSpec works
In ContractSpec, migrations are defined using MigrationSpec. Each migration has:
- Version – A unique identifier (e.g., "2025-11-13-001") that determines the order of execution.
- Up function – The forward migration that applies the change (e.g., "add column 'email_verified'").
- Down function – The reverse migration that undoes the change (e.g., "drop column 'email_verified'").
- Dependencies – Other migrations that must run before this one.
- Validation – Optional checks to ensure the migration succeeded (e.g., "verify all users have an email address").
Example MigrationSpec
Here's a migration that adds an email verification field to the users table:
migrationId: add-email-verified
version: 2025-11-13-001
dependencies: []
up:
- sql: |
ALTER TABLE users
ADD COLUMN email_verified BOOLEAN DEFAULT FALSE NOT NULL;
- sql: |
CREATE INDEX idx_users_email_verified
ON users(email_verified);
down:
- sql: |
DROP INDEX idx_users_email_verified;
- sql: |
ALTER TABLE users
DROP COLUMN email_verified;
validation:
- sql: |
SELECT COUNT(*) FROM users
WHERE email_verified IS NULL;
expectZeroRows: trueRunning migrations
Migrations are applied automatically during deployment. The ContractSpec runtime:
- Checks which migrations have already been applied (stored in a migrations table).
- Identifies new migrations that need to run.
- Executes them in order, respecting dependencies.
- Runs validation checks to ensure success.
- Records the migration as applied.
If a migration fails, the deployment is aborted, and the system remains in its previous state. You can then fix the migration and redeploy.
Rolling back migrations
If you need to roll back a deployment, ContractSpec automatically runs the down functions of any migrations that were applied. This restores the database to its previous state.
Note that rollbacks are not always possible—for example, if you've deleted a column, you cannot recover the data unless you have a backup. For destructive changes, it's best to use a multi-step migration:
- Add the new column (reversible).
- Backfill data from the old column to the new column (reversible).
- Update application code to use the new column (reversible).
- Drop the old column (irreversible—only do this after confirming the new column works).
Best practices
- Test migrations locally – Run them against a copy of production data to catch issues before deploying.
- Keep migrations small – Each migration should do one thing. This makes them easier to understand and roll back.
- Write reversible migrations – Always provide a down function, even if you don't plan to roll back.
- Use transactions – Wrap migrations in database transactions so they either fully succeed or fully fail.
- Avoid destructive changes – Prefer additive changes (adding columns) over destructive ones (dropping columns). If you must delete data, archive it first.
- Version your migrations – Use timestamps or sequential numbers to ensure migrations run in the correct order.
- Document breaking changes – If a migration requires application code changes, note this in the migration description.
Zero-downtime migrations
Some migrations can cause downtime if not handled carefully. For example, adding a NOT NULL column to a large table can lock the table for minutes. To avoid this, use a multi-step approach:
- Add the column as nullable.
- Backfill the column in batches (without locking the table).
- Add the NOT NULL constraint once all rows are populated.
ContractSpec's migration system supports this pattern by allowing you to split a logical change into multiple versioned migrations.