safe-migrate finds risky PostgreSQL migrations before they reach production. It parses SQL, simulates schema changes, and checks the result against a synchronized database snapshot. It never executes migration SQL.
PostgreSQL 14–18 are supported.
With Rust installed:
cargo install safe-migrate --lockedPrebuilt binaries are available from GitHub Releases. The installer verifies release checksums:
VERSION='v0.9.0'
curl -fsSL "https://raw.githubusercontent.com/dsecurity49/safe-migrate/${VERSION}/install.sh" |
bash -s -- --version "${VERSION}"Download install.sh first and run sh install.sh --help to review destination
and target options.
export DATABASE_URL='postgres://readonly_user@localhost:5432/app'
safe-migrate sync
safe-migrate lint-chain --dir migrationssync writes .safe-migrate.cache. Later checks use that snapshot offline.
Run safe-migrate cache inspect to view its provenance and redacted contents.
The 29 built-in rules cover:
- blocking locks, table rewrites, constraints, indexes, partitions, and materialized-view refreshes;
- destructive changes, cascades, schema drift, dependency breakage, and migration ordering conflicts;
- grants, policies, disabled triggers, roles, and privilege-sensitive changes;
- missing timeouts, transaction-incompatible operations, dynamic SQL, volatile defaults, and rerun safety.
Run safe-migrate rules for the catalog or inspect one rule directly:
safe-migrate rules --rule require-concurrent-indexCreate and protect the safe-migrate-baseline GitHub environment, then run:
safe-migrate init github-actions --path migrations --configure-secretsThis generates a trusted baseline refresh and an offline PR check. Follow the GitHub Action guide to connect the runner and create the first baseline.
| Tier | Meaning | Default command result |
|---|---|---|
Tier1 |
Blocking safety problem | Exit 2 |
Tier2 |
Needs review | Exit 0 |
Tier3 |
Informational guidance | Exit 0 |
Operational failures—such as invalid SQL, configuration, or cache data—exit
1. Every finding includes a stable rule ID, a reason, and remediation:
[HALT] Require concurrent index (require-concurrent-index)
reason : Creating this index can block writes on a large table.
recipe : Use CREATE INDEX CONCURRENTLY outside a transaction.
Use --json for automation or --markdown for review artifacts. See the
CLI and report contract for schemas, confidence, verdicts,
and compatibility guarantees.
| Command | Purpose |
|---|---|
lint --file migration.sql |
Check one migration. |
lint-chain --dir migrations/ |
Check ordered migrations with state carried forward. |
sync |
Refresh the database baseline. |
cache inspect |
Show baseline provenance and redacted counts. |
rules |
Browse rules and effective settings. |
init github-actions --path migrations/ |
Generate the GitHub integration. |
init cache-key |
Generate a cache-encryption key. |
Run safe-migrate <command> --help for every option.
sync reads PostgreSQL catalogs in a read-only, repeatable-read transaction.
Direct remote connections are rejected; use localhost, a Unix socket, or a
trusted tunnel:
ssh -N -L 5433:db.internal:5432 bastion
export DATABASE_URL='postgres://readonly_user@localhost:5433/app'
safe-migrate syncThe snapshot reflects the connected role and its session defaults. Choose between a restricted catalog reader and the real migration role based on the accuracy and credential tradeoff described in the Action guide.
The cache contains infrastructure metadata, including schema, roles, privileges, dependencies, and statistics. It contains no credentials, password hashes, or subscription connection strings, but should still be treated as sensitive.
--no-cache is an explicit degraded mode for parser investigation and limited
SQL-only checks. Existing objects are unknown, so confidence is Tainted and
many findings become conservative.
Most projects can start with the built-in defaults. Place overrides in
safe-migrate.toml:
schemas = ["public", "auth"]
tier1_threshold_rows = 100000
[rules.missing-idempotency]
disabled = trueUnknown settings and rule IDs are rejected. safe-migrate rules --json lists
the configuration supported by each rule.
Without a synchronized baseline, the built-in version fallback is deliberately
conservative. Set assume_pg_version only when the target is known to be
PostgreSQL 14–18; for example, assume_pg_version = 170000.
Suppress a reviewed finding with its primary rule ID:
-- safe-migrate: ignore(require-concurrent-index)
CREATE INDEX users_email_idx ON users (email);Keep suppressions narrow and explain the reason in the migration review.
If the migration runner does not already set timeouts, add them before lock-sensitive changes:
SET lock_timeout = '5s';
SET statement_timeout = '15min';Keep a positive lock_timeout shorter than a positive statement_timeout.
Rust integrations use safe_migrate::api. Load a synchronized baseline when
one is available; otherwise choose explicit conservative analysis.
use safe_migrate::api::{self, Baseline, Config};
use std::path::Path;
let config = Config::load_from_file(Path::new("safe-migrate.toml"))?;
let baseline = Baseline::load_optional(Path::new(".safe-migrate.cache"), &config)?;
let outcome = api::analyze(
&config,
"2026-09-05_add_index.sql",
"CREATE INDEX ...",
&baseline,
)?;
if outcome.should_halt() {
eprintln!("{}", outcome.markdown());
}
# Ok::<(), Box<dyn std::error::Error>>(())load_optional treats only a missing cache as unavailable; corrupt,
incompatible, or incorrectly encrypted caches remain errors. The API exposes
typed immutable findings, verdicts, evidence, baseline inspection, rule
metadata, and synchronization. Mutable parser, cache, and state-machine
internals are not public. Full API documentation is on
docs.rs.
Embedded applications can call sync_with_secrets with a validated
DatabaseUrl and optional CacheKey. This avoids changing process-wide
environment variables; the CLI continues to read secrets from its environment.
See CONTRIBUTING.md for development commands, test suites, and pull-request expectations.
Dual-licensed under MIT or Apache-2.0.