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 36 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.
Metadata
Release files for pg-migration-guard 1.0.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| pg_migration_guard-1.0.2.tar.gz | 9.4 MB | Without provenance | Details for pg_migration_guard-1.0.2.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| pg_migration_guard-1.0.2-py3-none-any.whl | Python 3 | none | any | Details for pg_migration_guard-1.0.2-py3-none-any.whl |
Total release size: 18.9 MB
Release files / pg_migration_guard-1.0.2.tar.gz
| Download URL | pg_migration_guard-1.0.2.tar.gz |
|---|---|
| Size | 9.4 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
bc17a2038ea8e73bc57e86d1944ae7d427920ace5eaa8401c7ca864d5838c610
|
|
BLAKE2b-256 checksum How to use checksums |
0dd359ec1ed651622b1775219ad287c47449ba0db7864705e02b6296bc17d234
|
| 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.2-py3-none-any.whl
| Download URL | pg_migration_guard-1.0.2-py3-none-any.whl |
|---|---|
| Size | 9.5 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3b934fde38f6710add6216b19eaa69be7e3f7b82cf491a34177a7420b5a8d676
|
|
BLAKE2b-256 checksum How to use checksums |
c6303883df549b4929d702fa319cf1aa0c10cc63ce57cfe125cea5f37e1561ea
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.10.12
|
Security for pg-migration-guard 1.0.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.