Skip to main content

safemigrate-lint

CI PyPI License: MIT

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.

safemigrate-lint comment demo

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 with safemigrate-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)

Source distribution for safemigrate-lint 1.3.0
File Size Uploaded
safemigrate_lint-1.3.0.tar.gz 247.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for safemigrate-lint 1.3.0
File Interpreter ABI Platform
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}

Release history Release notifications | RSS feed

This release

1.3.0 This release

2 release files

1.2.1

2 release files

1.2.0

2 release files

1.1.3

2 release files

1.1.2

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page