Running database migrations through CI/CD is safest when schema changes are treated as versioned application code: reviewed in Git, tested against an isolated database, deployed in a deliberate order, and paired with a recovery plan. This checklist explains how to build that workflow for PostgreSQL or a comparable relational database, regardless of whether your pipeline uses a hosted developer platform or self-hosted CI/CD runners.
Overview
A database migration changes the structure or data of a database over time. Examples include creating a table, adding an index, introducing a nullable column, backfilling records, or removing an obsolete field. The migration tool may differ—such as a framework-integrated tool, a standalone command-line utility, or a project-specific script—but the operational questions remain similar.
A reliable database migration CI/CD workflow should answer five questions before production deployment:
- What exact schema change is being introduced?
- Has the change been reviewed and tested from a clean database state?
- Will the old and new application versions both work during deployment?
- How will the team detect schema drift or a partially completed migration?
- What is the recovery plan if the migration is slow, blocked, or incorrect?
Keep migration files in the same repository as the application code, and give each file a clear, ordered identifier and description. A structure such as 2026_10_30_add_status_to_orders.sql is easier to inspect than an ambiguous filename. Do not edit an already-applied migration to fix a production issue. Create a new migration so the repository remains an accurate record of database history.
For local consistency, document the expected runtime, database version range, migration command, seed process, and required environment variables. A reproducible development setup, such as the approach described in the dev containers workflow, can reduce differences between developer machines and CI runners.
Checklist by scenario
1. Creating and reviewing a migration
- Describe the intended schema or data change in the pull request.
- Include the migration file and any application code that depends on it.
- Identify expected effects on indexes, constraints, foreign keys, triggers, and permissions.
- Estimate whether the operation could lock a large table or run for an extended period.
- Explain whether the change is reversible, and document the practical recovery option if it is not.
- Check that the migration is safe to run once and that retries will not corrupt data.
For a new field, a safer deployment often adds the field first without making it immediately required. The application can then write the new value while continuing to support the old schema. After existing rows are backfilled and the application no longer depends on the old path, a later migration can add a constraint or remove obsolete structures.
2. Testing in local development
- Start from a clean database and apply every migration in order.
- Run the application test suite against the migrated schema.
- Test representative reads, writes, transactions, and error paths.
- Run the migration a second time or use the tool’s status command to verify repeat behavior.
- Test rollback or recovery procedures separately instead of assuming they work because the forward migration succeeded.
Use realistic table sizes when assessing an index, backfill, or data transformation. A migration that completes instantly on a developer laptop may behave differently on a production-sized table. When production data cannot be copied into a test environment, use a structurally similar database with representative row counts and value distributions.
3. Validating in CI
The CI job should provision an isolated database for each relevant change. It can then apply all migrations from an empty state, load controlled fixtures, run application tests, and verify the final schema. A second job may test the upgrade path from a previous release schema rather than only testing a fresh installation.
A generic pipeline sequence looks like this:
checkout repository
start isolated database service
install application dependencies
apply migrations to the empty database
run schema and application tests
compare expected schema with generated database state
build release artifact
publish migration status and test results
The exact commands depend on your migration tool. Keep them in version-controlled scripts such as make db-test, ./scripts/migrate-check, or a package-manager task so local and CI execution use the same entry point. This is easier to maintain in a self-hosted CI/CD setup or a managed deploy-from-Git workflow.
4. Detecting schema drift
Schema drift occurs when a database no longer matches the migration history or expected schema. It can result from manual production changes, an interrupted deployment, a migration applied in the wrong environment, or differences between database versions.
- Compare a clean database built from migrations with the schema expected by the application.
- Run a migration status or pending-change check in CI.
- Restrict direct production schema edits and record any emergency change immediately.
- Review generated schema snapshots or database introspection output when your tool supports them.
- Fail the release when unexpected pending migrations or destructive differences are detected.
5. Deploying with approval and recovery controls
Separate application delivery from production database execution when the risk warrants it. A pipeline can build and test automatically, then pause for an approval gate before connecting to the production database. The migration job should use a narrowly scoped database credential, log its version and outcome, and fail clearly rather than continuing after an error.
Before a destructive or data-changing migration, confirm that backups or another tested recovery mechanism are available. A backup is not a rollback plan until the team knows how to restore it, how long restoration may take, and what data could be lost between the backup and failure. For large changes, consider a phased migration, a maintenance window, or an online operation supported by the database and migration tool.
6. Ordering an application and schema release
Use an expand-and-contract pattern for changes that cross application versions:
- Expand: add the new table, column, index, or compatible structure without breaking the current application.
- Deploy compatibility: release code that can read or write both the old and new representation as needed.
- Migrate data: backfill records in controlled batches, monitoring load and failures.
- Switch usage: direct reads and writes to the new structure after validation.
- Contract: remove the old structure only in a later, separately reviewed change.
This ordering matters when multiple application instances are updated gradually. Never assume every instance changes at exactly the same moment.
What to double-check
Before approving a database migration deployment, use this review checklist:
- Compatibility: Can the current production application run while the migration is in progress?
- Performance: Could the statement scan a large table, consume substantial storage, or hold a lock?
- Data quality: Do existing rows satisfy the new constraint, data type, or relationship?
- Idempotency: What happens if the job is retried after a timeout but the database completed the operation?
- Observability: Are migration start, completion, duration, errors, and applied version recorded?
- Permissions: Does the migration identity have only the permissions it needs, and are application credentials prevented from making schema changes?
- Recovery: Is the fallback a reverse migration, a compensating migration, a feature flag, or a database restore?
- Documentation: Does the pull request state any manual steps, expected downtime, or post-deployment verification?
Keep database changes connected to the release record. The same discipline used for dependency and supply-chain checks in an SBOM workflow also helps here: make the change visible, attributable, and reproducible.
Common mistakes
- Running migrations automatically on every application start: this can create race conditions when several instances start together and makes permission boundaries unclear. A dedicated, serialized deployment job is usually easier to control.
- Testing only from an empty database: fresh-install success does not prove that an upgrade from the previous production schema will work.
- Adding a required column to a populated table in one step: separate the structural change, data backfill, and constraint enforcement when necessary.
- Treating a reverse migration as a complete rollback plan: a down migration may not restore deleted data or undo external side effects.
- Ignoring database engine behavior: locking, transactional DDL, index creation, defaults, and constraint validation can vary by database engine and version. Verify behavior in an environment that resembles production.
- Allowing undocumented manual fixes: an emergency change can be appropriate, but it must be recorded and reconciled with version-controlled migrations afterward.
- Combining unrelated schema changes: smaller migrations are easier to review, observe, retry, and recover than a single release containing many independent operations.
When to revisit
Revisit this workflow before every schema-changing pull request, release, and failed deployment. Also review it when the database engine, migration tool, CI/CD runner, hosting environment, backup process, or deployment topology changes. A tool update can alter command behavior, transaction handling, generated SQL, or compatibility requirements, so rerun the migration checks rather than relying on an older pipeline result.
At regular planning cycles, choose one recent migration and perform a short operational review: Was the duration understood? Did logs identify the applied version? Could the team restore or recover within the required window? Did the application and database remain compatible throughout rollout? Use the answers to improve scripts and runbooks before the next change.
For the next migration, take these actions in order:
- Create a versioned migration and describe its compatibility impact.
- Test it from a clean database and from a representative previous schema.
- Run the same migration command in an isolated CI database.
- Check for drift, locks, data quality issues, and retry behavior.
- Prepare backup verification and a named recovery procedure.
- Deploy expand changes before code that requires them, then contract only after validation.
- Record the result and update the workflow when the environment or toolchain changes.
This makes automated database migrations a controlled part of the CI/CD system rather than an opaque step hidden inside application deployment.