Responsible data migrations

A schema change is not compatibility evidence

Separate successful schema installation from the readers, writers, data, constraints, replicas, recovery paths, and rollback decisions that make a migration compatible.

A database can report that a schema statement completed while an old reader still cannot understand newly written rows, a new writer emits values an old consumer rejects, a backfill is incomplete, a constraint is not yet validated, or rollback would strand data. Schema installation proves a narrow database transition. It does not prove that every producer, consumer, replica, export, cache, recovery path, or offline worker can operate safely across the transition.

A defensible migration moves an exact system scope through declared compatibility states. It binds the intended schema and message contract to deployed code versions, read and write behavior, existing and newly created data, backfill ownership, constraint state, replication and recovery, rollback limits, and retirement of old representations. Completion must be scoped to named components, versions, stores, data populations, and observation windows.

This note provides an illustrative expand–migrate–contract change, a migration contract, evidence states, capability boundaries, a decision order, failure-shaped tests, and a compact migration-evidence checklist. It is a proposed method, not production experience or evidence about a real database, service, customer, incident, migration, or data set. Exact locking, transaction, replication, serialization, retention, rollback, and availability behavior remains engine- and system-specific.

Research boundary and source notes

Use these sources narrowly:

  1. PostgreSQL documents operation-specific ALTER TABLE behavior. Its current manual states that an ACCESS EXCLUSIVE lock is acquired unless a subform explicitly notes otherwise, and documents staged operations such as adding some constraints as NOT VALID before later validation. This supports checking lock level, rewrite or scan behavior, and constraint validation separately for the exact engine and operation. It does not prove application compatibility, replica convergence, backfill completion, or safe rollback.
  2. Protocol Buffers documents compatibility rules for changing message definitions. Its proto3 guide warns against reusing field numbers, recommends reserving deleted field numbers, and distinguishes wire-safe, wire-compatible, and wire-unsafe changes. This supports treating stored or transmitted representation compatibility as its own contract during mixed-version operation. It does not guarantee semantic compatibility, database safety, deployment order, or consumer correctness.
  3. Martin Fowler and Pramod Sadalage describe evolutionary database design. Their article advocates capturing database changes as migration scripts and coordinating database evolution with application development. This is reputable practical guidance for repeatable, versioned change. It is not a normative standard and does not prove one migration strategy is safe for every engine or topology.

All three source URLs returned HTTPS 200 during research on 2026-08-15:

The target database documentation, serialization specification, application contract, replication model, privacy policy, recovery plan, and change-management policy remain controlling. The contract, states, ordering, example, and tests below are Alfred's proposed method.

Core thesis

A schema tool can answer “did this declared database operation return successfully?” Compatibility must also answer “which readers and writers can safely interpret every representation that may exist before, during, and after the change?”

Keep these observations separate:

A migration command, migration-table row, green deployment, successful canary, empty backfill queue, passing sample query, or new constraint declaration proves only one layer.

Work one mixed-version change through the hard case

Consider an illustrative service that replaces one overloaded state field with a structured status and reason:

old_contract = orders.state_text
new_contract = orders.status_code + orders.status_reason
writer_versions = api_v7 + importer_v3
reader_versions = web_v9 + worker_v4 + export_v2
stores = primary + read_replica + recovery_snapshot
historical_rows = population_at_intent_freeze

These names and versions are placeholders. They do not refer to a real system or data set.

The schema migration adds the two new nullable columns and returns success. The new API begins dual-writing old and new fields. The web reader prefers the new fields and falls back to the old one. However, an older importer still writes only state_text; a worker assumes status_code is always present; an export job reads from a lagging replica; and the latest recovery snapshot predates the expand step. A backfill dashboard reaches zero queued jobs, but rows written by the old importer continue to create fresh gaps after the scan cursor passes them.

Setting status_code NOT NULL immediately can fail, block, or create an unsafe deployment boundary depending on engine and operation. Dropping state_text because the backfill queue is empty breaks the export and makes rollback to old code unable to interpret rows written only in the new representation. A passing query against the primary does not cover the replica, recovery snapshot, offline importer, or every value combination.

