SQL Migration Linter
Rule-based linter for SQL migration files. Catches mistakes that make migrations non-idempotent, destructive, or unsafe under concurrent load. Pure Python stdlib — no dependencies.
Supports dialects: generic, postgres, mysql, sqlite.
Commands
# Lint a single file
python3 scripts/sql_migration_linter.py lint migrations/001_init.sql
# Lint a directory recursively
python3 scripts/sql_migration_linter.py lint migrations/
# Specify dialect (unlocks Postgres-specific rules)
python3 scripts/sql_migration_linter.py lint migrations/ --dialect postgres
# Filter by minimum severity
python3 scripts/sql_migration_linter.py lint migrations/ --min-severity warning
# JSON output for CI
python3 scripts/sql_migration_linter.py lint migrations/ --format json
# Compact summary
python3 scripts/sql_migration_linter.py lint migrations/ --format summary
# List all rules
python3 scripts/sql_migration_linter.py rules
Rules (17 total)
Structure
missing-trailing-semicolon(error) — file does not end with;mixed-indentation(warning) — tabs and spaces mixed in the same linetrailing-whitespace(info)keyword-case-inconsistent(info) — same keyword appears in mixed case
DDL safety
drop-without-if-exists(warning) —DROP TABLE/INDEX/...withoutIF EXISTSdestructive-drop-table(warning) —DROP TABLEflagged for reviewcreate-without-if-not-exists(warning) —CREATE TABLE/INDEX/...withoutIF NOT EXISTScreate-index-locks-table(warning, postgres) —CREATE INDEXwithoutCONCURRENTLYadd-column-not-null-no-default(error, postgres) —ADD COLUMN ... NOT NULLwithoutDEFAULTreserved-word-identifier(warning) — identifier matches a SQL reserved word (e.g.user,order)
DML safety
update-without-where(error)delete-without-where(error)truncate-is-destructive(warning)select-star(info) —SELECT *in migrationsinsert-without-conflict-handling(info) —INSERTwithoutON CONFLICT/ON DUPLICATE KEY
Transactions
missing-transaction(warning) — 2+ DDL statements without explicitBEGIN/COMMITbegin-without-commit(error)
Output formats
- text (default) — grouped by file,
line:severity: [rule] message, with totals - json — array of
{file, line, rule, severity, message}objects - summary — counts per severity + top 10 rules by frequency
Exit codes (CI-friendly)
0— clean (or onlyinfobelow min-severity)1— warnings present, no errors2— errors present
Examples
# Pre-commit hook — fail on any warning or error
python3 scripts/sql_migration_linter.py lint migrations/ --min-severity warning
# CI gate — fail only on errors
python3 scripts/sql_migration_linter.py lint migrations/ --min-severity error
# Postgres-specific audit
python3 scripts/sql_migration_linter.py lint migrations/ --dialect postgres --format json > report.json
Why this exists
Migrations that look fine locally fail in production because:
- They aren't idempotent (re-run fails)
- They lock large tables (Postgres
CREATE INDEX,ADD COLUMN NOT NULL) - They mutate every row (
UPDATE/DELETEwithoutWHERE) - They use reserved words as identifiers and break under different parsers
This linter catches those before the PR gets merged.
Limitations
- Uses regex + statement splitting; not a full SQL parser
- No schema knowledge — cannot check FK targets, column types, etc.
keyword-case-inconsistentis per-statement, not repo-wide