Keeping Schema Migrations in Sync Across Microservices

Two services deploy in the same release train, both carry migrations, and one of them fails at startup with relation "billing_accounts" does not exist — even though the table is right there in the schema history of the other service. Or worse: both services own migration files that touch the sharded orders database, their pipelines run in parallel, and the deploy that lands second overwrites a column the first one just added, leaving flyway_schema_history describing a schema no single service actually produced. When several services share or depend on one database, migration correctness stops being a property of any one repository and becomes a property of the order they run in and who is allowed to run them. This page is the runbook for diagnosing cross-service ordering failures, mitigating them mid-incident, and replacing the race with an ownership contract. It assumes you already work within the broader environment parity strategies this section covers and the migration fundamentals that define a correct, backward-compatible change.

Symptom / Error Signatures

The tell-tale signature is a migration or an application that references a database object owned or created by a different service, at a moment when that object does not yet exist. It surfaces at deploy time, at startup, or as a lock fight on the shared history table:

  • PostgreSQL ERROR: relation "billing_accounts" does not exist at startup, when the table is created by a sibling service’s migration that has not run yet
  • MySQL ERROR 1146 (42S02): Table 'app.billing_accounts' doesn't exist during a foreign-key add that references another service’s table
  • PostgreSQL ERROR: constraint "fk_orders_account" for relation "orders" already exists when two services both try to own the same cross-table constraint
  • FlywayValidateException: Validate failed: Detected resolved migration not applied to database: 20260724_02 — one service’s history table is missing a version another service expects
  • Liquibase Validation Failed: change sets check sum ... but the change set has already been applied when two repositories share a changelog table but not the changelog file
  • Prolonged ACCESS EXCLUSIVE (Postgres) or metadata lock waits on flyway_schema_history / databasechangelog while two pipelines migrate the same database concurrently

Confirm which service is ahead and which is behind with read-only queries before touching anything:

-- PostgreSQL · read-only · safe against production with a SELECT-only role
-- Show every service's recorded migration state in the SHARED database.
-- Each service should write to its own history table; a single shared
-- flyway_schema_history is itself a warning sign.
SELECT 'orders_svc'  AS service, version, description, installed_on, success
FROM   orders_svc.flyway_schema_history
UNION ALL
SELECT 'billing_svc' AS service, version, description, installed_on, success
FROM   billing_svc.flyway_schema_history
ORDER  BY installed_on DESC
LIMIT  20;
-- PostgreSQL · read-only · run during the incident to catch the concurrent-migration race
-- Any two ALTER/CREATE statements waiting on the same relation = a cross-service collision.
SELECT pid, usename, state, wait_event_type, LEFT(query, 80) AS query
FROM   pg_stat_activity
WHERE  wait_event_type = 'Lock'
  AND  query ~* '(ALTER|CREATE|DROP)';
Cross-service ordering failure on a shared database The orders_svc migration deploys first and adds a foreign key referencing billing_svc.billing_accounts, but billing_svc has not run its migration, so the referenced table does not exist yet and the deploy fails. Shared physical database — one schema state orders_svc schema orders (has fk_orders_account) orders_svc.flyway_schema_history head: 20260724_02 (applied) billing_svc schema billing_accounts (not created) billing_svc.flyway_schema_history head: (empty — not run yet) FK 1. orders_svc deploys first adds FK to billing_accounts 2. billing_svc — still queued table not created yet ERROR: relation "billing_accounts" does not exist
A dependent service that deploys ahead of the service owning the referenced table fails: the cross-service foreign-key edge lives outside either tool's dependency graph.

Root Cause Analysis

A migration tool computes the next action by diffing its recorded history against its migration files, and it reasons about exactly one lineage of versions. That model breaks the instant a second service can write to the same physical schema, because now the true schema state is the interleaving of two independent version lines that neither tool can see. Two distinct failure classes follow. The first is an ordering violation: service B’s migration (or its application code) references an object that service A owns, but B’s pipeline reached the database first. Nothing in B’s history table is wrong — the object simply is not there yet, because the cross-service dependency edge lives outside any single tool’s dependency graph. The second is an ownership collision: two services both believe they own the same table, index, or constraint, so their migrations race, and whichever lands second either duplicates the object or overwrites the first service’s change — the same drift that breaks the additive, backward-compatible contract described in idempotent script design.