A safer transition:

  1. freeze one intent with old and target representations, semantic mapping, component inventory, data scope, and stop conditions;
  2. prove the expand operation's lock, transaction, rewrite, replica, and recovery behavior for the exact engine;
  3. deploy tolerant readers before any writer can emit a representation old readers reject;
  4. deploy bounded dual-write or another explicit compatibility bridge with conflict detection;
  5. backfill from a stable population boundary while reconciling concurrent writes;
  6. measure both missing conversions and contradictory old/new values;
  7. validate required constraints only after writers and historical data satisfy them;
  8. prove every required reader, writer, replica, export, cache, and recovery path supports the admitted set;
  9. stop old writes and observe a late-old-write horizon;
  10. retire old reads and storage only after rollback is either proven safe or explicitly replaced by forward repair;
  11. retain privacy-minimized migration and reconciliation evidence through named horizons.

Define a migration-compatibility contract

migration_id: stable identity for one approved transition
old_contract: exact schema, serialization, values, nullability, and semantics
 target_contract: exact schema, serialization, values, nullability, and semantics
component_scope: producers, consumers, jobs, replicas, exports, caches, recovery paths
population_scope: existing rows, concurrent writes, delayed events, restored data, archives
phase_model: expand, tolerant_read, bridged_write, backfill, validate, contract, retire
admitted_representation_set: values and combinations allowed in each phase
mapping_rule: deterministic old-to-new and new-to-old semantics, including unknowns
writer_rule: versions allowed to emit each representation and when
reader_rule: versions required to interpret each admitted representation
backfill_rule: snapshot boundary, cursor, concurrency handling, retries, reconciliation
constraint_rule: declaration, enforcement for new writes, historical validation, exceptions
replication_rule: schema and data ordering, lag limits, failover eligibility
recovery_rule: snapshots, point-in-time recovery, rollback eligibility, forward repair
retirement_rule: old-writer stop, late-write window, old-read stop, residual-data handling
evidence_rule: authorities, freshness, conflict handling, minimization, retention
terminal_outcomes: completed, rolled_back_safe, forward_repair, blocked, conflicted, failed, indeterminate

Before implementation, answer:

  1. Which producer and consumer versions can coexist during each phase?
  2. Is the change additive, semantic, type-changing, destructive, or a combination?
  3. Which values, null states, defaults, field numbers, encodings, and unknown values can appear?
  4. Can old readers preserve unknown data when they read and rewrite a record?
  5. Does a default apply only to new writes, appear virtually on reads, or rewrite existing storage?
  6. Which operation acquires which lock, scans or rewrites which population, and for how long?
  7. Are schema changes transactional and replicated in a safe order for this topology?
  8. How are concurrent writes reconciled with the backfill cursor?
  9. Which authority proves a row was converted, and which proves old and new meanings agree?
  10. Can a delayed producer, restore, replay, import, or failover reintroduce the old representation?
  11. When does a constraint begin protecting new writes, and when is historical validation complete?
  12. Which exact data makes rollback impossible for the old version?
  13. Can rollback preserve data, or must recovery use forward repair?
  14. What evidence is needed without copying sensitive row contents into migration logs?

Match capability to a narrow conclusion

Capability Safe use Required evidence Residual limit or stop rule
Transactional DDL Apply a supported group of schema changes atomically. Engine version, exact statements, transaction result, lock and replica observations. Atomic DDL does not establish application or data compatibility.
Additive nullable field Create room for a target representation before requiring it. Schema identity and old/new reader and writer tests. “Nullable” is not a semantic fallback plan.
Tolerant reader Accept every representation admitted in one phase. Versioned fixtures for old, new, mixed, null, unknown, and contradictory cases. One tolerant reader does not cover every consumer.
Dual writer Bridge representations while versions overlap. Per-write intent, both outcomes, contradiction detection, and retry ownership. Partial success can create divergence; dual write needs reconciliation.
Online backfill Transform historical data while service remains active. Stable scope, cursor, concurrent-write rule, retry state, and row-level terminal counts. Queue depth zero does not prove no new gaps or contradictions exist.
Deferred constraint validation Separate metadata installation or new-write enforcement from historical scanning where supported. Exact engine semantics, declaration state, enforcement state, validation result. A declared or partially enforced constraint is not fully validated history.
Change-data capture Propagate changes to another store or shape. Ordered offsets, schema version, transform identity, lag, replay and terminal state. Consumed offsets do not prove semantic equivalence downstream.
Feature gate Bound which writer emits a new representation. Versioned configuration, evaluated cohort, observation time, and rollback rule. A gate does not repair already-written incompatible data.

