Skip to content
Julien Danjou Julien Danjou
June 2, 2026 · 6 min read

We fixed Alembic's multiple-heads error with Git history

We fixed Alembic's multiple-heads error with Git history

Every team that writes Alembic migrations in parallel eventually hits the multiple-heads error. We stopped hardcoding down_revision and let Git decide the order instead.

We build a merge queue for a living, so it was a little embarrassing that the merge conflict we kept losing time to was in our database.

Here is the shape of it. Two of us are working in parallel. I add an Alembic migration on my branch, a colleague adds one on theirs. Both migrations point their down_revision at the same place: whatever the current head was when each of us branched. Individually they pass CI. Then both branches land, and Alembic refuses to move:

ERROR [alembic.util.messaging] Multiple head revisions are present for given argument 'head'; please specify a specific target revision, '<branchname>@head' to narrow to a specific head, or 'heads' for all heads

Two migrations claim the same parent, so the migration history forks into two heads, and alembic upgrade head no longer knows which one you mean:

graph RL
    mine["b2f1 (mine)"] --> base["a1c3 (shared head)"]
    theirs["c7d9 (theirs)"] --> base

Nothing is broken, exactly. It just stops, and someone has to go fix the graph by hand.

The two fixes everyone reaches for, and why they wear thin

The first option is alembic merge heads, and it is more manual than it sounds. You run the command yourself, Alembic generates a new migration whose only job is to join the two heads back into one, you commit that file, and you re-push the PR before it can merge. So you pay the same round-trip you were trying to dodge, and you are left with an empty merge migration that carries no schema change. Do it once and it is fine. Do it every week and your versions/ directory fills up with these little apologies for the previous merge. They are scar tissue.

The second option is to rebase and rewrite down_revision by hand. You pull main, find the new head, point your migration at it, and push. This is fine until you are racing someone. They merge while you are rebasing, the head moves again, and you are back to editing the same line. It is the merge-skew problem that merge queues exist to kill, except here you are solving it manually, in a Python file, under time pressure.

Both fixes share a root cause: down_revision is hardcoded. Each migration commits, at authoring time, to a claim about what came before it. That claim is a guess about merge order, and parallel branches make liars of guesses.

Let Git answer the question instead

The ordering already exists. Git knows exactly when each migration file was first committed. So instead of writing the answer into every migration, we compute it from history.

We wrote a small library, alembic-git-revisions, that does this. The migration template stops hardcoding down_revision and asks for it at runtime:

from alembic_git_revisions import get_down_revision

revision = "a1b2c3d4e5f6"
down_revision = get_down_revision(revision)

Under the hood it runs git log --reverse --diff-filter=A over the migrations directory to recover the order in which migration files were first added, then chains them linearly. A migration’s parent is simply the migration committed before it. No two migrations can claim the same parent, because no two files were first committed at the exact same point in history. The same two migrations that forked into two heads above now fall into a single line:

graph RL
    theirs["c7d9 (theirs)"] --> mine["b2f1 (mine)"] --> base["a1c3"]

The properties that fall out of this are the ones we wanted all along:

  • New migrations never collide on merge, because the chain is derived after the fact, not declared up front.
  • The history stays linear no matter what order branches land in.
  • Existing migrations with a hardcoded down_revision keep working untouched, so you can adopt it without rewriting your back catalog.

There is one environment where Git history is not available: a built Docker image, or a CI job with a shallow clone. For those, the library pre-computes the chain into a revision_chain.json file at build time and reads from that when git is absent. (Use a full clone when you generate it. A shallow clone produces the wrong order, which is exactly the bug you were trying to avoid.)

The same idea, one layer down

This is the merge queue insight wearing different clothes. A merge queue stops asking each pull request to predict the state of main at merge time, and instead tests changes in the real order they land. alembic-git-revisions stops asking each migration to predict its predecessor, and instead reads the order they actually landed. In both cases the fix is to delete the guess and defer to a source of truth that already knows the answer.

We have been running it in production since February. The empty merge migrations stopped appearing, and the Multiple head revisions are present error has not come back.

It is open source under Apache-2.0:

pip install alembic-git-revisions

If you want the longer technical write-up, including how hybrid static-and-dynamic migrations are handled, I wrote one up here. And if you recognized the merge-skew problem in this post, that is the thing our merge queue handles for your code, not just your migrations.

Merge Queue

Tired of broken main branches?

Mergify's merge queue tests every PR against the latest main before merging. Try it free.

Learn about Merge Queue

Recommended posts

How we built stacked PRs without a new git workflow
June 2, 2026 · 8 min read

How we built stacked PRs without a new git workflow

We wanted small reviewable PRs without making engineers learn a new git workflow. Here is how we built Mergify Stacks: one local branch, a stable trailer that survives every rebase, four git hooks, and unmodified GitHub.

Julien Danjou Julien Danjou
Making Dashboard Charts Actually Useful: Brush Zoom, Click-to-Filter, and Shareable URLs with ECharts
May 29, 2026 · 8 min read

Making Dashboard Charts Actually Useful: Brush Zoom, Click-to-Filter, and Shareable URLs with ECharts

How we wired ECharts brush zoom, click-to-filter, and URL-shareable state into a 10-chart dashboard, plus the dataZoom approach and ghost-dot bug we hit on the way.

Alexandre Gaubert Alexandre Gaubert
What GitHub Webhook Latency Actually Looks Like
April 29, 2026 · 8 min read

What GitHub Webhook Latency Actually Looks Like

We instrumented GitHub webhook delivery latency for ourselves. The p95 stays under 60 seconds in steady state but climbs close to 40 minutes during check-run incidents.

Mehdi Abaakouk Mehdi Abaakouk