Blocking destructive operations in autogenerated migrations

Add a process_revision_directives hook to env.py that scans the generated operations for DropColumnOp and DropTableOp and raises unless the revision explicitly acknowledges them — autogenerate emits a drop for every attribute someone removes or renames, and a drop is the one migration no downgrade can undo. This guide belongs to autogenerating and reviewing migration scripts.

Quick Answer

Autogenerate compares the model with the database and proposes removing anything the model no longer has. That is correct behaviour and a data-loss risk, because a renamed attribute looks exactly like a deleted one.

From a deleted attribute to a dropped column Five steps. A developer removes an attribute from a model, or renames it. Autogenerate compares the model with the database and sees a column present in the database and absent from the metadata. It emits a drop column operation, which is written into the revision. Review misses it among twenty other lines. The migration runs in production and the data is gone. A process_revision_directives hook inspects the operations before the file is written and can refuse or annotate them. an attribute is removed or renamed in the model an ordinary code change autogenerate compares in the database, not in the metadata so: drop it op.drop_column(...) written one line among many review misses it upgrade runs the column and its data are gone downgrade cannot restore data process_revision_directives inspect ops before writing A rename is the dangerous case: autogenerate sees a drop and an add, not a rename.

Before — a rename generating a drop and an add:

# The developer renamed Customer.fax to Customer.fax_number.
# $ alembic revision --autogenerate -m "rename fax"
import sqlalchemy as sa
from alembic import op


def upgrade() -> None:
    op.add_column("customers", sa.Column("fax_number", sa.String(length=32), nullable=True))
    op.drop_column("customers", "fax")     # every fax number, deleted

After — a hook that refuses unless the revision says so:

# alembic/env.py (excerpt)
import os

from alembic import context
from alembic.operations import ops

from shop.models import Base

DESTRUCTIVE = (ops.DropColumnOp, ops.DropTableOp)


def process_revision_directives(migration_context, revision, directives) -> None:
    script = directives[0]

    # Nothing to do: do not add an empty revision to the history.
    if script.upgrade_ops.is_empty():
        directives[:] = []
        print("No schema changes detected; no revision generated.")
        return

    found = [op_ for op_ in script.upgrade_ops.as_diffs() if op_[0] in
             ("remove_column", "remove_table")]
    if found and not os.environ.get("ALEMBIC_ALLOW_DESTRUCTIVE"):
        details = "\n  ".join(str(item) for item in found)
        raise RuntimeError(
            "autogenerate produced destructive operations:\n  "
            f"{details}\n"
            "If this is intended, re-run with ALEMBIC_ALLOW_DESTRUCTIVE=1 and put the drop "
            "in a revision of its own."
        )


def do_run_migrations(connection) -> None:
    context.configure(
        connection=connection,
        target_metadata=Base.metadata,
        compare_type=True,
        process_revision_directives=process_revision_directives,
        transaction_per_migration=True,
    )
    with context.begin_transaction():
        context.run_migrations()

The rename above now fails at generation time with the column named in the message, which is the moment the developer can still say "that is not what I meant".

Execution Context & Async Workflow Integration

process_revision_directives is called once per alembic revision invocation, after autogenerate has finished comparing and before any file is written. It receives the MigrationScript directive — the in-memory representation of the revision — and can inspect it, modify it, or remove it entirely by emptying the directives list.

Four operations that lose data Four tiles. Drop column removes the column and every value in it, and no downgrade can bring the values back. Drop table is the same at table scale. Drop constraint is usually recoverable but can permit data that later violates it. Altering a column type can truncate or fail, depending on the values present. op.drop_column values gone no downgrade restores them op.drop_table the whole table same, at scale op.drop_constraint rule removed bad data can arrive op.alter_column(type_=...) may truncate or fail mid-migration Autogenerate is doing its job: the model says these objects should not exist.

Two views of the same information are available. script.upgrade_ops.ops is the tree of operation objects (ops.DropColumnOp, ops.AddColumnOp, ops.ModifyTableOps containing others), which is precise but needs recursion because table-level operations nest. script.upgrade_ops.as_diffs() is the flat tuple form autogenerate's comparison produced — ("remove_column", None, "customers", Column("fax", ...)) — which is easier to filter and is what the example above uses.

Because the hook runs at generation time, it costs nothing at migration time and cannot break a deploy. It also runs on the developer's machine, in the same process as env.py, so it can read environment variables, print guidance, and raise with a message that names exactly what it objected to. That last point matters: a guard that fails with "destructive operation detected" teaches nothing, while one that prints ("remove_column", None, "customers", Column("fax", String(32))) tells the developer which change caused it.

