Skip to content
Julien DanjouJulien 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

Our Frontend Was Guessing What Our Parser Accepts. It Was Wrong for 70 of 75 Attributes.
July 17, 2026·8 min read

Our Frontend Was Guessing What Our Parser Accepts. It Was Wrong for 70 of 75 Attributes.

Most of the wrong menus were hiding valid operators, so nobody filed a bug. Instead of patching the heuristic, we made the engine's parser publish its own grammar through the config schema.

Thomas BerdyThomas Berdy
Our Auth Library's Last Release Was July 2022. We Noticed in June 2026.
July 15, 2026·6 min read

Our Auth Library's Last Release Was July 2022. We Noticed in June 2026.

A dead library ran our dashboard's authentication for four years without a single alert, because dependency bots only report new versions and known CVEs.

Thomas BerdyThomas Berdy
Two counters instead of a materialized view
July 3, 2026·10 min read

Two counters instead of a materialized view

Materialized views recompute from source data, but we delete ours within 30 days. Here's the two-writer, two-counter rollup we built on plain Postgres, and the race a boolean dirty flag would have quietly lost.

Rémy DuthuRémy Duthu