Setting Up Automated Testing of Database Migrations in CI/CD

A rollout fails because of schema changes, and you only find out about it in production. We set up turnkey automated testing of database migrations: pipeline checks catch failures before release. Time for coordination and rollbacks is reduced many times over.

Our competencies:

Frequently Asked Questions

Latest works

  • Development of a web application for FEEDME
    Development of a web application for FEEDME
    1359
  • Development of an online store for the company FURNORO
    Development of an online store for the company FURNORO
    1330
  • B2B Advance company logo design
    B2B Advance company logo design
    776
  • Development of a web application for Enviok
    Development of a web application for Enviok
    1077
  • AIDER company logo development
    AIDER company logo development
    1022
  • CRM development for Chasseurs
    CRM development for Chasseurs
    1113

Automated database migration testing: where deployments break

A deployment failure rarely happens in the migration itself — it grows in schema drift.

The production database and the repository describe different states: a change was once applied manually to unblock a release, was not recorded in the migration chain — and the next deployment fails on a nonexistent object or creates a duplicate.

Schema change validation is arranged as a comparison of a production database snapshot with the result of running migrations from scratch: without it, drift surfaces only at the moment of failure.

The second failure point is locking. Changing a column type or rebuilding an index on a large table holds a lock for minutes: writes stop, the connection queue grows, and the service degrades.

On an empty test environment, such a change passes instantly, so the deployment decision is made blindly. A run on a copy of production data shows how long the lock is held and whether peak load can survive it.

The third is rollback of a change that no one validated. Reverse scripts are written as an afterthought and are not run in the pipeline.

When a release is rolled back, the code returns to the previous version, but the schema remains new: queries fail on type mismatch, and recovery takes longer than the deployment itself lasted.

This is closed by a cycle: apply migrations on a copy of production data, roll back, apply again, and compare the final schema with the reference snapshot. Then an irreversible change is visible before production, not after.

What schema change validation before deployment provides: time and failure classes

While schema changes are validated manually on a copy of the production database, every deployment includes a pause: the engineer waits for the migration set to apply and only then checks whether the application came up.

With a flow of several releases per week, this means hours of waiting and a separate class of failures — “failed on schema” — that is caught only after traffic is switched.

Automated migration runs in the pipeline close this at the build stage. An isolated database environment is deployed from a snapshot on the same PostgreSQL version as production: applying all migrations in order, rolling back to the previous state, and reapplying. Release time stops depending on who is on duty today.

Next, properties are checked that are almost never tested manually. Schema backward compatibility — the previous version of the application continues to work with the new schema, so rolling back a release does not require rolling back the migration.

Idempotency of migrations — rerunning yields the same result, and a migration that failed midway can be safely rerun.

A separate check is downtime due to locks: the test measures how long an exclusive lock is held when a table is changed and flags operations that stop writes on a live database.

Such steps are split into safe ones: add a column, backfill in the background, switch reads, remove the old one.

The result is measurable: schema-related failures leave release statistics, reruns require no manual intervention, and release predictability stops depending on a specific engineer.

Approaches to running schema changes: empty database, copy, replica

The reliability of a run is determined not by the mere fact of running a migration, but by which database it runs on.

An empty database catches syntax, application order, and change reversibility, but says nothing about what will be encountered on production data: existing rows in a new mandatory column, a unique index conflict, rebuilding a large table. These are exactly the discrepancies where deployment most often fails.

Therefore, in CI/CD, several options are usually kept rather than one — for different stages and different risk levels.

Option When it fits Limitations
Run on an empty database On every commit: validation of syntax, application order, and rollback Does not reproduce conflicts with existing data or large table rebuilds
Simplified database with a representative sample Quick checks where data is needed, but a full copy is too heavy Drift from the production schema accumulates: tests pass where a real deployment would fail
Copy of the production schema with anonymized data Pre-release validation before deployment to a critical environment Requires a snapshot, anonymization, and regular refresh; preparation takes time
Shadow replica Assessment of lock duration and behavior on real volume and traffic Requires isolation from production traffic and separate resources, more expensive to maintain

