DevTools Hub

Search tools

Search for a developer tool

Why Auto-Generated Schema Migrations Can Silently Drop Your Data

Part of the SQL Toolkit

Diffing two CREATE TABLE statements and generating the ALTER TABLE migration between them sounds like a purely mechanical job — compare two lists of columns, emit the SQL for whatever changed. It mostly is. But there's one kind of change a diff-based tool structurally cannot tell apart from something much more dangerous, and the migration it generates for both cases looks equally confident.

A diff has no concept of "renamed"

A schema diff works by matching columns between two snapshots — by name. There's no third snapshot showing the steps in between, no history, nothing that could tell the tool "this one column became that one column." So when a column is renamed, the diff sees exactly what it would see if one column had simply vanished and an unrelated one had appeared in its place: one name present on the left and missing on the right, a different name present on the right and missing on the left.

column renamed: email → email_address

diffed purely by name — no concept of “renamed”

DROP COLUMN email;
ADD COLUMN email_address ...

runs cleanly — and silently deletes every value that was in email

unnamed CHECK constraint removed

no name means no safe way to target it in a DROP

-- DROP CONSTRAINT <name>; -- add a name first

commented out on purpose — the tool declines to guess

Both of those get handled the only way the structure of the diff allows — as a plain DROP COLUMN and a plain ADD COLUMN. For a column that genuinely was deleted, that's correct. For a column that was only renamed, it's the single most destructive possible migration: email is dropped, taking every value it held with it, and a brand-new empty email_address column is added in its place. Nothing about the generated SQL looks unusual or flags a warning — it reads like a perfectly ordinary migration, because structurally, as far as the diff is concerned, it is one.

This isn't a bug to fix — it's a limit of diffing two snapshots

A real rename-detector would need to guess at intent from evidence a plain diff doesn't have: matching column type, position, and any heuristic for "these two are probably the same column." That's inherently a guess, and a wrong guess here is worse than no guess at all — silently matching the wrong two columns as a "rename" could just as easily corrupt a migration as silently treating a real rename as drop-and-add. Any tool that diffs two independent schema snapshots — not just this one — runs into the same wall: the fix isn't a smarter diff, it's reviewing the generated SQL before running it, every time, specifically for any column that disappeared on one side while a suspiciously similar one appeared on the other.

A related case the tool handles more carefully: unnamed constraints

Removing a CHECK or other constraint that was never given an explicit name hits a smaller version of the same problem — there's no reliable identifier to put in a DROP CONSTRAINT statement. Rather than guess at one (databases do generate implicit names, but they're not something a text-based diff can reliably reconstruct), the migration generator emits the drop as a comment with an explicit note instead of a runnable statement. It's the opposite choice from the rename case — not because the rename case was overlooked, but because an unnamed constraint has no plausible DROP target at all, while a dropped-and-added column pair always has some valid (if sometimes wrong) SQL it could mean.

The same diff also has to handle three dialects of quoting

A schema pulled from PostgreSQL quotes identifiers with double quotes, MySQL uses backticks, and SQL Server uses square brackets — the same column in three different real-world exports. Diffing two schemas that came from different databases (a common migration-planning scenario) only works at all because the parser normalizes all three quoting styles down to the same bare identifier before comparing anything.

Try it yourself

SQL Diff compares two sets of CREATE TABLE statements and generates a best-effort migration from the result — read every line of output before running it against a real database, especially anywhere a column you expect to still exist shows up as both dropped and added. Runs entirely in your browser.

Related tools