Project Format¶
A pglifecycle project is a directory of YAML files, one file per
database object, validated against the JSON-Schema definitions in
schemata/.
my-project/
├── project.yaml # name, encoding, extensions, languages
├── schemata/ # one file per schema
│ └── test.yaml
├── tables/ # <schema>/<table>.yaml
│ └── test/
│ └── users.yaml
├── views/ # <schema>/<view>.yaml
├── materialized_views/
├── functions/ # <schema>/<function>.yaml
├── sequences/
├── domains/
├── types/ # one container file per schema
├── roles/ # <role>.yaml
├── users/
├── groups/
└── ... # aggregates, casts, collations, conversions,
# event_triggers, operators, publications,
# servers, subscriptions, tablespaces,
# text_search, user_mappings, dml
Objects are structured data, not SQL. A table file, for example:
---
name: users
schema: test
owner: postgres
columns:
- name: id
data_type: uuid
nullable: false
default: uuid_generate_v4()
- name: email
data_type: test.email_address
nullable: false
indexes:
- name: users_unique_email
unique: true
method: btree
columns:
- name: email
primary_key:
- id
Conventions¶
- The file location implies
schemaandname; both may be omitted from the file body and are injected on load. - A
dependencieskey (e.g.dependencies: {tables: [test.users]}) records relationships the topological sort cannot infer, such as foreign-key ordering between tables. - ACL grants and revocations live on the grantee's role, user, or
group file under
grants:/revocations:, keyed by object:
- Role memberships live in the same place, as
roles:/groups:arrays naming the roles the grantee is a member of.buildemits them asGRANT role TO grantee(orREVOKE ... FROMunderrevocations:). The granted role does not have to be a project file — reserved roles likepg_read_all_datacan only ever be referenced, never created, and this is how to express membership in them:
GRANT role TO
member grants it is written as a mapping instead of a bare name.
admin: true emits WITH ADMIN OPTION; inherit and set emit the
per-membership WITH INHERIT / WITH SET options, which need
PostgreSQL 16 or later. Omit inherit to defer to the member role's
own inherit option, which is what PostgreSQL does — so pull keeps
an explicit inherit: true only for a NOINHERIT member, where it
is the only thing making the membership inherit:
---
name: alice
grants:
roles:
- developers
- role: partition_writer
inherit: false
- role: analytics
admin: true
The grantor (GRANTED BY) is not carried: it records who granted a
membership in one cluster, not what the schema is, and PostgreSQL 16
and later writes one for every membership.
- A column's default belongs on the column. A table that inherits a
column has no column entry to carry one, so a default on an
inherited column lives at the table level under
column_defaults:, whichbuildemits asALTER TABLE ONLY <table> ALTER COLUMN <column> SET DEFAULT ...the waypg_dumpwrites it:
---
name: audit_events_archive
schema: test
parents:
- test.audit_events
column_defaults:
- column: recorded_at
default: CURRENT_TIMESTAMP
- Row-level security lives on the table.
row_level_securitystates whether it is enabled and forced, andpolicieslists the policies. Each policy keeps only what differs fromCREATE POLICY's defaults:restrictive: trueforAS RESTRICTIVE, acommandother thanALL,rolesother thanPUBLIC.usingandwith_checkhold the expression inside theUSING (...)/WITH CHECK (...)clause, written the way PostgreSQL reports it: an operator expression keeps its own parentheses, as in(tenant = CURRENT_USER). Write it that way, or deploy sees a change on every run.
---
name: tenant_notes
schema: test
row_level_security:
enabled: true
forced: true
policies:
- name: tenant_notes_own
using: (tenant = CURRENT_USER)
with_check: (tenant = CURRENT_USER)
comment: tenant isolation
- name: tenant_notes_no_blank
restrictive: true
command: INSERT
with_check: (body <> ''::text)
pull writes row_level_security for every table, and a table with
that key has exactly the policies listed. A table without either key,
such as one pulled before pglifecycle modeled row security, is not
managed: deploy leaves its row security and policies as the
database has them. Pull the project again to record them.
- An exclusion constraint lists each element as an index column (a
nameor anexpression, with itscollation,opclass,directionandnull_placement) and theoperatortwo rows are compared with.replica_identityisFULL,NOTHING, or{index: name}; absent isDEFAULT, the primary key.
exclude_constraints:
- name: room_bookings_no_overlap
method: gist
elements:
- name: room
operator: =
- name: during
operator: '&&'
where: (status <> 'cancelled'::text)
replica_identity: FULL
-
Columns may carry
storage,compression,statistics(the statistics target) andoptions(such asn_distinct), whichbuildwrites asALTER COLUMN ... SETafterCREATE TABLE, aspg_dumpdoes. Comments on a table's primary key, unique, check, foreign key and NOT NULL constraints live inconstraint_comments, keyed by constraint name. -
Tables and views carry
rules. A rule'scommandsare absent forDO INSTEAD NOTHING. A view's internal_RETURNrule is its query, never a rule.
rules:
- name: ledger_audit_insert
event: INSERT
condition: (new.amount > (0)::numeric)
commands:
- |-
INSERT INTO test.ledger_audit (id)
VALUES (new.id)
- name: ledger_no_delete
event: DELETE
instead: true
comment: Append only
Conditions and commands keep the form pg_dump writes them in.
- Extended statistics live in
statistics/<schema>/<name>.yaml, not on the table: the name is schema-qualified, and the owner need not own the table.
---
name: measurements_ab
schema: test
owner: postgres
table: test.measurements
kinds: [ndistinct, dependencies]
elements: [a, b]
- Default privileges live in
default_privileges/<role>.yaml, one file for each role whose new objects they apply to (ALTER DEFAULT PRIVILEGES FOR ROLE). Global and per-schema declarations compose in PostgreSQL, so each is kept as written;buildemits the revocations first, then the grants, aspg_dumpdoes.
---
name: app_owner
grants:
- schema: reporting
object_type: TABLES
grantee: analyst
privileges: [SELECT]
revocations:
- object_type: FUNCTIONS
grantee: PUBLIC
privileges: [EXECUTE]
-
A cast has no schema of its own.
pullfiles it in thecasts/<schema>.yamlof the first schema its function or types name, orpublicwhen they are all built-in. -
A publication lists each table as its qualified name, or as a mapping that also limits the columns or rows published, and names whole schemas under
schemas:. Each table is exactly the table named:pulllists an inheritance child on its own, aspg_dumpdoes, andbuildadds every table withONLY.
---
name: replicated_positive
tables:
- name: test.replicated
columns: [id, amount]
where: (amount > 0)
- test.audit_log
schemas:
- reporting
parameters:
publish: [insert, update]
-
Grants on views may be written under either
tables:orviews:. PostgreSQL grants on views withTABLEsyntax, so both emitGRANT ... ON TABLEand coalesce into a single ACL entry;pullwrites view grants undertables:. -
create: falsedefines a role without creating it — used for built-in pseudo-roles likePUBLIC.