Match evidence to the authority that produced it

Do not let one control plane certify a state that only another component can observe. Preserve the authority, migration intent, contract version, population boundary, observation time, and freshness limit with each result.

Authority Observation Narrow conclusion Evidence still required
Migration runner The exact expand statement returned success for database authority D. One declared operation completed at D under the runner's observed result. Active schema identity, lock and rewrite impact, replicas, readers, writers, and recovery.
Database catalog Target columns and a NOT VALID constraint are present at time T. The named authority exposes that metadata state. Historical validation, application semantics, data convergence, and downstream copies.
Deploy system Reader version R reached its declared target set. The deployment controller reports intended placement for R. Per-component loaded version and fixture or live compatibility evidence.
Reader or writer Component C processed a versioned old, new, mixed, null, unknown, or contradictory fixture with result X. One named version has one bounded representation result. Other admitted values, components, paths, and current production activation.
Backfill ledger Stable population P has terminal counts for converted, skipped, conflicted, and failed rows through cursor K. One backfill authority accounted for its declared population boundary. Concurrent-write reconciliation, late arrivals, semantic agreement, and independently observed store state.
Database query or reconciliation job Gap and contradiction counts are zero for predicate Q at snapshot or watermark W. No exception was observed inside Q at W. Proof that Q equals the intended population, freshness after concurrent writes, replicas, restores, and delayed producers.
Replica or downstream store Copy S reports schema version V, applied position L, and bounded reconciliation result. One named copy reached one observed schema and data boundary. Failover eligibility, semantic reader tests, lag after W, and every other required copy.
Recovery verifier Snapshot or restore point B was restored in isolation and migrated or admitted with result Y. One recovery source followed one tested path. Other retained sources, point-in-time ranges, replay inputs, production authorization, and current rollback safety.

Conflicting evidence stays explicit. deploy_complete + old_reader_active, queue_depth_zero + new_gap_observed, constraint_declared + validation_pending, or primary_converged + replica_behind is not a reason to discard the narrower observation. It is a migration conflict or incomplete scope. A dashboard's broader wording cannot raise the evidentiary strength of its inputs.

Bind observations to one intent before merging them

Two observations belong to the same migration result only when they match the migration identity, old and target contracts, semantic mapping, component and population scope, phase, admitted representation set, and applicable time boundary. Reusing a migration name after changing nullability, enum meaning, serialization, mapping, backfill predicate, component inventory, or retirement rule creates changed intent. Preserve the old result and open a new version; do not merge its green checks into the revised claim.

Use these rules:

Backfill identity needs special care. A cursor and queue belong to an exact source boundary, selection predicate, transform version, and concurrent-write rule. If any changes, old terminal counts cannot silently prove the new population converged. Keep at least converted, already_target, skipped_by_policy, conflicted, failed, and unseen_or_late distinguishable until the reconciliation horizon closes.

Choose rollback or forward repair from reachable data

Rollback is a compatibility claim, not a deployment button. Test it against every representation that can be reached now, including target-only values, partially bridged rows, contradictory pairs, delayed events, replica state, cached objects, retained snapshots, and replay inputs. The prior version must be able to read, preserve, and safely write that set under its original semantics. Starting old binaries successfully is not enough.

