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.

🔒 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

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.1
    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.

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.

Release files for safemigrate-lint 1.2.1

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.2.1
File Size Uploaded
safemigrate_lint-1.2.1.tar.gz 233.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for safemigrate-lint 1.2.1
File Interpreter ABI Platform
safemigrate_lint-1.2.1-py3-none-any.whl Python 3 none any Details

Total release size: 322.0 kB

Release files / safemigrate_lint-1.2.1.tar.gz

Download URL safemigrate_lint-1.2.1.tar.gz
Size 233.6 kB
Tags Source
SHA-256 checksum
How to use checksums
1ff7eaede4942eea51bf0b678b679099322d82952c88b6ce749dbc847e9bf4b5
BLAKE2b-256 checksum
How to use checksums
79e81bfcaf844cb85550113a9d5996c35f838db40ff97e706db5296fae61f9df
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.2.1-py3-none-any.whl

Download URL safemigrate_lint-1.2.1-py3-none-any.whl
Size 88.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8dd36fda8ad1d6395ec2f61f1d1b88d42228ee657255577fb22f61fe00f0a3ec
BLAKE2b-256 checksum
How to use checksums
ac080379f963ea201ae5af49e8303596fa658bf0ccb427e11437e49c9c3e9033
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

1.3.0

2 release files

This release

1.2.1 This release

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