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.

  • 33 safety rules + 6 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
  • 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, holding AccessExclusiveLock for the duration.

🔒 Lock: ACCESS EXCLUSIVE | held: table scan to validate | blocks: reads + writes
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

We scanned ~700 production migrations from Cal.com, Mattermost, Supabase, Hasura, and TimescaleDB. Zero of them ran the textbook DANGEROUS operations the popular linters warn loudest on (raw DROP TABLE in app code, etc.). The real risks 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.

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.2.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": "ACCESS EXCLUSIVE",
    "held": "table scan to validate",
    "blocks": "reads + writes",
    "note": "safe path: ADD ... NOT VALID (instant), then VALIDATE CONSTRAINT (ShareUpdateExclusive — non-blocking)"
  }
}

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.

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 — actual Postgres parser) Rust reimplementation
Extension SQL (TimescaleDB / PostGIS) parses cleanly known parser gaps on newer SQL
Cross-statement context yes — suppresses FK / index / constraint rules on same-migration tables 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 33 safety + 6 opt-in style 37 rules
Default-mode signal on a 23-fixture corpus 39 findings, all actionable 205 findings

Measured with squawk 2.56.0, both tools in their default configuration, on this repo's fixtures/migrations/. 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.

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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

safemigrate_lint-1.2.0.tar.gz (227.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

safemigrate_lint-1.2.0-py3-none-any.whl (86.4 kB view details)

Uploaded Python 3

File details

Details for the file safemigrate_lint-1.2.0.tar.gz.

File metadata

  • Download URL: safemigrate_lint-1.2.0.tar.gz
  • Upload date:
  • Size: 227.6 kB
  • Tags: Source
  • Uploaded using 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}

File hashes

Hashes for safemigrate_lint-1.2.0.tar.gz
Algorithm Hash digest
SHA256 015ad590e7c491e6b722b0528002fe1d1d06d9cea2da0c1305a89a52fc701f39
MD5 640697841bcc532b53ced002819f41b5
BLAKE2b-256 c519d5d307d05b90d1ea7ef89276d129ea100e9102f2c0a0a4b309c51c7c6a7f

See more details on using hashes here.

File details

Details for the file safemigrate_lint-1.2.0-py3-none-any.whl.

File metadata

  • Download URL: safemigrate_lint-1.2.0-py3-none-any.whl
  • Upload date:
  • Size: 86.4 kB
  • Tags: Python 3
  • Uploaded using 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}

File hashes

Hashes for safemigrate_lint-1.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 61d4084cb04ac206d3552ce56ba69004ffa0c425608b81d4489866b29b0eb3c7
MD5 9592f0774cce128802f683cc63d2e2da
BLAKE2b-256 8ffcfe7a1bdb72a99e6d8c2d99484ad7af5ad7a11ec55344831d029c11ac86bf

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page