safe-migrate checks PostgreSQL migrations against a synchronized database
baseline. Run safe-migrate sync, then use lint or lint-chain offline to
simulate migrations against the captured state.
safe-migrate is a review aid, not a substitute for testing migrations on a representative database or planning application rollouts and backfills.
If Rust is installed:
cargo install safe-migrate --locked
safe-migrate --versionThe installer selects a supported Linux, macOS, Windows/MSYS, or Termux target and verifies the release checksum:
curl -fsSL https://raw.githubusercontent.com/dsecurity49/safe-migrate/main/install.sh | bash
safe-migrate --versionTo pin the installer and binary to one release:
VERSION='<release-tag>'
BASE_URL='https://raw.githubusercontent.com/dsecurity49/safe-migrate'
curl -fsSL "${BASE_URL}/${VERSION}/install.sh" |
bash -s -- --version "${VERSION}"Run bash install.sh --help after downloading the installer for pinning and
destination options. Archives, checksums, and manual downloads are available on
the
GitHub Releases page.
Create a database baseline, then lint one migration or a directory of ordered migrations:
export DATABASE_URL='postgres://readonly_user:password@localhost:5432/app'
safe-migrate sync
safe-migrate cache inspect
safe-migrate lint --file migrations/001_add_status.sql
safe-migrate lint-chain --dir migrations/sync writes .safe-migrate.cache by default. Use a least-privilege database
role that can read the required PostgreSQL catalogs, and keep DATABASE_URL
out of source control.
Direct remote connections are rejected. Connect through localhost, a Unix socket, or an SSH tunnel:
ssh -N -L 5433:db.internal:5432 bastion
export DATABASE_URL='postgres://readonly_user@localhost:5433/app'
safe-migrate synclint and lint-chain do not connect to PostgreSQL unless auto_sync = true
is configured.
--no-cache runs the parser and state machine without a verified database
baseline. Findings use Tainted confidence, meaning some database evidence is
missing, and settings such as migration timeouts remain unknown.
SQL alone cannot show the existing schema, table statistics, dependencies,
role and search-path context, or inherited timeout settings. sync captures
that baseline once, so later lint runs are offline and review the same state.
The cache records schema, dependency, privilege, and statistics metadata, but never connection credentials or password hashes. Treat it as sensitive and do not publish it.
Sync with the database, role, and defaults used by the migration runner; it only reads them. Refresh the cache when that baseline changes. If the runner does not already enforce timeouts, put them explicitly in the migration:
SET lock_timeout = '5s';
SET statement_timeout = '15min';lock_timeout should be positive and shorter than a positive
statement_timeout; otherwise PostgreSQL can reach the statement timeout
first.
| Command | Use it for | Important options |
|---|---|---|
lint --file migration.sql |
Check one migration. | --cache, --config, --no-cache, --json, --markdown |
lint-chain --dir migrations/ |
Check an ordered migration directory while carrying state forward. | --cache, --config, --no-cache, --json, --markdown |
sync |
Refresh the database baseline. | --out, --schemas, --config |
cache inspect |
Show cache provenance and redacted object counts. | --cache, --json |
rules |
Discover rules, remediation, and effective settings. | --rule, --json, --config |
--no-auto-sync suppresses configured automatic refresh for one lint or
lint-chain run. --no-color works with every command.
Use the CLI for less common options and subcommands:
safe-migrate --help
safe-migrate <command> --helpMachine-readable output, confidence values, and exit codes are defined in the CLI and report contract.
safe-migrate rules lists rule IDs, tiers, remediation, supported
configuration fields, and effective settings. Rule discovery JSON uses schema
version 2; lint JSON uses schema version 1.
safe-migrate rules
safe-migrate rules --rule require-concurrent-index
safe-migrate rules --rule require-concurrent-index --jsonUnknown IDs are errors. Pass --config to include settings from a TOML file.
lint-chain analyzes files in filename order and carries modeled schema,
transaction, search-path, and role state across statements and files. This can
catch failures caused by interactions between otherwise valid migrations.
The Tier 2 require-lock-timeout and require-statement-timeout rules apply to
statements that Squawk classifies as potentially disruptive to normal database
queries. They use the synchronized values and follow ordered SQL changes from
SET, SET LOCAL, SET ... DEFAULT, RESET, and RESET ALL, including
commit, rollback, and savepoint scope. A missing baseline is reported as
unknown evidence rather than silently treated as a configured timeout.
Findings use three tiers:
| Tier | Meaning |
|---|---|
Tier 1 — HALT |
Fix before deployment. |
Tier 2 — WARN |
Review required. |
Tier 3 — SAFE |
Informational or lower-risk. |
Reports also include confidence:
| Confidence | Meaning |
|---|---|
Exact |
Analysis stayed consistent with the supplied SQL and baseline. |
Tainted |
Baseline evidence or modeled state is incomplete or uncertain. |
Exact means exact relative to the modeled evidence; it is not a production
deployment guarantee.
- Exit
0: analysis completed without a Tier 1 finding. - Exit
1: invocation, configuration, parser, cache, I/O, or internal failure. - Exit
2: analysis completed with at least one Tier 1 finding.
Diagnostics go to standard error. JSON and Markdown reports go to standard output.
Without --config, the CLI reads safe-migrate.toml from the current
directory when it exists and otherwise uses built-in defaults. A path passed
with --config must exist and pass validation.
# Lock-sensitive size thresholds.
tier1_threshold_rows = 100000
tier2_threshold_rows = 10000
default_rows = 10000
toast_width_threshold_bytes = 2048
# Cache policy.
stale_stats_days = 7
schemas = ["public", "auth"]
auto_sync = false
cache_encryption = false
# Used only when no cache supplies a PostgreSQL version.
assume_pg_version = 100000
# Primary rule IDs disabled globally.
disabled_rules = ["missing-idempotency"]
[rules.blocking-constraint]
tier1_threshold_rows = 5000
tier2_threshold_rows = 1000
[rules.missing-idempotency]
disabled = trueEvery primary rule supports disabled; only row-sensitive rules support one or
both threshold fields. safe-migrate rules --json lists the supported fields
for each rule. Unknown settings, unsupported fields, and unknown primary rule
IDs are errors.
Use a primary rule ID in a SQL comment to suppress that rule for one statement or the whole file:
-- safe-migrate: ignore(require-concurrent-index)
CREATE INDEX users_email_idx ON users (email);
/* safe-migrate: ignore-file(missing-idempotency) */Keep suppressions narrow and explain the operational reason in the migration or its review.
auto_sync = true refreshes the cache before lint and lint-chain. There is
no command-line flag to enable it. Use --no-auto-sync to suppress it for one
lint run. If refresh fails, safe-migrate prints the cause and continues with the
previous readable cache; the old cache is replaced only after a new cache has
been written successfully. --no-cache also bypasses automatic sync. The
existing cache must use a supported format; otherwise rerun sync once
database access is available.
Set cache_encryption = true and provide a 32-byte key as 64 hexadecimal
characters:
export SAFE_MIGRATE_CACHE_KEY='64 hexadecimal characters'
safe-migrate syncThe key is accepted only through the environment. Encrypted mode rejects
plaintext caches, and plaintext mode rejects encrypted caches. Changing modes
requires a fresh sync.
When safe-migrate encounters an unsupported cache format, rebuild it from the database:
safe-migrate syncCache formats are checked before use. If a format is unsupported, rebuild the
cache with safe-migrate sync; no migration SQL is changed.
Use safe-migrate cache inspect to view cache provenance and redacted object
and role counts without connecting to PostgreSQL. It never lists role names or
membership edges.
The Action uses a baseline: one cache file containing a snapshot of your database metadata. The Action manages that file and its GitHub cache entry for you.
Trusted default-branch job
PostgreSQL -> sync -> runner baseline file -> GitHub Actions cache
Pull-request job
GitHub Actions cache -> runner baseline file -> lint-chain -> reports
The trusted workflow refreshes the baseline. Pull-request workflows restore it
and run lint-chain without connecting to PostgreSQL.
Run this after checkout in a trusted default-branch workflow. PostgreSQL must
be reachable through localhost or a Unix socket; keep its URL in a secret. We
recommend encrypting the saved baseline: it contains schema and role metadata,
and GitHub cache contents are not signed. Store a 64-character hexadecimal key
as SAFE_MIGRATE_CACHE_KEY and pass it to both workflows.
- uses: dsecurity49/safe-migrate@v0.7.0
env:
DATABASE_URL: ${{ secrets.SAFE_MIGRATE_DATABASE_URL }}
SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }}
with:
path: migrations
sync: "true"
schemas: public
encrypted-cache: "true"Replace public with the schemas that contain your migrations, or omit
schemas to synchronize all non-system schemas.
Add this after checkout in the pull-request workflow:
- uses: dsecurity49/safe-migrate@v0.7.0
env:
SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }}
with:
path: migrations
encrypted-cache: "true"Do not set sync: "true" here, and do not add actions/cache. The Action
restores the baseline itself, passes it to lint-chain, and publishes the
report. Fork pull requests do not receive the encryption key, so they lint
without the baseline and report Tainted confidence.
For TOML configuration, encrypted caches, named baselines, and complete workflows, see the GitHub Action guide.
Dual-licensed under MIT or Apache-2.0.