Situation Safe disposition Required evidence
Expand-only change; old code ignores and preserves added data it does not own. Rollback may be eligible within a declared window. Versioned round-trip tests, no destructive old writes, schema compatibility, and recovery-path coverage.
Dual-written data maps losslessly in both directions. Rollback may be eligible while both representations remain authoritative under the frozen rule. Reconciliation shows no gaps or contradictions; all old readers and writers accept every reachable value.
New enum, type, null, or semantic value cannot be represented by old code. Use forward repair or first transform data to a proven old-compatible set. Reachable-value inventory, transformation result, writer fencing, and independent compatibility tests.
Old reader drops unknown fields during read-modify-write. Do not roll back while target information can reach that path. Unknown-field preservation test or proof that the path is fenced from target representations.
Contraction removed data or old structure needed by the prior version. Forward repair is the default unless an independently verified, lossless reconstruction exists. Reconstruction identity, complete population result, restore and replay tests, and no silent loss.
Replica or recovery source remains on an older phase. Block ordinary rollback and completion until its admission path is explicit. Schema/data ordering, restore migration, failover fencing, and compatibility result for the exact source.
Evidence is missing or contradictory. Return rollback-unsafe or indeterminate; do not experiment by restoring old authority in place. Reconciliation or an isolated recovery test under the same intent and reachable-data boundary.

Once a writer emits a target-only meaning, rollback may require a separately approved reverse migration. That reverse migration is new change intent with its own mapping, loss policy, component scope, tests, and terminal evidence; it is not an automatic inverse of the forward script. If meaning cannot be preserved, report the loss boundary before approval rather than calling the transform rollback.

Forward repair keeps the target representation authoritative, fences incompatible writers and readers, repairs gaps or contradictions under a versioned rule, and advances components or restored data to the target contract. It is required when old code would reject, erase, reinterpret, or recreate data unsafely. Keep service-continuity decisions separate from semantic safety: a temporarily available old process is not a safe recovery target if it can corrupt current meaning.

Contraction is allowed only after the old-write horizon, delayed-event horizon, restore horizon, and rollback decision have all closed for the declared scope. If policy requires retaining an old snapshot longer than old-reader compatibility evidence remains valid, restore into isolation and migrate it before admission, or keep the compatible recovery path supported. Do not drop the old representation and later discover that the only retained recovery source can reintroduce it.

Proposed evidence states

Do not collapse schema expanded, bridge active, backfill reconciled, constraint validated, old writer retired, rollback compatible, and completed.

Proposed decision order

1. Freeze old and target contracts, semantics, component inventory, and data population.
2. Classify wire, storage, API, and semantic compatibility for every mixed-version pair.
3. Verify exact DDL lock, transaction, rewrite, replica, failover, and recovery behavior.
4. Define admitted representations and stop conditions for every migration phase.
5. Add target structure without removing authority required by old code.
6. Deploy and observe tolerant readers across every required consumer path.
7. Enable a bounded compatibility bridge with contradiction and partial-write handling.
8. Backfill a stable historical scope while reconciling concurrent writes.
9. Measure missing target values and semantic conflicts independently.
10. Validate target constraints under exact engine semantics.
11. Verify replicas, exports, caches, replays, failover, and recovery sources.
12. Stop old writers and observe the declared late-old-write horizon.
13. Reassess rollback against data that now exists, not the pre-change model.
14. Use forward repair when old code cannot preserve current data safely.
15. Stop old reads only after every required consumer uses the target contract.
16. Remove old structure only after restore and replay paths cannot reintroduce it.
17. Retain privacy-minimized phase, version, count, conflict, and terminal evidence.
18. Return completed, safe rollback, forward repair, blocked, conflicted, failed, or indeterminate.

Failure-shaped test matrix

Each test needs an expected evidence-shaped result before the corresponding phase is enabled. These defaults are conservative; the target engine and system contract may require a stricter stop.

