# Why AI-Generated Migrations Need a Different Gate Than Code Patches

> Source: <https://dev.to/github_7727/why-ai-generated-migrations-need-a-different-gate-than-code-patches-1m7>
> Published: 2026-08-14 00:55:37+00:00

Most patch reviews ask: does this change behave the way we intended? A migration review has to answer a harder question: what does this change make impossible to undo?

A code change can usually be reverted by applying the old diff again. A migration that drops a column, truncates a table, or rewrites data may make the previous state unrecoverable even if the commit is reverted. That asymmetry is why schema changes deserve a separate pre-merge gate from ordinary AI-generated code.

One practical way to use MonkeyCode's free model access and free server option is not to generate final schema changes and push them, but to generate `up.sql`

and `down.sql`

pairs and then crash-test them against a throwaway Postgres schema before a human reviewer spends time on them. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I won't quote quota or hardware specifics because those change; check the current plan before building a pipeline around them.

A typical AI patch gate checks that generated tests pass, that a diff is small, and that fixtures still match. That is useful for application logic, but it can pass a migration that has all three of these problems:

`down.sql`

.`down.sql`

that recreates the column but cannot restore the data.The result is often discovered too late: a developer approves a reasonable-looking `ALTER TABLE`

, CI is green, and then production data disappears or the deploy stalls while the DDL waits on a lock.

The gate below checks three things before a human sees the migration:

`DROP TABLE`

, `TRUNCATE`

, or unguarded `UPDATE`

.`up.sql`

against a throwaway database.`down.sql`

and comparing the schema snapshot with the baseline.The third check is the important one. If the down migration cannot restore the original schema, then the up migration is proposing a one-way door, and a human should be forced to approve that explicitly.

The script below assumes you have local `psql`

and `pg_dump`

installed and can create databases on the server. Treat it as a runnable starting point, not a guarantee.

``` bash
#!/usr/bin/env python3
"""
migration_probe.py: run a migration pair against a throwaway Postgres database.

Usage:
  python migration_probe.py \
    --uri postgresql://user:pass@localhost:5432/postgres \
    --dir migrations/2026_08_14_add_flags
"""
import argparse
import os
import subprocess
import sys
import uuid

def sh(args, label=""):
    result = subprocess.run(args, capture_output=True, text=True)
    if result.returncode != 0:
        print(f"[fail] {label}\n{result.stderr}")
        sys.exit(result.returncode)
    return result.stdout

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--uri", required=True)
    parser.add_argument("--dir", required=True)
    args = parser.parse_args()

    base = args.uri
    db = f"shadow_{uuid.uuid4().hex[:10]}"

    def schema(uri):
        return sh(
            ["pg_dump", "--schema-only", "--no-owner", uri],
            "schema snapshot",
        )

    before = schema(base)

    sh(["psql", base, "-c", f'CREATE DATABASE {db}'], "create shadow db")
    db_uri = base.replace("/postgres", f"/{db}")

    try:
        sh(["psql", db_uri, "-f", os.path.join(args.dir, "up.sql")], "apply up")
        after = schema(db_uri)

        sh(["psql", db_uri, "-f", os.path.join(args.dir, "down.sql")], "apply down")
        rolled_back = schema(db_uri)
    finally:
        sh(["psql", base, "-c", f'DROP DATABASE IF EXISTS {db}'], "drop shadow db")

    if after == before:
        print("[warn] up migration produced no schema change")

    if rolled_back != before:
        print("[fail] down migration did not restore original schema")
        diffs = subprocess.run(
            ["diff", "-", "-"],
            input=f"{before}\n{rolled_back}",
            capture_output=True,
            text=True,
        )
        print(diffs.stdout)
        sys.exit(1)

    print("[ok] migration pair is schema-roundtrip clean")

if __name__ == "__main__":
    main()
```

Pair that with a simple destructive-keyword check that runs before the shadow apply:

```
DESTRUCTIVE = ["DROP TABLE", "DROP COLUMN", "TRUNCATE", "DELETE FROM"]

def blockers(path):
    text = open(path, encoding="utf-8").read().upper()
    return [kw for kw in DESTRUCTIVE if kw in text]
```

If the blocker list is not empty, the workflow should not reject automatically. It should switch the migration from normal review to **destructive-review mode**, requiring an explicit note about what data is lost and whether the down migration can recover it.

The distinction from a patch gate is the success condition. For code, the useful question is often: does the new behavior match specification? For a migration, the useful question is: can we return to the previous state?

A schema round-trip test catches the common failure where `down.sql`

recreates a column but leaves out a default, loses a constraint, or reverses operations in the wrong order. It cannot prove the migration is safe, but it moves the obvious failures from production to a disposable server.

`pg_dump --schema-only`

compares structure, not data. A down migration may restore a table but erase rows. Use a data fixture or a subset dump for critical changes.This is not a replacement for a human reviewer who understands the production schema, the retention policy, and the business reason for deleting data. Skip the automatic path entirely for migrations that touch high-risk tables, regulated data, or anything where deletion is unrecoverable by design.

A sensible loop is to let a model generate the up-and-down pair, let the probe filter out the reversible failures, and then take the remaining destructive cases to a human with the full context. That makes AI-generated migrations useful without making them authoritative.