We combine them by increasing cost and reliability: empty database on every commit, copy on the branch before deployment, shadow replica selectively for migrations that touch large tables or change column types.

A simplified database is useful as a quick compromise, but it needs to be periodically compared with the production schema — otherwise tests begin confirming what does not exist in reality.

How to build migration validation in the pipeline: stages of work

Migrations are the only deployment step that changes data and is almost always irreversible. An unnoticed lock on a large table stops writes right during release, and schema desynchronization surfaces afterward. Below is an order of work that makes this operation predictable.

  1. Migration inventory. We collect accumulated files, separate idempotent ones from one-time ones, and flag dangerous ones: changing a column type, rebuilding a large table, dropping columns.

  2. Reference schema and schema versioning. We take a dump of the current state and store it with the code — every migration must bring the structure to the reference. A deterministic check appears instead of “it seemed to work on the test database.”

  3. Clean environments in the pipeline. We spin up an empty database of the required branch and run two scenarios: applying all migrations from scratch and updating from the previous release state.

  4. Automated tests for schema and data. We compare the structure with the reference, check reversibility of rollback migrations and data integrity on a prepared dataset. A red test blocks the build until fixed.

  5. Lock validation under load. We run the migration on a volume close to production and capture the query execution plan: how long the lock is held, whether the index is rebuilt entirely, and whether the operation fits within the maintenance window.

  6. Policy. A migration enters a release only with green checks, and manual application to the production environment is done via a separate request with the reason recorded.

  7. Handover. We hand over the pipeline description, on-call procedure, and analysis of typical failures so that the team can maintain the schema on its own going forward.

What the customer gets: configurations, tests, policies

The work ends not with a report but with a package that your team runs and maintains without us.

Everything configured in the test environment is handed over in the same form in which it works in the main one — with a description of why such thresholds were chosen and how to change them.

  • Continuous delivery pipeline settings: build steps, running migrations on an empty database and on a copy of production data, and the condition for stopping a release when schema drift occurs.
  • A set of automated schema tests: applying and rolling back each migration, checking tables, indexes, constraints, default values, and privileges after applying.
  • Environment settings and bring-up/reset scripts: database deployment in minutes, predictable test data, reset without residual objects between runs.
  • Access policies for copies: to whom and for how long anonymized copies are issued, where they are stored, how rights are revoked, and what is logged.
  • Release policy: actions on migration conflict, compatibility rules for adjacent schema versions, rollback point, and the person responsible for the decision.
  • Documentation package: pipeline map, environment description, list of checks with failure examples, and incident analysis procedure.

We separately document how to add a new check or environment on your own: usually it is a copy of an existing block with changed parameters. Such a package removes dependence on the contractor — migration testing can be developed inside the team.

Case study: how the schema stopped breaking deployments after validation was introduced

Before: schema diverged between environments

Migrations were released through the same pipeline as code, but were checked by eye: a reviewer read the migration text and compared it with the current schema. The test environment caught up with production manually and not always.

Out of ten releases, two or three failed on application — a table lock on a large data volume, a conflict with an existing index, a dropped column that the previous code release still read.

Schema drift accumulated for months: the test environment was missing some fields, while production had indexes that were not in any repository.

What we changed

We run every migration twice: on an empty database — to validate applicability from scratch, and on a fresh copy of the production schema — to validate compatibility with accumulated data.

The reverse scenario is mandatory: without a working rollback, the migration does not pass the stage. We keep the order of changes in a Liquibase changelog, so rolling a step back is as automatic an operation as applying it.

Schema snapshots from dev, the test environment, and production are compared on every build: a discrepancy blocks deployment and points to the specific object.

What the result was