The is_empty() check earns its place separately. Running autogenerate when nothing has changed produces a revision whose upgrade() contains only pass, and that file is permanent: it has to be reviewed, it lengthens every replay from base, and it makes alembic history harder to read. Clearing the directives means no file is written at all.

Under an async env.py the hook is unchanged. Autogenerate runs inside connection.run_sync(), so the comparison happens on a synchronous facade over asyncpg and the directives arrive as ordinary Python objects — the setup described in setting up Alembic env.py for asyncpg.

One limitation is worth stating: the hook only sees operations autogenerate generated. A hand-written op.drop_column() added to a revision afterwards bypasses it completely, which is why the CI check in the last section complements it rather than duplicating it.

Resolving Warnings, Errors & Common Mistakes

SymptomRoot CauseProduction Fix
A rename generated add_column plus drop_columnAutogenerate compares presence, not identity; it cannot see a rename.Hand-write op.alter_column(..., new_column_name=...), or use expand and contract.
A column you never mapped was droppedThe table is not in target_metadata, so everything in it looks removed.include_object/include_name to exclude it.
IndexError: list index out of range in the hookdirectives was already emptied by another hook, or by --autogenerate producing nothing.Guard with if not directives: return.
The hook did not runNot passed to context.configure(), or passed to the offline branch only.Pass process_revision_directives= in every configure() call.
Drops still reach productionSomeone added op.drop_column() by hand after generation.A CI check on the revision files as well.
Empty revisions keep appearingNo is_empty() check.Clear the directives when there are no operations.
A legitimate drop cannot be generatedThe guard has no escape hatch.An environment variable or a CLI flag, with the reason recorded in the revision.
Make the intent explicit Left: the generated revision contains a drop column among other operations, indistinguishable from the safe ones, and a reviewer has to notice it. Right: the hook raises unless the revision carries an explicit acknowledgement, so a drop can only be committed by someone who read the operation and wrote down that it is intended. as generated op.add_column(...) op.create_index(...) op.drop_column("customers", "fax") three lines, one of them lossy with the guard ALLOW_DESTRUCTIVE = True # fax removed in release 41; # archived 2026-09-10 the guard passes, the intent is recorded The goal is not to forbid drops but to stop them arriving unnoticed.

The unmapped-table case is the one that has caused real incidents. If target_metadata does not describe a table — a Django app's tables in a shared database, a third-party extension's tables, a reporting table created by hand — then from autogenerate's point of view that table exists in the database and not in the model, so it proposes dropping it. The guard catches it; the real fix is to tell autogenerate the table is not its business:

def include_object(object_, name, type_, reflected, compare_to) -> bool:
    if type_ == "table" and name.startswith(("django_", "metabase_", "pg_stat_")):
        return False
    return True

That, and the include_name variant for whole schemas, is covered in excluding tables and schemas from Alembic autogenerate.

Renames deserve one more note, because the guard turns them from a silent data loss into a decision. On a small table, the right answer is usually a hand-written op.alter_column(..., new_column_name=...), which preserves the data. On a table serving live traffic, a rename breaks the previous release, and the safe sequence is expand and contract — as in renaming a column without downtime.

Advanced: Rewriting Operations Instead of Refusing Them

A hook can do more than raise. Because it holds the operation tree before anything is written, it can also rewrite what autogenerate proposed — which is useful for enforcing house rules rather than blocking mistakes.

Dropping a column safely Three bands. Release one removes every reference to the column from the application, so nothing reads or writes it, and the column stays in the database. A waiting period, long enough to be confident a rollback will not need it. Release two drops the column, in a revision of its own, after the data has been archived if it has any value. release 1 · stop using the column remove it from the model and from every query; the column stays in the database wait · long enough to roll back safely a rollback to the previous release must not need the column to exist release 2 · drop it, alone, after archiving a revision containing nothing else, so a mistake is trivial to identify and revert

The most valuable rewrite is making index builds concurrent by default on existing tables, so the reviewer does not have to remember. Alembic's Rewriter makes this declarative:

# alembic/env.py (excerpt)
from alembic.autogenerate import rewriter
from alembic.operations import ops

writer = rewriter.Rewriter()


@writer.rewrites(ops.CreateIndexOp)
def _concurrent_index(context, revision, op_):
    # A table created in the same revision has no traffic; everything else does.
    created_here = {
        o.table_name for o in context.opts["_autogen_ops"]  # populated below
    }
    if op_.table_name in created_here:
        return op_
    op_.kw["postgresql_concurrently"] = True
    return op_


def process_revision_directives(migration_context, revision, directives) -> None:
    if not directives:
        return
    script = directives[0]
    if script.upgrade_ops.is_empty():
        directives[:] = []
        return
    migration_context.opts["_autogen_ops"] = list(script.upgrade_ops.ops)
    writer(migration_context, revision, directives)

