pg-migration-guard
A free, static safety checker for PostgreSQL SQL migrations. It reads a proposed Flyway or raw SQL migration and flags the DDL patterns that quietly take a heavy lock, rewrite a whole table, or destroy data, before that migration reaches production. Every finding names the hazard, explains why it blocks, and prints a safe rewrite.
It parses SQL with libpg_query, the same C parser PostgreSQL itself uses, so it reads real statements rather than guessing with regexes: a column named like a keyword, a schema-qualified table name, or dollar-quoted code is read correctly, not misread by a pattern match. The parser is bundled as a self-contained BSD-3-Clause binary and bound through the Python standard library (ctypes), so there are no third-party Python dependencies, and the tool never connects to a database or the network.
What it catches
Ten migration hazards (12 rule IDs):
| Rule | Hazard |
|---|---|
| IDX01 / IDX02 | CREATE INDEX without CONCURRENTLY, and a CONCURRENTLY index op inside a transaction (which errors) |
| COL01 | ADD COLUMN with a volatile DEFAULT (rewrites the table) |
| COL03 | ALTER COLUMN ... TYPE (rewrites the table) |
| NN01 | SET NOT NULL (scans the whole table under ACCESS EXCLUSIVE) |
| FK01 | ADD FOREIGN KEY without NOT VALID (validates under a write-blocking lock) |
| CHK01 | ADD CHECK without NOT VALID (scans the whole table) |
| PK01 | ADD PRIMARY KEY / UNIQUE (builds the index under ACCESS EXCLUSIVE) |
| ORD01 / ORD02 | a backfill and its constraint in one migration, and an unbatched UPDATE/DELETE with no WHERE |
| LCK01 | lock-taking DDL with no lock_timeout guard |
| DES01 | destructive ops: TRUNCATE, DROP TABLE, DROP COLUMN, DROP SCHEMA, DROP DATABASE |
Install
pip install pg-migration-guard
Requires Python 3.8+. No other dependencies. A prebuilt parser binary is bundled for Linux (x86_64, aarch64), macOS (arm64, x86_64), and Windows (x86_64).
Use
pg-migration-guard db/migration/V42__add_index.sql
pg-migration-guard --format json db/migration/V42__add_index.sql
pg-migration-guard --fail-on warn --pg-version 15 db/migration/V42__add_index.sql
Options:
--format {human,json}(defaulthuman)--fail-on {blocker,warn,advisory,none}(defaultblocker) sets the severity that makes the command exit nonzero.--pg-version Nassumes a Postgres major version for version-gated rules.--self-testruns the internal checks.--versionprints the version.
Exit codes: 0 clean, 1 a finding at or above --fail-on, 2 a usage or
parse error.
GitHub Action
Add a workflow that fails the pull request and annotates each risky line:
name: migration-guard
on:
pull_request:
paths: ["db/migration/**.sql"]
jobs:
guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- uses: technical-turtle/pg-migration-guard@v1
with:
path: db/migration
fail-on: blocker
# pg-version: "15" # optional
Findings are emitted as GitHub annotations (::error:: for blocker, ::warning::
for warn, ::notice:: for advisory), so they appear inline on the diff.
Before and after
A migration that looks harmless:
CREATE INDEX idx_orders_customer ON orders (customer_id);
ALTER TABLE orders DROP COLUMN legacy_note;
$ pg-migration-guard V42__orders.sql
BLOCKER DES01 Destructive operation (TRUNCATE / DROP TABLE / DROP COLUMN) (high)
stmt #1, line 2: ALTER TABLE orders DROP COLUMN legacy_note
Why: DROP COLUMN is fast and metadata-only, but it is irreversible and breaks any
app version still selecting the column during a rolling deploy.
Safe: Confirm intent. For a live column, stop referencing it first (contract phase), then drop.
WARN IDX01 CREATE INDEX without CONCURRENTLY (high) [latent blocker on a large/hot table]
stmt #0, line 1: CREATE INDEX idx_orders_customer ON orders (customer_id)
Why: Plain CREATE INDEX takes a SHARE lock, blocking all writes to the table until the build finishes.
Safe: CREATE INDEX CONCURRENTLY idx ON tbl (col); -- outside a transaction
Summary: 1 blocker, 1 warn, 0 advisory
The safe rewrite: build the index without holding a write lock, and drop the column in two steps (stop reading it in the app first, then drop it in a later migration):
CREATE INDEX CONCURRENTLY idx_orders_customer ON orders (customer_id); -- outside a transaction
-- ship a release that stops reading legacy_note, then in a later migration:
ALTER TABLE orders DROP COLUMN legacy_note;
Known limitations
- Liquibase changelogs (XML, YAML, JSON) are not parsed. Point this tool at SQL migrations (raw SQL or Flyway SQL); for Liquibase changelog formats, use the paid Postgres Migration Safety Auditor.
- The tool assumes a migration runs inside a transaction (the Flyway and
Liquibase default). A Liquibase formatted-SQL file with
runInTransaction:falsecan therefore get a spurious IDX02 finding on aCONCURRENTLYindex operation, since outside a transaction that operation is actually fine.
Full coverage
pg-migration-guard is the free version of the Postgres Migration Safety Auditor. The paid tool checks 35 rules (not 12), each linked to an official Postgres source, and adds Liquibase XML / YAML / JSON changelog support, Postgres-version and table-size aware checks, a config file with per-rule mute controls, and a Claude Code skill: https://technical-turtle.com/postgres-migration-auditor
License
MIT. Bundles the libpg_query parser (BSD 3-Clause); see THIRD-PARTY-NOTICES.md.
Release files for pg-migration-guard 1.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pg_migration_guard-1.0.1.tar.gz | 9.4 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pg_migration_guard-1.0.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 18.9 MB
Release files / pg_migration_guard-1.0.1.tar.gz
| Download URL | pg_migration_guard-1.0.1.tar.gz |
|---|---|
| Size | 9.4 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4b61fa572e54d6aa3e26c40cb89c36da306ce7094c4b39e9e31660f789d45704
|
|
BLAKE2b-256 checksum How to use checksums |
2e75dfad1a4e4a2c702ddeb17874587a2ecd53fd27599f733798ac01b932691d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.10.12
|
Release files / pg_migration_guard-1.0.1-py3-none-any.whl
| Download URL | pg_migration_guard-1.0.1-py3-none-any.whl |
|---|---|
| Size | 9.5 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6b9f683df77fb98b924db1e34fda33c03e5c3f6128e8592b1d74854034af13ce
|
|
BLAKE2b-256 checksum How to use checksums |
50e9cf8afc7a7f330a82690ab2cc059b2a627ecd689128773a89ee5d28fb766d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.10.12
|