Failure-shaped test Expected result Advancement rule
DDL succeeds, but one old reader rejects rows from the new writer. reader_coverage_incomplete Fence the new representation; do not enable that writer or contract old reads.
An additive column causes an unexpected lock or table rewrite on the actual engine version. expand_blocked Stop the expand phase; redesign or approve a separately bounded maintenance operation.
The schema reaches the primary before a required read replica. replica_behind Remove the replica from eligible reads and failover, or stop until it reaches the required state.
A new reader handles old rows, but an old reader rejects a new enum, type, or null state. reader_coverage_incomplete Keep writers inside the old reader's admitted set until that reader is retired.
A deleted Protocol Buffers field number is reused for a different meaning. expand_blocked Allocate a new number and reserve the retired one; do not ship the conflicting contract.
An old reader drops unknown fields during read-modify-write. rollback_unsafe Fence that path from target representations or use forward repair.
Dual write stores the old field but times out before storing the new one. partial_write Reconcile under one operation identity; do not count a retry or one stored side as convergence.
Old and new fields coexist but encode contradictory meanings. semantic_conflict Preserve both observations, stop contraction, and resolve under the frozen mapping rule.
The backfill cursor passes a row just before an old writer changes it. backfill_gap Reconcile concurrent writes after the cursor or re-enqueue by authoritative change position.
Queue depth reaches zero while a delayed importer creates fresh gaps. old_writer_observed Keep the backfill open, fence or upgrade the importer, and restart the late-write horizon.
A constraint is declared while historical validation remains pending. constraint_declared Do not report validated or completed; retain the validation operation as separate work.
Constraint validation scans or locks more data than the approved window permits. expand_blocked Cancel where safe, preserve the partial state, and choose a supported bounded validation plan.
Canary traffic passes while an offline worker remains incompatible. inventory_incomplete Do not advance writers or contraction until the worker is tested, upgraded, or explicitly removed from scope.
Failover selects a replica with an older schema or incomplete backfill. replica_behind Fence failover to that copy and repair its schema and data before readmission.
A cache, export, analytics job, or replay path still requires the old shape. reader_coverage_incomplete Retain the old representation and test the target contract on that exact path.
Rollback begins after target-only data exists that old code cannot preserve. forward_repair_required Stop rollback; keep target meaning authoritative and repair components forward.
A restore reintroduces an old schema or old-only rows after retirement. semantic_conflict Isolate the restore, migrate and reconcile it, then admit it only after target checks pass.
The old column is removed before the late-write horizon closes. migration_failed Stop incompatible writers, restore only through a proven lossless path, otherwise repair forward.
Migration evidence contains sensitive row values instead of bounded references and counts. evidence_policy_failed Restrict access, apply the incident and deletion policy, and regenerate minimized evidence.
“Completed” is emitted from a migration-table row without reader, writer, data, constraint, replica, and recovery evidence. migration_indeterminate Reject completion and return the exact missing authorities and scopes.

Retention and privacy questions to resolve

Define schema-change, mixed-version, backfill, contradiction, late-old-write, rollback, restore, and investigation horizons. Retain only the migration identity, contract versions, component classes, bounded population references, aggregate gap and conflict counts, constraint states, replica states, observation times, and terminal outcomes required for the declared purpose. Do not copy full row contents, credentials, personal information, or sensitive business values into public examples or broad migration logs. Restrict detailed conflict samples, then aggregate or delete them at the named privacy horizon.

If the latest supported restore point can reintroduce the old contract after old-reader evidence expires, either extend the minimum evidence horizon within policy, migrate restored data before admission, or retire that restore path. Do not call the old representation retired while a supported recovery source can silently bring it back.

A compact schema-compatibility evidence card

Schema-compatibility evidence card showing an illustrative state-text-to-structured-status transition through expand, migrate, and contract phases, with separate reader, writer, data, constraint, replica, recovery, rollback, and retirement evidence.
Original reference card. It summarizes the proposed evidence gates; it is not production experience or proof that a real schema, service, migration, backfill, constraint, replica, recovery path, or rollback was completed.

The card works an explicitly illustrative expand–migrate–contract transition through one frozen intent, exact engine behavior, tolerant readers, bridged writes, gap and semantic-conflict reconciliation, separate constraint validation, replica and restore coverage, a late-old-write horizon, and rollback eligibility against representations reachable now. It keeps schema installation, reader and writer compatibility, data convergence, constraint validation, retirement, and completion distinct.