The generated revision then carries postgresql_concurrently=True, and the reviewer's job becomes checking that it is wrapped in an autocommit block — the requirement explained in creating indexes concurrently in Alembic migrations.

Two other rewrites pay for themselves in review time. Splitting destructive operations into their own revision, rather than refusing them, keeps a mixed change reviewable:

def process_revision_directives(migration_context, revision, directives) -> None:
    script = directives[0]
    destructive = [op_ for op_ in script.upgrade_ops.ops
                   if isinstance(op_, (ops.DropTableOp,))]
    if destructive and len(script.upgrade_ops.ops) > len(destructive):
        raise RuntimeError(
            "this change mixes drops with other operations; generate them separately "
            "so the drop can be reviewed and deployed on its own"
        )

And annotating every generated revision with its provenance — who generated it, against which database — costs one line and answers a question that comes up during every incident:

    script.message = f"{script.message} [autogenerated against {migration_context.dialect.name}]"

The line between rewriting and refusing is worth drawing deliberately. Rewrite mechanical things a reviewer would always want the same way: concurrency flags, naming, imports. Refuse anything where the right answer depends on intent — drops, renames, type changes — because a hook that silently "fixes" those has made a decision nobody recorded.

A CI Check for Hand-Written Drops

The hook cannot see a op.drop_column() someone typed into a revision after generating it, and that is a realistic path: a developer hits the guard, decides the drop is fine, and edits the file. A check over the revision files closes the gap and, unlike the hook, runs on every commit including ones nobody generated.

Empty revisions are noise Left: running autogenerate when nothing has changed produces a revision with an empty upgrade function, which still becomes part of the history, still has to be reviewed, and still lengthens every replay. Right: the same hook clears the directives when no operations were produced, so no file is written and the command reports that there was nothing to do. without the guard alembic revision --autogenerate def upgrade(): pass a file in the history forever reviewed for nothing with the guard directives.clear() no file written the history stays meaningful and shorter to replay The same hook does both jobs: it sees every operation set before a file exists.
import ast
import pathlib
import re
import sys

DESTRUCTIVE = {"drop_column", "drop_table", "drop_constraint", "drop_index"}
ACKNOWLEDGED = re.compile(r"^#\s*destructive:\s*\S+", re.M)


def destructive_calls(path: pathlib.Path) -> list[str]:
    tree = ast.parse(path.read_text(), filename=str(path))
    found = []
    for node in ast.walk(tree):
        if (
            isinstance(node, ast.Call)
            and isinstance(node.func, ast.Attribute)
            and node.func.attr in DESTRUCTIVE
            and isinstance(node.func.value, ast.Name)
            and node.func.value.id == "op"
        ):
            found.append(f"{path.name}:{node.lineno} op.{node.func.attr}")
    return found


problems: list[str] = []
for path in sorted(pathlib.Path("alembic/versions").glob("*.py")):
    calls = destructive_calls(path)
    if calls and not ACKNOWLEDGED.search(path.read_text()):
        problems.extend(calls)

if problems:
    print("destructive migration operations without an acknowledgement comment:")
    for problem in problems:
        print(f"  {problem}")
    print('\nAdd a comment such as "# destructive: fax removed in release 41, archived 2026-09-10"')
    sys.exit(1)

Parsing with ast rather than grepping avoids two classes of false positive: the words appearing in a docstring or a comment, and drop_index inside a downgrade() that merely reverses a create. Restricting the match to calls on the op name keeps it to Alembic operations.

Requiring a comment rather than forbidding the call is the point. The comment is where the reviewer learns why — which release stopped using the column, whether the data was archived, and when. That is the information the next person needs when the same column is missed by a report three months later, and it is information no schema diff can carry.

Two more practices make the whole arrangement hold. Keep downgrade() honest: for a drop, it can recreate the column but not its values, so it should say so in a comment rather than implying a reversal it cannot perform. And pair the check with alembic check in the same job, so the build fails both for a migration that should not exist and for a model change whose migration is missing — the CI shape described in running Alembic migrations in CI/CD pipelines.

Frequently Asked Questions

Why did autogenerate drop a column I only renamed?

Because it compares which columns exist, not what they are called relative to each other. A rename looks like one column removed and another added. Write the rename by hand, or use expand and contract on a live table.

How do I stop Alembic generating empty revisions?

In process_revision_directives, check script.upgrade_ops.is_empty() and set directives[:] = []. No file is then written.

Can the hook modify operations instead of refusing them?

Yes. The operation tree is mutable, and alembic.autogenerate.rewriter.Rewriter makes per-operation rewrites declarative — adding postgresql_concurrently=True to index creation, for example.

Does the hook catch hand-written drops?

No. It only sees operations autogenerate produced. Add a CI check over the revision files to cover drops added by hand afterwards.