Why Database Migrations Conflict in Git and How We Fixed It
Database migration sounds simple until you're the one doing it. Moving data between systems, engines, or environments is one of the highest-risk projects a developer can take on. One bad cutover and you're looking at downtime, corrupted records, or a rollback nobody planned for.
That is what happened to us at Amrood Labs.
Three developers worked on three separate database changes. Each person started from the same code and created a migration on a different Git branch. Every branch generated the same next migration number.
Nobody made a careless mistake. The problem came from how Git branches and ordered migration files work. Our database migration history needed to stay in one straight line. However, we allowed several branches to write the next step at the same time.
Here is why our migrations conflicted in Git, why renaming the duplicate files did not fully solve the issue, and how we fixed the process with GitHub Actions.
What Is Database Migration?
A database migration is a saved change to a database structure or its data. Database migration is the process of moving data, schema, and database objects from one system to another. Whether that's on-premises to cloud, one database engine to another, or one version of a platform to a newer one.
It covers everything from a small schema change deployed through a CI/CD pipeline to a full enterprise database migration.
Migrations generally fall into a few categories:
- Schema migration: structural changes to tables, columns, indexes, and constraints
- Data migration: moving the actual records from a source to a target system
- Storage migration: changing where and how data is physically stored
- Homogeneous migration: moving between the same database engine (Oracle to Oracle)
- Heterogeneous migration: moving between different engines (Oracle to PostgreSQL, SQL Server to MySQL)
- Cloud migration: moving a database from on-premises infrastructure to a cloud provider
Many database migrations tools save these changes as numbered files:
0000_init.sql
0001_add_orders.sql
0002_add_index.sql
0003_rename_column.sql
We thought of this list as a book. If the book ended at Chapter 0003, the next migration should be Chapter 0004. That system works when only one person creates the next migration. The trouble starts when several developers work from the same starting point.
Three Developers Created Three Versions of 0004
Our team had three Node.js developers working on different tasks:
- Dev A changed the users table.
- Dev B changed the products table.
- Dev C changed the categories table.
Each developer pulled the latest code. At that time, the migration history ended at 0003. They then created separate Git branches. Dev A completed the work first and ran the migration command. Drizzle checked the branch, found 0003 as the latest migration, and created:
0004_update_users.sql
Dev B could not see that file because it only existed inside Dev A’s branch. When Dev B generated a migration, the tool also saw 0003 as the last file and created:
0004_update_products.sql
Dev C later received:
0004_update_categories.sql
We now had three different versions of migration 0004. Each result was correct based on the files available in that branch. The issue came from Git branch isolation.
Why Git Branches Caused the Conflict
A Git branch works like a closed room. Changes inside one branch do not appear in another branch until the work is merged, rebased, or pulled. That separation is useful for regular software development. It allows several people to work without constantly changing each other’s code.
However, a numbered migration history does not work well when several isolated branches choose the next number. Each branch asked the same question:
What migration comes after 0003?
Each branch received the same answer:
0004 comes next.
This became one of our biggest database migration challenges. Drizzle was not broken. It simply could not see migrations that had not yet been merged.
What Happened During the Merge
Dev A merged first. The shared migration history moved from 0003 to 0004 without a problem.
Dev B merged next. Git found that the base branch already contained a file numbered 0004.
At first, the fix seemed easy. We thought we could rename Dev B’s migration from 0004 to 0005. However, the duplicate filename was only the visible part of the conflict.
Drizzle also kept a hidden record that tracked the migration order. Dev A’s branch had updated the record to say that its migration followed 0003. Dev B’s branch had changed the same lines to say that its migration followed 0003. Git could not safely choose one version.
Even after fixing the conflict by hand, we could not be certain that the SQL files and the migration metadata still described the same sequence. Dev C was also waiting with a third version of 0004.
Why Renaming the File Was Not Enough
Migration systems may store more than numbered SQL files. They may also keep:
- Migration IDs
- Journal records
- Schema snapshots
- Checksums
- Timestamps
- Parent migration references
- Generated schema states
Changing the filename does not always update these records.
A folder may appear to contain 0004, 0005, and 0006, while the hidden migration data still treats them as three competing versions of 0004.
That can produce different results in local, staging, and production environments. This is why database migration testing must include both the generated SQL and its related migration records.
The Real Cause Was Our Process
It would have been easy to blame the developers, but everyone followed the normal process:
- Pull the latest code.
- Create a feature branch.
- Change the schema.
- Generate a migration.
- Open a pull request.
The real problem was an unwritten rule:
Everyone was allowed to write the next migration at the same time. A book cannot have three different Chapter 4 files in one final sequence. One person or one controlled system must decide which chapter comes next. That lesson became the basis of our new database migration strategy.
Our Fix: One Process Writes Shared Migrations
We changed one rule: developers no longer create final shared migrations inside feature branches. Developers still change the schema. For example, one developer may add a users field while another updates the products table.
If they need migrations for local testing, they can generate temporary files. Those files go into a folder ignored by Git, so they never enter the shared migration history. The official migration is created only after the pull request is merged. We assigned that job to a GitHub Actions workflow.
Our Database Migration Steps
Our updated database migration steps work like this:
- A developer creates a feature branch.
- The developer updates the required schema files.
- Temporary local migrations may be used for testing.
- Temporary files stay outside the shared Git history.
- The developer opens a pull request.
- The team reviews the schema change.
- The pull request is merged into develop or main.
- GitHub Actions checks whether schema files changed.
- The workflow generates the official migration.
- The workflow commits it to the base branch.
- The migration is tested in staging.
- The approved change moves to production.
This gives one controlled process ownership of the migration sequence.
How the GitHub Actions Workflow Works
The workflow runs when a pull request is merged against develop or main. It continues only if the pull request was merged.
on:
pull_request:
types: [closed]
branches:
- develop
- main
jobs:
generate-migration-and-deploy:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
It checks whether schema files were changed:
- name: Check for schema changes
id: schema-changes
run: |
CHANGED_FILES=$(git diff --name-only \
${{ github.event.pull_request.base.sha }} \
${{ github.event.pull_request.merge_commit_sha }})
if echo "$CHANGED_FILES" | grep -q "lib/db/schemas/"; then
echo "schema_changed=true" >> $GITHUB_OUTPUT
else
echo "schema_changed=false" >> $GITHUB_OUTPUT
fi
If the workflow finds schema changes, it generates a migration and checks the Drizzle folder for new files.
It then commits the migration:
- name: Commit and push migration
if: steps.migration-check.outputs.has_new_migration == 'true'
env:
HUSKY: "0"
run: |
git add drizzle/
git commit \
-m "ADDED: Auto-generate migration from PR #${{ github.event.pull_request.number }} [skip ci]" \
--no-verify
git push origin ${{ github.event.pull_request.base.ref }}
Why Generating Migrations After the Merge Works
Before the fix, the migration history looked like this:
Dev A: 0003 → 0004
Dev B: 0003 → 0004
Dev C: 0003 → 0004
After the fix, pull requests merged one at a time:
Dev A merges: 0003 → 0004
Dev B merges: 0004 → 0005
Dev C merges: 0005 → 0006
The workflow always checks the latest shared history before creating the next file.
The final list remains correct:
0003_rename_column.sql
0004_update_users.sql
0005_update_products.sql
0006_update_categories.sql
There are no duplicate numbers and no competing migration records.
Add Concurrency Protection
Two pull requests may merge within a short time. That can start two workflow runs before the first job pushes its migration. A concurrency group can prevent this race, the same state-locking principle our Terraform developers rely on to stop two applies from touching infrastructure state at once:
concurrency:
group: migration-${{ github.event.pull_request.base.ref }}
cancel-in-progress: false
The second workflow waits until the first one is complete.
This is one of the most useful database migration best practices for teams with frequent merges.
Test Every Generated Migration
Automation does not remove the need for testing. A useful migration test process should include:
- Applying the new migration to the previous schema
- Testing with realistic data volumes
- Running application integration tests
- Checking schema changes
- Reviewing data quality
- Reviewing generated SQL
Developers should check for dropped columns, missing indexes, long table locks, type changes, null value issues, and possible data loss. For large changes, the expand and contract database migration pattern can help. The team first adds the new structure, then moves the application gradually, and removes the old structure later.
This method is often used for a zero downtime database migration because the application can support both versions during the change.
Database Migration Tools
1. AWS Database Migration Service (AWS DMS)
AWS Database Migration Service is a managed offering built to move databases into AWS with minimal downtime. It supports both homogeneous migrations (same engine, like Oracle to Oracle) and heterogeneous migrations (different engines, like Oracle to PostgreSQL or SQL Server to MySQL.
AWS DMS uses Change Data Capture to replicate ongoing changes, which is what makes near-zero-downtime cutovers possible. It also supports schema conversion and, through DMS Serverless, removes the need to manually provision or scale replication instances.
2. Azure Database Migration
Azure's approach to database migration service has shifted recently. The original Azure Database Migration Service (classic) for SQL Server scenarios is being retired. Microsoft stopped allowing new classic DMS resources for SQL Server migrations in mid-2023, with full retirement in March 2026.
Azure Data Studio, along with its SQL migration extension, was also retired at the end of February 2026. Teams migrating SQL Server workloads to Azure now use the current Azure Database Migration Service through the Azure portal.
3. Oracle Database Migration
Oracle database migration typically involves either staying within the Oracle ecosystem. It uses tools like Oracle Data Pump, GoldenGate, or Zero Downtime Migration, or moving off Oracle entirely to a target like PostgreSQL, which is a heterogeneous migration.
Final Thoughts
Our migration conflict was not caused by careless developers. It happened because three isolated branches were allowed to claim the same next position in one ordered history.
The fix was clear:
Many developers at Amrood Labs can change the schema, but only one controlled process should create the official migration sequence. By generating migrations after pull requests merge, the GitHub Actions workflow always works from the latest base branch.
Dev A creates 0004. Dev B creates 0005. Dev C creates 0006.
No duplicate migration numbers, and no last-minute fight with migration files before deployment.
Frequently Asked Questions
How to Test Database Migration?
Test migrations in staging first. Check schema changes, data accuracy, indexes, application functions, loading speed, backups, and rollback steps before applying the migration to production.
What Is Database Migration Service?
A database migration service moves schemas and data between databases, servers, or cloud platforms. It may also support data replication, monitoring, testing, and migration validation.
What Are Some Good Database Migration Tools?
Good database migrations tools include Drizzle, Flyway, Liquibase, Prisma Migrate, Rails Active Record, AWS Database Migration Service, and Azure tools. Choose one based on your technology.
What Are the Core Reasons for Database Migration Conflicts?
Conflicts happen because branches create duplicate migration numbers, developers edit the same metadata, branches become outdated, migrations run in the wrong order, or applied files are changed.
What Are the Best Practices to Resolve Database Migration Conflicts?
Generate shared migrations through one process. Update branches, inspect metadata, review SQL, add concurrency controls, test in staging, keep backups, and avoid editing migrations already applied.
When Does Database Migration Become Necessary, Not Optional?
Migration becomes necessary when an old database lacks support, security, storage, speed, scalability, compatibility, or required features. Business growth and cloud adoption may also require it.
Why Do Database Migrations Fail, and What Are the Bottlenecks?
Migrations fail due to poor data quality, incompatible schemas, limited bandwidth, large tables, long locks, broken scripts, missing dependencies, weak testing, or no rollback plan.



.png)