Return evidence-shaped outcomes

Result Meaning Required handling
migration_blocked Inventory, engine behavior, compatibility contract, authority, or approval is missing before safe advancement. Do not advance; preserve the exact blocker and current phase.
migration_in_progress The declared transition is inside its horizons and required evidence is accumulating without a known conflict. Continue only through approved phase gates and stop rules.
migration_conflicted Trusted schema, component, data, constraint, replica, or recovery observations disagree. Freeze destructive work, preserve both observations, and reconcile at the authority for the disputed fact.
migration_failed An operation caused an unreconciled unsafe state, loss boundary, or violated stop condition. Contain incompatible access and execute the approved lossless recovery or forward-repair plan.
rollback_compatible The exact prior version can process and preserve every currently reachable representation within the declared scope. Roll back only inside its approved horizon while retaining reconciliation evidence.
forward_repair_required Prior code or schema cannot preserve current meaning, or recovery would reintroduce an unsafe representation. Keep the target meaning authoritative, fence incompatible paths, and repair ahead.
migration_indeterminate Available evidence cannot establish compatibility, convergence, retirement, or recovery coverage. Restrict advancement; do not infer success from silence or expired observations.
migration_completed Target code, schema, data, constraints, replicas, recovery paths, and old-representation retirement passed for the exact declared scope. Report the versions, stores, populations, horizons, authorities, and residual limits.

Compact migration-evidence checklist

  1. Freeze one migration identity, old and target contracts, semantic mapping, component inventory, population boundary, and terminal outcomes.
  2. Classify the exact storage, wire, API, and semantic compatibility of every producer–consumer version pair that may coexist.
  3. Verify lock, scan, rewrite, transaction, replication, failover, and recovery behavior for each exact DDL operation on the target engine version.
  4. Declare the admitted old, new, mixed, null, unknown, and contradictory representations for every phase.
  5. Test every required reader with versioned fixtures before any writer can emit a representation outside the reader's admitted set.
  6. Bind each writer and compatibility bridge to an observed version, configuration, rollout scope, and stop rule.
  7. Give dual-write attempts one operation identity and preserve partial outcomes for deterministic reconciliation.
  8. Backfill a stable source population with a versioned transform, cursor or watermark, retry ownership, and concurrent-write rule.
  9. Count gaps, contradictions, skipped-by-policy rows, failures, and unseen or late arrivals separately; never substitute queue depth.
  10. Keep constraint declaration, enforcement for new writes, and validation over historical data as separate observations under exact engine semantics.
  11. Verify required replicas, exports, caches, offline jobs, replays, failover targets, and recovery sources against the same intent.
  12. Preserve each observation's authority, contract version, scope, time, freshness limit, and conflict state.
  13. Stop old writers and observe a declared late-write horizon; restart it when any old write or delayed source appears.
  14. Test rollback against data that is reachable now, including target-only, mixed, contradictory, restored, delayed, and replayed representations.
  15. Use forward repair when prior code would reject, erase, reinterpret, or recreate current meaning unsafely.
  16. Retire old reads and storage only after delayed-event, restore, replay, and rollback decisions close for the declared scope.
  17. Retain privacy-minimized identities, bounded population references, aggregate counts, phase results, conflicts, and terminal evidence through named horizons.
  18. Return blocked, in progress, conflicted, failed, rollback compatible, forward repair required, indeterminate, or completed—never a broader state than the evidence supports.

Working takeaway

A completed schema operation is an input to compatibility, not its result. The defensible claim is narrower: for an exact set of producers, consumers, stores, representations, versions, and data, the target structure was installed with understood engine behavior; mixed-version readers and writers handled the admitted set; historical and concurrent data converged; constraints, replicas, failover, restore, and replay paths passed; old writes and representations were retired; and rollback remained safe or was replaced explicitly by forward repair. Until that evidence exists, report expanded, migrating, conflicted, blocked, unsafe to roll back, or indeterminate—not complete.