Skip to content

robert-sjoblom/pg-migration-lint

pg-migration-lint

Static analyzer for PostgreSQL migration files.

CI License: MIT OR Apache-2.0 codecov

What it does

pg-migration-lint replays your full migration history to build an internal table catalog, then lints only new or changed migration files against 52 safety and correctness rules. It catches dangerous operations -- missing CONCURRENTLY, table rewrites, missing indexes on foreign keys, unsafe constraint additions, silent constraint removal, risky renames, type anti-patterns -- before they reach production.

Output formats include SARIF (for GitHub Code Scanning inline PR annotations), SonarQube Generic Issue Import JSON, and human-readable text.

Comparison with Squawk and Eugene

Three active static analyzers exist for PostgreSQL migrations. This is how their linting coverage differs:

What gets checked pg-migration-lint Squawk Eugene
Unsafe DDL (missing CONCURRENTLY, table rewrites, unsafe constraint additions) Yes Yes Yes
Lock safety — statements holding AccessExclusiveLock unnecessarily Partial Partial Yes — static analysis plus dynamic lock tracing against a live Postgres instance
Missing lock timeout No Yes (require-timeout-settings) Yes (E9)
Type anti-patterns (char, money, serial, json, timestamp without tz, varchar(n), floating-point) Yes (9 rules) Partial (prefer-text, prefer-timestamptz, prefer-bigint, prefer-identity) No
Missing FK index Yes — catalog-aware, detects across migration boundaries No Trace-only (E15)
Destructive operations (DROP TABLE, TRUNCATE, DROP SCHEMA CASCADE) Yes Partial (ban-drop-table, ban-truncate-cascade) No
DML in migrations (INSERT/UPDATE/DELETE on existing tables) Yes No No
Idempotency guards (IF EXISTS / IF NOT EXISTS) Yes No No
Schema design (missing PK, risky renames, unlogged tables, redundant indexes, reserved-word identifiers) Yes No No
Catalog-aware analysis (schema state before vs. after each migration) Yes — replays full migration history No No
PostgreSQL version-aware rules No Yes (pg_version config suppresses rules that don't apply to your version) No

pg-migration-lint replays your full migration history to build a table catalog before linting. This enables checks that require knowing prior schema state — flagging unsafe ALTER TABLE only on pre-existing tables, detecting a missing FK index even when the FK and the covering index live in different migration files, or warning about redundant indexes that accumulated over time.

Squawk focuses on safety-critical DDL patterns and type preferences. It also ships a VS Code extension for live feedback while writing migrations.

Eugene specializes in lock safety. Its trace mode runs your SQL against an actual Postgres instance and records exactly which locks each statement acquires, making it the most precise tool for diagnosing lock contention and verifying safe migration sequences.

Quick Start

Install from release

curl -LO https://github.com/robert-sjoblom/pg-migration-lint/releases/latest/download/pg-migration-lint-x86_64-linux.tar.gz
tar xzf pg-migration-lint-x86_64-linux.tar.gz
chmod +x pg-migration-lint

Run locally

# Lint specific changed files (text output for local development)
./pg-migration-lint --format text --changed-files db/migrations/V042__add_index.sql

# Lint all migrations (useful for first adoption or full-repo scans)
./pg-migration-lint --format text

# Explain what a specific rule checks for
./pg-migration-lint --explain PGM001

PostgreSQL Version Support

pg-migration-lint targets currently supported PostgreSQL versions (14+). Rule advice (e.g. recommending REINDEX CONCURRENTLY or DETACH PARTITION CONCURRENTLY) assumes a modern PostgreSQL release. Running against migrations intended for older, unsupported versions may produce false positives.

Rules

pg-migration-lint ships with 52 rules across seven categories:

  • Unsafe DDL (PGM001-PGM022) -- Critical/Major. Missing CONCURRENTLY, table rewrites, unsafe constraint additions, silent side effects from DROP COLUMN, VACUUM FULL, CLUSTER.
  • Type Anti-patterns (PGM101-PGM109) -- Minor/Info. timestamp without time zone, char(n), money, serial, json, varchar(n), floating-point columns. Derived from the PostgreSQL wiki "Don't Do This" page.
  • Destructive Operations (PGM201-PGM205) -- Minor/Major/Critical. DROP TABLE, TRUNCATE, DROP SCHEMA CASCADE.
  • DML in Migrations (PGM301-PGM303) -- Info/Minor. INSERT, UPDATE, DELETE on existing tables.
  • Idempotency Guards (PGM401-PGM403) -- Minor. Missing IF EXISTS / IF NOT EXISTS, misleading no-ops.
  • Schema Design (PGM501-PGM509) -- Major/Info. Missing FK index, no primary key, risky renames, unlogged tables, redundant indexes, mixed-case identifiers.
  • Meta-behavior (PGM901) -- Down migrations cap all findings to Info.

Use --explain <RULE_ID> for a detailed explanation of any rule, including why it is dangerous and how to fix it:

./pg-migration-lint --explain PGM001

See the full rule reference for every rule with examples and fixes.

Integrations

  • GitHub Actions -- Workflow YAML for linting changed migrations on PRs with SARIF upload
  • SonarQube -- Generic Issue Import JSON setup
  • Liquibase XML -- Bridge JAR and update-sql configuration

Configuration Reference

Default config file: pg-migration-lint.toml in the working directory. Override with --config <path>.

If no config file is found at the default path, the tool uses built-in defaults and prints a warning.

You can also view this reference from the CLI with --explain-config:

./pg-migration-lint --explain-config              # all sections
./pg-migration-lint --explain-config migrations    # just [migrations]
[migrations]
# Paths to migration sources. Scanned in order.
# For filename_lexicographic: directories containing .sql files.
# For liquibase: the root changelog file (e.g. "db/changelog/migrations.xml").
# Default: ["db/migrations"]
paths = ["db/migrations"]

# How to determine migration order.
#   "filename_lexicographic" - sorted by filename (go-migrate, Flyway convention)
#   "liquibase" - order derived from Liquibase changelog includes
# Default: "filename_lexicographic"
strategy = "filename_lexicographic"

# File patterns to include when scanning migration directories.
# Default: ["*.sql", "*.xml"]
include = ["*.sql", "*.xml"]

# File patterns to exclude.
# Default: []
exclude = ["**/test/**"]

# Default schema for unqualified table names.
# Unqualified names like "orders" are normalized to "public.orders" for
# catalog lookups, so that "orders" and "public.orders" resolve to the
# same table. Set this to your service's search_path schema if it
# differs from "public".
# Default: "public"
default_schema = "public"

# Whether plain SQL files run inside a transaction by default.
# Set to false for golang-migrate repos where files run outside transactions.
# Default: true
run_in_transaction = true

[liquibase]
# Path to liquibase-bridge.jar.
# Default: "tools/liquibase-bridge.jar"
bridge_jar_path = "tools/liquibase-bridge.jar"

# Path to the liquibase binary (used by the "update-sql" secondary strategy).
# Default: "liquibase"
binary_path = "/usr/local/bin/liquibase"

# Path to a liquibase properties file (passed as --defaults-file to the CLI).
# Default: none
# properties_file = "liquibase.properties"

# Liquibase processing strategy.
#   "auto" - tries bridge -> update-sql in order
#   "bridge" - bridge JAR only
#   "update-sql" - liquibase update-sql only
# Default: "auto"
strategy = "auto"

[output]
# Output formats to produce. One or more of: "sarif", "sonarqube", "text"
# Default: ["sarif"]
formats = ["sarif", "sonarqube"]

# Directory for output files.
# SARIF is written to <dir>/findings.sarif
# SonarQube JSON is written to <dir>/findings.json
# Default: "build/reports/migration-lint"
dir = "build/reports/migration-lint"

# Optional prefix to strip from finding file paths before emitting reports.
# Useful when running from a project root but SonarQube expects module-relative paths.
# Example: strip_prefix = "impl/" turns "impl/src/main/..." into "src/main/..."
# Default: none
# strip_prefix = "impl/"

[rules]
# Rule IDs to disable globally. Findings from disabled rules are not emitted.
# Invalid rule IDs cause a config-load error (exit 2).
# Default: []
disabled = []

[cli]
# Exit non-zero if any finding meets or exceeds this severity.
# One of: "blocker", "critical", "major", "minor", "info", "none"
# Default: "critical"
fail_on = "critical"

Suppression

Sometimes a finding is intentional and should be suppressed. pg-migration-lint supports inline suppression comments in both SQL and XML files.

SQL files

Suppress a single rule on the next statement:

-- pgm-lint:suppress PGM001
CREATE INDEX idx_foo ON bar (col);

Suppress multiple rules on the next statement:

-- pgm-lint:suppress PGM001,PGM501
CREATE INDEX idx_foo ON bar (col);

Suppress rules for the entire file (must appear before any SQL statements):

-- pgm-lint:suppress-file PGM001,PGM501

Liquibase XML files

The same directives work inside XML comments:

<!-- pgm-lint:suppress PGM001 -->
<changeSet id="42" author="dev">
    <createIndex indexName="idx_foo" tableName="bar">
        <column name="col"/>
    </createIndex>
</changeSet>
<!-- pgm-lint:suppress-file PGM001,PGM501 -->

Only single-line XML comments are recognized. Multi-line <!-- ... --> comments spanning multiple lines are not parsed for directives.

CLI Reference

pg-migration-lint [OPTIONS]

OPTIONS:
  -c, --config <path>              Path to configuration file
                                   (default: ./pg-migration-lint.toml)
  --changed-files <list>           Comma-separated list of changed files to lint
  --changed-files-from <path>      Path to file containing changed file paths
                                   (one per line)
  --format <format>                Override output format: sarif, sonarqube, text
  --fail-on <severity>             Override exit code threshold:
                                   blocker, critical, major, minor, info, none
  --explain <rule>                 Print detailed explanation of a rule and exit
  --explain-config [section]       Print configuration reference and exit.
                                   Omit section to print all; valid sections:
                                   migrations, liquibase, output, cli, rules
  -V, --version                    Print version and exit
  -h, --help                       Print help

When --changed-files is omitted, all migration files are linted.

When --format is provided, it overrides the [output].formats setting from the config file with a single format. To produce multiple formats in one run, use the config file.

Exit Codes

Code Meaning
0 No findings at or above the configured severity threshold
1 One or more findings at or above the threshold (blocks CI)
2 Tool error (invalid config, missing files, parse failure)

Building from Source

Requires Rust 1.70+ and a C compiler (for the pg_query native bindings).

# Install system dependencies (Debian/Ubuntu)
sudo apt-get install build-essential libclang-dev clang

# Build optimized release binary
cargo build --release

# Binary is at target/release/pg-migration-lint

To run the test suite:

cargo test

To build the Liquibase bridge JAR (requires Docker or a local Maven + JDK 21 installation):

cd bridge
docker run --rm -v "$PWD:/build" -w /build maven:3.9-eclipse-temurin-21 mvn package -q -DskipTests
# JAR is at bridge/target/liquibase-bridge.jar

License

Licensed under either of

at your option.

About

Lint PostgreSQL migrations for bad migration patterns, bad types, and other gotchas that may or may not be known to your developers.

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors