Keep your PostgreSQL schema in version control.

pglifecycle reads a PostgreSQL database into plain YAML, with one file for each object. It validates those files against JSON Schema and builds archives that work with pg_restore.

Single static binary No runtime, no deps
~/app · zsh
$ pglifecycle pull -h db.internal -U postgres -d app schema/ --exclude-schema pgq
pglifecycle v2.0.0-alpha.0 Creating postgres@db.internal → schema/

Created schema/ from postgres@db.internal with 3036 objects:

  37  schemas          278  sequences         80  views
  13  extensions      1734  tables            11  materialized views
   3  domains          523  functions        191  users
  62  types            104  roles

  $ git diff --stat
schema/tables/public/users.yaml  | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
         

Review a schema change as an ordinary Git diff.

01: The problem Reviewing current state

Migration files record changes, but the current schema can be difficult to inspect.

When a table has changed across many numbered SQL files, understanding its current definition means following the full migration history or inspecting a database. pglifecycle adds a checked-in representation of the resulting schema.

Migrations: the log

The definition is spread across migrations

The current shape of users may depend on several SQL files. A reviewer has to combine those changes to understand the complete table.

0001_create_users.sql
0014_add_email_citext.sql
0039_drop_legacy_name.sql
current state of users = ???
pglifecycle: the state

The complete definition is in one file

pglifecycle stores each object as structured data. The file for users contains the table definition that will be used when the project is built.

tables/public/users.yaml
tables/public/orders.yaml
roles/PUBLIC.yaml
current state of users = read the file
You can continue to use migrations for deployment. pglifecycle provides a separate, reviewable description of the schema and a round-trip check for keeping it accurate.
02: The commands

The workflow uses four commands.

Create a project, pull a live database into YAML, build those files into a restorable archive, or deploy the differences to a database.

pull

Connect to a database and write its objects to a schema directory. pglifecycle uses the PostgreSQL catalog information exposed through pg_dump.

  • Tables, views, functions, sequences, types, roles, and grants.
  • Parses SQL bodies with tree-sitter and formats them before writing.
  • Includes cluster roles and users; password hashes are omitted by default.
  • With --update, rewrites only files whose content has changed.
pglifecycle pull
$ pglifecycle pull -h db.internal -U postgres -d app schema/ --exclude-schema pgq

pglifecycle v2.0.0-alpha.0 Creating postgres@db.internal → schema/

Created schema/ from postgres@db.internal with 3036 objects:

    37  schemas          278  sequences         80  views
    13  extensions      1734  tables            11  materialized views
     3  domains          523  functions        191  users
    62  types            104  roles
            

build

Compile the schema directory into an archive accepted by pg_restore. libpgdump orders the objects using PostgreSQL's dependency information.

  • Resolves dependency order through libpgdump.
  • Produces a stable restore order for unchanged YAML.
  • Writes a custom-format archive for pg_restore.
pglifecycle build
$ pglifecycle build # files → archive
    schema/ build.dump
 
  topological sort … 3036 objects
  wrote build.dump
   restore-order verified

create

Create an empty project directory. Use it as the destination for pull, or add schema objects by hand.

  • Creates the full directory layout and a project.yaml config.
  • Sets encoding, superuser, and standard-conforming strings.
  • Adds .gitkeep files so empty dirs survive your first commit.
pglifecycle create
$ pglifecycle create # scaffold → disk
    my-project/
 
  created my-project/
   project.yaml · tables/ · views/ · roles/
   functions/ · schemata/ · sequences/

deploy

Compare the project with a live database and generate the DDL needed to reconcile them. Output goes to stdout or -o FILE. With --apply, pglifecycle runs the script through psql in one transaction.

  • Reconciled in place where PostgreSQL allows it: columns, constraints, indexes, triggers, CREATE OR REPLACE for functions and views, ALTER TYPE … ADD VALUE for enums.
  • Destructive statements are excluded unless you pass --allow-drop, and --apply refuses while any are pending.
  • Compare against a pg_dump -Fc file instead of a live connection with -D.
pglifecycle deploy
$ pglifecycle deploy # diff → DDL
    -d app_production -o deploy.sql schema/
 
  Comparing app against postgres@localhost/app_production
  Plan: 7 statement(s) included, 1 excluded
  wrote deploy.sql
 
$ psql --single-transaction -v ON_ERROR_STOP=1 -f deploy.sql
03: The proof Round-trip verification

Check the generated archive against the source database.

Start with a database dump, convert the database to YAML, build and restore it, then dump the restored database. Comparing the two archive listings checks that the same objects appear in the same restore order.

