Managing Multiple Heads and Database Branching
Multiple head revisions are present means two migrations claim the same parent — almost always because two branches were merged without either noticing the other's revision — and the fix is alembic merge, which creates an empty revision with both as parents rather than rewriting either. This page sits under Alembic async migrations and schema evolution, which covers the single-branch workflow this extends.
Concept & Execution Model
Alembic stores revisions as a directed graph: each revision names its parent in down_revision, and a head is any revision no other revision points at. In normal single-branch work there is exactly one head, and alembic upgrade head is unambiguous.
Two heads appear the moment two revisions share a parent. That happens naturally with parallel feature branches — both developers generate a revision whose down_revision is the current head, and merging both branches to main leaves two leaves in the graph. Nothing is corrupt; Alembic simply cannot tell which one you mean.
$ 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
$ 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
Both revisions descend from c3a1029ef455. The alembic_version table holds whichever one the database has applied — and it can hold more than one row, which is exactly how a database at multiple heads is represented.
$ alembic merge -m "merge order status and customer tier" 8f2c14a9b7d3 4b91de77c012
Generating alembic/versions/e77a01bb4c19_merge_order_status_and_customer_tier.py
The generated file is empty apart from its parents:
revision = "e77a01bb4c19"
down_revision = ("8f2c14a9b7d3", "4b91de77c012") # a tuple: both are parents
def upgrade() -> None:
pass
def downgrade() -> None:
pass
That emptiness is the point. The merge records that two lines of development joined; it performs no schema work, so there is nothing in it to review beyond the parent tuple.
Migration Construction & Alembic Execution Patterns
What a merge cannot do is resolve a semantic conflict. Two revisions that both add a status column to orders merge without complaint and then fail at upgrade, because the second add_column hits a column that already exists.
# revision 8f2c14a9b7d3 — developer A
def upgrade() -> None:
op.add_column("orders", sa.Column("status", sa.String(32), server_default="new"))
# revision 4b91de77c012 — developer B, same column, different default
def upgrade() -> None:
op.add_column("orders", sa.Column("status", sa.Text(), nullable=True))
Merging these produces a graph that upgrades until the second add_column raises DuplicateColumn. The resolution is a human decision — which definition is correct — followed by editing one of the revisions to remove the duplicate work, or superseding both with a third. That is safe here precisely because neither revision has reached production yet; the same edit after deployment is not.
The check is cheap enough to automate as a review step:
"""Fail CI when two heads touch the same table, which merging will not resolve."""
from __future__ import annotations
import re
from pathlib import Path
TOUCHES = re.compile(r'op\.\w+\(\s*["\']([a-z_]+)["\']')
def tables_touched(revision_file: Path) -> set[str]:
return set(TOUCHES.findall(revision_file.read_text()))
def conflicting(a: Path, b: Path) -> set[str]:
return tables_touched(a) & tables_touched(b)
A non-empty intersection is not proof of a conflict — two revisions can legitimately add different columns to one table — but it identifies exactly the pairs worth reading before merging, which is a far shorter list than "all of them".
Running the upgrade after a merge needs no special handling. alembic upgrade head walks whichever branch the database has not yet applied and then applies the merge revision, and the async env.py from configuring Alembic with async SQLAlchemy engines requires no changes for any of this.
Transaction Boundaries and Multi-Head Upgrades
An upgrade crossing a merge applies several revisions in sequence, and whether they share a transaction is decided by transaction_per_migration in env.py.
With the default — one transaction for the whole upgrade — a failure anywhere rolls the entire run back, and the alembic_version table stays where it started. That is the safest behaviour for a normal deployment: either the database moves to the new head or it does not move at all.
With transaction_per_migration=True, each revision commits separately, so a failure part-way leaves the database at an intermediate revision. That is required for revisions containing statements PostgreSQL refuses to run inside a transaction — CREATE INDEX CONCURRENTLY above all — and it changes the recovery story: after a failure you are at a known-but-unexpected revision rather than at the original one.
# alembic/env.py — the setting that decides both behaviours
def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
# Required for CONCURRENTLY; changes what a mid-run failure leaves behind.
transaction_per_migration=True,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
The practical consequence for a multi-head upgrade is worth stating: the merge revision itself is empty, so it can never be the thing that fails. What fails is one of the two branch revisions, and the recovery is the same as for any failed migration — which is to fix forward, for the reasons set out in the rollback guidance below.
There is one genuine trap. A database that has been left at two heads — because someone ran alembic upgrade heads, plural — has two rows in alembic_version. That is a valid state, and alembic current reports both. Applying the merge revision collapses them back to one row. Before the merge exists, tooling that assumes a single current revision will misbehave, which is why CI should assert a single head rather than discovering this during a deploy.
Advanced: Branch Labels for Genuinely Separate Schemas
Branch labels solve a different problem from feature-branch heads: two schema lines that evolve independently and are never meant to converge. A reporting database with its own tables, a plugin schema owned by another team, a tenant-configuration store on a separate server.
# alembic/versions/0001_reporting_base.py
revision = "0001reporting"
down_revision = None
branch_labels = ("reporting",) # names this line of development
depends_on = None
$ alembic upgrade reporting@head # upgrade only the reporting branch
$ alembic upgrade heads # upgrade every branch to its own head
$ alembic current --verbose # shows a current revision per branch
With labels in place, alembic_version holds one row per branch, and every command that would otherwise be ambiguous takes a branch qualifier. That is the cost: every upgrade, every downgrade and every autogenerate now needs to say which branch it means, and a command that omits it fails rather than guessing.
That cost is worth paying when the branches genuinely are independent, and is not worth paying for feature work. Teams that reach for branch labels to organise ordinary development end up with a graph that is harder to reason about, deploy scripts that grow branch arguments, and no benefit — because feature branches are meant to converge, which is exactly what a merge revision expresses.
depends_on is the other tool in this area and is often the better answer:
revision = "9c14ab02f7de"
down_revision = "8f2c14a9b7d3"
depends_on = ("0001reporting",) # ordering without joining the branches
depends_on states that this revision must not run before another one, without making that revision its parent. It is the right construct when a migration on the main line needs a table the reporting branch created — the ordering is enforced, and the graphs stay separate.
Hybrid Strategies and Migration Path
Most teams arrive at multiple heads by accident and then want a policy. Three work, and the differences matter more in a large team than a small one.
Merge on sight. Whenever alembic heads reports more than one, merge immediately. Nothing is ever rewritten, so no developer's local database is invalidated. The graph accumulates merge nodes, which is cosmetic. This is the right default and the one to adopt if the question has not come up before.
Rebase before merging. The developer whose branch is behind regenerates their revision with the current head as down_revision, keeping the graph linear. Safe while the revision exists only on the feature branch; not safe once anyone else has applied it, because their alembic_version then names a revision that no longer exists. Worth adopting only with a CI rule that rejects a revision whose down_revision is not the current head.
Serialise migrations. A single queue — only one open pull request may contain a migration — makes multiple heads impossible. It works in a small team and becomes a bottleneck in a large one.
Whichever policy applies, one CI check makes it enforceable:
# Fail the build when the branch introduces a second head
test "$(alembic heads | wc -l)" -eq 1 || {
echo "multiple heads; run: alembic merge -m 'merge' heads"
exit 1
}
Running that on every pull request converts a deploy-time surprise into a review comment, which is the whole objective. Wiring it alongside the migration run described in running Alembic migrations in CI/CD pipelines means the same job that proves the migrations apply also proves there is exactly one way to apply them.
Auditing the Revision Graph
A revision graph that has been accumulating for a couple of years is worth reading occasionally, and Alembic exposes enough to do it programmatically rather than by opening files.
"""Report the shape of the revision graph: heads, merge points and branch depth."""
from __future__ import annotations
from alembic.config import Config
from alembic.script import ScriptDirectory
def audit(alembic_ini: str = "alembic.ini") -> None:
script = ScriptDirectory.from_config(Config(alembic_ini))
heads = script.get_heads()
print(f"heads: {len(heads)}")
for head in heads:
revision = script.get_revision(head)
print(f" {head} {revision.doc}")
merges = [r for r in script.walk_revisions() if len(r._all_down_revisions) > 1]
print(f"merge revisions: {len(merges)}")
for revision in merges:
print(f" {revision.revision} parents={revision._all_down_revisions}")
total = sum(1 for _ in script.walk_revisions())
print(f"total revisions: {total}")
Three readings are worth taking from it. More than one head at any moment outside an active pull request is a state that should not persist — merge it. A high ratio of merge revisions to total revisions says the team is generating migrations in parallel constantly, which is fine but suggests the single-head CI check is earning its place. And a very large total is a signal to consider squashing history: Alembic supports starting a fresh revision line from a known baseline, which is worth doing when the oldest revisions describe tables that no longer exist and nobody would ever replay them from scratch.
Squashing is a deliberate, one-off operation and not something to do casually. The safe form keeps the old revisions in place, adds a new baseline revision whose down_revision is None and whose upgrade() creates the current schema, and marks environments as being at that baseline with alembic stamp. Nothing is deleted, so a database at an old revision can still walk forward; only new environments take the short path.
Production Pitfalls & Anti-Patterns
Multiple head revisions are present for given argument 'head'— two revisions share a parent. Runalembic heads --verbose, read both, and merge. Do not pick one and delete the other.Can't locate revision identified by '...'— a revision was rebased or deleted after another database had applied it, soalembic_versionnames a revision the files no longer contain. Restore the revision file; editing the version table by hand is a last resort.DuplicateColumnafter a clean merge. The merge resolved the graph, not the semantics: both revisions added the same column. Fix the revisions before they reach any shared environment.alembic upgrade headsin a deployment script. Pluralheadsupgrades every branch and quietly tolerates the multi-head state that should have been caught in review. Use singularheadso the deploy fails loudly.- Branch labels used for feature work. Every subsequent command needs a branch qualifier, and the branches never converge because that is what labels mean. Merge revisions are the construct for work that is meant to join.
- Editing
down_revisionon a released revision. Any database that has applied it now has a version identifier with no matching file, and every subsequent upgrade fails. - Merging without reading both revisions. The merge always succeeds; the upgrade is where a semantic conflict surfaces, usually in whichever environment is deployed to first.
Frequently Asked Questions
Is a merge revision safe to apply to a database already at one of the heads?
Yes. The upgrade applies whichever branch revision that database has not yet seen, then the empty merge revision. Because the merge does no schema work, applying it is a single row change in alembic_version.
Can I just delete one of the two revisions instead of merging?
Only if it has never been applied anywhere, including developer databases. Once any alembic_version row names it, deleting the file produces Can't locate revision identified by on the next upgrade in that environment. Merging is safe in every case, which is why it is the default answer.
What is the difference between down_revision as a tuple and depends_on?
A tuple down_revision makes both revisions parents, joining the branches into one line. depends_on enforces ordering without joining them, so the branches stay separate. Use the tuple when the lines should converge — which for feature work they should — and depends_on when a main-line revision merely needs something a separate branch created.
How do I prevent multiple heads rather than resolving them?
A CI check asserting alembic heads returns exactly one line, run on every pull request. It converts a deploy-time failure into a review comment, and it is three lines of shell. Preventing the situation entirely requires serialising migrations, which is a process cost most teams should not pay.
Does any of this differ for an async env.py?
No. Head resolution, merging and branch labels are all properties of the revision graph, which is independent of how the migration connects to the database. The async env.py needs no changes.
Related
- Alembic Async Migrations and Schema Evolution — The parent guide: the single-branch workflow this extends.
- Resolving Alembic multiple head revisions errors — The error in detail, including recovery when a revision has already been deleted.
- Running Alembic migrations in CI/CD pipelines — Where the single-head check belongs, and how to run migrations safely on deploy.
- Writing data migrations safely in Alembic — Batched backfills, and why they do not belong in the same revision as DDL.
- Configuring Alembic with async SQLAlchemy engines — The env.py these commands run through.