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.
Metadata
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 | Provenance | Actions |
|---|---|---|---|---|
| safemigrate_lint-1.3.0.tar.gz | 247.2 kB | Without provenance | Details for safemigrate_lint-1.3.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| safemigrate_lint-1.3.0-py3-none-any.whl | Python 3 | none | any | Details for safemigrate_lint-1.3.0-py3-none-any.whl |
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}
|
Security for safemigrate-lint 1.3.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.