skip to content

When tests auto-generate their schema and production applies versioned migrations, what breaks?

level: middleimportance: should knowfreq 54%

answer

  1. Two schemas built by two different paths
  2. The suite tests the model, not the history
  3. Backfills and orderings never execute
  4. Generation omits indexes, precision, collation
  5. Same runner, same ordered steps

basics

~20 s

The suite tests the code model's schema while production runs the migration history. Nothing executes the migrations, so a bad backfill, a wrong ordering or an unsafe alteration reaches production first, and the generated schema silently drifts.

solid answer

~50 s

Auto-generating the test schema from the code model means the suite exercises the *model*, while production exercises the *migration history*. Nothing then executes the migrations except the deploy, so their defects arrive in production first: a step that succeeds on an empty schema but times out or fails on populated data, a backfill that misses rows created between steps, a constraint added before the data satisfies it, a default that applies only to new rows. The generated schema also silently differs — index definitions, precision and scale, collation, cascade rules and check constraints are often approximated or omitted by generation. The fix is provenance: build the test schema with the same migration runner and the same ordered steps that production uses, so a migration defect fails a test rather than a deploy. Keeping generation only makes sense if a separate check proves the two schemas match.

code

pseudocode · 10 lines
pseudocode
setupSuite():
    engine = startEngine()
    migrations.applyAll(engine)      # same ordered steps as deploy
    assert migrations.appliedCount(engine) == migrations.stepCount()

setupCase():
    engine.beginTransaction()

teardownCase():
    engine.rollback()

go deeper

for a junior

Be able to say what the difference is: one schema is derived from the code model at start-up, the other is the result of an ordered history of change steps, and only the second is what production actually has.

for a middle

Explain the mechanics an interviewer is fishing for — ordering between steps, backfills over existing rows, constraints added before the data satisfies them, and the structural details generation approximates or drops.

for a senior

Demonstrate the operational half: a step correct on ten rows can lock or time out on millions, so argue for rehearsing the history against realistic volume rather than only asserting the end schema.

for a principal

Own the policy. Decide where migration provenance is mandatory versus where generation is an accepted shortcut, and name the check — a scheduled structural diff, or a real-engine tier — that keeps the shortcut honest.

## Two schemas, two build paths A schema can reach a database by two routes. **Generation** derives it from the application's declared model at start-up: the framework reads the mapped types and emits the tables it thinks they need. **Migration** applies an ordered, append-only history of explicit change steps, each recorded so it runs exactly once. Production almost always uses the second route, because it must transform data that already exists rather than create a schema from nothing. When the suite runs against an engine substitute, generation is tempting: it is instant, it needs no dialect-specific migration steps, and it always produces a schema matching today's code. That convenience is exactly the problem — it produces *a* schema, not *the* schema. ## What goes untested If no test ever runs the migration history, the history has no test coverage at all. Its characteristic defects are then discovered by the deploy: - **Ordering between steps.** A step reads a column another step has not created yet, or a backfill runs before the rows it must fill exist. On a fresh generated schema, ordering is irrelevant; on a history, it is the whole contract. - **Backfills over real data.** A step that populates a new column from an old one is trivially correct on an empty table. Against populated data it meets null values, mixed casing, out-of-range values and rows written concurrently while the step runs. - **Constraints added ahead of the data.** Adding a non-null or unique constraint succeeds on an empty schema and fails on production data that violates it. - **Defaults applied only forward.** Some engines apply a new default only to rows inserted afterwards, leaving existing rows null. Generation never has existing rows, so the gap is invisible. - **Operations that lock or rewrite.** A change that rewrites a table or takes an exclusive lock is instant on an empty schema and an outage on a large one. This is about duration and locking, not correctness, and generation cannot show it. - **Idempotency and rerun behaviour.** A partially applied step that is retried must not double-apply. Nothing in a generated schema exercises the runner's record-keeping. ## Drift between the two artefacts Even ignoring migration defects, the generated schema is usually a *different* schema. Generation approximates: it often omits secondary and partial indexes, chooses a default numeric precision and scale rather than the declared one, picks its own text length and collation, skips check constraints, and simplifies cascade rules. Tests therefore run without the index that makes the production query viable, with a wider numeric type that never rounds, and with a text column that compares differently. The drift is one-directional and grows: someone adds an index or a check constraint in a migration for a production incident, the code model never mentions it, and the generated schema never gains it. Nothing fails, so nobody notices. ## The worked case A renewal history table gets a new column holding the renewal boundary date, added by a migration plus a backfill that derives it from the previous expiry. The generated test schema has the column from the moment the model declares it, so every test row has it populated by the object that created it. The backfill itself — which derives the boundary by adding a month and then subtracting a day — is never executed by any test. In production it runs over 41,283 existing rows and lands one day early for every subscription created on the 31st of a month, an off-by-one at the boundary that only shows up when those subscriptions renew. The suite was green throughout, because the suite never ran the step that was wrong. ## Making provenance the same The durable fix is to build the test schema the way production builds it: run the same ordered migration steps, with the same runner, before the suite starts. Then a migration defect fails a test. Costs are real and worth naming — the run is slower, the steps must be expressible on whichever engine the suite uses, and dialect-specific steps may not run on a substitute at all. That last point is often the moment a team stops substituting the engine for its persistence tier and moves those cases to the real engine, because a history full of engine-specific steps cannot be honestly rehearsed anywhere else. Intermediate positions exist and should be argued on their merits: apply migrations in the suite that touches the real engine while letting fast logic cases keep a generated schema; or keep generation and add a scheduled check that compares the generated schema against a schema built by the migration history, failing on any difference. Both are defensible. What is not defensible is believing a green suite says anything about migrations it never ran. A separate discipline worth keeping: migrations that only ever run forward on the deploy still deserve rehearsal against realistic data volume, because duration and locking are properties of the data, not of the step.

  • Applying every migration before each test run makes the suite slower. How do you keep the feedback loop tight?
    Apply the history once per suite or per worker rather than per case, and isolate cases by transaction rollback or truncation instead of rebuilding. Where the history has grown long, periodically collapse it into a checkpoint schema plus the steps added since, keeping the recent steps — the ones most likely to be wrong — genuinely executed.
  • If you keep generating the test schema, what check makes that defensible?
    A scheduled comparison that builds one schema by generation and one by applying the full migration history, then diffs them structurally — tables, columns, types, precision, nullability, defaults, indexes, constraints and collation — and fails on any difference. Without that diff, the claim that the two schemas match is an assumption nobody is testing.
  • Which migration defects survive even when the suite does run the whole history?
    Those that depend on data volume and concurrency: a step whose duration or locking only becomes a problem on production-sized data, and a step that races with live writes. A suite runs the history over a handful of rows and an idle engine, so it proves correctness of the steps, not their operational safety.

saying these in an interview costs you the question

  • Assumes a generated schema equals the migrated one
  • Never runs the migration history anywhere before deploy
  • Thinks a migration that succeeds empty will succeed populated
  • Ignores omitted indexes and approximated numeric precision
  • Treats a green suite as coverage of the backfill logic

context