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

Two heads, and the empty revision that joins them The revision graph before and after. Before: revision c3 is the head everyone branched from, and two developers each add a revision whose down_revision is c3. Both merge to the main branch, and the graph now has two leaves — Alembic refuses to upgrade because it cannot tell which one head means. After: alembic merge creates an empty revision whose down_revision is a tuple naming both leaves, so there is one head again and both original revisions are untouched. Nothing is rewritten, no identifier changes, and any database already at one of the leaves upgrades cleanly through the merge. two heads c3 → a1 (developer A) c3 → b1 (developer B) both merged to main "Multiple head revisions are present" after alembic merge a new empty revision m1 down_revision = ("a1", "b1") one head again neither original revision is changed A merge revision is empty by design: it records that two lines of development joined, and does no schema work of its own.

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.

Resolving two heads, in order Five steps. List the heads so you know exactly which revisions are involved, rather than guessing from branch names. Then read both revisions and decide whether they conflict semantically — two revisions that both add a column called status to the same table are a genuine conflict that merging will not fix, and this is the step no tool can do for you. Create the merge revision, which is empty and names both parents. Review it, because Alembic occasionally picks a down_revision order that matters for readability. Then upgrade, which applies whichever of the two revisions the database has not yet seen, followed by the merge. alembic heads --verbose list them explicitly never guess from branch names read both revisions do they conflict semantically? no tool can answer this alembic merge -m "..." head1 head2 creates an empty revision both as parents review the generated file check the parent order reads sensibly it is otherwise empty alembic upgrade head applies the missing revision, then the merge The second step is the one that matters: two revisions that both add the same column merge cleanly and then fail at upgrade, because the second add hits a column that exists.

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.

Three strategies for keeping the graph sane Three approaches. Rebasing a feature revision onto the current head before merging keeps the graph perfectly linear, at the cost of editing a revision identifier that may already have been applied to a developer database — safe before merge, dangerous after. Merging heads whenever they appear accepts a graph with occasional merge nodes and never rewrites anything, which is the safest default and the one most teams settle on. Named branch labels are for genuinely independent schema lines — a separate reporting database, a plugin schema — and are not a way to organise ordinary feature work. rebase before merging perfectly linear graph rewrites a revision id safe only before it is shared merge heads as they appear never rewrites anything occasional merge nodes the safe default named branch labels genuinely independent schemas a separate reporting database not for feature work Branch labels are frequently reached for as a way to organise feature work, and they make that harder rather than easier — every upgrade then needs a branch specified.

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. Run alembic 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, so alembic_version names a revision the files no longer contain. Restore the revision file; editing the version table by hand is a last resort.
  • DuplicateColumn after 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 heads in a deployment script. Plural heads upgrades every branch and quietly tolerates the multi-head state that should have been caught in review. Use singular head so 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_revision on 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.