Getting started

Make your first schema change with pglifecycle.

This guide installs pglifecycle, pulls an existing database into YAML, changes a table, previews the generated DDL, and applies it.

Before you start

  • The PostgreSQL client tools on your PATH. pglifecycle runs pg_dump and pg_dumpall to read a cluster, and psql to apply a script.
  • A database you can read. To include roles and users, your database role must also be able to run pg_dumpall --roles-only.
  • A database you can change in step 5. Use a development database or a copy of production for your first run.
  • The usual PostgreSQL connection flags or environment variables, such as -h/PGHOST, -U/PGUSER, and -d/PGDATABASE.
Step 01

Install the binary

pglifecycle is distributed as a single static binary. Select your platform for installation options.

Confirm that the binary is available before continuing.

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

If Homebrew fails with build.rb … exited with 1, run brew trust --formula gmr/postgres/pglifecycle first.

install · Docker
# includes the PostgreSQL 17 client tools
$ docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/project" \
    ghcr.io/gmr/pglifecycle:latest --version

Pre-built Linux binaries for x86_64 and aarch64 are attached to each release.

install · Cargo
# requires a Rust toolchain
$ cargo install pglifecycle
$ pglifecycle --version
  pglifecycle 2.0.0-alpha.1

Pre-built macOS binaries are also available on the releases page.

Step 02

Pull the database into a project

pull reads the database and writes one YAML file for each object. The project directory contains project.yaml and directories for tables, views, functions, roles, and other object types. Objects that belong to a schema are grouped by schema.

Commit the project before changing it. This gives you a baseline for the changes that follow.

  • Skip objects you do not want to own with -N/--exclude-schema, -T/--exclude-table, and --exclude-extension. Each is repeatable.
  • Cluster roles and users come from pg_dumpall and are included by default. Leave them out with --no-roles, or leave privileges out entirely with -x.
  • Password hashes are omitted unless you pass --include-password-hashes. They are written to the project as plain text, so only enable it for a repository you trust.
  • To pull from an existing pg_dump -Fc file instead of a live database, pass it with -D. Roles are skipped because there is no cluster to read them from.
pglifecycle pull
$ pglifecycle pull -h localhost -U postgres -d app_production schema/
pglifecycle v2.0.0-alpha.1 Creating app_production@localhost → schema/

Created schema/ with 412 objects:

    4  schemas         38  sequences        11  views
    2  extensions      37  tables            1  materialized view
    3  types           26  functions        14  users
                                             9  roles
commit the baseline
$ git init -q # if this is a new repository
$ git add schema/
$ git commit -qm "Pull app_production into schema/"
Step 03

Change the schema in YAML

Edit the YAML file for the object you want to change. In this example, the users table gets a last_login_at column and an index. The file path identifies the schema and object name, so those fields are optional in the file.

Every file is validated against the JSON Schema for its object type when the project loads, so a typo in a key or an unknown field is reported before pglifecycle talks to a database.

The result is a small Git diff that shows the table definition being changed.

schema/tables/public/users.yaml
name: users
schema: public
owner: app
columns:
  - name: id
    data_type: BIGINT
    nullable: false
  - {name: email, data_type: citext, nullable: false}
  - name: created_at
    data_type: TIMESTAMP WITH TIME ZONE
    nullable: false
    default: CURRENT_TIMESTAMP
  - name: last_login_at
    data_type: TIMESTAMP WITH TIME ZONE
primary_key: id
indexes:
  - name: users_email_key
    columns:
      - name: email
    unique: true
  - name: users_last_login_at_idx
    columns:
      - name: last_login_at
review the change
$ git diff --stat schema/
schema/tables/public/users.yaml | 5 +++++
1 file changed, 5 insertions(+)
Step 04

Preview the DDL

deploy compares the project with a database and generates the DDL needed to make them match. Without --apply, it does not change the database. The DDL is written to standard output or to the file specified by -o.

Add -v to show a summary of the plan. Log messages go to standard error, leaving standard output available for the generated SQL.

  • Changes are reconciled in place where PostgreSQL allows it: columns, constraints, indexes, triggers, CREATE OR REPLACE for functions and views, ALTER TYPE … ADD VALUE for enums.
  • Drops and drop-and-recreate fallbacks are left out unless you pass --allow-drop. Each one excluded is reported as a warning.
  • Preview against a dump instead of a live database with -D, or leave grants out of the comparison with -x.