pg_dump
live database
pglifecycle pull
→ schema/*.yaml
pglifecycle build
→ build.dump
pg_restore
→ fresh database
pg_dump
→ re-dump
diff <(pg_restore -l original.dump) <(pg_restore -l roundtrip.dump) → 0 differences
An empty diff means the two archive listings contain the same objects in the same order.
# .github/workflows/schema.yml
roundtrip-test ✓ passed
round-trip · CI gate
$ 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
 
  # run this comparison in CI to detect a round-trip difference
04: No manifest Dependency ordering

Object dependencies determine the restore order.

pglifecycle derives most dependencies from the schema objects and passes them to libpgdump for topological sorting. You do not have to maintain a separate, hand-ordered manifest.

dependency graph resolved automatically · 0 manifests
role schema users index grant view orders fk
ordered with pg_dump's topological sort
No separate ordering manifest

There is no order.txt or set of numbered prefixes. Most relationships come from the objects themselves. A dependencies: key handles relationships that cannot be inferred.

Dependencies come from the objects

Views, grants, and foreign keys already refer to other objects. pglifecycle uses those references to construct the dependency graph.

Topological sorting from pg_dump

During a build, libpgdump uses the topological sorting behavior from pg_dump to place objects in restore order.

05: Design

Structured schema files with PostgreSQL-compatible output.

The project format is intended for version control, while the build process stays compatible with PostgreSQL's archive and restore tools.

Schema as data

Schema objects are stored as YAML, where they can be reviewed, queried, linted, or generated with existing tools.

A JSON-Schema contract

Each object is validated against the published JSON Schema when the project loads, so invalid fields and shapes are reported before a build or deploy.

Native pg_dump fidelity

Builds use the pg_dump archive format and its topological ordering, and the result can be restored with pg_restore.

One file per object

Each table, view, function, and role has its own file, keeping diffs and Git history scoped to the object that changed.

First-class roles & grants

Roles, memberships, and per-object ACLs, including PUBLIC, are stored in the project alongside schema objects.

Auto-formatted SQL bodies

libpgfmt normalizes function, view, and trigger bodies so formatting differences do not dominate a diff.

A single Rust binary

pglifecycle is distributed as a static binary for Linux and macOS. It does not require an interpreter or a project-level runtime environment.

06: Project files

Examples from a schema directory.

These examples show a table, the ACL for the PUBLIC role, and the directory layout produced and consumed by pglifecycle.

schema/tables/public/users.yaml
# schema/tables/public/users.yaml
name: users
schema: public
owner: app
comment: Application users
columns:
  - name: id
    data_type: BIGINT
    nullable: false
    generated:
      sequence_behavior: ALWAYS
  - name: email
    data_type: citext
    nullable: false
  - {name: full_name, data_type: TEXT}
  - name: created_at
    data_type: TIMESTAMP WITH TIME ZONE
    nullable: false
    default: CURRENT_TIMESTAMP
primary_key: id
indexes:
  - name: users_email_key
    columns:
      - name: email
    unique: true
dependencies:
  extensions:
    - citext

A complete table definition

The columns, generated identity, primary key, indexes, and comment are kept together in the table's YAML file.

pull writes this representation from a database. build converts it into the statements stored in the output archive.

  • Validated against the JSON Schema when the project loads.
  • The file path implies schema and name, so both are optional in the body.
schema/roles/PUBLIC.yaml
# schema/roles/PUBLIC.yaml
# The PUBLIC pseudo-role, locked down by default.
name: PUBLIC
create: false   # defined, never created
revocations:
  schemata:
    public:
      - ALL
  databases:
    app:
      - ALL
grants:
  databases:
    app:
      - CONNECT

Review access granted to PUBLIC

The implicit PUBLIC role has its own versioned file, so changes to its grants and revocations appear in pull requests.

Grants and revocations are grouped by object type and used to reproduce the ACL during a build.

  • ACLs live on the grantee, so one file shows everything a role can reach.
  • Changes to access control show up in git blame like any other change.
schema/: project tree
schema/ ├── project.yaml # name, encoding, extensions, languages ├── schemata/ │ └── public.yaml ├── roles/ │ ├── PUBLIC.yaml │ ├── app_readwrite.yaml │ └── app_readonly.yaml ├── users/ │ └── app.yaml ├── tables/ │ └── public/ │ ├── users.yaml │ ├── orders.yaml │ └── sessions.yaml ├── views/ │ └── public/ │ └── active_users.yaml ├── functions/ │ └── public/ │ └── set_updated_at.yaml └── sequences/ └── public/ └── orders_id_seq.yaml

Directory layout

Objects are grouped first by kind and then by schema, as in tables/public/users.yaml. You can browse the project without connecting to a database.

The binary validates every file against its JSON Schema when the project loads.

  • A column change is normally contained in one table file.
  • Directory paths can be used with CODEOWNERS for separate review rules.
07: Under the hood Rust libraries

The CLI is built on three supporting crates.

The parser, archive handling, and SQL formatting are implemented in separate, published libraries.

An incremental parser for the PostgreSQL SQL dialect. It parses function, view, and check bodies into syntax trees for validation and formatting.

Reads and writes the pg_dump archive format and implements its topological object ordering for generated archives.

A formatter for PostgreSQL SQL that normalizes embedded bodies to a consistent style before they are written to YAML.

Rewritten in Rust v2

Version 2 rewrites the CLI in Rust and distributes it as a static binary. The project files are validated against a defined schema.

Version 1 was written in Python and has managed AWeber's production schema for more than seven years. Version 2 retains the existing command names and schema-directory workflow.

Open source BSD 3-Clause

pglifecycle is available under the BSD 3-Clause license, the same license used by PostgreSQL.

The original implementation has been used with AWeber's production schema for more than seven years.

Try pglifecycle with an existing database.

Install the binary, pull the database into a schema directory, and review the generated files.

Getting started guide

install · Homebrew
$ brew tap gmr/postgres
$ brew install pglifecycle
$ pglifecycle --version
  pglifecycle 2.0.0-alpha.0

Homebrew 6.0+ may require trusting the tap first: brew trust --formula gmr/postgres/pglifecycle

install · Cargo
$ cargo install pglifecycle
$ pglifecycle --version
  pglifecycle 2.0.0-alpha.0
install · Docker
$ docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/project" \
    ghcr.io/gmr/pglifecycle:latest build my-project/ mydb.dump

The image bundles the PostgreSQL 17 client tools pglifecycle shells out to, and works in /project. It runs as uid 65532, so --user hands the container your own uid for a bind-mounted directory you own.

Pre-built binaries for Linux and macOS (x86_64 and aarch64) are attached to each release. No Rust toolchain required.

Browse releases