Release time decreased from two or three days to several hours: the validation stage runs in parallel with the build and does not require a manual run before release. Deployment failures are one per quarter instead of two or three out of ten releases.

Discrepancies between environments were more than thirty objects; now there are zero — validation does not let them past the build.

Incident analysis at the schema change stage has gone out of practice: the conflict is visible at the validation stage, not on the production database under load.

How does a run on an empty database differ from a run on a production copy?

An empty database checks only that the migration is syntactically correct and applied in the right order: tables are created, indexes are added, foreign keys are valid. The reliability of such a check ends where behavior on data begins.

The query planner chooses one plan on empty tables and another on production volumes: an index scan that is unnoticeable on ten rows causes locks and increased response time on a real distribution.

A production copy closes this gap: a slice with preserved test data volume and value distribution — rare branches, nulls, duplicates, maximum-length strings — makes constraints actually work.

An integrity constraint triggers only when the table has a row that it must reject; on an empty database this scenario is not reproduced.

A separate cause of discrepancies is the database dialect. On a different dialect, type casting, case-sensitive string comparison, sort order, and null handling are not reproduced. The migration passes in the pipeline and fails on production, when rollback is most expensive.

Therefore we keep both runs: the empty database catches errors in the migration itself and in the from-scratch application order, while the production copy is responsible for the reliability of validation on real volumes.

We anonymize the slice so that personal data does not enter the test environment, but we preserve the column composition, types, and distribution — otherwise validation again becomes formal.

Engineer doubts: complex schema changes, drift, rollbacks

Objections to automated migration tests are usually concrete: the schema is already live in production, some data is under constraints, and a change can hold a table for hours. Below is how each of these cases is validated on a test environment, not in production.

The schema in production has already diverged from migrations — will tests show this?

The test environment is brought up from the applied migration chain, and structural divergence from production is visible on the very first run — before deployment, not after.

Then the divergence is either closed with a new migration or production is brought to the chain with a separate script, which also passes validation.

A schema change takes a long time and locks the table — what is done about it?

A run on a copy of the production volume shows how long the lock is actually held. If it is longer than the allowed window, the change is split into steps: add a column without a constraint, fill it in batches, switch reads, remove the old one. Each step is a separate migration with its own test.

How is backward compatibility checked and is rollback possible?

Tests bring up the previous and new application versions side by side and check that both work on the same schema — then rolling back the deployment does not require rolling back the database.

The reverse script is run on data remaining after the forward migration; if rollback without loss is impossible, this is recorded during approval in advance.

The data is sensitive — how can we test if it cannot be copied?

The test environment receives a synthetic dataset with the same structure and similar value distribution, so foreign key and uniqueness constraints are fully reproduced. Sensitive fields are replaced by generators — validation runs against real rules, not empty tables.

Let’s discuss your task: what we will clarify and what we will come back with

To come back with an estimate and plan rather than general considerations, we need an analysis of your pipeline: how schema changes travel from a change in a branch to release and what happens between those points. Below are the inputs that are usually enough for us to calculate the scope of work.

  • DBMS and migration tool: what applies changes, whether they are versioned per file, whether there is a transactional wrapper and a separate rollback step.
  • Volume and nature of changes: how many schema changes per week, whether there are heavy rebuilds of large tables and blocking operations.
  • What is already validated: manual runs on a copy, query reviews, custom scripts — and at which step they fail.
  • Environments and data: whether there is a separate environment for runs, how the test dataset is prepared, whether data is anonymized.
  • Constraints: allowed downtime window, rollback requirements, release approval procedure.
  • Access and responsibility: who owns the repository, runners, and environment settings, who will accept the policies on the operations side.

After the analysis, we will come back with a pipeline design, estimate, and work plan, and after implementation we will hand over the policies: what runs on every change, which checks block a merge, and how to act on failure.

After that we remain on support — we fine-tune the rules for new migration types and analyze incidents. Describe your task — we will come back with an estimate and plan.