sqlalchemy/alembic · error · LoopDetected

Self-loop is detected in revisions (%s)

Error message

Self-loop is detected in revisions (%s)

What it means

Raised as LoopDetected (revision.py:1592, kind='Self-loop') during Revision.__init__ when the revision's own id appears inside its down_revision (after tuple normalization). A migration cannot be its own parent — that would create a cycle of length one in the revision DAG, making upgrade/downgrade traversal non-terminating.

Source

Thrown at alembic/script/revision.py:1592

    @classmethod
    def verify_rev_id(cls, revision: str) -> None:
        illegal_chars = set(revision).intersection(_revision_illegal_chars)
        if illegal_chars:
            raise RevisionError(
                "Character(s) '%s' not allowed in revision identifier '%s'"
                % (", ".join(sorted(illegal_chars)), revision)
            )

    def __init__(
        self,
        revision: str,
        down_revision: str | tuple[str, ...] | None,
        dependencies: str | tuple[str, ...] | None = None,
        branch_labels: str | tuple[str, ...] | None = None,
    ) -> None:
        if down_revision and revision in util.to_tuple(down_revision):
            raise LoopDetected(revision)
        elif dependencies is not None and revision in util.to_tuple(
            dependencies
        ):
            raise DependencyLoopDetected(revision)

        self.verify_rev_id(revision)
        self.revision = revision
        self.down_revision = tuple_rev_as_scalar(util.to_tuple(down_revision))
        self.dependencies = tuple_rev_as_scalar(util.to_tuple(dependencies))
        self._orig_branch_labels = util.to_tuple(branch_labels, default=())
        self.branch_labels = set(self._orig_branch_labels)

    def __repr__(self) -> str:
        args = [repr(self.revision), repr(self.down_revision)]
        if self.dependencies:
            args.append("dependencies=%r" % (self.dependencies,))
        if self.branch_labels:
            args.append("branch_labels=%r" % (self.branch_labels,))

View on GitHub (pinned to 44fb345033)

Solutions

  1. Open the offending migration file and set down_revision to the actual previous revision id (or None for a base).
  2. If this is a new root migration, use down_revision = None.
  3. Validate with `alembic history` to confirm the chain is linear where expected.

Example fix

# before
revision = 'abc123'
down_revision = 'abc123'
# after
revision = 'abc123'
down_revision = 'parent456'
Defensive patterns

Strategy: validation

Validate before calling

def check_no_self_loop(revision: str, down_revision):
    downs = (down_revision,) if isinstance(down_revision, str) else (down_revision or ())
    if revision in downs:
        raise ValueError(f'revision {revision!r} cannot be its own down_revision')

check_no_self_loop(revision, down_revision)

Try / catch

from alembic.script.revision import LoopDetected
try:
    rev = Revision(revision, down_revision)
except LoopDetected as e:
    print(f'Self-loop: {e}. Set down_revision to the real parent.')

Prevention

When it happens

Trigger: Writing a migration file where revision = 'abc' and down_revision = 'abc'; building a Revision('xyz', down_revision='xyz') programmatically; a copy-paste error where the down_revision string duplicates the revision string.

Common situations: Duplicating a migration template and forgetting to change down_revision; automated tooling that sets down_revision to the last known id which happens to equal the new id after a naming collision.

Related errors


AI-assisted analysis of sqlalchemy/alembic@44fb345033 (2026-08-04). Data as JSON: /data/errors/13ef770942a4ff2e.json. Report an issue: GitHub.