I got tired of migration linters that just grep for scary keywords. CREATE INDEX without CONCURRENTLY? Flag it. Done. That's most of what's out there.
It falls apart the second your migration does this:
ALTER TABLE users RENAME TO users_v2;
ALTER TABLE orders ADD CONSTRAINT fk_user
FOREIGN KEY (user_id) REFERENCES users_v2(id);
A text scanner has no clue users_v2 is the same table it saw two lines earlier under a different name. It just matches patterns line by line. But whether a migration is safe depends on what the schema actually looks like at that point in the file, and grep doesn't track state. It can't.
So I built safe-migrate to not scan text at all. It simulates the migration instead.
Parse it, resolve it against state, mutate, then check
Every statement goes through the same pipeline:
- Parse it into a typed AST node with
squawk_syntax - Turn it into a
Fact— a structured description of what the statement does - Resolve that
Factagainst the in-memory schema as it currently stands - Apply it as a
Mutationto that schema - Run the rules against the result
By the time a rule looks at ADD CONSTRAINT fk_user ... REFERENCES users_v2, the schema model already knows users_v2 is users renamed, because the earlier RENAME TO actually got applied as a mutation. It's not just noticed and discarded.
Here's roughly what that looks like for a rename:
pub fn apply_rename_table(&mut self, from: &str, to: &str) {
if let Some(mut relation) = self.relations.remove(from) {
relation.name = to.to_string();
// anything pointing at `from` needs to point at `to` now:
// foreign keys, views, triggers, all of it
self.rewrite_dependency_edges(from, to);
self.relations.insert(to.to_string(), relation);
}
}
That rewrite_dependency_edges call is the whole point, honestly. A text-based linter can't do this because there's no graph to rewrite in the first place. It can flag one risky-looking line at a time, but it can't tell you a constraint you're adding now points at a table that doesn't have that name anymore. That's not a fact about one line of SQL. It's a fact about everything that ran before it.
Every finding gets scored with one of four verdicts, HALT, CAUTIOUS, SAFE WITH RISK, SAFE, based on what the simulator actually knows at that point: row count, whether you're mid-transaction, whether an earlier statement already changed something the current one depends on.
squawk_syntax has some AST shapes that took me a minute
The state machine was the hard part I expected. What I didn't expect was how much time I'd spend on AST extraction, because squawk_syntax doesn't always hand you clean enums.
ROLLBACK is a good example. Plain ROLLBACK, ROLLBACK TO SAVEPOINT, and ROLLBACK PREPARED are all the same node type. You don't get a variant per kind, you get one node and have to check which optional token showed up:
if let Some(savepoint) = rollback.savepoint_name() {
// ROLLBACK TO SAVEPOINT <name>
} else if rollback.prepared_token().is_some() {
// ROLLBACK PREPARED
} else {
// plain ROLLBACK
}
Identifier casing got me too. Postgres lowercases unquoted identifiers and keeps quoted ones exactly as written, so Users and users are different names if either one was quoted, and the same name if neither was. I missed this once, and a migration touching a quoted "Users" table silently resolved against the wrong key in a schema hashmap. No crash, no warning. Just a linter confidently checking the wrong table.
the confidence tainting bug
The state machine tracks a Confidence level next to the schema model. Most SQL is fully typed, so the simulator knows exactly what it does. Dynamic SQL in a DO block, or an EXECUTE of a string it can't resolve statically, is different. The simulator genuinely doesn't know what that statement did to the schema, so confidence gets marked Tainted, and anything found after that point gets downgraded a tier.
That part's the right call. Here's the bug I shipped while building it:
if state.local.confidence == Confidence::Tainted {
for v in &mut all_violations {
if v.tier == ViolationTier::Tier1 {
v.tier = ViolationTier::Tier2;
}
}
}
This runs inside the per-statement loop, and all_violations holds every finding for the whole file. So once confidence flips to Tainted, this block runs again on the next statement, and the one after that, and every time it walks the entire vector, not just what's been added since the taint started. A CREATE INDEX at the top of the file, already scored correctly before any opaque SQL even existed, gets quietly downgraded because of something five statements later.
Nothing about Postgres justifies that. It's just a scoping bug, mutating shared state retroactively instead of checking confidence when each violation is actually created. I fixed it by moving the check to the point where the violation gets pushed, instead of sweeping the whole list afterward:
let mut v = v;
if state.local.confidence == Confidence::Tainted {
v.tier = ViolationTier::Tier2;
}
all_violations.push(v);
Small fix, once I found it. But it's exactly the kind of thing unit tests scoped to one statement at a time won't catch, and a real multi-statement migration file will hit constantly. Which is basically why it shipped and sat there until I went back and looked for it on purpose.
what going back through it actually looked like
After the initial release I went back through the state machine, the AST layer, and the I/O code specifically hunting for this kind of bug: things that are correct in isolation but wrong once you run a real file through them. That pass turned up 31 confirmed issues. Some were silent no-ops, like ALTER COLUMN ... TYPE on a column that doesn't exist just returning () with nothing to tell the caller it did nothing. One was a DROP SCHEMA CASCADE that correctly figured out everything it was dropping, but never cleaned up the trigger and publication edges pointing at those objects, so the in-memory schema kept thinking a trigger existed for a few statements after it was actually gone.
None of that came from staring harder at the code. It came from applying the same rule to myself that the linter applies to a migration: don't trust a finding until you've traced what actually happens, and don't call something fixed without a test that proves the old behavior is gone. Kind of funny that a tool built to catch "looks fine but isn't" needed the same discipline turned on its own source.
safe-migrate is open source and written in Rust: github.com/dsecurity49/safe-migrate. If you deal with Postgres migrations, I'd genuinely like people to throw real migration files at it and open issues when it's wrong. That's how most of the bugs above got found in the first place.
Top comments (2)
Stateful simulation is a much stronger foundation than keyword matching. The next confidence step I would want is differential testing against real PostgreSQL: generate multi-statement migrations, run them both through the simulator and an ephemeral server for each supported major version, then compare catalogs, dependencies, errors, and transaction outcomes. That catches cases where the in-memory model is internally consistent but differs from PostgreSQL semantics. I would also keep a separate runtime-risk layer for facts the catalog model cannot prove alone, such as lock acquisition under concurrent traffic, table rewrite duration, replication lag, and disk headroom. Semantic safety and operational safety are related, but they need different evidence.
Differential testing against real Postgres instances is the right next step, honestly. It'd catch exactly the class of bug I can't find by staring at my own state machine: cases where the model is self-consistent but has drifted from what Postgres actually does. I've got fuzz-generated migrations already; wiring those through an ephemeral Postgres per major version and diffing catalog state is very doable.
The runtime-risk layer is a fair distinction I should be clearer about in the docs. sync gives me a real snapshot (row counts, table stats from pg_class/pg_attribute), so I can reason about "this ALTER TABLE will rewrite 80M rows," but that's static, pre-execution info. Lock contention under live concurrent traffic, actual rewrite duration, replication lag, that's a different kind of fact and I don't think a pre-execution simulator can fully own that without becoming something closer to a staging-environment dry-run tool. Worth being explicit that's a boundary rather than a roadmap item I'm quietly assuming away.
But thanks for taking your time to check it out and give suggestions 🙏