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.
The normal patch gate misses migration failures
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:
- It drops a column but has no
down.sql. - It has a
down.sqlthat recreates the column but cannot restore the data. - It passes CI because it is valid SQL, but it fails only when applied to a real schema with existing locks, ordering constraints, or indexes.
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.
A migration-specific probe
The gate below checks three things before a human sees the migration:
-
Destructive keyword scan for clauses such as
DROP TABLE,TRUNCATE, or unguardedUPDATE. -
Shadow apply by running
up.sqlagainst a throwaway database. -
Round-trip schema parity by applying
down.sqland 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.
A reusable probe script
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.
#!/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.
Why this is a different gate
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.
Limitations
-
pg_dump --schema-onlycompares structure, not data. A down migration may restore a table but erase rows. Use a data fixture or a subset dump for critical changes. - A destructive-keyword list can produce false positives; a migration might legitimately drop a temporary table.
- The probe does not test lock contention, long transaction behavior, or performance against a production-sized table.
- The free server option may not be durable or approved for all data. Do not copy production data into a shared or short-lived environment.
Who should not use this approach
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.
Top comments (0)