Backward-compatible migrations
A schema migration is backward-incompatible when it breaks code or data that
the currently-deployed application still depends on: dropping a column a release
still reads, renaming a table, tightening a column to NOT NULL on a table that
already has rows. These changes turn a routine deploy into an outage.
Policy: migrations must be backward-compatible. Land the schema change and the application change as separate, ordered deploys (expand → migrate → contract) so that old and new code both work against the schema at every step.
banshee enforces this with a dedicated gate.
The --breaking gate
Section titled “The --breaking gate”banshee lint --breaking <path> lints every SQL file under <path> with the
full rule set, but only backward-incompatible changes fail the run (exit
code 1, reported as errors). Every other finding — locking hazards, type
preferences, style — is still printed, but stays advisory and does not affect
the exit code.
banshee lint --breaking migrations/One invocation therefore does both jobs: it surfaces the whole migration-safety pack and gates the merge on breaking changes — no need for a second run.
The rules it enforces:
| Rule | Breaking change |
|---|---|
| MG04 | ADD COLUMN NOT NULL without DEFAULT (fails on a non-empty table) |
| MG05 | DROP COLUMN |
| MG06 | ALTER COLUMN … TYPE |
| MG07 | RENAME of a table or column |
| MG08 | TRUNCATE … CASCADE |
| MG16 | DROP TABLE |
| MG17 | ALTER COLUMN … DROP NOT NULL |
| MG18 | DROP DATABASE |
Each finding links to a rule page with the safe, multi-step rewrite. Run
banshee explain MG05 for the same guidance on the command line.
Excluding legacy files
Section titled “Excluding legacy files”Existing migrations that predate the policy should not block new work. List them
under exclude-paths in banshee.toml — the globs are matched against each
input path and matching files are skipped entirely:
[lint]exclude-paths = [ "migrations/2019*.sql", # frozen: pre-policy "migrations/legacy/**",]Add files to this list only when they are already merged. New migrations should pass the gate, not be exempted from it.
Wiring it into CI
Section titled “Wiring it into CI”--breaking exits non-zero when it finds a breaking change, so it drops
straight into a pipeline. With the GitHub Actions formatter, findings annotate
the diff:
- uses: octofhir/banshee@v0.2.2 with: command: lint args: --breaking --format github migrations/See CI integration for SARIF code-scanning and the full Action reference.
Pre-commit hook
Section titled “Pre-commit hook”Catch breaking migrations before they are committed:
- repo: local hooks: - id: banshee-breaking name: no backward-incompatible migrations entry: banshee lint --breaking language: system files: ^migrations/.*\.sql$What the gate does not flag
Section titled “What the gate does not flag”The gate is context-aware to avoid false alarms on changes that are not actually breaking:
- A table created in the same migration file has no deployed clients or rows
yet, so adding a
NOT NULLcolumn to it, or dropping it/its columns, is not flagged (handy for temp scaffolding). - An
ADD COLUMN NOT NULLthat isGENERATED(identity or computed) supplies its own value for every row, so it is safe. RENAME CONSTRAINTis not flagged — constraint names are not referenced by client code.- An
ALTER COLUMN … TYPEthat is a provable safe widening — determined from the column’s type history across the whole migration directory — is not flagged:int → bigint,real → double precision,varchar(n) → textorvarchar(m)withm ≥ n. Narrowings (bigint → int,varchar(100) → varchar(50)) still fail, and a column whose type is unknown or changed more than once is treated conservatively (flagged).
These never hide a real breaking change: when banshee cannot prove a change is safe, it flags it.
Advisory vs blocking
Section titled “Advisory vs blocking”--breaking already runs the rest of the migration-safety
pack (MG01–MG24) — locking hazards, non-idempotent
statements, type preferences — and prints those findings; they just don’t fail
the run. To make any of them blocking too, raise its severity to error in
banshee.toml:
[lint.rules.MG01]severity = "error" # also block CREATE INDEX without CONCURRENTLYWith --breaking, the run fails on any error-severity finding, so
configured errors and the built-in breaking rules gate together in one pass.