sqlalchemy/alembic · critical · CycleDetected

Cycle is detected in revisions

Error message

Cycle is detected in revisions (%s)

What it means

Raised as CycleDetected (RevisionError subclass, revision.py:89-97) by _detect_cycles (revision.py:272-273) when, after building the revision map, there are no heads or no bases reachable through the down_revision chain. With no head and no base, the down_revision graph cannot be a DAG, so the entire revision set is reported as cyclic. This blocks Alembic from computing head/base for upgrades/downgrades.

Solutions

  1. Inspect every revision file's down_revision and ensure each points to a strictly earlier revision with no loop.
  2. Use `alembic history` to list revisions and find the cycle; correct the down_revision of the offending file.
  3. For branches, use merge revisions correctly and avoid pointing two parents at each other.

Example fix

// before
# revision abc: down_revision = 'def'
# revision def: down_revision = 'abc'  # cycle
// after
# revision abc: down_revision = 'def'
# revision def: down_revision = None  # def is the base
Defensive patterns

Strategy: validation

Validate before calling

# Walk the revision DAG before running migrations and reject cycles.
from alembic.script import ScriptDirectory

def detect_down_revision_cycle(config):
    script_dir = ScriptDirectory.from_config(config)
    revs = {r.revision: r for r in script_dir.walk_revisions()}
    for rev in revs.values():
        seen, cur = set(), rev.revision
        while cur is not None and cur in revs:
            if cur in seen:
                raise ValueError(f'Cycle in down_revision chain at {cur}')
            seen.add(cur)
            cur = revs[cur].down_revision

import alembic.command  # constructing the map itself will raise CycleDetected too

Type guard

def is_acyclic_down_revision(revs: dict) -> bool:
    color = {}
    def dfs(r):
        if color.get(r) == 'gray':
            return False
        if color.get(r) == 'black':
            return True
        color[r] = 'gray'
        parent = revs[r].down_revision
        if parent in revs and not dfs(parent):
            return False
        color[r] = 'black'
        return True
    return all(dfs(r) for r in revs)

Try / catch

from alembic.script.revision import CycleDetected

try:
    alembic.command.upgrade(config, 'head')
except CycleDetected as e:
    print('Revision cycle detected; offending revisions:', e.revisions)

Prevention

When it happens

Trigger: A revision whose down_revision points to a descendant, forming a loop in the parent chain (e.g. rev A -> down B, rev B -> down A); or a self-referential down_revision. The traversal finds no terminus, heads/bases come back empty, and CycleDetected is raised with the full revision list.

Common situations: Hand-editing a revision's revision/down_revision identifiers; merge revisions wired with incorrect parents; copy-pasting a revision file and forgetting to change down_revision, creating a mutual reference.

Related errors


AI-assisted analysis of sqlalchemy/alembic@5551b5d35f (2026-08-11). Data as JSON: /api/errors/76c3243f372fb140. Report an issue: GitHub.

Appendix: source

Thrown at alembic/script/revision.py:273

        self._real_heads = tuple(rev.revision for rev in _real_heads)
        self.bases = tuple(rev.revision for rev in bases)
        self._real_bases = tuple(rev.revision for rev in _real_bases)

        self._add_branches(has_branch_labels, revision_map)
        return revision_map

    def _detect_cycles(
        self,
        rev_map: _InterimRevisionMapType,
        heads: set[Revision],
        bases: tuple[Revision, ...],
        _real_heads: set[Revision],
        _real_bases: tuple[Revision, ...],
    ) -> None:
        if not rev_map:
            return
        if not heads or not bases:
            raise CycleDetected(list(rev_map))
        total_space = {
            rev.revision
            for rev in self._iterate_related_revisions(
                lambda r: r._versioned_down_revisions,
                heads,
                map_=cast(_RevisionMapType, rev_map),
            )
        }.intersection(
            rev.revision
            for rev in self._iterate_related_revisions(
                lambda r: r.nextrev,
                bases,
                map_=cast(_RevisionMapType, rev_map),
            )
        )
        deleted_revs = set(rev_map.keys()) - total_space
        if deleted_revs:
            raise CycleDetected(sorted(deleted_revs))

View on GitHub (pinned to 5551b5d35f)