sqlalchemy/alembic · error · ValueError

Can only return single object for DowngradeOps traverse

Error message

Can only return single object for DowngradeOps traverse

What it means

Symmetric to the UpgradeOps case: when the rewriter walks downgrade_ops_list, each DowngradeOps must yield exactly one replacement DowngradeOps. Returning a list of length != 1 breaks the migration script's single-downgrade-tree contract.

Source

Thrown at alembic/autogenerate/rewriter.py:179

        revision: _GetRevArg,
        directive: MigrationScript,
    ) -> None:
        upgrade_ops_list: list[UpgradeOps] = []
        for upgrade_ops in directive.upgrade_ops_list:
            ret = self._traverse_for(context, revision, upgrade_ops)
            if len(ret) != 1:
                raise ValueError(
                    "Can only return single object for UpgradeOps traverse"
                )
            upgrade_ops_list.append(ret[0])

        directive.upgrade_ops = upgrade_ops_list

        downgrade_ops_list: list[DowngradeOps] = []
        for downgrade_ops in directive.downgrade_ops_list:
            ret = self._traverse_for(context, revision, downgrade_ops)
            if len(ret) != 1:
                raise ValueError(
                    "Can only return single object for DowngradeOps traverse"
                )
            downgrade_ops_list.append(ret[0])
        directive.downgrade_ops = downgrade_ops_list

    @_traverse.dispatch_for(ops.OpContainer)
    def _traverse_op_container(
        self,
        context: MigrationContext,
        revision: _GetRevArg,
        directive: OpContainer,
    ) -> None:
        self._traverse_list(context, revision, directive.ops)

    @_traverse.dispatch_for(ops.MigrateOperation)
    def _traverse_any_directive(
        self,
        context: MigrationContext,

View on GitHub (pinned to 44fb345033)

Solutions

  1. Mutate the existing DowngradeOps.ops list in place rather than replacing the DowngradeOps with a list.
  2. Return exactly one DowngradeOps; nest extra ops inside it.
  3. If multiple downgrade paths are needed, model them as separate MigrationScripts.

Example fix

// before
def process_revision(ctx, revision, directives):
    directives[0].downgrade_ops = [down_ops_a, down_ops_b]
// after
def process_revision(ctx, revision, directives):
    directives[0].downgrade_ops.ops.extend([op_a, op_b])
Defensive patterns

Strategy: validation

Validate before calling

downgrade_ops = directives[0].downgrade_ops
assert isinstance(downgrade_ops, DowngradeOps)
downgrade_ops.ops.append(new_op)  # mutate, don't return a list

Type guard

def is_single_downgrade_ops(obj) -> bool:
    from alembic.operations.ops import DowngradeOps
    return isinstance(obj, DowngradeOps)

Prevention

When it happens

Trigger: A process_revision_directives hook returns zero or more than one DowngradeOps when replacing directives[0].downgrade_ops.

Common situations: Custom hooks that try to produce multiple downgrade branches or forget to wrap a single DowngradeOps; copy-paste from an upgrade hook that mutated a different structure.

Related errors


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