safemigrate-lint
A GitHub Action that lints Postgres migration SQL on every PR. Catches the operations that actually break production — written for the real shape of production migrations, not the textbook one.
- 32 safety rules + 7 opt-in style rules across CRITICAL / WARNING / STYLE tiers
- Real Postgres parser via pglast (libpg_query) — handles extension SQL (TimescaleDB, PostGIS) that other linters trip on
- Cross-statement context — suppresses FK-to-new-table and similar false positives that pile up in single-statement linters, using ordered, schema-qualified state so a later statement can't excuse an earlier hazard
- Lock impact on each finding — which lock the operation takes, how long it's held, and what it blocks
- Posts a find-or-create PR comment with per-finding detail; creates a Check Run with severity-mapped conclusion
Demo
On every pull request, safemigrate-lint posts a comment that groups findings by severity — each with the lock it takes and the safe rewrite — and sets a Check Run conclusion you can require in branch protection.
Example PR comment (click to expand)
## 🛡️ SafeMigrate Lint
**2 findings** — 1 critical, 1 warning.
🔒 Heaviest lock: ACCESS EXCLUSIVE — blocks reads + writes.
### 🔴 CRITICAL — drop-column-restricted
migrations/0042_cleanup.sql:2
DROP COLUMN deleteat on threads is irreversible data loss.
🔒 Lock: ACCESS EXCLUSIVE | held: instant (catalog only) | blocks: reads + writes (briefly)
the real risk is irreversible data loss, not the lock
### 🟡 WARNING — constraint-not-valid-required
migrations/0042_cleanup.sql:8
ADD CONSTRAINT orders_user_fk FOREIGN KEY without NOT VALID requires a full
table scan.
🔒 Lock: SHARE ROW EXCLUSIVE | held: table scan to validate | blocks: writes on
both the referencing and referenced table (reads still OK)
safe path: ADD ... NOT VALID (instant), then VALIDATE CONSTRAINT
(ShareUpdateExclusive — non-blocking)
Suggested fix:
ALTER TABLE orders ADD CONSTRAINT orders_user_fk FOREIGN KEY (...) NOT VALID;
-- then, in a separate migration:
ALTER TABLE orders VALIDATE CONSTRAINT orders_user_fk;
Why
The rules were chosen by reading real migration history from Cal.com, Mattermost, Supabase, Hasura, and TimescaleDB, rather than from a list of textbook hazards. What stood out is that the operations popular linters warn loudest about — a raw DROP TABLE in application migrations, say — barely occur. The risks that do occur live one layer deeper: ADD COLUMN GENERATED triggering a table rewrite, ADD CONSTRAINT FK without NOT VALID, dynamic SQL the analyzer can't see, constraint drops that silently break invariants. safemigrate-lint is built around those.
That reading informed the rule set; it isn't a published study, and this repo doesn't ship the full corpus or a script to reproduce it. What it does ship is fixtures/migrations/ — 23 real migrations from those projects, each with a committed golden output — so every claim about this tool's behavior is reproducible with pytest.
Atlas Pro charges $9/dev + $59/CI + $39/db per month for many of these checks. This action ships them free, MIT.
Quickstart
Drop this into .github/workflows/lint-migrations.yml:
name: Lint migrations
on:
pull_request:
paths:
- 'migrations/**/*.sql'
permissions:
contents: read
pull-requests: write
checks: write
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Harshith029/safemigrate-lint@v1
continue-on-error: true
with:
paths: 'migrations/**/*.sql'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
For maximum reproducibility, pin to a commit SHA (@<full-sha>) instead of @v1.
Why continue-on-error: true?
The action's step exits non-zero whenever the lint finds anything (so workflows that don't set this turn red on every PR with findings). Use the Check Run as the semantic signal instead — it maps severity to conclusion:
| findings | check conclusion | meaning |
|---|---|---|
| none | success |
safe to merge |
| warnings / style only | neutral |
review, but doesn't block |
| any critical | action_required |
look at this before merging |
In branch protection, require safemigrate-lint (the Check Run name) as a status check. The PR will be blocked on critical findings while warnings stay non-blocking.
Linting only the migrations a PR changed
By default the action lints every file matching paths. On a repo with a lot
of existing migrations, that re-reports findings on old, already-shipped ones on
every PR. To judge a PR only on the migrations it actually introduces, compute
the diff and pass it to paths — pure git, no third-party action:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # so the diff can see the base branch
- id: changed
run: |
base="${{ github.base_ref }}"
files=$(git diff --name-only --diff-filter=ACMR "origin/$base...HEAD" \
| grep -E '^migrations/.*\.sql$' | tr '\n' ' ' || true)
echo "files=$files" >> "$GITHUB_OUTPUT"
- if: steps.changed.outputs.files != ''
uses: Harshith029/safemigrate-lint@v1
continue-on-error: true
with:
paths: ${{ steps.changed.outputs.files }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
This is the recommended setup for existing projects: new PRs are judged only on the migrations they add, not your whole history.
Other ways to run it
The same engine ships three ways — use whichever fits your workflow.
CLI
# from PyPI
pipx install safemigrate-lint # or: uv tool install safemigrate-lint
# …or straight from source
pipx install git+https://github.com/Harshith029/safemigrate-lint
safemigrate-lint migrations/*.sql # exit 0 clean · 1 findings · 2 input error
safemigrate-lint migrations/*.sql --severity=critical,warning,style --format=markdown
pre-commit
Catch dangerous migrations before they're even committed:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/Harshith029/safemigrate-lint
rev: v1.3.0
hooks:
- id: safemigrate-lint
Runs on staged *.sql files and blocks the commit on any finding.
Reference
Inputs
| name | default | description |
|---|---|---|
paths |
(required) | Glob or newline-separated list of SQL files to lint |
severity |
critical,warning |
Comma-separated severity levels to include: critical,warning,style |
format |
json |
Output format for the action log: json or markdown |
Outputs
| name | type | description |
|---|---|---|
findings-count |
integer | Total findings emitted after severity filter |
has-critical |
"true" / "false" |
Whether any critical-severity finding was emitted |
Required permissions
| scope | needed for |
|---|---|
contents: read |
checking out migration files |
pull-requests: write |
posting / editing the PR comment |
checks: write |
creating the Check Run |
Lock impact
Findings whose concern is a lock carry a lock_impact object — the lock mode the
operation acquires, how long it's held, and what it blocks. This is derived
statically from the Postgres documentation; no database connection is involved.
{
"rule_id": "constraint-not-valid-required",
"severity": "warning",
"lock_impact": {
"lock": "SHARE ROW EXCLUSIVE",
"held": "table scan to validate",
"blocks": "writes on both the referencing and referenced table (reads still OK)",
"note": "safe path: ADD ... NOT VALID (instant), then VALIDATE CONSTRAINT (ShareUpdateExclusive — non-blocking)"
}
}
The lock can depend on the statement, not just the rule. This same rule reports
ACCESS EXCLUSIVE for ADD CONSTRAINT ... CHECK, because Postgres takes
SHARE ROW EXCLUSIVE for a foreign key (on both tables) and ACCESS EXCLUSIVE for
a check constraint.
The markdown report adds a per-finding lock line plus a "Heaviest lock" summary at the top, so a reviewer can see the worst lock in the migration without reading every finding.
25 of the 39 rules carry a lock impact. The other 14 are omitted deliberately rather than left as a gap:
| omitted | why |
|---|---|
| style + type-choice rules | opinions about types and syntax, not locks |
| correctness rules (duplicate index columns, enum value ordering) | the concern is a broken result, not blocking |
analyzer-blind-on-dynamic-sql |
unknowable by definition |
DROP DATABASE, transaction nesting / uncommitted transaction |
no table lock to report |
index-concurrent-in-transaction-banned |
Postgres rejects it before it acquires anything |
The note field is used honestly. Several operations take a heavy lock only
briefly — DROP COLUMN is ACCESS EXCLUSIVE but catalog-only and instant — so the
note says the real risk is data loss or application breakage rather than implying
an outage the operation won't cause.
Supported Postgres versions
The grammar comes from libpg_query (via pglast), so it's Postgres's own parser rather than a reimplementation. But it's one specific version's parser:
| Grammar version | Postgres 17 |
| Parses cleanly | anything valid in PG 17 and earlier, including extension SQL (TimescaleDB, PostGIS) |
| Known gap | PG 18 syntax. GENERATED ALWAYS AS (...) VIRTUAL is reported as a syntax error |
If you write PG 18-only syntax, the affected file reports a syntax-error
finding rather than being silently skipped. The version is pinned by a test, so
upgrading it is a deliberate change rather than a side effect of a dependency
bump.
Inline suppression
For a one-off justified exception, prefix the statement with an ignore comment:
-- safemigrate:ignore=drop-column-restricted reason="column archived to data warehouse before drop"
ALTER TABLE users DROP COLUMN legacy_referrer;
Configuration via .safemigrate.toml
Optional repo-level config. Walks upward from the first linted file to find it.
[rules]
disabled = ["timestamptz-over-timestamp-preferred"] # hard-disable, never fires
[rules.style]
enabled = ["bigint-over-int-preferred"] # promote STYLE -> WARNING in default mode
How it compares to squawk
squawk is the closest other free OSS option. Both lint Postgres migrations, both are MIT.
| safemigrate-lint | squawk | |
|---|---|---|
| Parser | pglast (libpg_query — Postgres's own parser, PG 17 grammar) | Rust reimplementation |
| Extension SQL (TimescaleDB / PostGIS) | parses cleanly | known parser gaps on newer SQL |
| Cross-statement context | yes — ordered, schema-qualified; suppresses FK / index / constraint rules only on tables created earlier and still empty | per-statement only |
| Out-of-the-box GitHub Action | yes (this repo) | shipped binary + DIY workflow |
| PR comments + Check Run | built-in | DIY |
| Rule count | 32 safety + 7 opt-in style | 37 rules |
| Default-mode signal on a 23-fixture corpus | 29 findings | 205 findings |
squawk's count was measured with squawk 2.56.0 in its default configuration on this repo's
fixtures/migrations/; reproduce this tool's number withsafemigrate-lint fixtures/migrations/*.sql. Most of squawk's extra findings are its style/opinion rules (prefer-robust-stmts,prefer-bigint-over-int,prefer-identity, …), which safemigrate-lint ships as opt-in STYLE rules rather than firing by default. A lower count is not automatically better — it reflects a deliberate choice about what belongs in a default-on gate, and squawk's broader catalog may suit you better.
If you want the broadest rule catalog and you're comfortable wiring the action yourself, squawk is mature and well-maintained. If you want a one-paste install plus FK-to-new-table suppression by default, this is the trade.
Contributing
Contributions welcome — especially new rules and false-positive reports. See CONTRIBUTING.md for the dev setup and rule philosophy, and docs/writing-a-rule.md for a step-by-step rule walkthrough.
License
MIT — see LICENSE.
Release files for safemigrate-lint 1.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| safemigrate_lint-1.3.0.tar.gz | 247.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| safemigrate_lint-1.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 339.8 kB
Release files / safemigrate_lint-1.3.0.tar.gz
| Download URL | safemigrate_lint-1.3.0.tar.gz |
|---|---|
| Size | 247.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b69d4842f6fb72d5d38e0dd7a8cda779a36ddcd55e0eace57b20a9867659a5eb
|
|
BLAKE2b-256 checksum How to use checksums |
49a5643c38baeb070c677a1a0e2d09b7d6f1d5992d77e8021f69a9d080c2dfee
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / safemigrate_lint-1.3.0-py3-none-any.whl
| Download URL | safemigrate_lint-1.3.0-py3-none-any.whl |
|---|---|
| Size | 92.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
232a1906e09e4387a99ba956a3ffba892ac73a499929b1a494bb8f0e66a7cec6
|
|
BLAKE2b-256 checksum How to use checksums |
f291de117ca49aa95d46f787c569c5b1472d8d0e9a78856cb3c658e558077a6b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|