Resolving Alembic multiple head revisions errors
Run alembic heads --verbose, read both revisions, then alembic merge -m "merge" <rev1> <rev2> — the merge is an empty revision naming both as parents, so nothing is rewritten and any database already at either head upgrades cleanly. This is the error in detail from managing multiple heads and database branching.
Quick Answer
The error:
$ alembic upgrade head
FAILED: 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
The fix:
# 1. Identify them — the verbose form gives the file paths too
$ alembic heads --verbose
Rev: 8f2c14a9b7d3 (head)
Parent: c3a1029ef455
Path: alembic/versions/8f2c14a9b7d3_add_order_status.py
Rev: 4b91de77c012 (head)
Parent: c3a1029ef455
Path: alembic/versions/4b91de77c012_add_customer_tier.py
# 2. Read both files. Do they touch the same table and column?
# 3. Merge — creates an empty revision with both as parents
$ alembic merge -m "merge order status and customer tier" 8f2c14a9b7d3 4b91de77c012
Generating alembic/versions/e77a01bb4c19_merge_order_status_and_customer_tier.py
# 4. Upgrade
$ alembic upgrade head
The generated merge revision is empty by design:
revision = "e77a01bb4c19"
down_revision = ("8f2c14a9b7d3", "4b91de77c012") # a tuple — both are parents
def upgrade() -> None:
pass
def downgrade() -> None:
pass
Do not delete either original revision. A merge is safe regardless of what any other environment has applied; deleting is not.
Execution Context & Async Workflow Integration
None of this changes for an async env.py — head resolution happens in the revision graph, before any connection is made. What does interact with async setups is where the error surfaces, because a deployment that runs migrations at application start-up hits it inside the container rather than in a pipeline step.
# Async — a start-up migration that fails clearly on multiple heads
from __future__ import annotations
import asyncio
from alembic import command
from alembic.config import Config
from alembic.script import ScriptDirectory
async def upgrade_to_head(url: str) -> None:
config = Config("alembic.ini")
config.set_main_option("sqlalchemy.url", url)
script = ScriptDirectory.from_config(config)
heads = script.get_heads()
if len(heads) != 1:
raise RuntimeError(
f"refusing to migrate: {len(heads)} heads present ({', '.join(heads)}). "
"Run: alembic merge -m 'merge' heads"
)
await asyncio.to_thread(command.upgrade, config, "head")
Checking the head count before running is worth the four lines: the message names the revisions and the command to fix them, where Alembic's own error explains the concept but not which revisions are involved.
Running migrations at start-up has its own problem in a multi-replica deployment — several containers racing to migrate the same database — which is why a dedicated job is preferable. The head check belongs in both.
Resolving Warnings, Errors & Common Mistakes
| Error / Warning | Root Cause | Production Fix |
|---|---|---|
Multiple head revisions are present for given argument 'head' | Two revisions share a parent. | alembic heads --verbose, read both, alembic merge. |
Can't locate revision identified by '4b91de77c012' | A revision was deleted or rebased after some database applied it. | Restore the revision file from version control. Editing alembic_version is the last resort. |
DuplicateColumn: column "status" of relation "orders" already exists after merging | Both revisions added the same column; the merge fixed the graph, not the semantics. | Edit one revision to remove the duplicate work — safe only before it reaches a shared environment. |
alembic current shows two revisions | The database itself is at two heads, usually from upgrade heads. | Apply the merge revision; the two rows collapse to one. |
| The merge revision has three parents | alembic merge heads was run with three heads present. | This is correct and supported; a merge may name any number of parents. |
| A merge revision contains schema operations | Someone edited it. | Merges should stay empty. Put the schema change in its own revision after the merge. |
Advanced: Recovering From a Deleted or Rebased Revision
The genuinely awkward case is a database whose alembic_version names a revision the files no longer contain. It happens when a revision is rebased after being shared, or deleted in an attempt to resolve two heads.
The first move is always to restore the file, because that returns every environment to a consistent state:
# Find the revision in history and restore it
$ git log --all --diff-filter=D --name-only -- 'alembic/versions/*'
$ git checkout <commit>^ -- alembic/versions/4b91de77c012_add_customer_tier.py
If the revision is genuinely gone and cannot be recovered, the remaining option is to move the affected database to a revision that does exist. That is a manual, environment-by-environment operation and must be done knowing exactly what schema the database is in:
"""Move a database to a known revision WITHOUT running any migration.
`stamp` writes alembic_version and applies no DDL, so it is only correct when the
schema already matches the target revision. Verify that first — an incorrect stamp
makes every later migration operate on assumptions that do not hold.
"""
from alembic import command
from alembic.config import Config
def stamp_to(url: str, revision: str) -> None:
config = Config("alembic.ini")
config.set_main_option("sqlalchemy.url", url)
command.stamp(config, revision)
Before stamping, confirm the schema really does match the target, using the same compare_metadata check described in using mapped_column instead of Column: an empty diff means the stamp is honest, and a non-empty one means stamping would record a lie that every subsequent migration builds on.
The prevention is a rule rather than a tool: a revision that has left your machine is immutable. Rebasing is fine on a feature branch nobody has pulled; after that, merging is the only safe operation. Enforcing it needs one CI check — reject a pull request that modifies or deletes a revision file already present on the main branch — which is a one-line git diff --name-status filter and removes the entire class of problem.
Preventing Recurrence With One CI Check
Resolving two heads takes two minutes; discovering them during a deploy costs considerably more. A single-head assertion in CI moves the discovery to review time.
#!/usr/bin/env bash
# ci/check-migrations.sh — run on every pull request
set -euo pipefail
heads=$(alembic heads | grep -c '(head)' || true)
if [ "$heads" -ne 1 ]; then
echo "ERROR: $heads heads present. Resolve with:"
alembic heads --verbose
echo " alembic merge -m 'merge heads' heads"
exit 1
fi
# A revision that has reached main is immutable: reject edits and deletions.
changed=$(git diff --name-status "origin/main...HEAD" -- alembic/versions/ \
| grep -E '^(M|D)' || true)
if [ -n "$changed" ]; then
echo "ERROR: existing revisions modified or deleted:"
echo "$changed"
exit 1
fi
Those two checks between them prevent both failure modes on this page: the second head that nobody noticed, and the rebase that breaks another environment. Neither needs a database, so they run in seconds on every pull request.
The one refinement worth adding for a larger team is to run the check against the merged result rather than the branch tip, since two branches can each have one head and produce two after merging. Most CI systems test the merge commit by default, which handles this — but a system configured to test the branch tip will pass both pull requests and fail on main, which is the exact scenario the check exists to prevent.
What the Two Heads Actually Cost
It is worth being clear that two heads are not damage. Nothing is corrupt, no revision is lost, and every database in every environment is still at a valid revision. What has happened is that the graph now has two leaves and head no longer names one thing, so Alembic refuses to guess.
That refusal is the right behaviour. Guessing would mean picking one branch and leaving the other unapplied, which produces a database that matches neither developer's expectations and no revision anyone can name. The error is Alembic declining to create that state.
The cost is therefore entirely in the interruption: a deploy stops, someone reads two revision files, runs one command, and the deploy proceeds. Two minutes, if it happens at review time. Considerably more if it happens during a release, which is the whole argument for the CI check below — the work is identical either way, and only the moment differs.
Frequently Asked Questions
Can I have more than two heads?
Yes, and alembic merge -m "merge" heads merges all of them into a single revision with as many parents as there were heads. That is supported and correct; the resulting revision file simply lists more identifiers in its down_revision tuple.
Does merging lose any of the work in either revision?
No. The merge revision is empty and both originals keep their identifiers, their contents and their parents. An upgrade after the merge applies whichever revisions the database has not yet seen, in dependency order.
Why did the merge succeed but the upgrade fail?
Because merging resolves the graph and not the semantics. If both revisions add the same column, the graph becomes valid and the second add_column then fails at execution. Read both revisions before merging — that check is the one part of the process no tool performs.
Is it safe to edit alembic_version directly?
It is a last resort, and only when you have verified that the schema matches the revision you are about to record. alembic stamp is the supported way to do the same thing, and it is still only correct when the schema already matches. An incorrect stamp is silent and every later migration inherits the mistake.
Related
- Managing Multiple Heads and Database Branching — The parent guide: the revision graph, branch labels and team policies.
- Running Alembic migrations in CI/CD pipelines — Where the single-head check belongs in a deployment.
- Rolling back a failed Alembic migration in production — What to do when the upgrade after a merge fails part-way.
- Configuring Alembic with async SQLAlchemy engines — The env.py these commands run through, unchanged by any of this.