pgevolve
Postgres-specific declarative schema management.
pgevolve treats a directory of CREATE-style SQL files as the source of
truth for one or more Postgres schemas, introspects a live database to
derive its current state, and computes ordered, dependency-aware migration
plans that bring the database to the desired state. It refuses to lose
data unless explicitly authorized in a per-plan intent file.
Status: v0.3.3 is the current release. The v0.2 sub-spec series (views/MVs, types, extensions, functions/procedures, triggers, declarative partitioning) is complete, and the v0.3 cross-cutting state series (cluster roles, GRANT/REVOKE, row-level security, table/index/MV storage parameters) is complete through v0.3.3. See
CHANGELOG.mdfor what's in each version.
Install
From crates.io (recommended):
From source:
pgevolve requires Rust 1.95+.
Usage at a glance
# 1. Scaffold a project (creates `pgevolve.toml`, `schema/`, `plans/`).
# 2. Author your schema as CREATE-style SQL under `schema/`.
# File layout is enforced by the `layout_profile` setting; the default
# (`schema-mirror`) wants `schema/<schema>/<kind>/<name>.sql`.
# 3. Plan a migration against a configured environment.
# wrote plans/2026-05-12-<short-id>/{plan.sql, intent.toml, manifest.toml}
# 4. Review the generated plan.sql + intent.toml in code review.
# 5. Apply it.
CLI surface
| Command | What it does | Touches DB | Writes files |
|---|---|---|---|
pgevolve init |
Scaffold project files | no | yes |
pgevolve lint |
Run universal + layout-profile rules | no | no |
pgevolve validate |
Parse + build source IR; with --shadow, round-trip the IR through ephemeral Postgres; --shadow-validate cross-checks dep graph against pg_depend |
shadow only | no |
pgevolve diff --db <env> |
Build source + catalog IR; print change set (`--format=human | json | sql); --shadow-validate` opt-in |
pgevolve plan --db <env> [-o <dir>] |
Full pipeline; write plan directory; gates on unwaived LintAtPlan findings; --shadow-validate opt-in |
read-only | yes |
pgevolve apply <plan-dir> --db <env> |
Execute plan | read+write | no |
pgevolve status --db <env> |
Show recent applies and per-step state | read-only | no |
pgevolve dump --db <env> -o <dir> |
Introspect live DB and write source SQL | read-only | yes — v0.1.x |
pgevolve bootstrap --db <env> |
Install/upgrade the pgevolve metadata schema |
read+write | no |
pgevolve graph |
Render source dep graph (--graph-format dot|mermaid); no DB required |
no | optional (-o) |
pgevolve doctor --db <env> |
Health check: bootstrap status, NOT VALID constraints, INVALID indexes, object counts, recent failures | read-only | no |
pgevolve rewrite-table <qname> --db <env> --confirm-rewrite |
Destructive table rewrite — v0.2 skeleton; not yet implemented | — | — |
Exit codes follow spec §13: 0 success, 1 lint/validation error, 2
drift or pre-flight mismatch, 3 apply error, 4 config or CLI input
error.
Configuration
pgevolve.toml:
[]
= "myapp"
= "schema"
= "plans"
= "schema-mirror" # also: kind-grouped | feature-grouped | free-form | <path-to-custom.toml>
[]
= ["app", "billing"] # schemas under pgevolve's control
= ["app.legacy_etl_*"] # qname or glob
[]
= "online" # "atomic" | "online"
[]
= true
= true
= true
= true
[]
= "postgres://localhost/myapp_dev"
[]
= "DATABASE_URL_PROD" # read DSN from env var
= "online"
Connection precedence (mirrors psql):
--url CLI flag → [environments.<env>].url →
[environments.<env>].url_env → PGEVOLVE_DATABASE_URL →
libpq env (PGHOST, PGUSER, ...).
Documentation
docs/user/— user guide: installation, getting started, configuration reference, command reference, cookbook, troubleshooting, plan-format walkthrough. Start here if you're operating pgevolve on a project.docs/system/— architecture and internals: the dedicated architecture chapter, deep dives on the IR, planner, and executor. Start here if you want to understand how pgevolve works under the hood.docs/spec/— living capability catalogue: every object kind, column type, constraint, index option, CLI command, etc., with its implementation status (Implemented / Partial / Planned / Future / Not planned). Start here to find out whether pgevolve does / will do a given thing.docs/superpowers/specs/2026-05-09-pgevolve-design.md— original v0.1 design doc.docs/superpowers/plans/— phase-by-phase implementation plans.
v0.1 phase progress
| Phase | Title | Status |
|---|---|---|
| 0 | Workspace | done |
| 1 | IR | done |
| 2 | Source parser | done |
| 3 | Catalog reader | done |
| 4 | Differ | done |
| 5 | Planner | done |
| 6 | Rewrites | done |
| 7 | Plan format | done |
| 8 | Executor | done |
| 9 | CLI | done |
| 10 | Linter | done |
| 11 | Testkit | done |
| 12 | Shadow | done |
v0.2 sub-spec progress
Per the arch-readiness spec §16, v0.2 ships as a sequence of per-object-family sub-specs on the v0.2-readiness foundation.
| # | Sub-spec | Status |
|---|---|---|
| 0 | Architecture readiness (foundation) | ✅ Landed 26d8ebc..ec774ff |
| 1 | Views and materialized views | ✅ Landed 0e2a7a0; T13 shadow-validate b96899f |
| 2 | Types (enums, domains, composites) | ✅ Landed 6127bdd |
| 3 | Extensions | ✅ Landed c17b3bc..8522c95 |
| 4 | Functions and procedures | ✅ Landed 3c3f6ee |
| 5 | Triggers | ✅ Landed 25ca3a5 |
| 6 | Declarative partitioning + table reloptions | ✅ Landed fbff481 |
v0.3 sub-spec progress
v0.3 ships the cross-cutting state series: each release adds a new dimension of state that attaches to existing IR objects (rather than introducing new object kinds). All four sub-specs share the same design pattern — typed Option<T> fields with a lenient drift policy: source None means "unmanaged, never reset," surfaced via per-feature unmanaged-* lints rather than destructive changes.
| # | Sub-spec | Status |
|---|---|---|
| 0 | Cluster surface: roles, CREATE USER, role membership, ClusterCatalog |
✅ v0.3.0 |
| 1 | GRANT / REVOKE on all 8 grantable object kinds + column-level grants + ALTER DEFAULT PRIVILEGES |
✅ v0.3.1 |
| 2 | Row-level security: per-table rls_enabled / rls_forced + embedded policies: Vec<Policy> |
✅ v0.3.2 |
| 3 | Storage parameters: typed *StorageOptions on Table / Index / MaterializedView + unmanaged-reloption lint |
✅ v0.3.3 |
Next on the agreed v0.3 roadmap: PUBLICATION / SUBSCRIPTION (logical replication), CREATE VIEW WITH CHECK OPTION, aggregates, and CREATE STATISTICS.
v0.2 views/MVs — what's in 0e2a7a0
| Feature | Status |
|---|---|
Views (CREATE VIEW, DROP VIEW, CREATE OR REPLACE VIEW) |
✅ Implemented |
Materialized views (CREATE MATERIALIZED VIEW, REFRESH [CONCURRENTLY]) |
✅ Implemented |
AST body canonicalization (NormalizedBody::from_sql) |
✅ Implemented |
| OR-REPLACE compatibility predicate | ✅ Implemented |
| Dependent-view recreation cascade (transitive, topologically ordered) | ✅ Implemented |
Online rewrite: REFRESH CONCURRENTLY (when unique index present) |
✅ Implemented |
[[step_override]] in intent.toml |
✅ Implemented |
3 new lint rules (view-shadows-table, mv-no-unique-index, view-body-references-unmanaged-schema) |
✅ Implemented |
| 15 conformance fixtures (views, MVs, intent, dep-chains) | ✅ Implemented |
--shadow-validate cross-check extended for view bodies |
📋 Deferred past v0.2.0 — plan filed |
v0.2 types — what's in 6127bdd
| Feature | Status |
|---|---|
Enum types (CREATE TYPE … AS ENUM, ALTER TYPE … ADD VALUE, RENAME VALUE) |
✅ Implemented |
Domain types (CREATE DOMAIN, ALTER DOMAIN ADD/DROP CONSTRAINT, SET/DROP DEFAULT, SET/DROP NOT NULL) |
✅ Implemented |
Composite types (CREATE TYPE … AS (…), ALTER TYPE … ADD/DROP/ALTER ATTRIBUTE) |
✅ Implemented |
ReplaceWithCascade fallback (drop + recreate when in-place ALTER is unsafe) |
✅ Implemented |
NodeId::Type + dep-graph edges (type → column, type → type) |
✅ Implemented |
| Catalog reader for all three type families | ✅ Implemented |
4 new lint rules (type-shadows-table, enum-value-collision, composite-attribute-collision, domain-check-references-unmanaged-type) |
✅ Implemented |
| 20 conformance fixtures (enums, domains, composites, cascades, lints) | ✅ Implemented |
Property test enum_add_value_preserves_existing_values (pure, #[ignore]'d) |
✅ Implemented |
v0.2 extensions — what's in c17b3bc..8522c95
| Feature | Status |
|---|---|
CREATE EXTENSION [IF NOT EXISTS] name [WITH SCHEMA s] [VERSION 'v'] parser |
✅ Implemented |
Catalog reader for pg_extension (joined with pg_namespace, pg_description) |
✅ Implemented |
pg_depend deptype='e' filter on tables/indexes/sequences/views/MVs/types/functions queries |
✅ Implemented |
ExtensionChange variants: Create, Drop, AlterUpdate, ReplaceWithCascade, CommentOn |
✅ Implemented |
| 4 new step kinds: CreateExtension, DropExtension (destructive), AlterExtensionUpdate, CommentOnExtension | ✅ Implemented |
Source-None symmetry for schema/version/comment (unpinned = "any") |
✅ Implemented |
NodeId::Extension + Extension → Schema dep edges |
✅ Implemented |
COMMENT ON EXTENSION parser arm |
✅ Implemented |
2 new lint rules (extension-version-unpinned, extension-references-unmanaged-schema) |
✅ Implemented |
| 11 conformance fixtures (objects + scenarios + lint integration) | ✅ Implemented |
v0.2 functions and procedures — what's in 3c3f6ee
| Feature | Status |
|---|---|
SQL functions (CREATE FUNCTION … LANGUAGE sql) — full attribute matrix |
✅ Implemented |
PL/pgSQL functions (CREATE FUNCTION … LANGUAGE plpgsql) — body parsed via pg_query::parse_plpgsql |
✅ Implemented |
Procedures (CREATE PROCEDURE) — qname-only identity |
✅ Implemented |
Function overloads — identity = (qname, NormalizedArgTypes) per arch Decision 2 |
✅ Implemented |
CREATE OR REPLACE for body / attribute changes |
✅ Implemented |
ReplaceWithCascade fallback (return-type kind change, language change, OUT-param shape change) |
✅ Implemented |
NodeId::Function + NodeId::Procedure + dep-graph edges (routine → schema, routine → type, routine → body deps) |
✅ Implemented |
Static-SQL dep extraction from PL/pgSQL bodies (SELECT INTO, embedded queries) |
✅ Implemented |
-- @pgevolve dep: <qname> directives close the dynamic-SQL gap per arch Decision 11 |
✅ Implemented |
Auto-detected COMMIT/ROLLBACK → step runs with transactional=OutsideTransaction |
✅ Implemented |
Catalog reader via pg_proc + pg_get_functiondef body re-parse (prokind IN 'f','p') |
✅ Implemented |
Unsupported-language rows surface as DriftReport::UnmanagedLanguageRoutine, skipped silently |
✅ Implemented |
3 new lint rules (pl-pgsql-dynamic-sql, procedure-contains-commit, function-references-unmanaged-schema) |
✅ Implemented |
| 22 conformance fixtures (functions, procedures, intent, scenarios) | ✅ Implemented |
Property test plpgsql_canonicalization_is_idempotent (pure, #[ignore]'d) |
✅ Implemented |
v0.2 triggers — what's in 25ca3a5
| Feature | Status |
|---|---|
CREATE TRIGGER parser — BEFORE/AFTER/INSTEAD OF, ROW/STATEMENT, WHEN clause, REFERENCING transition tables |
✅ Implemented |
CONSTRAINT TRIGGER with DEFERRABLE [INITIALLY DEFERRED|IMMEDIATE] |
✅ Implemented |
UPDATE OF column, … column-list variant |
✅ Implemented |
COMMENT ON TRIGGER parser arm |
✅ Implemented |
Catalog reader via pg_trigger (filtered: NOT tgisinternal, NOT extension-owned) |
✅ Implemented |
TriggerChange variants: Create, Drop (destructive; intent required), CommentOn |
✅ Implemented |
Any structural diff → Drop + Create (no ALTER TRIGGER beyond rename, which is not supported) |
✅ Implemented |
3 new step kinds: CreateTrigger, DropTrigger (destructive), CommentOnTrigger |
✅ Implemented |
NodeId::Trigger + dep-graph edges (trigger → table/view/MV target; trigger → function) |
✅ Implemented |
2 new lint rules (trigger-references-unmanaged-table, trigger-references-unmanaged-function) |
✅ Implemented |
Conformance fixtures (Tier C): objects/triggers/ covering create/drop/comment and all trigger variants |
✅ Implemented |
v0.2 partitioning — what's in fbff481
| Feature | Status |
|---|---|
partition_by: Option<PartitionBy> field on Table — strategy (Range/List/Hash) + key columns/expressions |
✅ Implemented |
partition_of: Option<PartitionOf> field on Table — parent reference + partition bounds |
✅ Implemented |
ir/partition.rs — PartitionBy, PartitionOf, PartitionStrategy, PartitionColumn, PartitionBounds (Range/List/Hash/Default), BoundDatum (Literal/MinValue/MaxValue) |
✅ Implemented |
Source Form 1: CREATE TABLE child PARTITION OF parent FOR VALUES … — declarative inline form |
✅ Implemented |
Source Form 2: standalone CREATE TABLE child PARTITION OF parent … in separate file |
✅ Implemented |
Source Form 3: ALTER TABLE parent ATTACH PARTITION child FOR VALUES … form (Form 2 + attach) |
✅ Implemented |
| Form 2 and Form 3 unify into the same IR; equivalence verified by conformance fixture | ✅ Implemented |
Sub-partitioning: PARTITION BY on a child that is itself PARTITION OF another parent |
✅ Implemented |
Catalog reader — pg_class.relkind='p' + pg_get_partkeydef for parents; relispartition + pg_get_expr(relpartbound) for children |
✅ Implemented |
TableChange::AttachPartition + TableChange::DetachPartition differ variants |
✅ Implemented |
| Rebound partition (bounds change) → DetachPartition + AttachPartition | ✅ Implemented |
Parent partition_by rekey → UnsupportedDiff (no safe in-place path) |
✅ Implemented |
| Child column/constraint diff suppressed when either side is a partition | ✅ Implemented |
2 new step kinds: AttachPartition, DetachPartition (destructive; intent required) |
✅ Implemented |
| Dep edge: child partition → parent table (child created after parent) | ✅ Implemented |
partition-references-unmanaged-parent lint rule (Error) |
✅ Implemented |
| 14 conformance fixtures: create range/list/hash parents, add/drop/replace partitions, attach-from-standalone, Form 2 vs Form 3 equivalence, sub-partitioning, reject paths, lint | ✅ Implemented |
Workspace layout
crates/pgevolve-core— IR, parser, diff, planner, plan I/O, lint engine. Pure library; no I/O at the type level (the parser owns the only filesystem walk).crates/pgevolve— CLI binary + executor. Holds thetokio_postgresadapter, advisory lock, audit writers, and the apply loop.crates/pgevolve-testkit— internal test infra:EphemeralPostgres(testcontainers wrapper),PgCatalogQuerier, IR generator and mutator, equivalence asserter, migration-fixture loader.crates/pgevolve-conformance— deterministic fixture-driven conformance suite: one directory per fixture, asserts diff / plan / plan.sql golden / apply roundtrip. New goldens viacargo xtask bless --conformance.xtask—cargo xtask blessregenerates tier-3 catalog goldens.
Test tiers
| Tier | Where | Runs | Needs Docker | CI gate |
|---|---|---|---|---|
| 1 | unit tests in src/ |
cargo test --workspace --lib |
no | yes |
| 2 | parser/IR fixture corpora | cargo test --workspace --tests |
no | yes |
| 3 | catalog round-trip goldens | cargo test --workspace --tests |
yes | yes |
| C | conformance suite (crates/pgevolve-conformance) |
cargo test -p pgevolve-conformance |
yes (apply layer) | yes — canonical CI gate |
| 5 | property tests | cargo test --workspace --tests -- --ignored |
partial | no — nightly only |
| 7 | weekly soak | manual / cron | yes | no |
Tier C is the canonical CI gate; every deterministic correctness
expectation lives there as a fixture. Fixture authoring subtrees under
crates/pgevolve-conformance/tests/cases/:
| Subtree | Purpose |
|---|---|
objects/ |
Per-object-kind DDL coverage (tables, indexes, sequences, etc.) |
scenarios/ |
Multi-step migration scenarios (add column, reorder, FK cycle, etc.) |
intent/ |
Destructive-intent approval and waiver flows |
failure/ |
Error paths: bad SQL, cycle, NOT VALID drift, INVALID index |
regressions/ |
Fixtures captured from property-test failures |
Layers L1–L9 within each fixture:
| Layer | What it asserts |
|---|---|
| L1 | Parse (no panics, canonical IR) |
| L2 | Lint (finding set) |
| L3 | Diff (ChangeSet golden) |
| L4 | Plan order (OrderedChangeSet golden) |
| L5 | Rewrite (RawStep golden) |
| L6 | Plan SQL golden (plan.sql) |
| L7 | Apply round-trip (apply + re-diff → empty) |
| L8 | Dep-graph golden (DOT output from pgevolve graph) |
| L9 | Doctor output golden |
Backend selection for Docker-gated layers: set
PGEVOLVE_TEST_PG_MODE=testcontainers|compose|dsn.
testcontainers(default) — pullspostgres:<major>-alpine.compose— connects to a running container started bydev/docker-compose.pg.yml(faster in local dev).dsn— connects to an explicit DSN fromPGEVOLVE_TEST_PG_URL.
Set PGEVOLVE_DISABLE_DOCKER_TESTS=1 to skip every Docker-gated test;
the suite skips cleanly when docker info fails.
Regenerate tier-3 catalog goldens with cargo xtask bless (runs against
ephemeral containers per PG major). Regenerate tier-C conformance goldens
with cargo xtask bless --conformance.
Dependencies
The workspace deliberately avoids unmaintained / archived crates. Notable choices:
- Parsing:
pg_query(official Postgres parser bindings). - Hashing:
blake3— plan id, target identity, and IR canonical hashing. - Encoding: bincode for the plan-id input hash payload (binary &
deterministic), serde_json for the embedded catalog snapshot in
manifest.toml.serde_yamlis deliberately not used (upstream archived it in 2024). - CLI: clap v4 derive.
- Async runtime: tokio multi-thread; tokio-postgres for the executor.
- Property tests: proptest v1, with
EphemeralPostgresfrom testcontainers for tier-4/5.
License
Dual-licensed under MIT or Apache-2.0, at your option.
License + dependency policy
pgevolve is dual-licensed under MIT or Apache-2.0.
All dependencies must be MIT/Apache-2.0/BSD-style licensed (no copyleft); the
policy is enforced by cargo deny check (see deny.toml) and verified
in CI. Only permissive licenses are allowed: MIT, Apache-2.0, BSD-2-Clause,
BSD-3-Clause, ISC, Unicode-DFS-2016, Unicode-3.0, CC0-1.0, and Zlib. Vulnerable
and yanked crates are denied; the check enforces that all dependencies come
from crates.io.