Ordering Migrations Across Parallel Branches
Two pull requests, both green, both adding a migration. The first merges. The second merges an hour later, and the deploy fails on a migration that references a column the other branch renamed. Neither pull request was wrong β the schema each was tested against no longer exists.
When to use this pattern
- More than one person ships schema changes in the same week.
- Migrations are numbered sequentially and collisions have started appearing.
- A migration has failed on main after passing on the branch that introduced it.
Prerequisites
Why βgreen on the branchβ is not enough
A migration is tested against the schema its branch sees. That schema is the base branch at fork time β not the base branch at merge time, and those diverge the moment someone else merges first.
Complete working example
#!/usr/bin/env bash
# scripts/migration-order-check.sh β required check on every pull request.
set -euo pipefail
BASE="origin/${GITHUB_BASE_REF:-main}"
DIR=migrations
# 1. Filenames must be timestamped, not sequential. Timestamps do not collide
# between branches and sort stably regardless of merge order.
for f in $(git diff --name-only "$BASE"... -- "$DIR" | grep -E '\.sql$' || true); do
base=$(basename "$f")
echo "$base" | grep -qE '^[0-9]{8}_[0-9]{6}_[a-z0-9_]+\.sql$' \
|| { echo "β $base β expected YYYYMMDD_HHMMSS_description.sql"; exit 1; }
done
# 2. This branch's new migrations must sort AFTER everything already on the base.
newest_base=$(git ls-tree --name-only "$BASE" "$DIR/" | sort | tail -1 | xargs -r basename)
oldest_new=$(git diff --name-only --diff-filter=A "$BASE"... -- "$DIR" \
| xargs -r -n1 basename | sort | head -1)
if [ -n "$oldest_new" ] && [ -n "$newest_base" ]; then
# String compare works because the prefix is a fixed-width sortable timestamp.
if [ "$oldest_new" \< "$newest_base" ]; then
echo "β ${oldest_new} sorts before ${newest_base}, already on ${BASE}."
echo " Rebase and re-date the migration so it applies last."
exit 1
fi
fi
# 3. Apply the WHOLE chain from empty, in the post-merge order. This is the check
# that would have caught the branch-A/branch-B case above.
createdb migration_probe
trap 'dropdb --if-exists migration_probe' EXIT
git merge-tree "$(git merge-base HEAD "$BASE")" HEAD "$BASE" >/dev/null # sanity: no textual conflict
npm run migrate:deploy -- --database-url "postgres:///migration_probe"
echo "β
migrations apply cleanly in merged order"# .github/workflows/migrations.yml
name: Migrations
on: [pull_request]
jobs:
order:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env: { POSTGRES_PASSWORD: probe }
options: >-
--health-cmd pg_isready --health-interval 5s --health-retries 10
ports: ['5432:5432']
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # the base comparison needs history
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci --ignore-scripts
# Re-run against the MERGED result, not the branch tip: this is the whole point.
- run: git merge --no-commit --no-ff "origin/${{ github.base_ref }}" || true
- run: bash scripts/migration-order-check.sh
env:
PGHOST: localhost
PGPASSWORD: probe// package.json β generate timestamped names, so nobody has to remember the format.
{
"scripts": {
"migrate:new": "node -e \"const d=new Date().toISOString().replace(/[-:T]/g,'').slice(0,15); const n=process.argv[1]; require('fs').writeFileSync(`migrations/${d.slice(0,8)}_${d.slice(8,14)}_${n}.sql`, '-- write the migration here\\n')\" --"
}
}Step-by-step walkthrough
Timestamps instead of sequence numbers. Two branches created from the same base both compute βnext number = 42β. Timestamps are generated at file-creation time and effectively never collide, and they sort correctly as plain strings, which makes every downstream check a string comparison rather than a graph traversal.
Ordering is checked against the base, not within the branch. A migration whose timestamp is older than something already merged will apply before it on a fresh database and after it on an existing one β which means the schema you get depends on when the database was created. That divergence is subtle, survives review, and is exactly what the second check rejects.
Apply the whole chain from empty. Applying only the new migration to a copy of the current schema tests the incremental path. Applying every migration from an empty database tests the path a new environment takes β and a preview environment created tomorrow takes exactly that path. Both matter, and only the second catches an ordering fault.
Merge the base into the branch before checking. Running the check on the branch tip re-tests what CI already tested. Running it on the merged tree tests the thing that will actually exist after merge, which is where the conflict lives.
Rebase and re-date rather than merging. If the check fails, the fix is to rebase onto the updated base and rename the migration file with a fresh timestamp. That is a rename plus a re-run, not a redesign β and it is why the timestamp lives in the filename rather than inside the file.
Verification
# 1. The check rejects an out-of-order migration.
git switch -c probe origin/main
touch migrations/20200101_000000_backdated.sql
bash scripts/migration-order-check.sh; echo "exit=$?"Expected:
β 20200101_000000_backdated.sql sorts before 20260731_084500_add_display_name.sql, already on origin/main.
Rebase and re-date the migration so it applies last.
exit=1# 2. A fresh database and an incrementally-migrated one end up identical.
createdb from_scratch && npm run migrate:deploy -- --database-url postgres:///from_scratch
pg_dump --schema-only from_scratch | grep -v '^--' > /tmp/scratch.sql
pg_dump --schema-only production_replica | grep -v '^--' > /tmp/live.sql
diff /tmp/scratch.sql /tmp/live.sql && echo "schemas match"
# 3. No two migrations share a timestamp prefix.
ls migrations | cut -c1-15 | sort | uniq -d | head # β no outputThe second check is the one worth running on a schedule as well as on pull requests. A drift between the from-scratch schema and the live one means some migration was applied out of order at some point in the past, and finding that early is far cheaper than discovering it when a new preview environment behaves differently from production.
Choosing between rebase and merge
The mechanics above assume rebase, and that assumption is worth making explicit because a merge-based workflow needs a different answer.
With rebase, a branchβs migrations are always the newest in the history at the moment it merges, so re-dating on rebase keeps filename order and applied order identical forever. The cost is that rebasing rewrites history, which some teams avoid for shared branches.
With merge commits, a branchβs migration keeps its original timestamp, so a long-lived branch can merge a migration whose timestamp predates several already-applied ones. Filename order and applied order then permanently disagree. Tools that track applied migrations by name cope β they simply record it as applied late β but a fresh database will apply them in filename order and produce a different schema. If your workflow requires merge commits, the compensating control is check 2 above running on every merge to main, so the divergence is caught the same day rather than months later when someone provisions a new environment.
The third option β squashing each pull request into one commit on main β sidesteps the question entirely for ordering purposes, because the migration lands with a single timestamp at merge time. It is the least discussed and often the simplest fit for teams that ship several schema changes a week.
Common pitfalls
Sequential numbering with a βnext numberβ script. It reads the current maximum, which is per-branch, so two branches confidently produce the same number. Timestamps remove the shared counter entirely.
Editing a migration that has already been applied anywhere. Some tools checksum applied migrations and will refuse to run; others silently skip it, leaving environments permanently different. Always add a new migration instead.
Testing only the incremental path. Applying the new migration to a copy of production says nothing about whether a fresh database produces the same schema β and a preview environment provisioned tomorrow is a fresh database, which is why environment parity validation gates catch this class of drift when the pull-request check does not.
Related
- Database Migrations & Backward-Compatible Deploys β the parent guide on safe schema evolution.
- Renaming a Postgres Column Without Downtime β a multi-release change that spans exactly this ordering problem.
- Environment Parity Validation Gates β catching schema drift between environments.
- Deployment Strategies & Progressive Delivery β the section overview.
β Back to Database Migrations & Backward-Compatible Deploys