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

ON CONFLICT DO UPDATE Is Rewriting Rows You Never Changed
June 19, 2026 · 13 min read

ON CONFLICT DO UPDATE Is Rewriting Rows You Never Changed

A bare INSERT ... ON CONFLICT DO UPDATE rewrites the whole row even when nothing changed. Production numbers showing what that costs, the one-line WHERE clause that stops it, and the 21 call sites where we had to leave the write alone.

Mehdi Abaakouk Mehdi Abaakouk
A Disk Alert, a 392 GB Table, and Indexes Bigger Than the Data
June 14, 2026 · 8 min read

A Disk Alert, a 392 GB Table, and Indexes Bigger Than the Data

A routine Postgres disk alert turned into more than 150 GB of reclaimable waste: blobs nobody reads back, the same JSON copied across 200 million rows, and indexes larger than the data they index.

Mehdi Abaakouk Mehdi Abaakouk
Swapping a Primary Key on a 4M-Row Table: Why I Took 10 Minutes of Downtime
June 11, 2026 · 8 min read

Swapping a Primary Key on a 4M-Row Table: Why I Took 10 Minutes of Downtime

Dropping two columns from a 4-million-row table meant swapping the primary key on the busiest table in our system. Why we skipped the zero-downtime dance and took ten minutes of downtime on purpose.

Thomas Berdy Thomas Berdy