sqlalchemy/alembic · error · ValueError

Can only return single object for UpgradeOps traverse

Error message

Can only return single object for UpgradeOps traverse

What it means

The Rewriter._traverse_script walks a MigrationScript's upgrade_ops_list; for each UpgradeOps it calls the user's process_revision_directives hook and expects exactly one replacement UpgradeOps object back. Returning zero or multiple ops would break the in-place rewrite contract (the script must still have a single upgrade ops tree), so the length check raises.

Source

Thrown at alembic/autogenerate/rewriter.py:168

        revision: _GetRevArg,
        directives: list[MigrationScript],
    ) -> None:
        self.process_revision_directives(context, revision, directives)
        for process_revision_directives in self._chained:
            process_revision_directives(context, revision, directives)

    @_traverse.dispatch_for(ops.MigrationScript)
    def _traverse_script(
        self,
        context: MigrationContext,
        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(

View on GitHub (pinned to 44fb345033)

Solutions

  1. Have the hook mutate the single UpgradeOps it received (append ops to its .ops list) rather than returning a new list.
  2. Return exactly one UpgradeOps instance; nest additional operations inside it.
  3. If multiple upgrade paths are genuinely needed, restructure so they are separate MigrationScript entries, not multiple UpgradeOps in one script.

Example fix

// before
def process_revision(ctx, revision, directives):
    upgrade_ops = directives[0].upgrade_ops
    upgrade_ops.ops = [op1, op2]  # OK
    return [upgrade_ops, extra_ops]   # wrong: returns 2
// after
def process_revision(ctx, revision, directives):
    upgrade_ops = directives[0].upgrade_ops
    upgrade_ops.ops.append(extra_op)
    # return nothing; mutation is enough
Defensive patterns

Strategy: validation

Validate before calling

# inside process_revision_directives
upgrade_ops = directives[0].upgrade_ops
assert isinstance(upgrade_ops, UpgradeOps)
upgrade_ops.ops.append(new_op)  # mutate in place, do not return a list

Type guard

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

Prevention

When it happens

Trigger: Passing a process_revision_directives callable to command.revision / RevisionContext that returns a list of more than one (or zero) UpgradeOps in place of the single directive it received. Typically happens when a custom hook appends or replaces directives[0].upgrade_ops with a list rather than a single UpgradeOps instance.

Common situations: Custom process_revision_directives that splits work into multiple upgrade ops trees; a hook that mistakenly returns [op1, op2] instead of op1 with op2 nested as an op inside it.

Related errors


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