The correct mental model is that a shared database has exactly one physical schema but potentially many writers, so someone must own each object and something must sequence changes that cross an ownership boundary. The way the two dominant tools behave when pointed at a shared schema diverges enough to matter:

Concern Flyway (shared schema) Liquibase (shared schema)
History table One flyway_schema_history per schema by default; two services sharing a schema fight over one table unless you set a per-service table Single databasechangelog; logicalFilePath distinguishes ownership but a shared table still serializes writes
Cross-service ordering No native cross-repo dependency; versions are per-history-table only <preConditions> can assert a sibling object exists and halt, but cannot order the sibling’s run
Collision detection Checksum applies only within one history line; a sibling’s DDL is invisible Checksum + logicalFilePath catch a re-applied changeset, not a foreign object
Safe pattern Separate schema (or separate history table) per service, ordered by the pipeline runAlways/preConditions guards plus a pipeline-level sequence

Neither tool sequences across repositories on its own. Ordering has to be imposed above them — by the pipeline, by an explicit precondition guard, or by removing the dependency edge entirely — which is what the mitigation and the permanent fix below do. For the deeper commit-model reasons a half-applied cross-service ALTER cannot self-heal on MySQL, see transactional vs non-transactional databases.

Immediate Mitigation

Apply every corrective step against a staging clone with the same two-service topology first, and take a verified snapshot before editing any shared history table. The goal is to get the dependency ordered correctly and stop the concurrent race — not to redesign ownership under fire.

1. Freeze both pipelines. Stop the CI/CD runners for every service that writes to the shared database so no second migration races the reconciliation.

# bash · run from the CI control host · read-write to the CI system, not the DB
# Pause the deploy jobs for every service that owns migrations on this database.
for svc in orders_svc billing_svc; do
  ci pipeline pause --project "$svc" --reason "cross-service migration reconcile"
done

2. Establish who is ahead. Compare each service’s recorded head against what the failing service expects. If the dependency (service A) simply has not run, the fix is ordering, not repair — run A’s migration to the required version first.

# bash · run with a migration-capable role · applies real DDL, use a low-write window
# Bring the DEPENDENCY service to the version the dependent service requires, first.
flyway -configFiles=orders_svc.conf -target=20260724_02 migrate

3. Guard the dependent change so it cannot run early. Add a precondition that halts (rather than errors) when the dependency is absent, so a re-ordered pipeline converges instead of failing. In raw SQL, wrap the dependent step in an existence check:

-- PostgreSQL · run as the dependent service's migration role · must run AFTER the dependency
-- Halts cleanly if the owning service has not created the table yet, instead of a hard error.
DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM information_schema.tables
    WHERE table_schema = 'billing_svc' AND table_name = 'billing_accounts'
  ) THEN
    RAISE EXCEPTION 'dependency billing_accounts absent — run billing_svc migration first';
  END IF;
  ALTER TABLE orders_svc.orders
    ADD CONSTRAINT fk_orders_account
    FOREIGN KEY (account_id) REFERENCES billing_svc.billing_accounts (id) NOT VALID;
END $$;

4. Serialize the shared writer. If the collision was two pipelines migrating at once, take a single advisory lock so only one migration touches the database at a time; the second waits rather than corrupting state.

-- PostgreSQL · run at the start of EVERY service's migration job · released on session end
-- One well-known key means concurrent migrations queue instead of racing.
SELECT pg_advisory_lock(hashtext('shared-db-migration'));
-- ... run the service's migration here ...
SELECT pg_advisory_unlock(hashtext('shared-db-migration'));

5. Resume in dependency order. Un-pause the owning service first, let it reach its target version, then un-pause dependents. Verify application error rate and connection-pool metrics returned to baseline before the next release train.

