pglifecycle keeps your schema in plain YAML: One file per object, validated against a JSON-Schema contract, and round-tripped through native pg_dump / pg_restore.
pg_dump fluently.$ pglifecycle pull --dbname app_production schema/ introspecting app_production … ✓ 41 tables ✓ 12 views ✓ 9 functions ✓ 6 roles ✓ 88 grants ✓ 3 sequences validating against contract v2 … ok wrote 71 objects → schema/ $ git diff --stat schema/tables/public.users.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-)
A schema change is now a 4-line diff, not an opaque migration.
A folder of 0001_…sql, 0002_…sql is a transaction log, not a schema. To know the current shape of a table you replay history in your head, or you dump the database and read raw SQL. The declared state is never written down.
The shape of users lives scattered across dozens of append-only SQL files. Reviewers approve deltas they can't fully picture. Drift goes unnoticed.
pglifecycle inverts the model: it versions the declared state as structured data. One object, one file. The current shape of users is exactly what's on disk.
pglifecycle does four things, and they compose into everything else: scaffold a new project, read a live database into files, compile files into a restorable archive, and apply the declared state to a live database.
Point it at any reachable database. pglifecycle reads the catalog through the same paths pg_dump uses, and writes one file per object into your schema directory.
--update: Merge fresh introspection into an existing project, preserving comments and ordering. planned$ pglifecycle pull # db → files --dbname app_production --host db.internal schema/ ✓ 71 objects written to schema/ contract v2 · 0 violations
Compile the whole declaration into a pg_restore-compatible archive. Object ordering isn't ours; it comes straight from pg_dump's own topological sort, so the output restores cleanly every time.
pg_restore -Fc-compatible archive; restore with pg_restore.$ pglifecycle build # files → archive schema/ build.dump topological sort … 71 objects wrote build.dump ✓ restore-order verified
Scaffold a new empty project directory, the starting point before you run pull on an existing database or build a schema from scratch by hand.
project.yaml config..gitkeep files so empty dirs survive your first commit.$ pglifecycle create # scaffold → disk my-project/ created my-project/ ✓ project.yaml · tables/ · views/ · roles/ ✓ functions/ · schemata/ · sequences/
Diff the declared state against a live database and apply the delta with no migration files and no replay surprises. Today, deploys still run through your migration tool.
$ pglifecycle deploy # diff → apply schema/ --dbname app_production not yet available; coming in a future release
Dump a database. Pull it into YAML. Build it back. Restore it. Dump again. The two dumps are identical, and that comparison can run as an automated gate on every change.
$ pg_dump -Fc app_production -f original.dump $ pglifecycle pull --dbname app_production schema/ $ pglifecycle build schema/ roundtrip.dump $ pg_restore -d app_roundtrip roundtrip.dump $ pg_dump -Fc app_roundtrip -f roundtrip2.dump $ pg_restore -l original.dump > a.txt; pg_restore -l roundtrip2.dump > b.txt $ diff a.txt b.txt && echo "round-trip OK" round-trip OK # if the dumps ever drift, this check catches it, loudly, in CI.
Other tools make you maintain a hand-ordered list: a manifest that says "run roles, then schemas, then tables, then views." pglifecycle never asks. It reads the dependencies out of your objects and resolves them the same way Postgres does.
There's no order.txt, no numbered prefixes, no "depends_on" key for you to keep in sync. Add a foreign key and the edge appears in the graph on its own.
A view references a table; a grant references a role; a foreign key references another table. pglifecycle reads those relationships directly, using the same catalog facts Postgres itself relies on.
When it's time to build, the topological sort is borrowed straight from pg_dump via libpgdump. So "what depends on what" is decided by the same engine that has gotten it right for decades.
Plenty of tools generate SQL. pglifecycle is opinionated about exactly two things: schema is data, and the source of truth is whatever pg_dump agrees with.
Your declared state is structured YAML, not opaque SQL blobs. Query it, diff it, lint it, template it: it's just data.
Every object is validated against a published JSON-Schema before it's accepted. Typos and invalid shapes fail fast, not at deploy time.
Builds round-trip through pg_dump / pg_restore, including pg_dump's own topological sort. We don't reinvent dependency ordering.
Each table, view, function, and role is its own file. Git history per object: blameable lines, reviewable pull requests, clean merges.
Roles, memberships, and per-object ACLs (including PUBLIC) are versioned objects, not an afterthought bolted on at the end.
Function, view, and trigger bodies are normalized with libpgfmt, so diffs reflect a logic change, not someone's whitespace.
One static binary. No interpreter, no virtualenv, no dependency tree to resolve in CI. Download it, drop it on the runner, and it just runs on Linux and macOS.
Not a marketing mock-up. The actual users table, the PUBLIC role's ACL, and a real project tree, exactly as pglifecycle writes and reads them.
# schema/tables/public.users.yaml schema: public name: users owner: app columns: - name: id type: bigint nullable: false identity: always - name: email type: citext nullable: false - name: full_name type: text - name: created_at type: timestamptz nullable: false default: now() primary_key: name: users_pkey columns: [id] indexes: - name: users_email_key columns: [email] unique: true grants: - role: app_readwrite privileges: [SELECT, INSERT, UPDATE, DELETE] - role: app_readonly privileges: [SELECT]
Columns, identity, primary key, indexes, and grants all live in a single file. There's no migration to read alongside it. This is the table.
pglifecycle pull wrote this file. pglifecycle build turns it back into the exact CREATE TABLE + GRANT statements pg_dump would emit.
# schema/roles/PUBLIC.yaml # The PUBLIC pseudo-role, locked down by default. name: PUBLIC revoke: - on: SCHEMA public privileges: [ALL] - on: DATABASE app privileges: [ALL] grants: - on: DATABASE app privileges: [CONNECT] default_acls: - in_schema: public on: TABLES privileges: [] # no implicit grants to PUBLIC
In most tools the implicit PUBLIC grants are invisible until they become a security finding. Here they're a versioned file you can review in a pull request.
Revokes and grants are both explicit, so the resulting ACL is exactly what pg_dump prints, and exactly what restores.
git blame like any other change.Objects are grouped by kind, named schema.object.yaml. It reads like a filesystem because it is one. No database required to browse your schema.
pglifecycle.yaml pins the contract version, so a checkout from two years ago still validates against the schema it was written for.
pglifecycle is the CLI on top. The interesting parts are the libraries underneath, each one published and reusable on its own.
An incremental parser for the PostgreSQL SQL dialect. Function, view, and check bodies become real syntax trees, so they can be validated and reformatted rather than string-matched.
Reads and writes pg_dump's archive format and reproduces its topological object ordering: the reason a built schema restores cleanly without hand-rolled dependency logic.
A canonical formatter for PostgreSQL SQL. Normalizes embedded bodies to one stable style, so version control shows what changed in meaning, not in spacing.
v2 is a ground-up Rust rewrite: a single static binary, faster introspection, and a strict contract. Same workflow, far fewer moving parts.
pglifecycle is released under the BSD 3-Clause license: permissive, business-friendly, and the same license the PostgreSQL project itself uses.
One binary, four commands, and a round-trip test you can trust. Point it at a database and read the diff.
$ brew tap gmr/postgres $ brew install pglifecycle $ pglifecycle --version pglifecycle 2.0.0-alpha.0 · contract v2 · BSD-3
Homebrew 6.0+ may require trusting the tap first: brew trust --formula gmr/postgres/pglifecycle
$ cargo install pglifecycle $ pglifecycle --version pglifecycle 2.0.0-alpha.0 · contract v2 · BSD-3
Pre-built binaries for Linux and macOS (x86_64 and aarch64) are attached to each release. No Rust toolchain required.
Browse releases →