A practical workflow for running legacy and refactor branches safely when their migration histories diverge
Large refactors often touch more than application code. They rename tables, replace relationships, split modules, consolidate old migrations, or introduce an entirely new schema baseline. During that transition, your legacy branch and your refactor branch represent two valid but incompatible views of the same database.
Pointing both branches at the same development database is risky. A migration from one branch can make the database unusable from the other — even when both branches work perfectly against a clean database.
The safest development strategy is simple:
Give each divergent migration history its own database.
This article walks through why migration conflicts happen, how to isolate branch databases, how to switch between them safely, and how to converge back to a single migration history before the refactor is merged.
The real source of migration conflicts
A migration tool tracks more than the final shape of a database — it tracks the sequence of operations used to reach that shape.
Imagine a legacy branch with this history:
001_initial_schema
002_add_archive_timestamp
003_add_source_column
004_add_unique_indexA long-running refactor branch might replace that entire history with:
100_refactor_baseline
101_add_public_tokenThe final schemas might look similar, but migration tools don’t treat these histories as equivalent. They identify migrations by name and checksum — not by inferring whether two different sets of SQL files happen to produce similar tables.
If the refactor branch initializes a shared database first, and the legacy branch later tries to run 001initialschema, its first statement can fail because the table already exists. The reverse happens just as easily.
This isn’t just an inconvenient error, either. MySQL can apply part of a migration before a later statement fails, leaving the database in a state that mixes both histories.
Why IF NOT EXISTS isn't a general solution
It’s tempting to patch every statement like this:
CREATE TABLE IF NOT EXISTS example (...);That silences one specific error, but it doesn’t verify that the existing table is actually correct. The table could differ in:
- columns or data types
- nullability and default values
- indexes and uniqueness rules
- foreign keys and delete behavior
- enum values or collations
Later statements in the same migration may still fail — or worse, succeed while leaving an incorrect schema behind. Migration errors should surface drift, not paper over it.
The same logic applies to manually marking a migration as “applied.” That’s only safe once you’ve verified the database already contains exactly what the migration represents. Migration resolution is a reconciliation tool, not a substitute for schema validation.
The isolation model
Locally, one MySQL server can host multiple independent databases:
MySQL server
├── app_legacy
└── app_refactorEach database has its own application tables, migration-history table, schema version, seed data, and record of failed or completed migrations.
This is lightweight because everything else — Redis, object storage, and the rest of your infrastructure — stays shared. Only the relational database selected by the application changes.
For stronger isolation (say, different branches need different MySQL versions), give each branch its own Docker Compose project and volume instead. For most refactors, though, two databases on one local MySQL instance is enough.
Creating branch-specific databases
Start the local MySQL service:
docker compose up -d mysqlOpen an interactive session — avoid typing the password directly into the command, since shells often preserve history:
docker exec -it local-mysql mysql -u root -pCreate the two databases:
CREATE DATABASE app_legacy
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
CREATE DATABASE app_refactor
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;Grant your application user access (replace app_user with your local, non-secret username):
GRANT ALL PRIVILEGES ON app_legacy.* TO 'app_user'@'%';
GRANT ALL PRIVILEGES ON app_refactor.* TO 'app_user'@'%';
FLUSH PRIVILEGES;If a teammate’s container-init script already created the databases and grants, you can skip this step.
Never publish real usernames, passwords, tokens, connection strings, or database exports. Use placeholders in documentation and examples — always.
Keeping secrets out of version control
A tracked environment template should only ever hold placeholders:
DB_HOST=local-mysql
DB_PORT=3306
DB_NAME=app_legacy
DB_USER=${LOCAL_DB_USER}
DB_PASSWORD=${LOCAL_DB_PASSWORD}Real credentials belong in a .gitignored file, a password manager, or your environment's secret store.
For Prisma, a local connection string might look like:
DATABASE_URL=mysql://${LOCAL_DB_USER}:${LOCAL_DB_PASSWORD}@local-mysql:3306/app_legacyThat trailing database name is the value that actually matters when switching branches. Most migration tools follow the URL over a separate DB_NAME variable, so keep the two in sync — a mismatch will silently migrate the wrong database.
A safe branch-switching routine
Switching branches should be deliberate. Stop the app before touching source or migration files:
docker compose stop appMySQL and its data keep running.
Switch to legacy:
git switch legacy
DB_NAME=app_legacy
DATABASE_URL=mysql://${LOCAL_DB_USER}:${LOCAL_DB_PASSWORD}@local-mysql:3306/app_legacy
docker compose up -d --no-deps --force-recreate appSwitch to refactor:
docker compose stop app
git switch refactor
DB_NAME=app_refactor
DATABASE_URL=mysql://${LOCAL_DB_USER}:${LOCAL_DB_PASSWORD}@local-mysql:3306/app_refactor
docker compose up -d --no-deps --force-recreate appWhy those flags specifically:
- --no-deps leaves MySQL and other dependencies untouched
- --force-recreate forces a new container that actually reads the updated environment
- app scopes the operation to just the application service
A plain restart usually isn’t enough — restarting a container doesn’t reload the environment variables it was created with.
Verify before migrations run
Check three things together before you trust the environment:
git branch --show-current
docker exec local-app printenv DB_NAME
docker logs --tail 100 local-appThey should all agree:
| Branch | Expected database | | --- | --- | | legacy | app_legacy | | refactor | app_refactor |
The migration log should confirm the same target:
Datasource "db": MySQL database "app_refactor"If the Git branch, the environment, and the migration log disagree — stop. Catching this now is far cheaper than repairing a mixed schema later.
When sharing a connection URL for debugging, redact the credentials first:
docker exec local-app sh -lc \
'printf "%s\n" "$DATABASE_URL" | sed "s#://[^@]*@#://[credentials]@#"'Keeping useful data without sharing migration history
Separate databases don’t mean giving up realistic development data. Three approaches work well:
Repeatable seed data — deterministic, idempotent scripts for roles, settings, reference catalogs, and test accounts.
Sanitized snapshots — export a sanitized snapshot, restore it into a temporary database, then migrate or transform it for the target branch. Don’t restore the source’s migration-history table unless the target shares that history.
Explicit data transformation — when the refactor changes the data model itself, write a versioned, restartable transformation. Test it against a copy and verify row counts and invariants before trusting it.
Copying an entire database — migration-history table included — from one divergent branch to another just recreates the original problem under a new name.
Recovering from an accidental cross-branch migration
Stop the application, confirm the target database, then inspect the migration-history table and the original error log.
How you recover depends on what’s at stake:
- Disposable local database — drop it, recreate it, and reapply that branch’s migrations.
- Valuable development data — back it up, diff the live schema against the intended one, and write an explicit forward or backward repair.
- Correct schema, wrong history — reconcile the migration record only after proving the schema actually matches.
Never reset an entire shared Docker volume to fix one branch database. Dropping a single database is narrower — but still destructive, so back up first and double-check the target.
Production demands stricter discipline. Don’t improvise by deleting migration records, editing migrations already applied elsewhere, or marking migrations as applied without an audited schema comparison.
CI and automated testing
CI should spin up an empty database per job, apply the checked-out branch’s migrations from zero, then tear it down.
That gives you two signals at once: the migration history can build a clean database, and the application actually works against the schema that history produces.
For a large refactor, add a second path that restores a representative legacy snapshot and runs the planned upgrade against it. Clean-install tests and upgrade tests catch different failure classes — you want both.
Converging before the refactor is merged
Two databases are a development isolation technique, not the final deployment strategy. Before merging, pick one canonical migration history.
A safe convergence plan:
- Declare the main development branch the source of truth.
- Rebase or merge its latest migrations into the refactor branch.
- Express refactor changes as new forward migrations wherever practical.
- Test those migrations against both an empty database and a recent sanitized snapshot.
- Deploy through your normal environment sequence.
- Remove the temporary branch databases only after cutover is verified, and backups have cleared their retention window.
If the refactor genuinely requires a new baseline, treat it as its own cutover project — document which environments get baselined, how their schemas are verified, how data is transformed, and how rollback works.
Practical checklist
Before switching branches:
- Stop the app
- Verify worktree changes are preserved
- Switch Git branches
- Select the matching database in the ignored environment file
- Confirm the database-name variable and the connection URL agree
- Recreate only the app container
- Read the migration target from startup logs
Before merging the refactor:
- Choose one canonical history
- Avoid editing migrations already used by shared environments
- Test clean installation
- Test upgrading realistic legacy data
- Validate constraints, indexes, row counts, and critical queries
- Document backup and rollback procedures
- Strip real credentials from code, logs, screenshots, and articles
Conclusion
Migration histories are executable records of how a schema evolved. Two branches with incompatible histories shouldn’t write to the same database, even when their final schemas look alike.
Isolating branch databases makes development predictable: each branch owns its own schema, data, and migration state. Careful environment switching prevents accidental cross-branch migrations, and clean-install plus upgrade tests make the eventual cutover safer.
The rule to remember:
Isolate divergent histories during development, then converge deliberately before release.