Parallel collision versus serialized dependency-first migration In the broken case both service pipelines write the shared database at the same time and the later write overwrites the earlier one. In the corrected case an advisory lock serializes them and the owning service migrates before the dependent, so both succeed. Broken — parallel, unserialized billing_svc orders_svc migrate (writes col) migrate (writes col) ! second write overwrites first history describes a schema no service produced Corrected — advisory lock + dependency-first order billing_svc orders_svc lock create billing_accounts unlock waits on lock add FK — succeeds dependency ready One lock key serializes writers; owner migrates before dependent
Above: two pipelines racing the shared database corrupt its history. Below: a shared advisory lock plus dependency-first ordering makes the second migration wait, then succeed.

Permanent Fix / Long-Term Pattern

The durable fix is to stop treating a shared database as a place where any service may create anything. Assign every object a single owning service and make the ownership boundary explicit — ideally one schema (or database) per service, so orders_svc physically cannot write billing_svc’s tables and each keeps its own history table. Where services genuinely must share a table, publish it as a schema contract: the owning service versions the table’s shape, and consumers depend on the contract rather than reaching across to alter it. Then remove the tight ordering edge instead of orchestrating it. A cross-service foreign key is the tightest coupling you can add; prefer adding it NOT VALID and validating later, or dropping the hard constraint in favor of application-level referential checks, so a dependent deploy no longer requires the dependency to have migrated first. When a column must move from one service’s ownership to another, run it as a phased expand-and-contract change coordinated with a dual-write window, so both old and new shapes are valid during the transition and neither service is forced to deploy in lockstep.

Sequencing that does have to cross a boundary belongs in the pipeline, not in a tool’s version numbers. Encode the dependency-first order as an explicit stage in migration pipeline gating — the owning service’s migration is a gate the dependent service’s deploy waits on — and keep every migration a guarded, idempotent script so a re-run after a re-ordering converges rather than double-applies. Give each service its own history table and its own logical file path, verified as part of schema version control basics, so no two lineages ever fight over one row. This is the same parity discipline the environment parity strategies overview applies to configuration and data, extended to the one thing multiple services genuinely share: the ordering contract between them.

Decision tree for a cross-service schema change Start from the change. Can one service own the object? If yes, give it a separate schema. If a table must be shared, publish it as a versioned schema contract. If a cross-service foreign key is genuinely required, add it NOT VALID behind a pipeline gate or replace it with an application-level referential check. Cross-service change One service can own the object? yes Separate schema, own history table no Must share the same table? yes Publish a versioned schema contract no Cross-service FK truly required? yes NOT VALID + pipeline gate no App-level referential check Remove the ordering edge before you orchestrate it
Resolve each cross-service change by ownership first: a separate schema where one service can own the object, a published contract where a table must be shared, and — only if a foreign key is unavoidable — a NOT VALID constraint behind a pipeline gate or an application-level check.

Verification Checklist

Return to the environment parity strategies overview for how this ordering contract fits the wider prepare-phase parity discipline.

Frequently Asked Questions

Should each microservice have its own database, or is a shared one ever acceptable? A schema (or database) per service is the design that eliminates ownership collisions outright, because no service can physically alter another’s objects and each keeps an independent migration history. A shared database is acceptable when services are tightly coupled around one dataset and the operational overhead of splitting outweighs the coupling — but only if you impose explicit ownership: one owning service per object, per-service history tables, and pipeline-level sequencing. The shared database is not the problem; unowned objects and unordered writers are.

How do I order a migration in service B that depends on a table owned by service A? Do not try to express it in either service’s version numbers — a migration tool only understands its own lineage. Make the ordering a pipeline gate: service A’s migration to the required version becomes a prerequisite stage that service B’s deploy waits on. As a safety net, guard B’s dependent DDL with an existence precondition that raises a clear error (or halts) when A’s object is absent, so a mis-ordered run fails loudly and re-runs cleanly once A has migrated. For a change that moves ownership between services, sequence it as an expand-and-contract transition with a dual-write window rather than a single hard cutover.

Two services share one flyway_schema_history table and their checksums now conflict — what do I do? That shared history table is the root cause: two independent version lineages cannot safely write one history row set. Immediately, snapshot the database, pause both pipelines, and split the history so each service has its own table (Flyway’s table config) or its own logicalFilePath (Liquibase). Reconcile by confirming the physical schema is correct, then let each service re-baseline against its own history rather than hand-editing rows. Long term, give every service a distinct history target and enforce object ownership so the two lineages never touch the same row again.