Combining Declarative Diffs with Versioned Migration Files
Your team likes the declarative model — one readable schema file, no hand-maintained chain of ALTER statements — but every serious migration ends up needing something the diff engine cannot produce: a CONCURRENTLY, a batched backfill between two DDL steps, a NOT VALID constraint that is validated in a later release. Pure declarative applies cannot express that sequencing; pure versioned migrations lose the single source of truth. The hybrid workflow keeps both: the desired state stays authoritative, the tool generates a versioned migration file from the diff, an engineer edits that file into a zero-downtime sequence, and the pipeline applies versioned files exactly like Flyway or Liquibase would. This guide sets that up and closes the loop so the edited files and the desired state cannot silently diverge. It is the pattern Declarative Schema Management recommends for most production teams.
Symptom / Error Signatures
Teams usually arrive at this workflow after one of these:
- A declarative apply ran a blocking statement because the change needed an online form the tool could not infer.
- A change needed a data backfill between two schema steps, and there was nowhere in the declarative flow to put it.
- Versioned migration files were edited by hand after generation and no longer produce the schema the desired-state file describes — discovered months later when a fresh environment built from migrations differs from production.
- The migration tool refuses to run with a checksum error such as Atlas’s
checksum mismatchonatlas.sum, or Flyway’sValidate failed: Migration checksum mismatch, because a file was edited after it was hashed.
Root Cause Analysis
The two models answer different questions. A desired-state file answers what should the schema be; a versioned migration answers what sequence of operations gets us there safely from where we are. Only the second can encode time: “add the column now, backfill over the next hour, add the constraint next release”. A diff engine sees only two snapshots, so it can generate a correct first draft of the sequence, but not the staging, online options and data steps that make it safe.
The hybrid model assigns each concern to the right artefact. Atlas supports it natively with atlas migrate diff, which writes a new versioned file containing the statements needed to move from the current migration directory’s end state to the desired state, and maintains an atlas.sum integrity file. The same pattern works with other generators — for example prisma migrate dev --create-only or drizzle-kit generate — followed by manual edits. The risk the model introduces is divergence: once you edit a generated file, nothing forces it to still converge on the desired state unless you add a check.
| Artefact | Owns | Edited by |
|---|---|---|
schema.sql (desired state) |
the target schema | engineers, reviewed in PRs |
migrations/NNN_*.sql |
the safe path, including online DDL and data steps | generated, then engineers |
integrity sum (atlas.sum, Flyway checksums) |
tamper-evidence for applied files | the tool, on every change |
| replay check in CI | proof that path and target agree | automated |
Immediate Mitigation
1. Generate instead of applying. Replace any direct declarative apply in the pipeline with file generation. With Atlas:
# Shell · engineer workstation · needs Docker for the dev database
# WARNING: the dev database is wiped and reused; never point --dev-url at a real environment.
atlas migrate diff add_region \
--dir "file://migrations" \
--to "file://schema.sql" \
--dev-url "docker://postgres/16/dev"
# writes migrations/<timestamp>_add_region.sql and updates migrations/atlas.sum
2. Edit the draft into online steps. Split statements that need different transaction handling into separate files, add CONCURRENTLY, and insert data steps. For PostgreSQL concurrent index builds, mark the file to run without a transaction using the tool’s directive (in Atlas, -- atlas:txmode none at the top of the file).
-- PostgreSQL · migrations/20260918120300_add_region_index.sql · runs outside a transaction
-- atlas:txmode none
-- WARNING: CREATE INDEX CONCURRENTLY cannot run inside a transaction block; keep it alone in this file.
SET lock_timeout = '2s';
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_region ON orders (region);
-- ROLLBACK PATH: DROP INDEX CONCURRENTLY IF EXISTS idx_orders_region;
3. Re-hash after editing. Editing a file invalidates the integrity sum. Regenerate it so the tool will accept the directory, and commit both together.
# Shell · after editing files in migrations/ · commits the new sum with the edits
atlas migrate hash --dir "file://migrations"
git add migrations/ && git commit -m "Add region column as online migration sequence"
4. Add the replay check before anything else ships. In CI, apply the whole migration directory to an empty scratch database and diff the result against the desired state. Any difference means the edited path no longer reaches the declared target.
# Shell · CI job · Docker available · no production credentials needed
atlas migrate diff --dir "file://migrations" --to "file://schema.sql" \
--dev-url "docker://postgres/16/dev" check_sync
# If a new file appears, history and desired state have diverged: fail the job.
test -z "$(git status --porcelain migrations/)" || { echo "migrations do not converge on schema.sql"; exit 1; }
Permanent Fix / Long-Term Pattern
Codify the division of labour. The desired-state file is the only place anyone describes the target schema. Versioned files are generated from it, edited only to change how the target is reached — online options, transaction boundaries, data steps, staging across releases — never what it is. Applied files are immutable; the integrity sum and the migration tool’s own checksum validation enforce that, as covered in squashing migration history safely for the rare legitimate rewrite. CI replays the directory and diffs against the desired state on every pull request.
Staging across releases fits naturally. A change that needs expand-and-contract becomes several pull requests, each moving the desired state one step and generating one small set of files: first the additive step, then — after the application release that stops using the old structure — the contract step. The versioned directory then reads as a faithful log of the path production actually took, which is valuable for audits and for rebuilding environments, and which a pure declarative workflow cannot provide. Lint the generated-and-edited files with the same rules you apply to hand-written migrations, as described in Migration Linting & Static Analysis.
Verification Checklist
Frequently Asked Questions
Why not just apply the desired state directly and add online options in the tool config? Tool configuration can cover some cases, such as concurrent index builds, but it cannot express data backfills or staging across releases. Versioned files can, and the hybrid model keeps the declarative file as the reference so you do not lose the single source of truth.
Is it safe to edit a generated migration file? Yes, before it has been applied anywhere shared. Edit, re-hash, and let the replay check confirm it still converges on the desired state. Never edit a file that has already run in a shared environment; write a new one instead.
What if the replay check keeps failing on formatting differences? Make sure the scratch database uses the same engine, major version and extensions as production, and that the desired state is compiled on the same kind of dev database. Genuine normalisation noise usually disappears once the environments match.
Does this work with ORMs rather than Atlas?
Yes. Prisma’s migrate dev --create-only, Drizzle Kit’s generate, Alembic’s autogenerate and TypeORM’s migration:generate all produce editable drafts from a model diff. The same rules apply: edit for online DDL, keep applied files immutable, and verify that the history converges on the model.