If you want to review the SQL in the pull request, write it to a file and commit it with the YAML change.

pglifecycle deploy · preview
$ pglifecycle deploy -v -d app_production schema/
  INFO  pglifecycle v2.0.0-alpha.1 running deploy
  INFO  Comparing app_production against localhost:5432/app_production
  INFO  Plan: 2 statement(s) included, 0 excluded

-- pglifecycle deploy
-- project: app_production
-- source: localhost:5432/app_production
-- destructive statements: none

-- TABLE public.users
ALTER TABLE public.users
    ADD COLUMN last_login_at timestamp with time zone;

-- INDEX public.users_last_login_at_idx
CREATE INDEX users_last_login_at_idx ON public.users USING btree (last_login_at);
keep the script
$ pglifecycle deploy -d app_production -o deploy.sql schema/

# or compare against a dump rather than a live database
$ pglifecycle deploy -D app_production.dump -o deploy.sql schema/
Step 05

Apply the change

With --apply, pglifecycle runs the generated script through psql using --single-transaction and ON_ERROR_STOP=1. If a statement fails, the transaction is rolled back.

  • --apply refuses to run while destructive statements are pending. Review them, then re-run with --allow-drop to include them.
  • --apply cannot be combined with -D: there is nothing to apply to in a dump file.
  • Prefer your own psql invocation? Keep the script from step 4 and run it yourself.

To verify the result, pull the database again with --update. It rewrites only files whose contents changed. If git diff has no output, the database and project match.

pglifecycle deploy --apply
$ pglifecycle deploy -v -d app_production --apply schema/
  INFO  Comparing app_production against localhost:5432/app_production
  INFO  Plan: 2 statement(s) included, 0 excluded
  INFO  Applying 2 statement(s)
   Deploy applied successfully
verify against the database
$ pglifecycle pull -d app_production --update schema/
pglifecycle v2.0.0-alpha.1 Updating app_production@localhost → schema/

$ git diff --stat schema/
  (no output: the project matches the database)

Test the deployment against a copy of production before applying it to the production database.

Git and CI

Where Git fits

A pglifecycle project is a directory of text files, so it works with the same Git workflow you use for code:

  1. Create a branch, edit the object's YAML, and commit the change.
  2. For the pull request, run deploy without --apply against a copy of the target database. Review the generated DDL with the YAML change.
  3. On merge, run the same command with --apply.
  4. Run pull --update against the live database periodically. A non-empty git diff shows a change made outside the project.

deploy compares the project with the database's current state. It does not replay a sequence of migrations.

A GitHub Action for installing pglifecycle is in progress. Until it is available, install a release binary or use the container image in your job.

.github/workflows/schema.yaml
name: schema
on: [pull_request]

jobs:
  preview:
    runs-on: ubuntu-latest
    container: ghcr.io/gmr/pglifecycle:latest
    steps:
      - uses: actions/checkout@v4
      - name: Preview the DDL
        env:
          PGHOST: ${{ secrets.PGHOST }}
          PGUSER: ${{ secrets.PGUSER }}
          PGDATABASE: app_staging
          PGPASSWORD: ${{ secrets.PGPASSWORD }}
        run: pglifecycle deploy -v -o deploy.sql schema/
      - uses: actions/upload-artifact@v4
        with:
          name: deploy-sql
          path: deploy.sql
the branch, end to end
$ git checkout -b users-last-login
$ $EDITOR schema/tables/public/users.yaml
$ pglifecycle deploy -d app_staging schema/ # read before committing
$ git commit -am "Track last_login_at on users"
$ git push -u origin users-last-login

Store the deploy credentials as repository secrets; pglifecycle reads the standard PG* variables, so no flags carry them.

Where to go next

You can also build a restorable archive or create a new project from scratch.

build a restorable archive
# compile the project into a pg_restore-compatible archive
$ pglifecycle build schema/ app.dump
$ createdb app_review && pg_restore -d app_review app.dump

# or start a project from nothing and write the objects by hand
$ pglifecycle create new-project/