RFC DPG-1: Declarative PG
| RFC | DPG-001 | Status | Standards Track |
|---|---|---|---|
| Version | 0.8.1 | Target | PostgreSQL 14+ |
| Date | 2026-05-13 | Authors | Daniel Tsegaw |
DPG Working Group D. Tsegaw
Request for Comments: 1 Independent
Category: Standards Track May 2026
ISSN: (N/A — project-internal specification)
Declarative PG (DPG): A Declarative State-Based Superset
of PostgreSQL DDL
Abstract
This document specifies Declarative PG (DPG), a declarative,
state-based superset of PostgreSQL SQL that compiles to idiomatic
PostgreSQL DDL. DPG source files describe the desired state of a
PostgreSQL database; the DPG compiler computes the minimal, safe,
ordered set of DDL statements required to transition the current
state to the desired state. This specification defines the DPG
source language, the compilation pipeline, the snapshot interchange
format, the migration output format, and all associated tooling
behaviour.
Status of This Memo
This document specifies a Standards Track protocol for the DPG
project ecosystem. Distribution of this memo is unlimited.
This document is the authoritative specification for DPG version
0.8.1 and supersedes all prior informal design documents including
rfc/v0.8.0.md. The Go implementation at github.com/thec1oud/dpg
MUST conform to every normative statement in this document.
Copyright Notice
Copyright (c) 2026 Daniel Tsegaw. All rights reserved.
Redistribution and use of this specification, with or without
modification, is permitted provided that the above copyright notice
and this permission notice appear in all copies.
Table of Contents
1. Introduction .................................................... 1
1.1. Purpose and Scope ........................................ 1
1.2. Problem Statement ........................................ 2
1.3. Prior Art ................................................ 2
1.4. Core Design Tenets ....................................... 3
1.5. Terminology .............................................. 4
2. Conventions Used in This Document .............................. 5
2.1. Requirements Notation .................................... 5
2.2. Syntax Notation .......................................... 5
2.3. Examples ................................................. 5
3. Project Structure and Configuration ............................ 6
3.1. Directory Layout ......................................... 6
3.2. Root dpg.toml ............................................ 7
3.3. Cluster dpg.toml ......................................... 8
3.4. Database dpg.toml ........................................ 9
3.5. Cluster-Level Objects Directory .......................... 9
3.6. Discovery Algorithm ...................................... 9
3.7. Block Merge Conflict Resolution .......................... 10
3.8. Minimum PostgreSQL Version Targeting .................... 10
4. Language Fundamentals .......................................... 11
4.1. Source File Format ....................................... 11
4.2. The Two-Part Syntax Model ................................ 11
4.3. The No-Verb Mandate ...................................... 12
4.4. Structural Scoping ....................................... 13
4.5. Statement Terminators .................................... 13
4.6. Dollar-Quoted String Parsing ............................. 14
4.7. Macro Preprocessor ....................................... 15
4.8. Dual Definition Modes .................................... 17
4.9. Block Merging ............................................ 17
4.10. Identifiers .............................................. 18
5. Type System .................................................... 19
5.1. ENUM Types ............................................... 19
5.2. Composite Types .......................................... 21
5.3. Range Types .............................................. 22
5.4. Domain Types ............................................. 22
5.5. Base (Shell) Types ....................................... 23
5.6. Virtual Types ............................................ 23
6. Schema and Namespace Objects ................................... 25
6.1. SCHEMA ................................................... 25
6.2. EXTENSION ................................................ 26
7. Tables ......................................................... 27
7.1. Table Declaration Syntax ................................. 27
7.2. Column Definitions ....................................... 28
7.3. Constraints .............................................. 30
7.4. The COLUMN Reference Block ............................... 33
7.5. Column-Level Grants ...................................... 35
7.6. Column Renaming .......................................... 35
7.7. Indexes .................................................. 37
7.8. Row Level Security ....................................... 39
7.9. Triggers ................................................. 40
7.10. Table-Level Grants and Revocations ....................... 42
7.11. Table Lifecycle Directives ............................... 42
7.12. Unlogged and Foreign Tables .............................. 43
7.13. Partitioned Tables ....................................... 44
8. Views .......................................................... 47
8.1. Regular Views ............................................ 47
8.2. Materialized Views ....................................... 49
8.3. Recursive Views .......................................... 50
9. Functions and Procedures ....................................... 51
9.1. Function Declaration Syntax .............................. 51
9.2. Function Attributes ...................................... 52
9.3. Procedures ............................................... 54
9.4. Aggregate Functions ...................................... 54
9.5. Function Body Diffing Semantics .......................... 55
10. Sequences ...................................................... 56
11. Access Control ................................................. 57
11.1. Roles .................................................... 57
11.2. Grants — the Additive Model .............................. 58
11.3. Revocations .............................................. 59
11.4. Default Privileges ....................................... 60
12. Full-Text Search Objects ....................................... 61
12.1. Text Search Configurations ............................... 61
12.2. Text Search Dictionaries ................................. 62
12.3. Text Search Parsers ...................................... 62
12.4. Text Search Templates .................................... 63
13. Logical Replication ............................................ 64
13.1. Publications ............................................. 64
13.2. Subscriptions ............................................ 65
14. Advanced PostgreSQL Objects .................................... 66
14.1. Event Triggers ........................................... 66
14.2. Collations ............................................... 66
14.3. Operators ................................................ 67
14.4. Operator Classes and Families ............................ 68
14.5. Casts .................................................... 69
14.6. Extended Statistics Objects .............................. 69
14.7. Tablespaces .............................................. 70
14.8. Foreign Data Wrappers .................................... 70
14.9. Foreign Servers .......................................... 71
14.10.User Mappings ............................................ 71
14.11.Security Labels ........................................... 71
15. Compilation Pipeline ........................................... 72
15.1. Phases Overview .......................................... 72
15.2. Phase 1 — File Discovery ................................. 73
15.3. Phase 2 — Macro Preprocessing ............................ 73
15.4. Phase 3 — Tokenization ................................... 74
15.5. Phase 4a — PG SQL Parsing ................................ 75
15.6. Phase 4b — Block Parsing .................................. 75
15.7. Phase 5 — IR Construction ................................ 76
15.8. Phase 6 — Merging ........................................ 76
15.9. Phase 7 — Dependency Resolution .......................... 77
15.10.Phase 8 — Linting ......................................... 78
15.11.Phase 9 — Differencing .................................... 79
15.12.Phase 10 — Emission ...................................... 80
16. Snapshot Format ................................................ 81
16.1. Purpose and Placement .................................... 81
16.2. Top-Level Fields ......................................... 81
16.3. Per-Object Snapshot Schema ............................... 82
16.4. Versioning ............................................... 88
17. Migration Output Format ........................................ 89
17.1. Output Structure ......................................... 89
17.2. Safety Classification .................................... 90
17.3. Transactional vs Non-Transactional Steps ................. 91
17.4. Idempotency Requirement .................................. 92
18. CLI Commands ................................................... 93
18.1. dpg plan ................................................. 93
18.2. dpg apply ................................................ 94
18.3. dpg verify ............................................... 95
18.4. dpg dump ................................................. 96
18.5. dpg diff ................................................. 96
18.6. dpg validate ............................................. 97
18.7. dpg fmt .................................................. 97
18.8. dpg portability .......................................... 98
18.9. dpg init ................................................. 98
18.10.dpg completion ........................................... 98
19. The Linter ..................................................... 99
19.1. Built-in Rules ........................................... 99
19.2. Configuration ........................................... 100
20. Introspection Engine .......................................... 101
20.1. Catalog Tables Read ..................................... 101
20.2. Drift Detection ......................................... 102
21. Per-Object Diff Algorithms .................................... 103
22. Dependency Ordering ........................................... 110
22.1. Topological Sort ........................................ 110
22.2. Circular Dependency Resolution .......................... 111
23. Deferred Features ............................................. 112
24. Security Considerations ....................................... 113
25. Feature Coverage Matrix ....................................... 115
Appendix A. ABNF Grammar Summary ................................ 120
Appendix B. Complete Example Project ............................ 126
Appendix C. Error Code Reference ................................ 132
Appendix D. Corrections and Additions to Earlier Sections ....... 138
D.1. Snapshot Format Corrections ............................. 138
D.2. CLI Command Corrections ................................. 142
D.3. Linter Rule ID Corrections .............................. 145
D.4. Pipeline Registry Key Constants ......................... 145
D.5. SecretResolver Protocol Specification ................... 146
D.6. Source Revision Detection ............................... 147
D.7. Additional CLI Error Codes .............................. 147
D.8. Root dpg.toml — [fmt]/[migrations] Integrated .......... 148
D.9. CLI Command Corrections ................................ 149
D.10. Name Maps .............................................. 150
D.11. SERIAL / BIGSERIAL / SMALLSERIAL Column Sugar ........... 151
Appendix E. Revision History ..................................... 152
Appendix F. Standard SQL / PostgreSQL-Specific Classification .... 153
Normative References .............................................. 156
Informative References ............................................ 157
Author's Address .................................................. 158
1. Introduction
1.1. Purpose and Scope
This document specifies the Declarative PG (DPG) language, its compilation model, and all associated tooling. DPG is a declarative, state-based superset of PostgreSQL SQL. A DPG source tree describes the desired state of one or more PostgreSQL databases. The DPG compiler ingests that description, compares it against a committed schema snapshot, and emits the minimal ordered set of PostgreSQL DDL statements required to transition the database to the desired state.
The scope of this specification covers:
a) The DPG source language syntax, including all object declaration forms, the two-part syntax model, the macro preprocessor, and all lifecycle directives.
b) Every category of PostgreSQL object that DPG can manage, including the precise DDL each declaration produces.
c) The full compilation pipeline from file discovery through DDL emission, including intermediate representations.
d) The snapshot interchange format and its versioning contract.
e) The migration output format, safety classification system, and idempotency guarantees.
f) All CLI commands, their flags, and their observable behaviour.
g) The static analysis linter and its built-in rules.
h) The live catalog introspection engine and drift detection.
This specification does NOT cover:
- The internal data structures of the Go reference implementation beyond what is required to define observable behaviour.
- PostgreSQL runtime behaviour (query planning, execution, etc.).
- Data manipulation language (SELECT, INSERT, UPDATE, DELETE).
- Inline data seeding (out of scope; DPG is a schema tool).
1.2. Problem Statement
PostgreSQL DDL is fundamentally imperative: to change a database one
issues commands (CREATE TABLE, ALTER TABLE ADD COLUMN,
DROP INDEX). SQL files in version control therefore describe
actions taken at a point in time, not the current intended state
of the schema. This creates four well-known failure modes:
(1) Schema drift. A production database that has been patched, hotfixed, or manually altered over time no longer matches its migration history. There is no reliable way to tell whether a given migration file has been applied.
(2) No single source of truth. To understand the current schema a developer must mentally replay every migration, in order, from the beginning. This is error-prone and does not scale.
(3) Idempotency is illusory. Running a migration file twice
fails or corrupts state. Most teams work around this with
IF NOT EXISTS guards that must be written manually.
(4) Redundant context. PostgreSQL forces re-statement of context
that is already structurally known. ALTER TABLE public.users ADD CONSTRAINT ... repeats schema and table name in every
alteration even when the context is fixed.
DPG resolves all four problems by inverting the model: the developer writes a description of the desired state; the compiler produces the imperative commands.
1.3. Prior Art
Atlas (ariga.io) uses HCL as its schema description language. HCL is a foreign DSL to a PostgreSQL developer — a new vocabulary must be learned to describe objects already expressible in SQL.
Prisma Schema invents parallel concepts for PostgreSQL objects
(@id instead of PRIMARY KEY). PostgreSQL-specific features not
modelled by Prisma are inaccessible.
Flyway / Liquibase / Sqitch are migration-based, not declarative. They manage the history of imperative changes rather than the desired state.
The DPG position: PostgreSQL SQL is already a nearly complete declarative schema language. The only missing pieces are structural scaffolding to remove redundancy and a diff engine to translate state changes into safe migrations. DPG adds exactly those two things.
1.4. Core Design Tenets
Tenet 1 — Full PostgreSQL feature parity. DPG MUST be capable of expressing anything that raw PostgreSQL DDL can express. A PostgreSQL feature that cannot be declared in DPG is a defect in DPG, not an out-of-scope request — with the sole exception of the specific, itemized carve-outs enumerated in Section 23 (“Deferred Features”), each justified there on its own terms (e.g. a feature that is runtime/session-scoped rather than schema DDL, or genuinely superseded by another PostgreSQL mechanism DPG already manages). Section 23’s list is closed, not a general license to omit — anything not named there is bound by this tenet without exception.
Tenet 2 — Prefer PG syntax exactly. When PostgreSQL already has a declarative way to express something, DPG uses it verbatim. DPG removes imperative verbs and adds structural scoping but does not invent new keywords for concepts PostgreSQL already names well.
Tenet 3 — Standard SQL / PG-extension boundary is tracked
internally.
The compiler knows which constructs are ISO/IEC 9075 Standard SQL
and which are PostgreSQL-specific. Users never annotate portability.
The compiler surfaces this via the dpg portability command. The
full per-construct classification is Appendix F.
PostgreSQL version target: this specification’s floor is
PostgreSQL 14; there is no ceiling. A feature introduced in any
PostgreSQL release is in scope for this document once a revision
adopts it (tracked in Appendix E’s revision history and Appendix
F’s classification table), on a rolling basis — this RFC is never
“done” catching up to a new PostgreSQL release the way a fixed
ceiling would require a new major RFC version to move past. A
construct requiring PostgreSQL 15+ is documented as such at its own
point of use; nothing below PostgreSQL 14 is a supported target.
This is distinct from the per-project, user-configurable
min_pg_version gating feature (Section 3.8) — that warns a user
about their own project’s version floor; this is the floor for what
this specification itself documents at all.
Tenet 4 — Offline-first diffing.
DPG MUST NOT require a live database connection to generate a
migration. The primary workflow compares .dpg source files against
a committed schema snapshot. Live catalog introspection is available
for verification and bootstrap but is never required for day-to-day
operation.
Tenet 5 — The { } block holds only what PG SQL cannot.
The native PostgreSQL DDL definition of an object — its column list,
its options, its clauses, its dollar-quoted body — MUST be written
exactly as PostgreSQL SQL dictates. The trailing { } block exists
exclusively for things PostgreSQL SQL expresses as separate DDL
statements (indexes, grants, policies, comments, per-column storage
attributes) and for DPG lifecycle directives (RENAMED FROM,
PROTECTED, DEPRECATED). Nothing that has a natural place in
PostgreSQL SQL’s own syntax SHALL be moved into the { } block.
1.5. Terminology
The following terms carry precise meanings throughout this document:
DPG source file — A UTF-8 text file with the .dpg extension
containing one or more DPG object declarations.
Part 1 — The native PostgreSQL SQL portion of a DPG declaration, written with the leading imperative verb removed.
Part 2 — The optional trailing { } block of a DPG declaration,
holding sub-objects and lifecycle directives.
Object — A named, independently manageable PostgreSQL schema entity (table, view, function, type, role, etc.).
Snapshot — A committed JSON file representing the compiler’s
normalised view of the database state after the most recent
successful dpg apply.
IR (Internal Representation) — The fully-qualified, typed in-memory form of a DPG object, produced by the IR Builder phase.
DiffOp — A single DDL statement produced by the Differ, annotated with safety class and source position.
Migration — The complete ordered output of the Emitter: a set of transactional DiffOps and a set of non-transactional DiffOps, together with a header block.
Safety class — One of SAFE, CAUTION, DESTRUCTIVE, or
MANUAL. Defined in Section 17.2.
Cluster — A single running PostgreSQL instance, hosting one or more databases. Maps to one cluster directory in the project tree.
Database — A single named PostgreSQL database within a cluster. Maps to one database directory in the project tree.
2. Conventions Used in This Document
2.1. Requirements Notation
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.
2.2. Syntax Notation
ABNF grammar rules are specified using the notation defined in
[RFC5234], Augmented BNF for Syntax Specifications. The following
core rules from [RFC5234] Appendix B are used without redefinition:
ALPHA, DIGIT, SP, HTAB, CRLF, LF, DQUOTE.
Where existing PostgreSQL DDL syntax is referenced, it is cited in
terms of the PostgreSQL 14+ CREATE statement grammar as documented
in the PostgreSQL official documentation [PGDOC14].
Inline examples appear in monospace code blocks. Within prose, DPG
keywords appear in monospace. Non-terminal grammar symbols appear
in italics. Normative text and examples are separated; an example
does not in itself constitute a normative requirement unless
explicitly stated.
2.3. Examples
Examples marked -- OK illustrate valid DPG source. Examples
marked -- ERROR illustrate input that MUST be rejected by the
compiler with a diagnostic. Example PostgreSQL DDL output is marked
-- emitted SQL.
3. Project Structure and Configuration
3.1. Directory Layout
A DPG project is a directory tree whose structure encodes the physical topology of the managed PostgreSQL deployment. The layout MUST conform to the following schema:
<project-root>/
├── dpg.toml (REQUIRED) root tool configuration
│
├── <cluster-name>/ (one per cluster)
│ ├── dpg.toml (REQUIRED) cluster configuration
│ ├── <cluster-objects-dir>/ (default: "cluster")
│ │ ├── roles.dpg
│ │ ├── tablespaces.dpg
│ │ └── ...
│ │
│ └── <database-name>/ (one per database)
│ ├── dpg.toml (REQUIRED) database configuration
│ ├── extensions.dpg
│ └── schemas/
│ └── <schema-name>/
│ ├── types.dpg
│ ├── tables/
│ │ └── <table>.dpg
│ ├── views.dpg
│ └── functions.dpg
│
└── .dpg/
└── snapshots/
└── <cluster-name>/
└── <database-name>.json
Discovery rules:
The project root is the directory containing the root
dpg.toml.A cluster directory is any immediate subdirectory of the project root that contains a
dpg.tomlwith a[cluster]section.A database directory is any immediate subdirectory of a cluster directory that contains a
dpg.tomlwith a[database]section, excluding the cluster objects directory.The cluster objects directory name is taken from the
cluster.cluster_objects_dirfield. It defaults to"cluster". No database within the cluster MAY share this name.All
.dpgfiles descending from a database directory are automatically scoped to that database. No in-file database header keyword exists..dpgfiles under the cluster objects directory are scoped to the cluster (no database context).
The compiler MUST traverse the entire subtree of each database
directory recursively. Files at any depth are included. Files
whose names do not end in .dpg are silently ignored.
3.2. Root dpg.toml
The root dpg.toml configures global compiler and linter behaviour.
All fields are OPTIONAL; unspecified fields take the defaults shown.
[compiler]
# default_drop_behavior controls whether DROP statements include
# CASCADE or RESTRICT. Valid values: "restrict" (default), "cascade".
# Per-object DROP CASCADE overrides this setting.
default_drop_behavior = "restrict"
# Minimum PostgreSQL major version this project targets (Section 3.8).
# Unset (the default) means no version gating. Overridable per-cluster
# and per-database (Sections 3.3/3.4); most specific wins.
# min_pg_version = 15
[linter]
# Emit a warning when any DEPRECATED object or column is referenced.
warn_on_deprecated = true
# Emit an error when any column lacks a COMMENT.
require_column_comments = false
# Emit an error when a ROLE PASSWORD value is a hardcoded string
# rather than an env: URI.
forbid_hardcoded_passwords = true
# Emit a warning when a table has more than this many columns.
# 0 = disabled.
max_columns_per_table = 50
# Emit a warning when two .dpg files set conflicting scalar values
# for the same object (last-declaration-wins applies silently without
# this flag).
warn_on_scalar_merge_conflict = true
[snapshots]
# Directory where snapshot JSON files are stored, relative to the
# project root.
directory = ".dpg/snapshots"
[fmt]
# Number of spaces per indentation level. Default: 4.
indent = 4
# Keyword casing applied to all DPG and PostgreSQL keywords.
# Valid values: "upper" (default), "lower".
keyword_case = "upper"
[migrations]
# Relative path from the project root where applied migration SQL
# files are archived after a successful `dpg apply`. Default:
# ".dpg/migrations". Set to "" to disable archiving entirely.
directory = ".dpg/migrations"
The compiler MUST reject any key in dpg.toml that is not listed
above with error DPG-E001 (unknown configuration key).
[fmt] controls dpg fmt (Section 18.7). [migrations] controls where
dpg apply (Section 18.2) archives applied SQL, one file per successful
apply, at <directory>/<cluster>/<database>/<timestamp>_<short-hash>.sql;
this directory SHOULD be committed to version control.
3.3. Cluster dpg.toml
Located at <cluster-dir>/dpg.toml. Configures the cluster
connection and options.
[cluster]
# Human-readable name. Used in snapshot file paths and migration
# headers. REQUIRED.
name = "production"
# Reserved directory name for cluster-level objects.
# MUST NOT match any database directory name within the cluster.
cluster_objects_dir = "cluster" # default
# Inline PostgreSQL connection string (libpq URI or keyword/value
# format). Mutually exclusive with `link`. OPTIONAL for offline use.
url = "postgresql://user@host:5432/postgres"
# Secrets-provider URI resolved at connection time. Supported schemes
# (see Appendix D.5): env:<VAR>, vault:<mount>/<path>#<field>, aws-sm:<secret-id>,
# gcp-sm:<project>/<secret-id>, azure-kv:<vault-name>/<secret-name>.
# Mutually exclusive with `url`. OPTIONAL.
# link = "env:PRIMARY_DB_URL"
[cluster.options]
# If true, the snapshot is updated atomically after every successful
# dpg apply. Default: true.
snapshot_on_apply = true
[compiler]
# Overrides the project root's min_pg_version (Section 3.2) for
# every database in this cluster, and for this cluster's own
# cluster-level objects (roles, tablespaces, PARAMETER PRIVILEGES).
# OPTIONAL; unset falls through to the root's value. See Section 3.8.
# min_pg_version = 16
Constraint: url and link are mutually exclusive. If both
are present the compiler MUST abort with error DPG-E002 (ambiguous
connection). If neither is present, commands that require a live
database connection (dpg apply, dpg verify, dpg dump) MUST
fail with error DPG-E003 (no connection configured). If name is
omitted or empty the compiler MUST abort with error DPG-E032
(cluster name required). name MUST also be unique across every
cluster directory in the project; a repeated name MUST abort with
error DPG-E034 (duplicate cluster name) — see Section 3.6.
3.4. Database dpg.toml
Located at <cluster-dir>/<database-dir>/dpg.toml.
[database]
# The name of the PostgreSQL database as it appears in pg_database.
# REQUIRED.
name = "myapp"
# The default schema for objects declared without an explicit schema
# qualifier. REQUIRED.
default_schema = "public"
[compiler]
# Overrides the cluster's (and, transitively, the project root's)
# min_pg_version (Sections 3.2/3.3) for this database only. OPTIONAL;
# unset falls through to the cluster's value. See Section 3.8.
# min_pg_version = 17
Constraint: if name is omitted or empty the compiler MUST
abort with error DPG-E033 (database name required). name MUST
also be unique among every database directory within the same
cluster (uniqueness is per-cluster, not project-wide — the same
database name MAY recur under a different cluster); a repeated
name within one cluster MUST abort with error DPG-E035
(duplicate database name) — see Section 3.6.
3.5. Cluster-Level Objects Directory
Files in the cluster objects directory declare objects that belong to
the cluster, not to any individual database: roles, tablespaces, and
(in the rare case of custom C-implemented FDWs) foreign data
wrappers. The compiler resolves the cluster objects directory name
from cluster.cluster_objects_dir and MUST reject any database
directory whose name matches it with error DPG-E004 (reserved name
conflict).
3.6. Discovery Algorithm
The compiler’s file discovery phase MUST execute the following algorithm:
Locate the project root by searching from the current working directory upward for a
dpg.tomlwith a[compiler]or[linter]or[snapshots]section.For each cluster directory (immediate subdirectory of the project root whose
dpg.tomlcontains[cluster]):a. Parse the cluster
dpg.toml.b. For each database directory (immediate subdirectory of the cluster directory whose
dpg.tomlcontains[database], excluding the cluster objects directory):i. Parse the database `dpg.toml`. ii. Walk the database directory tree recursively. Collect every file whose name ends in `.dpg`, in lexicographic order by full path. This ordering is the canonical file order used for last-declaration-wins scalar conflict resolution.c. Walk the cluster objects directory recursively in the same manner.
Pass the collected file sets to the macro preprocessor (Phase 2, Section 15.3).
Name validation. Step 2.a MUST validate that the cluster’s
declared name (Section 3.3) is non-empty (error DPG-E032 if not) and
unique across every cluster directory discovered under the project
root (error DPG-E034 if not, naming both directories). Step 2.b.i
MUST validate that the database’s declared name (Section 3.4) is
non-empty (error DPG-E033 if not) and unique among every database
directory within that same cluster (error DPG-E035 if not, naming
both directories) — database name uniqueness is scoped per-cluster
only, not project-wide. These checks exist because --cluster/
--database selection (Appendix D.2.2), the snapshot store, the migrations
archive, and dpg dump’s default output path all key off the
declared name, not the directory: an unvalidated empty or
duplicate name causes silent misbehavior — an unreachable second
cluster/database, or two unrelated clusters/databases sharing the
same persisted snapshot on disk — rather than a clear error.
3.7. Block Merge Conflict Resolution
The DPG compiler accumulates all declarations across all .dpg
files for the same logical database before compiling. When the same
named object appears in multiple files, its declared attributes are
merged according to the following rules.
Set-valued properties — columns, constraints, indexes, policies, triggers, grants, revocations, column sub-blocks — are merged by taking the UNION of all declared values. Identical duplicate entries (same name AND same definition) are silently deduplicated. Entries with the same name but different definitions are a compiler error (DPG-E005, conflicting set member).
Scalar properties — owner, comment, tablespace, RLS flags,
PROTECTED, DEPRECATED, DROP CASCADE, RENAMED FROM, drop
behaviour — apply last-declaration-wins semantics. Files are
ordered lexicographically by their fully-qualified path relative to
the project root. The declaration in the alphabetically last file
wins. This ordering is deterministic, reproducible on any machine,
and independent of filesystem directory-entry ordering.
When warn_on_scalar_merge_conflict is enabled in the linter
configuration (default: true), the compiler SHOULD emit a
LintDiagnostic (not a hard error) whenever two files provide
conflicting values for the same scalar property of the same object.
The winning value (lexicographically last file) is used regardless.
3.8. Minimum PostgreSQL Version Targeting
min_pg_version ([compiler], Sections 3.2-3.4) declares the oldest
PostgreSQL major version a project — or one of its clusters or
databases — is deployed against. This is a purely semantic gate,
not a parser restriction: the compiler always parses .dpg source
against its newest supported grammar, since PostgreSQL’s own SQL
grammar is overwhelmingly additive across major versions and a
project’s source is not expected to vary its own syntax by target
version. What min_pg_version controls is whether the linter (Section
19) warns — or, under --strict, errors — when a construct the
compiler did successfully parse requires a PostgreSQL version newer
than the effective floor. This lets a project declare “PARAMETER
PRIVILEGES on a PG14 target” and be told about the mismatch before
dpg apply fails against a real, older server, without DPG needing a
distinct parser build per PostgreSQL release.
Resolution order: min_pg_version MAY be set at the project root
(Section 3.2), overridden per-cluster (Section 3.3), and overridden
again per-database (Section 3.4). The most specific level that sets
it wins; an unset level falls through to the next level up.
Unset at every level means no gating at all. A cluster’s own
cluster-level objects (roles, tablespaces, PARAMETER PRIVILEGES)
are gated by that cluster’s own resolved value, since they have no
database of their own to resolve one from.
Validation: a declared min_pg_version below 14 (this
specification’s own supported floor, Section 1.4) MUST be rejected
at config-load time as an error, at whichever level declared it.
Enforcement: implemented as the linter’s min-pg-version rule
(Section 19.1, Appendix D.3) — a LintDiagnostic warning by default,
promoted to an error under --strict like any other rule, wired into
dpg plan/dpg apply/dpg validate (Section 18) the same way every
other linter rule already is; no separate CLI flag or command-specific
wiring is needed. Every version-gated construct this specification
documents is catalogued in Appendix F’s Min PG Version column — a
blank cell there means the construct has been available since this
specification’s own floor (PostgreSQL 14) and is never gated,
regardless of min_pg_version.
Coverage is incremental, by design: the min-pg-version rule
only fires for a construct whose reference implementation has a
concrete IR representation to inspect — a construct DPG cannot yet
express in its internal representation at all has nothing for this
rule to see, independent of whether min_pg_version is configured.
This is consistent with every other linter rule in this
specification: the rule describes an intended check against
whatever the compiler has actually built, not a promise that every
documented construct is checked from day one of its own
specification.
4. Language Fundamentals
4.1. Source File Format
DPG source files MUST be encoded in UTF-8 [RFC3629]. Byte-order marks (U+FEFF) MUST be silently stripped if present at the start of a file. Line endings MAY be LF (U+000A) or CRLF (U+000D U+000A); the compiler MUST normalise all line endings to LF before processing.
Comments follow PostgreSQL’s double-dash convention (--) and C-
style block comments (/* ... */). Comments are stripped by the
tokenizer before any deeper parsing. Block comments do NOT nest.
Identifiers are case-insensitive in conformance with PostgreSQL’s unquoted identifier rules, except when enclosed in double-quotes, in which case they are case-sensitive and may contain any Unicode character.
4.2. The Two-Part Syntax Model
Every DPG object declaration consists of at most two parts:
Part 1 — The native PG SQL definition.
Written exactly as PostgreSQL SQL dictates with only the leading
imperative verb (CREATE, ALTER, DROP) removed. Part 1 uses the
same keywords, the same clause ordering, and the same syntax as the
corresponding PostgreSQL CREATE statement. The compiler prepends
the correct verb internally when invoking the PostgreSQL parser; the
developer never writes it.
Part 2 — The DPG structural block { ... }.
An OPTIONAL trailing block that contains exclusively things
PostgreSQL SQL expresses as separate DDL statements (CREATE INDEX,
GRANT, COMMENT ON, ALTER TABLE ... ALTER COLUMN SET STATISTICS)
plus DPG lifecycle directives (RENAMED FROM, PROTECTED,
DEPRECATED, DROP CASCADE).
Decision rule: If PostgreSQL writes it as part of CREATE OBJECT,
it is Part 1. If PostgreSQL writes it as a separate statement, it
is Part 2. This rule MUST be applied consistently; no clause that
belongs in Part 1 SHALL be moved to Part 2, and no Part-2-only
directive SHALL appear in Part 1.
dpg-declaration = part1 [ part2 ]
part1 = object-keyword WSP object-body terminator
; object-body is the PG SQL text with CREATE verb absent
part2 = "{" WSP*
( block-directive WSP* ";" WSP* )*
"}"
object-keyword = "SCHEMA" / "TABLE" / "UNLOGGED TABLE" /
"FOREIGN TABLE" / "VIEW" / "MATERIALIZED VIEW" /
"RECURSIVE VIEW" / "FUNCTION" / "PROCEDURE" /
"AGGREGATE" / "ENUM" / "TYPE" / "DOMAIN" /
"SEQUENCE" / "ROLE" / "TABLESPACE" /
"FOREIGN DATA WRAPPER" / "SERVER" / "USER MAPPING" /
"PUBLICATION" / "SUBSCRIPTION" / "EVENT TRIGGER" /
"COLLATION" / "OPERATOR" / "OPERATOR CLASS" /
"OPERATOR FAMILY" / "CAST" / "STATISTICS" /
"TEXT SEARCH CONFIGURATION" /
"TEXT SEARCH DICTIONARY" /
"TEXT SEARCH PARSER" / "TEXT SEARCH TEMPLATE" /
"DEFAULT PRIVILEGES" / "VIRTUAL TYPE" /
"EXTENSION" / "MACRO"
4.3. The No-Verb Mandate
The keywords CREATE, ALTER, and DROP are PROHIBITED in DPG
source files at the declaration level. The compiler MUST reject any
source file containing these keywords outside of:
a) Dollar-quoted function, procedure, or aggregate bodies
(AS $$...$$, AS $tag$...$tag$), which are opaque text and
not interpreted by the compiler (Section 4.6).
b) MIGRATE REMOVE { ... } blocks on ENUM types (Section 5.1),
whose body is DML passthrough.
c) The value of a link = field in dpg.toml (not a .dpg file).
Rationale: the presence of imperative verbs indicates a migration file rather than a state description. Prohibiting them enforces the declarative contract at parse time, producing an early diagnostic (DPG-E006) rather than silent misuse.
4.4. Structural Scoping
The { } block of any container provides a scope. Nested object
declarations inherit their containing context:
A
TABLEdeclared inside aSCHEMA { }block inherits the schema name; the developer does not repeat it.An
INDEXdeclared inside aTABLE’s{ }block inherits the table and schema; the developer writes only the index name and column list.A
POLICYdeclared inside aTABLE’sPOLICIES { }block inherits the table, schema, and database.
Schemas have no ( ) list. Their { } block directly holds all
schema-level attributes and nested objects.
Explicit schema qualification is always legal in any DPG declaration. An explicit schema qualifier overrides any containing schema scope.
4.5. Statement Terminators
The terminator rules are as follows. These rules are NORMATIVE; a compiler that silently accepts deviations is non-conforming.
terminator = paren-close SP* [ with-clause / tablespace-clause /
inherits-clause / partition-clause /
server-clause ]
/ dollar-close ";"
/ ";"
Rule T1. A declaration whose Part 1 ends with a closing
parenthesis ) — tables, composite types, range types, aggregates,
base (shell) types declared with a full option list — MUST NOT have
a semicolon between ) and the { } block. The ) (optionally
followed by WITH, TABLESPACE, INHERITS, or PARTITION BY
clauses) is the Part 1 terminator.
Rule T2. A declaration whose Part 1 ends with a dollar-quoted
body — functions, procedures — terminates with $$; or $tag$;.
The semicolon is mandatory after the closing delimiter. An optional
{ } block MUST follow immediately after, with no intervening
whitespace beyond optional newlines. A BEGIN ATOMIC ... END body
(Section 9.1, PG14+ standard-SQL form) follows the same rule with END;
in place of $$;.
Rule T3. All other declarations — views, ENUM types, sequences,
roles, publications, subscriptions, extensions, schemas without
nested objects, domains, and a bare forward-declared base (shell)
type (TYPE name;, no option list — Section 5.5) — terminate their Part 1
with ;. An optional { } block follows immediately after ;.
Rule T4. A declaration with a { } block but no Part 1
terminator issue is the SCHEMA object: the schema body IS the
{ } block. The schema declaration ends when the } closes.
Rule T5. When no { } block is present, the ; (or $$; or
)) is the sole terminator of the complete declaration. No further
punctuation follows.
Examples:
-- T1: table with { } block, no semicolon before {
TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY,
CONSTRAINT pk PRIMARY KEY (id)
)
{
INDICES { idx_email (email); }
}
-- T3: view with { } block
VIEW active_users AS SELECT id FROM users WHERE active;
{
GRANTS { SELECT TO app_readonly; }
}
-- T2: function with { } block
FUNCTION foo() RETURNS TEXT LANGUAGE sql STABLE
AS $$
SELECT 'hello';
$$;
{
COMMENT 'Returns hello';
}
-- T1: table with no { } block
TABLE log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
msg TEXT NOT NULL
);
4.6. Dollar-Quoted String Parsing
Dollar-quoted strings use the PostgreSQL syntax $$...$$ or
$tag$...$tag$ where tag is any identifier string (including the
empty string).
The compiler’s dollar-quote parser MUST implement the following algorithm:
On encountering the token
ASfollowed by optional whitespace followed by a dollar-quoted delimiter$[tag]$, record the opening delimiter stringD.Scan forward byte-by-byte without interpreting any content. No brace counting, no keyword scanning, no SQL parsing occurs inside the dollar-quoted region. Embedded
{,},;,CREATE,ALTER,DROP, or any other DPG keyword are treated as plain text.The first occurrence of the exact byte sequence
D(the same opening delimiter) encountered during the scan terminates the dollar-quoted region. Partial matches do NOT terminate.The bytes immediately following the closing
DMUST be;(per Rule T2). The semicolon is the Part 1 terminator.Named dollar-quoting (
$body$,$func$,$sql$, or any$identifier$) is fully supported. The opening and closing tag MUST match exactly, including case.
This algorithm allows function bodies to contain any content including embedded SQL DML, PL/pgSQL blocks, Python, Perl, JavaScript, nested dollar-quoted strings, or arbitrary binary data encoded as text — without any escaping or modification.
4.7. Macro Preprocessor
The macro preprocessor runs as the first phase of compilation, before any parsing. Macros are source-level text fragments that are expanded inline at points of use.
4.7.1. Macro Declaration
A macro declaration uses the MACRO keyword and has one of two
body forms.
macro-decl = "MACRO" WSP identifier WSP paren-body
/ "MACRO" WSP identifier WSP brace-body
paren-body = "(" *( column-def "," ) column-def ")"
brace-body = "{" *( block-directive ";" ) "}"
A paren-body macro contains a comma-separated list of column definitions, exactly as they would appear inside a
TABLE ( )list. The opening(and closing)are part of the body and are stripped when the macro is expanded.A brace-body macro contains zero or more block directives, exactly as they would appear inside a
{ }block. The opening{and closing}are part of the body and are stripped when the macro is expanded.
A MACRO declaration generates no SQL whatsoever. It MUST appear
at the top level of a .dpg file (not inside any { } or ( )
block). A MACRO declaration inside a block is a compiler error
(DPG-E007).
Examples:
MACRO common_timestamps (
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
)
MACRO audit_block {
OWNER "app_admin";
ENABLE ROW LEVEL SECURITY;
}
4.7.2. Macro Spread
The spread operator ...name expands a macro inline at the point
of use.
spread = "..." identifier
A paren-body macro MUST only be spread inside a ( ) list.
Spreading it inside a { } block is a compiler error (DPG-E008).
A brace-body macro MUST only be spread inside a { } block.
Spreading it inside a ( ) list is a compiler error (DPG-E009).
Spreading an undefined macro name is a compiler error (DPG-E010).
Example:
TABLE accounts (
id UUID NOT NULL DEFAULT gen_random_uuid(),
...common_timestamps,
CONSTRAINT pk_accounts PRIMARY KEY (id)
)
{
...audit_block
}
The compiler expands ...common_timestamps to:
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
and ...audit_block to:
OWNER "app_admin";
ENABLE ROW LEVEL SECURITY;
Expansion is performed textually before tokenization. The resulting source text is then tokenized and parsed as if written verbatim.
4.7.3. Macro Scoping Rules
Macros are project-scoped. A macro defined in any
.dpgfile within the compilation scope (all files for a given database pass through the compiler together) is visible in every other file. The compiler performs a global collection pre-pass over all source files before any file is tokenized, so declaration order across files does not matter.When the same macro name is defined in multiple files, the file-local definition takes precedence over any globally-collected definition. This allows individual files to override a shared macro with a specialised version.
A macro name MUST be unique within its file. Redefining a macro name in the same file is a compiler error (DPG-E011).
Macros MAY NOT be recursive (a paren-body may not contain a spread of itself or of any other macro that eventually spreads it). Circular macro references are a compiler error (DPG-E012).
The
MACROkeyword is a DPG preprocessor keyword and does not violate the No-Verb Mandate (Section 4.3).
4.8. Dual Definition Modes
For every collection-typed sub-object (indexes, policies, triggers, grants, revocations, partitions, column blocks), DPG supports two equivalent syntactic modes that MAY be freely mixed within the same file:
Mode A — Plural block: The collection keyword is the block header; individual entries omit the singular keyword.
INDICES {
idx_email (email);
idx_status (status) WHERE (status = 'active');
}
Mode B — Singular keyword: The singular keyword precedes each individual entry outside a plural block.
INDEX idx_email (email);
INDEX idx_status (status) WHERE (status = 'active');
Both forms are semantically identical. The compiler MUST merge them into a single logical collection before differencing.
The complete mapping of plural to singular keywords is:
| Plural block header | Singular keyword |
|---|---|
INDICES | INDEX |
POLICIES | POLICY |
TRIGGERS | TRIGGER |
GRANTS | GRANT |
REVOCATIONS | REVOCATION |
PARTITIONS | PARTITION |
COLUMNS | COLUMN |
CONSTRAINTS | CONSTRAINT |
4.9. Block Merging
When the same object is declared across multiple .dpg files (the
same schema-qualified name and kind), the compiler MUST merge all
declarations into a single logical object before IR construction.
Merge semantics follow Section 3.7.
Block merging occurs after macro expansion and before tokenization. The merged result is treated as if all declarations had appeared in a single file.
4.10. Identifiers
DPG follows PostgreSQL’s identifier rules:
An unquoted identifier consists of letters (including Unicode letters), digits, dollar signs, and underscores. It MUST begin with a letter or underscore. Unquoted identifiers are case-folded to lowercase.
A quoted identifier is enclosed in double-quotes (
"). It may contain any character except a double-quote. To include a literal double-quote, write"". Quoted identifiers are case-sensitive and preserve their original casing.
Schema-qualified names use the form schema.name or, for nested
objects, schema.table.column. The compiler resolves unqualified
names using the enclosing scope context established by SCHEMA { }
blocks, falling back to the database.default_schema configuration
value.
5. Type System
5.1. ENUM Types
ENUM types use PostgreSQL’s natural parenthesised list syntax.
The Part 1 body is the value list enclosed in ( ), terminated with
; per Rule T3. An optional { } block holds comments, owner,
rename, and value-removal migration directives.
owner-dir/renamed-from-dir in enum-block follow the same rules
as every other object kind: OWNER "..." emits ALTER TYPE name OWNER TO role (SAFE); RENAMED FROM old emits ALTER TYPE old RENAME TO new (CAUTION), and — per Section 7.6’s generic cross-schema
extension to renamed-from-dir — a schema-qualified old name also
emits ALTER TYPE old_schema.name SET SCHEMA new_schema (SAFE).
This closes the same gap for ENUM that Sections 7.6/7.11 already closed for
Table/View/Sequence: real PostgreSQL’s ALTER TYPE supports
RENAME TO/OWNER TO/SET SCHEMA for every CREATE TYPE variant
(ENUM, Composite, Range, Domain, Base) identically — see Section 5.2’s,
Section 5.3’s, and Section 5.5’s own { } blocks below for the same three
directives applied consistently.
PG equivalent: CREATE TYPE name AS ENUM ('v1', 'v2', ...)
enum-decl = "ENUM" WSP identifier WSP
"(" enum-values ")" ";"
[ "{" enum-block "}" ]
enum-values = enum-value *( "," WSP enum-value )
enum-value = SQUOTE identifier SQUOTE
[ WSP ( "BEFORE" / "AFTER" ) WSP SQUOTE identifier SQUOTE ]
[ WSP "RENAMED FROM" WSP SQUOTE identifier SQUOTE ]
enum-block = *( enum-directive ";" )
enum-directive = comment-dir
/ owner-dir
/ renamed-from-dir
/ migrate-remove-dir
Examples:
-- Minimal ENUM
ENUM user_status ('active', 'suspended', 'deleted');
-- ENUM with comment
ENUM invoice_status ('draft', 'sent', 'paid', 'void', 'overdue');
{
COMMENT 'Billing lifecycle states for customer invoices';
}
-- ENUM with value removal
ENUM order_status ('pending', 'confirmed', 'shipped', 'delivered');
{
COMMENT 'Order lifecycle states';
MIGRATE REMOVE ('cancelled') {
UPDATE orders SET status = 'delivered' WHERE status = 'cancelled';
}
}
5.1.1. Adding ENUM Values
When a new value appears in the DPG source that is absent from the snapshot, the compiler emits:
ALTER TYPE <schema>.<name> ADD VALUE '<new_value>';
ALTER TYPE ... ADD VALUE is emitted as a transactional step
(Safety: SAFE). PostgreSQL did not permit it inside a transaction
block in versions prior to 12 — confirmed against PostgreSQL 12.0’s
own release notes (“Allow enumerated values to be added more
flexibly”: previously ADD VALUE could not run in a transaction
block at all unless the type itself was created in that same
transaction; from 12 onward it can, as long as the new value isn’t
referenced until after commit) — but this restriction is moot for
every server DPG supports: Section 1.4’s documented PostgreSQL version
floor is 14, already past the version-12 cutoff, so the compiler
requires no server-version detection here and always emits it inside
the normal transactional migration. DPG’s own migrations never
reference a newly added value in the same migration that adds it
(DPG emits schema DDL only, never data-manipulation depending on the
new value), so the “not referenced until after commit” condition is
always satisfied.
Positional control (BEFORE/AFTER): An enum-value written as
'new_value' BEFORE 'existing_value' (or AFTER) emits ALTER TYPE <schema>.<name> ADD VALUE '<new_value>' BEFORE '<existing_value>' —
same Safety as plain ADD VALUE above. Omitting BEFORE/AFTER
always appends at the end, matching real PostgreSQL’s own default.
Renaming a value (RENAMED FROM): An enum-value written as
'new_value' RENAMED FROM 'old_value' — where 'old_value' is
present in the snapshot and 'new_value' is not — emits ALTER TYPE <schema>.<name> RENAME VALUE '<old_value>' TO '<new_value>', Safety
SAFE (a catalog-only rename with no transactional restriction in
any supported PostgreSQL version, same as ADD VALUE above).
Follows the same three-state resolution algorithm as column/table
RENAMED FROM (Section 7.6) — a value already present under its new name is
a no-op; neither name present is a stale-directive error (DPG-E021).
Without this, an enum value rename is unexpressable except as a
destructive remove-and-add pair.
5.1.2. Removing ENUM Values
Removing a value from an ENUM is not directly supported by
PostgreSQL. The MIGRATE REMOVE directive provides a safe migration
path. The MIGRATE REMOVE body is DML that runs before the type
is rebuilt to migrate existing data away from the removed value.
When a value is absent from the DPG source but present in the
snapshot, and a MIGRATE REMOVE directive covers it, the compiler
MUST emit the following sequence, all within a single transaction:
CREATE TYPE <schema>.<name>__dpg_new AS ENUM (<reduced-values>);— a new type with the value removed.Execute each DML statement in the
MIGRATE REMOVEbody verbatim.Verify that no rows in any column typed as
<name>still hold the removed value. If any remain, the compiler MUST abort the transaction and report error DPG-E013 with a table-by-table row count.For each table column typed as
<name>:ALTER TABLE <schema>.<table> ALTER COLUMN <col> TYPE <schema>.<name>__dpg_new USING <col>::text::<schema>.<name>__dpg_new;DROP TYPE <schema>.<name>;ALTER TYPE <schema>.<name>__dpg_new RENAME TO <name>;
On any failure in steps 2–6: DROP TYPE IF EXISTS <schema>.<name>__dpg_new;
and rollback.
When a value is absent from the source but no MIGRATE REMOVE
directive covers it, the compiler MUST emit a diagnostic (DPG-E014,
unguarded ENUM value removal) and refuse to proceed without the
--allow-destructive flag. Even with --allow-destructive, the
compiler MUST attempt to verify that no rows hold the removed value
before proceeding.
5.1.3. Reordering ENUM Values
PostgreSQL ENUM values have a fixed ordering. Reordering values
is treated as a remove + add and classified as DESTRUCTIVE unless
all affected values are brand new (not in the snapshot).
5.2. Composite Types
Composite types declare a row-structured type with named, typed
attributes. The attribute list uses ( ) per Rule T1.
PG equivalent: CREATE TYPE name AS (attr1 type1 [COLLATE collation], attr2 type2, ...)
composite-decl = "TYPE" WSP schema-name WSP "AS" WSP
"(" attribute-list ")"
[ ";" ]
[ "{" composite-block "}" ]
attribute-list = attribute *( "," WSP attribute )
attribute = identifier WSP type-ref [ WSP "COLLATE" WSP collation-name ]
composite-block = *( composite-directive ";" )
composite-directive = comment-dir / owner-dir / renamed-from-dir
/ attribute-block
; COLUMN-equivalent sub-block for attribute renames — same shape as
; Section 7.4's column-block, reused here per that section's own cross-
; reference rather than inventing a parallel "ATTRIBUTE { }" name.
attribute-block = "COLUMN" WSP identifier WSP "{" "RENAMED FROM" WSP identifier ";" "}"
COLLATE is valid only on attributes whose type has a
collatable base type (text, varchar, etc.) — DPG performs no
validation of this itself, left to PostgreSQL’s own parser (same
passthrough principle used throughout this document).
Example:
SCHEMA public {
TYPE address AS (
street TEXT COLLATE "en_US",
city TEXT,
state CHAR(2),
postal_code TEXT,
country CHAR(2)
)
{
OWNER "schema_admin";
}
}
Diffing semantics:
- Adding an attribute:
ALTER TYPE <name> ADD ATTRIBUTE <col> <type>—SAFE. - Dropping an attribute:
ALTER TYPE <name> DROP ATTRIBUTE <col>—DESTRUCTIVE. - Changing an attribute type or
COLLATE:ALTER TYPE <name> ALTER ATTRIBUTE <col> TYPE <new> [COLLATE collation]—DESTRUCTIVE. - Renaming an attribute:
COLUMN new_name { RENAMED FROM old_name; }inside the{ }block — the sameRENAMED FROMmechanism as table columns (Section 7.6) — emitsALTER TYPE <name> RENAME ATTRIBUTE old_name TO new_name—SAFE. - Owner changed:
ALTER TYPE <name> OWNER TO role—SAFE. - Renamed (
RENAMED FROMon the type itself, Section 5.1’s cross-reference):ALTER TYPE old RENAME TO new—CAUTION; schema-qualified additionally emitsALTER TYPE old_schema.name SET SCHEMA new_schema—SAFE.
5.3. Range Types
Range types use two ( ) groups: the first is the keyword AS RANGE
following the type name; the body is the options list.
PG equivalent: CREATE TYPE name AS RANGE (options)
range-decl = "TYPE" WSP schema-name WSP "AS RANGE" WSP
"(" range-options ")"
[ ";" ]
[ "{" range-block "}" ]
range-block = *( ( comment-dir / owner-dir / renamed-from-dir ) ";" )
Example:
SCHEMA public {
TYPE float8range AS RANGE (
SUBTYPE = float8,
SUBTYPE_DIFF = float8mi
);
}
Diffing semantics: Any change to a range type’s options requires
DROP TYPE CASCADE followed by CREATE TYPE. This is classified
as DESTRUCTIVE. OWNER/RENAMED FROM follow the standard rules
(Section 5.1): ALTER TYPE name OWNER TO role (SAFE); ALTER TYPE old RENAME TO new (CAUTION), plus SET SCHEMA when schema-qualified
(SAFE).
Multirange types: Every range type gets an auto-created
multirange companion type (PostgreSQL 14+, e.g. float8range →
float8multirange) the same way every type gets an auto-created
array companion — DPG does not declare it separately, and no DDL is
ever emitted for it; it is created and dropped automatically by
PostgreSQL alongside the range type itself.
5.4. Domain Types
Domains add a default and constraints to an existing base type.
The base type appears after AS. DEFAULT and constraints are
native CREATE DOMAIN clauses in real PostgreSQL — they appear in
Part 1, exactly as PostgreSQL itself writes them, per Tenet 5 (which
reserves the { } block for concepts PostgreSQL expresses as a
separate statement; a domain’s own DEFAULT/CHECK/NOT NULL
have no such separate form — ALTER DOMAIN ... SET DEFAULT/ADD CONSTRAINT are how an existing domain is later changed, not a
sign these clauses belong outside CREATE DOMAIN itself). An
earlier draft of this section forced all three into the { } block
in violation of Tenet 5’s own rule; this is corrected as of
Appendix E’s E.12 — see that entry for the resulting breaking
syntax change.
PG equivalent:
CREATE DOMAIN name AS base_type [COLLATE collation] [DEFAULT expr] [[CONSTRAINT name] {NOT NULL | NULL | CHECK (expr) [NOT VALID]}] ...
domain-decl = "DOMAIN" WSP schema-name WSP "AS" WSP type-name
[ WSP "COLLATE" WSP collation-name ]
[ WSP "DEFAULT" WSP expr ]
*( WSP domain-constraint )
";"
[ "{" domain-block "}" ]
domain-constraint = [ "CONSTRAINT" WSP identifier WSP ]
( "NOT NULL" / "NULL" / "CHECK" WSP "(" expr ")" [ WSP "NOT VALID" ] )
[ WSP "RENAMED FROM" WSP identifier ]
domain-block = *( ( comment-dir / owner-dir / renamed-from-dir
/ grants-block / revocations-block ) ";" )
The { } block holds only what has no place in native CREATE DOMAIN syntax: COMMENT, OWNER, RENAMED FROM (Section 7.6’s generic
cross-schema SET SCHEMA extension applies here identically),
GRANTS, REVOCATIONS — the same directive set Tenet 5 already
sanctions for every other object kind.
COLLATE on the domain itself is valid only when the base type
is collatable — left to PostgreSQL’s own parser to enforce, same
passthrough principle as everywhere else in this document.
NOT VALID: Real PostgreSQL’s CREATE DOMAIN grammar has no
NOT VALID option — it exists only on ALTER DOMAIN ... ADD CONSTRAINT. domain-constraint’s NOT VALID is therefore only
meaningful when the differ is adding a constraint to an
already-existing domain (the domain itself present in the
snapshot, the named constraint absent): that case emits ALTER DOMAIN <name> ADD CONSTRAINT <name> CHECK (...) NOT VALID —
CAUTION. When the whole CREATE DOMAIN statement itself is being
emitted for a brand-new domain, NOT VALID on any of its
constraints is silently omitted from that statement (there is
nothing yet to skip validating) — this mirrors the “no separate
opt-in step needed at creation” logic table constraints already
follow, without needing table’s placement restriction (Section 7.3), since
Domain has no ( )-list-vs-{ }-block distinction to enforce here.
VALIDATE CONSTRAINT: When NOT VALID is removed from an
existing constraint in source, the compiler emits ALTER DOMAIN <name> VALIDATE CONSTRAINT <name> — CAUTION — the same two-step
lifecycle as table constraints (Section 7.3).
RENAMED FROM on a constraint emits ALTER DOMAIN <name> RENAME CONSTRAINT old TO new — SAFE — instead of the drop-and-recreate a
name-only difference would otherwise trigger under name-based
constraint identity.
Example:
SCHEMA public {
DOMAIN positive_integer AS INTEGER
DEFAULT 1
CONSTRAINT positive_only CHECK (VALUE > 0)
CONSTRAINT reasonable_max CHECK (VALUE < 1000000);
}
Diffing semantics:
- Adding a
DEFAULT:ALTER DOMAIN <name> SET DEFAULT <expr>—SAFE. - Dropping a
DEFAULT:ALTER DOMAIN <name> DROP DEFAULT—SAFE. - Adding a constraint:
ALTER DOMAIN <name> ADD CONSTRAINT <name> CHECK (...) [NOT VALID]—CAUTION. NOT VALIDremoved from an existing constraint:ALTER DOMAIN <name> VALIDATE CONSTRAINT <name>—CAUTION.- Constraint renamed only (
RENAMED FROM):ALTER DOMAIN <name> RENAME CONSTRAINT old TO new—SAFE. - Dropping a constraint:
ALTER DOMAIN <name> DROP CONSTRAINT <name>—SAFE. - Changing the base type or
COLLATE: requiresDROP DOMAIN CASCADE+CREATE DOMAIN—DESTRUCTIVE. - Owner changed:
ALTER DOMAIN <name> OWNER TO role—SAFE. - Renamed (
RENAMED FROMon the domain itself):ALTER DOMAIN old RENAME TO new—CAUTION; schema-qualified additionally emitsALTER DOMAIN old_schema.name SET SCHEMA new_schema—SAFE.
5.5. Base (Shell) Types
Base types implement a custom storage type using C-defined input and
output functions. The body is the PostgreSQL CREATE TYPE options
list. New base types are SAFE; removal is DESTRUCTIVE. A
property change is diffed per-key rather than as a single text
hash (see below), since real PostgreSQL’s ALTER TYPE ... SET (...)
can update a specific subset of properties in place.
PG equivalent: CREATE TYPE name (INPUT = func, OUTPUT = func, ...) — or, forward-declared, bare CREATE TYPE name;
base-type-decl = "TYPE" WSP schema-name
( WSP "(" storage-params ")" [ ";" ]
/ ";" )
[ "{" base-type-block "}" ]
base-type-block = *( ( comment-dir / owner-dir / renamed-from-dir ) ";" )
Bare forward-declaration shell: TYPE name; with no option list
and no support functions is a complete, standalone declaration — real
PostgreSQL’s “shell type,” a placeholder with only a name and an
owner. This is the first half of the standard workflow for a
self-referential base type: the type’s own INPUT/OUTPUT
functions (Section 9, LANGUAGE C) must declare an argument or return type
of mytype before mytype itself has a full definition, which is
impossible unless the shell already exists.
Automatic cycle-breaking: When a full-option base-type-decl
(e.g. TYPE mytype (INPUT = mytype_in, OUTPUT = mytype_out, ...)) is
new and its INPUT/OUTPUT/etc. functions are also new and
themselves reference mytype in their own argument or return type
(Section 9, e.g. FUNCTION mytype_in(cstring) RETURNS mytype ...), the
dependency graph (Section 22) detects the resulting circular reference the
same way it already detects circular DEFERRABLE foreign keys
(Section 22.2) — a distinct cycle kind, broken by a distinct mechanism
specific to this case rather than Section 22.2’s FK-specific algorithm:
the compiler simply omits the usual Function-depends-on-its-param/
return-type ordering edge for this specific case, so the CREATE FUNCTION statements that reference mytype get emitted before the
full CREATE TYPE mytype (INPUT = ..., OUTPUT = ..., ...) — no
separate, explicit bare-shell statement is ever emitted. Real
PostgreSQL’s own LANGUAGE internal/LANGUAGE c function creation
auto-creates the shell type implicitly the moment a not-yet-existing
return/argument type is referenced this way (“Creating a shell type
definition”, confirmed live), and later treats the full CREATE TYPE
as replacing that auto-created shell rather than erroring. A user
writing a self-referential base type declares only the single
full-option form in source; the reordering (and PostgreSQL’s own
implicit shell creation triggered by it) is internal.
SCHEMA public {
TYPE mytype (
INPUT = mytype_in,
OUTPUT = mytype_out,
INTERNALLENGTH = 16,
ALIGNMENT = double
);
}
Property-change diffing: Real PostgreSQL’s ALTER TYPE name SET (...) can update exactly seven support-function/storage properties
in place, without a rebuild: RECEIVE, SEND, TYPMOD_IN,
TYPMOD_OUT, ANALYZE, SUBSCRIPT (each settable to a function
name or NONE), and STORAGE (a storage mode, not a function —
note that changing it only affects columns created after the
change, not existing ones, a PostgreSQL-level caveat, not a DPG one).
Every other property (INPUT, OUTPUT, INTERNALLENGTH,
ALIGNMENT, PASSEDBYVALUE, CATEGORY, PREFERRED, DEFAULT,
ELEMENT, DELIMITER, COLLATABLE, LIKE) is fixed at creation
with no ALTER TYPE equivalent at all.
| Change | DDL emitted | Safety |
|---|---|---|
Only RECEIVE/SEND/TYPMOD_IN/TYPMOD_OUT/ANALYZE/SUBSCRIPT/STORAGE differ | ALTER TYPE name SET (key = value | NONE, ...) | SAFE |
| Any other property differs (alone or alongside the above) | DROP TYPE CASCADE + CREATE TYPE | DESTRUCTIVE |
| Type removed | DROP TYPE name [CASCADE] | DESTRUCTIVE |
OWNER/RENAMED FROM follow the standard rules (Section 5.1): ALTER TYPE name OWNER TO role (SAFE); ALTER TYPE old RENAME TO new
(CAUTION), plus SET SCHEMA when schema-qualified (SAFE).
5.6. Virtual Types
Virtual types are DPG-native DDL constructs that give a structural
schema to JSON / JSONB columns and JSON array columns. They
have no backing PostgreSQL type — no CREATE TYPE, ALTER TYPE, or
DROP TYPE is ever emitted. A table column or composite type
attribute may declare its type as a virtual type name; DPG resolves
that reference to jsonb (scalar) or jsonb[] (array) in all
generated SQL. The structured body is stored in the snapshot so
downstream consumers (ORM generators, type-safe query builders) can
read type information via the pkg/dpg API or the snapshot JSON.
virtual-type-decl = "VIRTUAL TYPE" WSP schema-name WSP "AS" WSP
vtype-body ";" [ "{" vtype-block "}" ]
vtype-body = vtype-union
vtype-union = vtype-term *( WSP "|" WSP vtype-term )
vtype-term = vtype-composite / vtype-typeref
vtype-composite = "(" vtype-field *( "," WSP vtype-field ) ")"
vtype-field = identifier WSP vtype-typeref
vtype-typeref = [ schema-name "." ] identifier [ "[]" ]
vtype-block = *( comment-dir / preferred-json-format-dir ) ";"
preferred-json-format-dir = "PREFERRED" WSP "JSON" WSP "FORMAT" WSP
( "json" / "jsonb" )
The compiler MUST parse and validate the body. A vtype-typeref
that appears in a column or composite attribute position resolves to
the virtual type’s preferred JSON format (json or jsonb, default
jsonb) in SQL output; the virtual type name is never written to the
database.
Body forms:
Type reference — a single PG built-in or user-defined type name, or a reference to another declared
VIRTUAL TYPE:VIRTUAL TYPE label AS text; VIRTUAL TYPE metric AS numeric; VIRTUAL TYPE named_point AS point; -- references virtual type "point" VIRTUAL TYPE tags AS text[]; -- array formComposite — an inline record with named, typed fields. Field types MAY themselves be virtual type references:
VIRTUAL TYPE point AS (x float8, y float8); VIRTUAL TYPE line_item AS ( sku text, quantity integer, price numeric );Union — two or more terms joined with
|. Any combination of composites and type references is valid:VIRTUAL TYPE payment AS (kind text, amount numeric, currency text) | (kind text, token text) | text;
Usage as a column or field type:
TABLE orders (
id bigint,
items line_item[] -- virtual type ref: emits "jsonb[]" in SQL
) { }
TYPE address AS (
street text,
detail address_detail -- virtual type ref: emits "jsonb"
);
Adding [] to the reference signals a JSON array column and causes
DPG to emit jsonb[] instead of jsonb.
Rules:
VIRTUAL TYPEMAY be schema-qualified; if unqualified it defaults todefault_schema.No
CREATE TYPE,ALTER TYPE, orDROP TYPEis EVER emitted for a virtual type.Virtual types appear in the snapshot under
"kind": "virtual_type"for round-trip consistency.dpg planproduces no SQL for additions, modifications, or removals of virtual types.When a column or composite attribute type resolves to a virtual type, the generated SQL uses
jsonb/jsonb[]. The virtual type name is never written to the database catalog.The
{ }block acceptsCOMMENTandPREFERRED JSON FORMAT. Any other directive is a compiler error (DPG-E015).PREFERRED JSON FORMAT jsoncauses DPG to emitjson/json[]instead of the defaultjsonb/jsonb[]when this virtual type is used as a column or attribute type.
6. Schema and Namespace Objects
6.1. SCHEMA
Schemas have no ( ) list. Their { } block directly holds all
schema-level attributes and nested objects.
PG equivalent: CREATE SCHEMA [IF NOT EXISTS] name [AUTHORIZATION role]
schema-decl = "SCHEMA" WSP identifier
"{" schema-block "}"
schema-block = *( schema-directive / nested-object )
schema-directive = owner-dir
/ comment-dir
/ renamed-from-dir
/ grants-block
/ default-privileges-decl
Examples:
SCHEMA public {
-- Objects in the public schema declared inline here
}
SCHEMA analytics {
OWNER "analytics_role";
COMMENT 'Derived tables and event aggregations';
TABLE events ( ... ) { ... }
FUNCTION compute_daily() RETURNS VOID LANGUAGE plpgsql AS $$ ... $$;
}
Renaming:
SCHEMA reporting {
RENAMED FROM old_reporting;
}
Emits: ALTER SCHEMA old_reporting RENAME TO reporting; — Safety CAUTION.
public schema default privileges (PostgreSQL 15+): PostgreSQL
15 changed a fresh cluster’s public schema to no longer grant
CREATE to PUBLIC by default (versions before 15 did) — a
behavioral default, not a DPG grammar concern, but worth stating
explicitly per Tenet 3: a project targeting a pre-15 server may find
public’s live privileges differ from what a project first
introspected against 15+ would show, through no DPG action of its
own. DPG does not special-case this — a declared GRANTS { CREATE TO PUBLIC; } on public (Sections 7.10-style grants, applied to the schema)
reproduces the pre-15 default explicitly and portably regardless of
server version, the same way any other declared grant does.
RENAMED FROM with no other content is valid:
SCHEMA new_name {
RENAMED FROM old_name;
}
Diffing semantics:
| Change | DDL emitted | Safety |
|---|---|---|
| New schema | CREATE SCHEMA name | SAFE |
| RENAMED FROM | ALTER SCHEMA old RENAME TO new | CAUTION |
| OWNER change | ALTER SCHEMA name OWNER TO role | SAFE |
| COMMENT change | COMMENT ON SCHEMA name IS '...' | SAFE |
| Schema removed | DROP SCHEMA name [RESTRICT|CASCADE] | DESTRUCTIVE |
6.2. EXTENSION
Extensions are database-level objects and MUST be declared in a
database .dpg file (not in the cluster objects directory).
PG equivalent:
CREATE EXTENSION [IF NOT EXISTS] name [SCHEMA schema] [VERSION version] [CASCADE]
extension-decl = "EXTENSION" WSP identifier
[ WSP "SCHEMA" WSP identifier ]
[ WSP "VERSION" WSP SQUOTE version SQUOTE ]
[ WSP "CASCADE" ]
";"
Examples:
EXTENSION pgcrypto;
EXTENSION postgis SCHEMA public VERSION '3.3';
EXTENSION pg_trgm CASCADE;
Diffing semantics:
| Change | DDL emitted | Safety |
|---|---|---|
| New extension | CREATE EXTENSION IF NOT EXISTS name [SCHEMA ...] [VERSION ...] [CASCADE] | SAFE |
| VERSION change | ALTER EXTENSION name UPDATE [TO version] | CAUTION |
| SCHEMA change | ALTER EXTENSION name SET SCHEMA new_schema | SAFE |
| Extension removed | DROP EXTENSION name [CASCADE] | DESTRUCTIVE |
SCHEMA changes use ALTER EXTENSION ... SET SCHEMA rather than
drop-and-recreate — real PostgreSQL supports moving any relocatable
extension this way without dropping it (most extensions are
relocatable; a handful, e.g. ones with hardcoded schema-qualified
references in their own SQL objects, are not — PostgreSQL itself
rejects the statement for those, left to its own error per the
passthrough-validation principle used throughout this document).
7. Tables
7.1. Table Declaration Syntax
Tables are the most syntactically rich object type in DPG. The complete grammar is:
table-decl = [ "UNLOGGED" WSP ] "TABLE" WSP schema-table-name WSP
( "(" column-list ")"
/ "OF" WSP type-ref [ WSP "(" typed-column-list ")" ] )
*( table-clause )
[ ";" ]
[ "{" table-block "}" ]
/ "FOREIGN TABLE" WSP schema-table-name WSP
"(" column-list ")"
*( table-clause )
WSP "SERVER" WSP identifier
[ WSP "OPTIONS" WSP "(" option-list ")" ]
[ ";" ]
[ "{" table-block "}" ]
column-list = column-item *( "," WSP column-item )
column-item = column-def / table-constraint / like-clause
; CREATE TABLE ... (LIKE source_table [{INCLUDING|EXCLUDING} ...]) — a
; column-list item copying another table's column definitions (and,
; selectively, its constraints/indexes/defaults/etc.) rather than
; restating them. May appear anywhere column-def/table-constraint may,
; including multiple times and mixed with either.
like-clause = "LIKE" WSP table-ref *( WSP like-option )
like-option = ( "INCLUDING" / "EXCLUDING" ) WSP like-attribute
like-attribute = "COMMENTS" / "COMPRESSION" / "CONSTRAINTS" / "DEFAULTS"
/ "GENERATED" / "IDENTITY" / "INDEXES" / "STATISTICS"
/ "STORAGE" / "ALL"
; CREATE TABLE ... OF type_name — "Form 2" typed tables, backing a
; previously-declared composite type (Section 5.2) instead of an independent
; column list. The optional parenthesized list may only narrow
; constraints on the type's existing attributes (WITH OPTIONS) or add
; table-level constraints — it cannot add new columns, matching real
; PostgreSQL's own restriction.
typed-column-list = typed-column-item *( "," WSP typed-column-item )
typed-column-item = col-name WSP "WITH OPTIONS" WSP *( col-constraint )
/ table-constraint
table-clause = WITH "(" storage-params ")"
/ TABLESPACE identifier
/ INHERITS "(" table-ref-list ")"
/ partition-by-clause
/ "USING" WSP method
table-block = *( table-directive )
table-directive = owner-dir
/ comment-dir
/ renamed-from-dir
/ detached-from-dir
/ protected-dir
/ deprecated-dir
/ drop-cascade-dir
/ rls-enable-dir
/ rls-force-dir
/ replica-identity-dir
/ cluster-dir
/ column-block
/ indices-block
/ policies-block
/ triggers-block
/ grants-block
/ revocations-block
/ constraint-dir
/ partitions-block
Per Rule T1, when the table has a { } block, there is NO
semicolon between ) (or the last table-clause) and {. When
the table has NO { } block, the Part 1 is terminated with ;
after the last table-clause (or directly after )).
LIKE source_table: May appear anywhere inside the ( ) list,
mixed freely with ordinary column definitions and table constraints,
including more than once. Each {INCLUDING|EXCLUDING} <attribute>
pair is emitted verbatim into the generated CREATE TABLE in
declaration order, matching real PostgreSQL’s own left-to-right
LIKE option semantics (a later option for the same attribute wins).
Omitting all {INCLUDING|EXCLUDING} clauses copies only column names
and types, PostgreSQL’s own default. Diffing treats a table declared
via LIKE no differently from one with an equivalent explicit column
list — the snapshot records the resolved columns/constraints, not
the LIKE clause itself, since source_table may change or
disappear independently after the copy is made.
TABLE order_items_archive (
LIKE order_items INCLUDING DEFAULTS INCLUDING CONSTRAINTS,
archived_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
OF type_name (typed tables): Backs the table’s row type with a
previously-declared composite type (Section 5.2) instead of an independent
column list. WITH OPTIONS narrows a constraint (NOT NULL,
DEFAULT, etc.) on one of the type’s existing attributes — real
PostgreSQL rejects any attempt to introduce a new column this way,
and DPG does not attempt to detect that error itself; it is left to
PostgreSQL’s own parser (per the passthrough principle already
applied to every other clause-combination rule in this document, see
e.g. Section 7.9’s REFERENCING note). Adding, changing, or removing the
OF type_name association after creation is an ALTER-side
operation — see Section 7.11’s OF type_name/NOT OF entry.
TYPE address AS (street TEXT, city TEXT, zip TEXT) {}
TABLE shipping_address OF address (
street WITH OPTIONS NOT NULL,
CONSTRAINT zip_format CHECK (zip ~ '^[0-9]{5}$')
);
USING method (table access method): Selects the storage access
method (e.g. a columnar extension’s method, once installed via
CREATE EXTENSION) instead of the cluster’s default. Positioned
among the other table-clause alternatives, matching real
PostgreSQL’s clause ordering after the column list / OF type_name
and before WITH (...)/TABLESPACE. Changing USING post-creation
is a separate, ALTER-side concern — see Section 21’s SET ACCESS METHOD row.
7.2. Column Definitions
Column definitions appear inside the ( ) list and follow
PostgreSQL’s CREATE TABLE column syntax exactly.
column-def = col-name WSP type-ref
*( col-constraint )
col-constraint = "NOT NULL" [ WSP "NO INHERIT" ]
/ "NULL"
/ "DEFAULT" WSP expr
/ "GENERATED ALWAYS AS" WSP "(" expr ")" WSP ( "STORED" / "VIRTUAL" )
/ "GENERATED ALWAYS AS IDENTITY" [ identity-opts ]
/ "GENERATED BY DEFAULT AS IDENTITY" [ identity-opts ]
/ "PRIMARY KEY" [ conflict-clause ]
/ "UNIQUE" [ nulls-distinct ] [ conflict-clause ]
/ "REFERENCES" WSP table-ref [ ref-opts ] [ WSP enforced-clause ]
/ "CHECK" WSP "(" expr ")" [ no-inherit ] [ WSP enforced-clause ]
/ "CONSTRAINT" WSP identifier WSP col-constraint
/ "COMPRESSION" WSP method
/ "COLLATE" WSP collation-name
identity-opts = "(" "START WITH" int
[ "INCREMENT BY" int ]
[ "MINVALUE" int / "NO MINVALUE" ]
[ "MAXVALUE" int / "NO MAXVALUE" ]
[ "CACHE" int ]
[ "CYCLE" / "NO CYCLE" ]
")"
ref-opts = [ "MATCH FULL" / "MATCH PARTIAL" / "MATCH SIMPLE" ]
[ "ON DELETE" ref-action ]
[ "ON UPDATE" ref-action ]
ref-action = "NO ACTION" / "RESTRICT" / "CASCADE" /
"SET NULL" [ WSP "(" col-list ")" ] /
"SET DEFAULT" [ WSP "(" col-list ")" ]
; PostgreSQL 18+: CHECK/FOREIGN KEY constraints may be declared
; metadata-only (validated by application logic instead of PostgreSQL
; itself) — a real Tenet-1 gap distinct from NOT VALID (which still
; enforces the constraint once validated; NOT ENFORCED never enforces
; it at all).
enforced-clause = "ENFORCED" / "NOT ENFORCED"
VIRTUAL generated columns (PostgreSQL 18+): computed on read
rather than stored — the alternative to the existing STORED form.
Diffed per Section 7.4’s generated-column diffing table — the
STORED/VIRTUAL keyword itself is part of the generated-column
identity the differ compares, so switching between them is a change
like any other, but real PostgreSQL’s own ALTER surface is narrower
for VIRTUAL than for STORED: there is no in-place path for adding
generation to a plain column or for switching STORED↔VIRTUAL
(both always drop-and-recreate the column, regardless of kind), SET EXPRESSION works identically for either kind, and DROP EXPRESSION
— unlike STORED — is rejected outright for VIRTUAL (see Section
7.4’s table for the exact transitions and DDL).
NOT NULL ... NO INHERIT: By default a NOT NULL constraint is
inherited by child tables (INHERITS/partitions). NO INHERIT
scopes it to this table only — the same modifier CHECK constraints
already had (no-inherit), now also expressible on NOT NULL,
inline (here) or as a named table-level constraint (Section 7.3’s new
NOT NULL table-constraint-body form, below).
ENFORCED/NOT ENFORCED (PostgreSQL 18+, CHECK/FOREIGN KEY
only): a NOT ENFORCED constraint is recorded in the catalog and
visible to tooling but never checked by PostgreSQL itself — for
constraints an application enforces at a different layer and wants
documented declaratively without paying PostgreSQL’s own enforcement
cost. Omitting the clause means ENFORCED, PostgreSQL’s default.
FK ON DELETE/ON UPDATE SET NULL/SET DEFAULT (col-list)
(PostgreSQL 15+): for a multi-column foreign key, scopes which
columns get set to NULL/their default on the referenced row’s
deletion/update — previously only “all FK columns” was expressible
(bare SET NULL/SET DEFAULT, still valid and still the default
when (col-list) is omitted).
PRIMARY KEY implies NOT NULL: PostgreSQL enforces that every PRIMARY KEY column is implicitly NOT NULL. The DPG compiler MUST apply the same inference:
- Writing
NOT NULLon a PRIMARY KEY column in the source is accepted but is silently treated as redundant. - The compiler MUST NOT emit a redundant
NOT NULLclause for PRIMARY KEY columns in generatedCREATE TABLEDDL. - The compiler MUST NOT emit a spurious
ALTER COLUMN SET NOT NULLwhen diffing a PRIMARY KEY column.
Inline vs. table-level constraints: Both forms are accepted and
treated as semantically equivalent. The compiler MUST normalise
single-column PRIMARY KEY, UNIQUE, CHECK, and REFERENCES
constraints to the inline form in its emitted CREATE TABLE.
Multi-column constraints MUST remain table-level. Named
single-column constraints (e.g. CONSTRAINT pk_x PRIMARY KEY) are
emitted inline in the CREATE TABLE output.
Examples:
-- Inline form (preferred for single-column constraints)
TABLE accounts (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
org UUID NOT NULL REFERENCES organisations (id) ON DELETE CASCADE
);
-- Named inline constraint
TABLE accounts (
id UUID NOT NULL DEFAULT gen_random_uuid()
CONSTRAINT pk_accounts PRIMARY KEY
);
-- Table-level (required for multi-column constraints)
TABLE order_items (
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
CONSTRAINT pk_order_items PRIMARY KEY (order_id, product_id)
);
Column type change diffing:
- A type change that PostgreSQL can apply implicitly
(e.g.,
VARCHAR(10)→VARCHAR(20)) is classifiedCAUTION. - A type change requiring an explicit cast is classified
DESTRUCTIVEunless aUSINGexpression is present in theCOLUMN name { USING expr; }block, in which case it is classifiedCAUTION. - The
ALTER TABLE <t> ALTER COLUMN <c> TYPE <new> [USING <expr>]statement acquires anACCESS EXCLUSIVElock for the duration of the rewrite, which is why it is always at leastCAUTION.
Implementation status of “PostgreSQL can apply implicitly” (as of this writing): fully implemented, across both mechanisms this bullet covers:
- A type change between two genuinely different base types (e.g.
smallint→integer,real→double precision) isCAUTIONwhen PostgreSQL has a real implicit cast (pg_cast.castcontext = 'i') between them,DESTRUCTIVEotherwise — seeinternal/diff/ implicit_casts.gofor the full table (extracted verbatim from a live PostgreSQL 17 catalog, not reconstructed from memory, with a live integration test cross-checking it against a fresh container so it can never silently drift out of sync with a future PostgreSQL version). - This bullet’s own literal example,
VARCHAR(10)→VARCHAR(20), is a same-base-type typmod (length/precision/scale) widening — a genuinely different mechanism frompg_cast(which has no entry for a type to itself). Implemented ininternal/diff/ typmod_widening.go, one rule per type family, each verified live against a real PostgreSQL 17 container (several turned out surprising, not guessable by analogy — see the file’s own comments for the live evidence behind each):character varying(n)/bit varying(n): widening (n₂ ≥ n₁) is safe; dropping the length entirely is always safe (target is unbounded); shrinking, or going from unbounded to bounded, is not.character(n): same widening rule — confirmed live it physically re-pads stored values. Barecharacter(no length) is not treated as unbounded, unlikecharacter varying— PostgreSQL’s own bare-word default forCHARACTERischaracter(1), a fixed narrow width.bit(n)(fixed-length): never safe in either direction — confirmed live that even widening errors outright (“bit string length 4 does not match type bit(8)”); unlikecharacter, fixedbithas no auto-pad/auto-truncate coercion at all.numeric(p,s): safe iff p₂≥p₁ and s₂≥s₁, or the target is fully unbounded. Shrinking scale alone (even with precision held or increased) does not error — PostgreSQL silently rounds the stored value with no signal — so any decrease in either component staysDESTRUCTIVE.time/timestamp(with or without time zone)/interval(p)(fractional-seconds precision): safe iff p₂≥p₁, or the target drops the precision entirely (PostgreSQL’s bare-word form is already maximum precision, 6). Shrinking silently truncates the stored value, same failure shape asnumericscale.- Arrays are deliberately excluded from this mechanism (stays
DESTRUCTIVE) — the widening rules above were only verified live for the scalar case.
Fixed alongside this workstream, found via live round-trip testing: a
real, high-impact pre-existing bug where a bare TIMESTAMP/TIME
column (no precision) rendered with a different spelling than
PostgreSQL’s own format_type() ("timestamp" vs. "timestamp without time zone"), causing permanent spurious DESTRUCTIVE drift
on every plan --live/verify for any table with one of these
extremely common column types; and a separate bug where BIT VARYING(n)/BIT(n)’s length was silently dropped from emitted DDL
entirely, which for fixed BIT(n) meant the applied column was
PostgreSQL’s own bare-word default (bit(1)) — genuinely different,
narrower, from what was declared, with no error anywhere in the
pipeline. Both self-heal on an existing project’s first plan/apply
after upgrading (legacyTypeNameBeforeFix in internal/diff/ differ.go), the same one-time upgrade-effect pattern already used for
the SERIAL IR-modeling and plpgsql BodyHash upgrades.
7.3. Constraints
Table constraints may appear in the ( ) list, in the { } block,
or in both. The compiler identifies constraints by name and merges
them into a single logical set. Same name + same definition =
deduplicated. Same name + different definition = DPG-E005.
table-constraint = "CONSTRAINT" WSP identifier WSP table-constraint-body
[ "NOT VALID" ]
[ WSP enforced-clause ]
[ "DEFERRABLE" [ "INITIALLY DEFERRED" / "INITIALLY IMMEDIATE" ] ]
[ WSP "RENAMED FROM" WSP identifier ]
table-constraint-body
= "PRIMARY KEY" WSP "(" col-list [ "," WSP col-name WSP "WITHOUT OVERLAPS" ] ")"
/ "UNIQUE" [ "NULLS NOT DISTINCT" ] WSP "(" col-list [ "," WSP col-name WSP "WITHOUT OVERLAPS" ] ")"
/ "CHECK" WSP "(" expr ")" [ "NO INHERIT" ]
/ "FOREIGN KEY" WSP "(" col-list [ "," WSP "PERIOD" WSP col-name ] ")" WSP
"REFERENCES" WSP table-ref WSP "(" col-list [ "," WSP "PERIOD" WSP col-name ] ")" ref-opts
/ "EXCLUDE" WSP "USING" WSP method WSP "(" excl-list ")"
[ "WHERE" "(" expr ")" ]
/ "NOT NULL" WSP col-name [ WSP "NO INHERIT" ]
NOT VALID lifecycle:
First migration:
ALTER TABLE <t> ADD CONSTRAINT <name> ... NOT VALID;— SafetyCAUTION.When
NOT VALIDis removed in source, the compiler emits:ALTER TABLE <t> VALIDATE CONSTRAINT <name>;— SafetyCAUTION.After validation, the constraint MAY be moved to the
( )list. The compiler identifies it by name and treats it as already existing (no new DDL emitted).
NOT VALID placement: A constraint with NOT VALID MUST be
declared in the { } block. Writing NOT VALID in the ( ) list
is a compiler error (DPG-E016) because PostgreSQL itself does not
support NOT VALID in CREATE TABLE.
DEFERRABLE FK cycles: When two tables have a circular foreign
key dependency and both FKs are DEFERRABLE, the compiler emits
both CREATE TABLE statements first, then the circular FK as a
subsequent ALTER TABLE ADD CONSTRAINT ... DEFERRABLE. If a cycle
exists with no DEFERRABLE FK, the compiler emits error DPG-E017
with the full dependency cycle listed.
RENAMED FROM on a constraint (like index RENAMED FROM, Section 7.7)
emits ALTER TABLE t RENAME CONSTRAINT old TO new — SAFE,
metadata-only — instead of the drop-and-recreate that a name-only
difference would otherwise trigger under name-based constraint
identity (Section 7.3’s opening paragraph). MUST appear in the { }
block, the same placement restriction as NOT VALID, since it is
itself a lifecycle directive rather than a CREATE TABLE-native
clause.
Deferrability-only changes: When an existing FOREIGN KEY
constraint’s DEFERRABLE/INITIALLY DEFERRED/INITIALLY IMMEDIATE
flags differ from the snapshot but every other part of the
constraint (columns, REFERENCES target, ref-opts) is unchanged,
the compiler emits ALTER TABLE t ALTER CONSTRAINT name [[NOT] DEFERRABLE] [INITIALLY DEFERRED|IMMEDIATE] — SAFE — instead of
drop + re-add. Real PostgreSQL’s ALTER CONSTRAINT supports only
this narrow deferrability-timing change and only for FOREIGN KEY
constraints; any other simultaneous change, or a non-FK constraint,
falls back to the general “constraint changed” drop-and-recreate row
below.
NOT NULL constraint inheritability (PostgreSQL 18+): When an
existing table-level NOT NULL constraint’s NO INHERIT flag
differs from the snapshot but the column it applies to is unchanged,
the compiler emits ALTER TABLE t ALTER CONSTRAINT name [NO] INHERIT — SAFE — instead of drop + re-add. This is a separate,
distinctly-named ALTER CONSTRAINT variant from the FK deferrability
one above: real PostgreSQL’s ALTER CONSTRAINT grammar is
constraint-kind-specific (deferrability for FK, inheritability for
NOT NULL), not one shared option set.
Table-level NOT NULL (PostgreSQL 18+): NOT NULL became a
proper named, catalogued constraint in PostgreSQL 18 — CONSTRAINT name NOT NULL col_name [NO INHERIT] [NOT VALID] is now addable via
ALTER TABLE ... ADD CONSTRAINT the same way CHECK already is,
including a NOT VALID/VALIDATE CONSTRAINT lifecycle identical to
Section 7.3’s existing one — retroactively marking an existing NOT NULL
column’s constraint NOT VALID (skipping the immediate full-table
scan) was not possible before PostgreSQL 18 at all. This is
additive to the inline column-level NOT NULL [NO INHERIT] form
(Section 7.2) real PostgreSQL has supported since 18 as well — both forms
describe the same underlying named constraint; DPG does not require
either one specifically.
Temporal keys (WITHOUT OVERLAPS/PERIOD, PostgreSQL 18+): A
trailing col_name WITHOUT OVERLAPS on PRIMARY KEY/UNIQUE
marks an existing range or multirange column as the temporal-overlap
comparison key. Under the hood, real PostgreSQL enforces this via an
exclusion search using the range type’s own operators (populated into
pg_constraint.conexclop), but the constraint itself is still
catalogued as an ordinary PRIMARY KEY/UNIQUE (contype 'p'/'u'),
not reclassified as EXCLUDE (contype 'x') — confirmed live
against a PostgreSQL 18 server. A FOREIGN KEY’s trailing PERIOD col_name in both the local and REFERENCES column lists declares a
temporal foreign key, valid only for the referenced row’s own
validity period; that column, too, must already exist as a range or
multirange type — real PostgreSQL 18 has no PERIOD FOR name (start_col, end_col) construct for declaring a generated range from
two plain columns (confirmed live: PERIOD FOR is a syntax error
against a real PostgreSQL 18 server, and the official PostgreSQL 18
documentation defines no such clause). This document previously
described such a construct in error; declaring the range or multirange
column itself (directly, or via EXCLUDE/a generated column, Section
7.4) is the only way to get one. Non-range key columns (e.g. a bare
DATE pair) need CREATE EXTENSION btree_gist for the exclusion
search to have an applicable operator class — PostgreSQL’s own error,
left to it per the passthrough-validation principle used throughout
this document:
TABLE room_bookings (
room_id BIGINT NOT NULL,
valid_at DATERANGE NOT NULL,
CONSTRAINT no_double_booking
PRIMARY KEY (room_id, valid_at WITHOUT OVERLAPS)
);
EXCLUDE and identity columns on partitioned tables (PostgreSQL
17+): Both were previously rejected by PostgreSQL on a partitioned
table (EXCLUDE required including the partition key in the
exclusion; identity columns were disallowed entirely). Neither
restriction was ever encoded in this document’s own grammar — table-constraint-body’s EXCLUDE alternative and col-constraint’s
GENERATED ... AS IDENTITY alternatives (Section 7.2) already applied
unconditionally to any table, partitioned or not — so both older
restrictions and PostgreSQL 17’s lifting of them are handled
entirely by PostgreSQL’s own version-aware parser at apply time,
with no DPG grammar change needed either direction (the same
passthrough-validation principle used throughout this document).
DROP CONSTRAINT ... ONLY on partitioned tables (PostgreSQL 18+):
real PostgreSQL can now drop a constraint from just a partitioned
parent (ALTER TABLE ONLY parent DROP CONSTRAINT name), leaving it
in place on the existing partitions — breaking the normal
parent-constraint-propagates-to-partitions link. Confirmed live
against PostgreSQL 17 and 18 servers: PostgreSQL 17 rejects the
statement outright (“cannot remove constraint from only the
partitioned table when partitions exist”); PostgreSQL 18 succeeds,
flipping the constraint’s existing row on every current partition
from inherited (conislocal = false) to local (conislocal = true) rather than dropping it there too — the identical catalog
signature an ordinary constraint declared directly on that
partition, and never on the parent at all, produces from the start.
PostgreSQL itself draws no distinction between the two histories, so
this document doesn’t either: a partition’s PARTITIONS { } entry
MAY carry its own trailing { } body declaring one or more
constraints independently of the parent, using the same
CONSTRAINT/CONSTRAINTS block-directive grammar (both Dual
Definition Modes, Section 4.8) a table’s own { } block already
accepts:
partition-decl =/ ... [ WSP "{" partition-block-body "}" ]
partition-block-body = *( ( "PARTITIONS" WSP "{" *partition-decl "}" )
/ constraint-decl ) -- CONSTRAINT/CONSTRAINTS
(PARTITIONS { } sub-partitioning, Section 7.13, and one or more
CONSTRAINT/CONSTRAINTS entries may appear together in the same
body, in either order — PostgreSQL treats “further sub-partitioned”
and “carries its own local constraint” as independent facts about a
partition.) When a constraint disappears from the parent’s own
declaration while a direct partition independently declares a
constraint of that same name, the Differ emits ALTER TABLE ONLY parent DROP CONSTRAINT name instead of the ordinary cascading drop
— SAFE, not DESTRUCTIVE, for the same reason NO INHERIT
(above) is SAFE: a catalog-only change, no table rewrite, and
nothing is actually destroyed, since the constraint keeps enforcing
everywhere it currently applies — only the propagate-to-future-
partitions guarantee is given up, which is the explicit, declared
intent here. No proactive min_pg_version gate is applied (matching
every other PostgreSQL 18+ construct in this document): a pre-18
target server rejects the statement with its own clear error at
apply time (the passthrough-validation principle used throughout
this document). A constraint declared directly on an
already-existing partition with no such parent-level history at all
is unaffected by any of this — it is simply an ordinary ALTER TABLE partition ADD CONSTRAINT, valid on any PostgreSQL version, the same
as adding a constraint to any other table. This declarative slot is
the sole exception to partitions otherwise never declaring
structure independently of their parent (Section 7.13) — it does
not extend to columns or any other partition attribute, and it does
not apply to an ATTACHED FROM entry (Section 7.13), whose
constraints are governed by its own standalone TABLE declaration
instead. Because ALTER TABLE ONLY parent DROP CONSTRAINT demotes
the constraint on every currently existing partition at once, not
a caller-chosen subset, retaining it on only some partitions while
letting it lapse on others requires two migrations: the ONLY drop
(demoting it everywhere), then an ordinary DROP CONSTRAINT
declared directly against whichever partitions should no longer
keep it, once the following plan/apply cycle’s snapshot reflects the
demoted state. Coordination between the parent-level drop and a
partition’s local declaration is likewise scoped to direct
partitions only — a constraint retained through more than one level
of sub-partitioning (Section 7.13) is not specially detected, and
falls back to the same ordinary, independent ADD/DROP CONSTRAINT
handling a from-scratch partition-local constraint gets.
Examples:
TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY,
amount NUMERIC(10,2) NOT NULL,
CONSTRAINT pk_orders PRIMARY KEY (id)
)
{
-- NOT VALID must be in the { } block
CONSTRAINT ck_amount_positive CHECK (amount > 0) NOT VALID;
CONSTRAINT fk_account FOREIGN KEY (account_id)
REFERENCES accounts (id)
ON DELETE CASCADE
DEFERRABLE INITIALLY DEFERRED;
}
7.4. The COLUMN Reference Block
Inside a table’s { } block, COLUMN name { ... } references an
existing column declared in the ( ) list and holds attributes
that PostgreSQL expresses as separate ALTER TABLE ... ALTER COLUMN
statements.
column-block = "COLUMN" WSP col-name WSP "{" col-block-body "}"
/ "COLUMNS" WSP "{" *col-named-block "}"
col-named-block = col-name WSP "{" col-block-body "}"
col-block-body = *( col-block-directive ";" )
col-block-directive
= "COMMENT" WSP SQUOTE text SQUOTE
/ "STATISTICS" WSP ( integer / "DEFAULT" )
/ "COMPRESSION" WSP method
/ "STORAGE" WSP storage-type
/ "DEPRECATED" WSP SQUOTE text SQUOTE
/ "USING" WSP expr
/ "RENAMED FROM" WSP col-name
/ grants-block
/ revocations-block
The complete attribute table:
| Directive | PostgreSQL DDL emitted |
|---|---|
COMMENT 'text' | COMMENT ON COLUMN t.c IS '...' |
STATISTICS n | ALTER TABLE t ALTER COLUMN c SET STATISTICS n |
STATISTICS DEFAULT (PostgreSQL 17+) | ALTER TABLE t ALTER COLUMN c SET STATISTICS DEFAULT |
COMPRESSION method | ALTER TABLE t ALTER COLUMN c SET COMPRESSION m |
STORAGE type | ALTER TABLE t ALTER COLUMN c SET STORAGE s |
DEPRECATED 'msg' | COMMENT ON COLUMN t.c IS '[DEPRECATED] msg' |
USING expr | ALTER TABLE t ALTER COLUMN c TYPE ... USING expr |
RENAMED FROM old | ALTER TABLE t RENAME COLUMN old TO new |
GRANTS { ... } | GRANT priv (col) ON TABLE t TO role |
REVOCATIONS { ... } | REVOKE priv (col) ON TABLE t FROM role |
Validation rules:
Every
COLUMN name { }MUST reference a column name that exists in the( )list. A reference to a non-existent column is a compiler error (DPG-E018).COLUMNblocks do NOT declare new columns. New columns are declared only in the( )list.After a rename, the
COLUMNblock MUST use the new name;RENAMED FROMcarries the old name.After any column rename, all index and constraint declarations in the
{ }block MUST reference the new column name. Any reference to the old column name is a compiler error (DPG-E019).
Statistics target values:
| Value | Meaning |
|---|---|
-1 | Reset to table default (normally 100 at cluster level) |
DEFAULT (PostgreSQL 17+) | Same effect as -1 — SET STATISTICS DEFAULT is real PostgreSQL’s more explicit spelling of the same reset, added alongside the pre-existing -1 form rather than replacing it |
0 | Disable statistics collection for this column |
1–10000 | Explicit target; above 100 gives more detail at higher ANALYZE cost |
Values above 10000 are a compiler error (DPG-E020).
Storage types: plain, main, extended, external.
Compression methods: pglz, lz4 (requires PostgreSQL 14+
compiled with LZ4 support).
Generated-column expression changes: No separate directive is
needed — the GENERATED ALWAYS AS ( expr ) STORED/VIRTUAL clause is
already part of column-def (Section 7.2). The differ recognises
four distinct transitions on it and emits the matching targeted
ALTER (or, where real PostgreSQL has no in-place ALTER path at
all, a drop-and-recreate of the column), instead of falling through
to the generic “column type changed” row:
| Change | DDL emitted | Safety |
|---|---|---|
GENERATED ... AS (expr) added where none existed | ALTER TABLE t DROP COLUMN c; ALTER TABLE t ADD COLUMN c type GENERATED ALWAYS AS (expr) STORED|VIRTUAL — confirmed live that real PostgreSQL has no ALTER COLUMN ... ADD GENERATED AS (expr) form at all (unlike ADD GENERATED ... AS IDENTITY, Section 7.4’s own Identity table below, which is real); turning an existing plain column into a generated one always requires dropping and re-adding it | DESTRUCTIVE (the column’s prior values are discarded, not merely rewritten in place) |
STORED↔VIRTUAL changed, expression unchanged | Same drop-and-recreate as above — confirmed live that SET EXPRESSION never changes a column’s STORED/VIRTUAL kind, only its expression text | DESTRUCTIVE |
Existing generated expression text changed, STORED/VIRTUAL kind unchanged | ALTER TABLE t ALTER COLUMN c SET EXPRESSION AS (expr) (PostgreSQL 18+; confirmed live working against a generated column of either kind) | CAUTION |
GENERATED ... AS (expr) removed, column kept (STORED) | ALTER TABLE t ALTER COLUMN c DROP EXPRESSION IF EXISTS | SAFE (freezes the column’s current values, no rewrite) |
GENERATED ... AS (expr) removed, column kept (VIRTUAL) | Same drop-and-recreate as the “added” row above, re-adding the column as plain — confirmed live that real PostgreSQL 18 rejects DROP EXPRESSION outright for a VIRTUAL column (“ALTER TABLE / DROP EXPRESSION is not supported for virtual generated columns”), unlike STORED just above | DESTRUCTIVE (a VIRTUAL column has no stored value to freeze in the first place; the re-added column comes back NULL, not the last computed value) |
Identity-column changes: Likewise driven entirely by column-def’s
already-declarative GENERATED ALWAYS/BY DEFAULT AS IDENTITY [identity-opts] (Section 7.2) — no new grammar, only new diffing rules,
distinguished from a generic column-type change:
| Change | DDL emitted | Safety |
|---|---|---|
| Identity clause added where none existed | ALTER TABLE t ALTER COLUMN c ADD GENERATED {ALWAYS|BY DEFAULT} AS IDENTITY [(seq-options)] | CAUTION |
ALWAYS↔BY DEFAULT changed, options unchanged | ALTER TABLE t ALTER COLUMN c SET GENERATED {ALWAYS|BY DEFAULT} | SAFE |
identity-opts (increment/min/max/cache/cycle) changed | ALTER TABLE t ALTER COLUMN c SET <option> ... (one SET per changed option, PostgreSQL’s own identity-sequence syntax — distinct from ALTER SEQUENCE, no sequence name involved) | SAFE |
| Identity clause removed, column kept | ALTER TABLE t ALTER COLUMN c DROP IDENTITY [IF EXISTS] | SAFE |
7.5. Column-Level Grants
Column-level grants use the GRANTS { } / REVOCATIONS { } syntax
inside a COLUMN name { } block. The column scope is inferred by
the compiler. The emitted DDL is:
GRANT privilege (col) ON TABLE schema.table TO role;
Column-level grants follow the same additive model as table-level
grants (Section 11.2): DPG only emits GRANT; it NEVER auto-revokes.
TABLE users ( id BIGINT ..., email TEXT, ssn TEXT )
{
COLUMN email {
GRANTS {
SELECT TO reporting_role;
SELECT TO analytics_role;
}
}
COLUMN ssn {
STORAGE extended;
GRANTS { SELECT TO compliance_role; }
REVOCATIONS { ALL PRIVILEGES FROM PUBLIC; }
}
GRANTS { SELECT, INSERT, UPDATE TO app_service; }
}
7.6. Column Renaming
Column renames are DPG lifecycle directives declared in
COLUMN new_name { RENAMED FROM old_name; }. The new name appears
in both the ( ) list and the COLUMN block. The old name appears
only inside RENAMED FROM.
TABLE users (
email_address TEXT NOT NULL,
CONSTRAINT uq_users_email UNIQUE (email_address)
)
{
COLUMN email_address {
RENAMED FROM email;
COMMENT 'Verified email address';
}
}
Compiler resolution algorithm:
The compiler sees
email_addressin the( )list andCOLUMN email_address { RENAMED FROM email; }in the{ }block.It looks up
emailin the snapshot.- If
emailis in the snapshot andemail_addressis NOT: this is State A (fresh rename). EmitALTER TABLE users RENAME COLUMN email TO email_address;(Safety:CAUTION). - If
email_addressis already in the snapshot: the rename has already been applied (State B). Treat as a normal column update; theRENAMED FROMdirective is a no-op. - If neither
emailnoremail_addressis in the snapshot: this is a new column with a staleRENAMED FROM. Emit error DPG-E021 (stale RENAMED FROM directive).
- If
After emitting the rename, all constraint and index declarations MUST use
email_address. The compiler validates and emits DPG-E019 on any reference toemail.
RENAMED FROM for tables and schemas follows the same algorithm
with QualifiedName() substituted for column name:
TABLE user_accounts { RENAMED FROM users; }→ALTER TABLE users RENAME TO user_accounts;SCHEMA reporting { RENAMED FROM old_reporting; }→ALTER SCHEMA old_reporting RENAME TO reporting;
Cross-schema moves (SET SCHEMA): For every schema-scoped object
kind whose RENAMED FROM uses the generic renamed-from-dir
production (Appendix A) — Table, View, Materialized View, Sequence,
and the kinds covered in Sections 9/12/14 that reuse the same directive —
identifier there is in fact qual-name ([schema.]name), so the
old name MAY be schema-qualified. When the resolved old name’s
schema differs from the object’s current declared schema, the
compiler emits ALTER <kind> old_schema.name SET SCHEMA new_schema
in addition to RENAME TO, in that order (SET SCHEMA first,
since a still-in-flight rename could otherwise collide with an
existing name in the target schema) — matching the fact that real
PostgreSQL has no single statement that does both at once for any of
these kinds:
TABLE archive.orders { RENAMED FROM public.orders; }→ALTER TABLE public.orders SET SCHEMA archive;(name unchanged, schema-only move;RENAME TOomitted since the bare name is the same).VIEW archive.order_summary { RENAMED FROM public.orders_summary; }→ALTER VIEW public.orders_summary SET SCHEMA archive;thenALTER VIEW archive.orders_summary RENAME TO order_summary;(both schema and name changed).
An unqualified RENAMED FROM old_name is always resolved within the
object’s current declared schema — this is a strictly additive
grammar change; every existing same-schema RENAMED FROM in this
document continues to mean exactly what it always has.
7.7. Indexes
Indexes are declared in the INDICES { } block (or using the
singular INDEX keyword) inside a table’s { } block.
index-decl = [ "UNIQUE" WSP ]
[ "CONCURRENTLY" WSP ]
[ "ONLY" WSP ]
index-name WSP
[ "USING" WSP method WSP ]
"(" index-col-list ")"
[ "INCLUDE" WSP "(" col-list ")" ]
[ WSP "NULLS" WSP ( "DISTINCT" / "NOT DISTINCT" ) ]
[ "WITH" WSP "(" storage-params ")" ]
[ "WHERE" WSP "(" predicate ")" ]
[ "TABLESPACE" WSP identifier ]
[ WSP "RENAMED FROM" WSP index-name ]
";"
index-col-list = index-col *( "," index-col )
index-col = ( col-name / "(" expr ")" )
[ "ASC" / "DESC" ]
[ "NULLS FIRST" / "NULLS LAST" ]
[ "COLLATE" WSP identifier ]
[ WSP identifier [ "(" storage-params ")" ] ] -- opclass [(params)]
UNIQUE, CONCURRENTLY, and ONLY are all prefix keywords before the
index name, in that fixed order — mirroring real PostgreSQL’s own
CREATE UNIQUE INDEX CONCURRENTLY name ON ONLY table USING method (columns)
exactly (only the implicit INDEX/ON table are dropped, since DPG’s
INDICES { } block already establishes both). In Mode B (Section 4.8),
which does carry the literal INDEX keyword, the same order applies
with INDEX inserted where PostgreSQL puts it:
[ "UNIQUE" WSP ] "INDEX" WSP [ "CONCURRENTLY" WSP ] [ "ONLY" WSP ] index-name ....
ONLY suppresses recursion into a partitioned table’s own
partitions — the index is created solely on the parent, matching real
PostgreSQL’s CREATE INDEX ... ON ONLY table exactly. Meaningful only
when the enclosing table has a PARTITION BY clause (Section 7.13); DPG
performs no validation of this and leaves the combination to
PostgreSQL’s own parser, per the passthrough principle already applied
to other clause-combination rules in this document (e.g. Section 7.9’s
REFERENCING note).
Index opclass parameters: index-col’s trailing identifier [ "(" storage-params ")" ] is the operator class name, optionally
followed by its own parameters (opclass(param = value, ...)) — the
identical shape already used by excl-list’s EXCLUDE element
(Appendix A), reused here rather than inventing a second name for it.
INDICES {
idx_search_doc USING gist (doc tsvector_ops(siglen = 32));
}
NULLS [NOT] DISTINCT controls whether a UNIQUE index treats
multiple NULLs in the indexed columns as distinct (PostgreSQL’s
default, NULLS DISTINCT) or as duplicates that violate uniqueness
(NULLS NOT DISTINCT). Valid only on a UNIQUE index — DPG performs
no validation of this combination itself, leaving it to PostgreSQL’s
parser (same passthrough principle as ONLY above). This clause was
already implemented identically at the inline PRIMARY KEY/UNIQUE constraint level (conflict-clause, Section 7.2) and at the
table-constraint level (table-constraint-body, Section 7.3); this closes
the one remaining position — the standalone index-decl — where the
grammar previously didn’t document what the implementation already
does.
INDICES {
UNIQUE idx_unique_optional_email (email) NULLS NOT DISTINCT;
}
CONCURRENTLY is a bare presence keyword, not a boolean — this
matches real PostgreSQL exactly: CONCURRENTLY is either written or it
isn’t, there is no CONCURRENTLY false and no project-wide default that
turns it on implicitly. Writing it is the only way an index is ever
created concurrently; omitting it always means plain CREATE INDEX.
Concurrency behaviour:
Writing
CONCURRENTLYon an individual index emitsCREATE INDEX CONCURRENTLY. This is aMANUALoperation (non-transactional; emitted afterCOMMIT) — except for a brand-new table’s own indexes (see below), where it is silently ignored.Omitting
CONCURRENTLYemits plainCREATE INDEX(transactional; SafetyCAUTIONfor index additions on non-empty tables). There is no configuration knob that changes this — matching real PostgreSQL, which has no such default either.Indexes on a brand-new table are always non-concurrent — this is a hard PostgreSQL restriction, not a preference. A new table’s indexes are emitted in the SAME transactional migration as its
CREATE TABLE, and PostgreSQL rejectsCREATE INDEX CONCURRENTLYinside a transaction block. The compiler therefore forces these non-concurrent unconditionally — an explicitCONCURRENTLYon such an index has no effect until a later migration adds the index to the now-existing table.
Index identity: An index is uniquely identified by its name within a schema. Two indexes with the same name but different definitions are a compiler error (DPG-E005).
Index renaming: RENAMED FROM old_name follows the same
algorithm as Table/Schema renaming (Sections 7.6/7.11) — a metadata-only
ALTER INDEX old_name RENAME TO new_name is emitted instead of the
usual drop-and-recreate path, and a rename combined with any other
structural change still falls back to drop + recreate (the rename
carve-out applies only when nothing else about the index differs).
INDICES {
idx_email_lower (lower(email)) RENAMED FROM idx_lower_email;
}
Partial index predicates are stored as normalised text and diffed by text equality. Whitespace normalisation is applied: all runs of whitespace are collapsed to a single space.
Expression indexes: The column expression ( expr ) is treated
as opaque text and diffed by text equality after whitespace
normalisation.
Covering indexes: INCLUDE (col1, col2) adds columns to the
index leaf pages without participating in the search key. Adding or
removing INCLUDE columns requires dropping and recreating the
index (Safety: CAUTION; no data loss but requires a lock and a
full index rebuild).
Diffing semantics:
| Change | DDL emitted | Safety |
|---|---|---|
| New index (existing table) | CREATE [UNIQUE] INDEX [CONCURRENTLY] [IF NOT EXISTS] name ON ... | MANUAL if CONCURRENTLY written, else CAUTION (default) |
| Index removed | DROP INDEX [CONCURRENTLY] name | CAUTION |
Index renamed only (RENAMED FROM, nothing else differs) | ALTER INDEX old_name RENAME TO new_name | SAFE |
| Any other structural change | Drop + recreate | CAUTION or MANUAL |
Indexes on new tables (emitted in the same migration as the
CREATE TABLE) are always emitted as non-concurrent CREATE INDEX in
the same transactional block — see Concurrency behaviour above.
Examples:
TABLE users ( email TEXT, status user_status, ... )
{
INDICES {
idx_email (email);
UNIQUE idx_unique_slug (slug);
idx_tenant_created (tenant_id ASC, created_at DESC);
idx_active_users (email) WHERE (status = 'active');
idx_location USING gist (location);
idx_tags USING gin (tags);
idx_lower_email (lower(email));
idx_covering (user_id) INCLUDE (email, created_at);
idx_brin USING brin (created_at)
WITH (pages_per_range = 128);
idx_archived (archived_at) TABLESPACE archive_space;
CONCURRENTLY idx_forced_concurrent (payload);
UNIQUE CONCURRENTLY idx_unique_concurrent (external_id);
}
}
-- Mode B (Section 4.8): the literal INDEX keyword carries UNIQUE/CONCURRENTLY in
-- the exact same order PostgreSQL's own CREATE UNIQUE INDEX CONCURRENTLY
-- statement does.
UNIQUE INDEX idx_unique_slug (slug);
INDEX CONCURRENTLY idx_forced_concurrent (payload);
UNIQUE INDEX CONCURRENTLY idx_unique_concurrent (external_id);
7.8. Row Level Security
Row Level Security (RLS) is enabled and configured in the table’s
{ } block.
rls-enable-dir = "ENABLE ROW LEVEL SECURITY"
rls-force-dir = "FORCE ROW LEVEL SECURITY"
policy-decl = policy-name WSP "FOR" WSP command
[ WSP "AS" WSP permissiveness ]
[ WSP "TO" WSP role-list ]
[ WSP "USING" WSP "(" expr ")" ]
[ WSP "WITH CHECK" WSP "(" expr ")" ]
[ WSP "RENAMED FROM" WSP policy-name ]
";"
command = "ALL" / "SELECT" / "INSERT" / "UPDATE" / "DELETE"
permissiveness = "PERMISSIVE" / "RESTRICTIVE"
Diffing semantics:
| Change | DDL emitted | Safety |
|---|---|---|
ENABLE ROW LEVEL SECURITY added | ALTER TABLE t ENABLE ROW LEVEL SECURITY | SAFE |
FORCE ROW LEVEL SECURITY added | ALTER TABLE t FORCE ROW LEVEL SECURITY | SAFE |
FORCE ROW LEVEL SECURITY removed, ENABLE still present | ALTER TABLE t NO FORCE ROW LEVEL SECURITY | SAFE |
| Both removed | ALTER TABLE t DISABLE ROW LEVEL SECURITY | SAFE |
| New policy | CREATE POLICY name ON t FOR ... TO ... USING (...) WITH CHECK (...) | SAFE |
Only TO/USING/WITH CHECK differ (FOR/AS unchanged) | ALTER POLICY name ON t [TO ...] [USING (...)] [WITH CHECK (...)] | SAFE |
Renamed only (RENAMED FROM, FOR/AS/TO/USING/WITH CHECK all unchanged) | ALTER POLICY old_name ON t RENAME TO new_name | SAFE |
Renamed AND TO/USING/WITH CHECK also differ (FOR/AS unchanged) | ALTER POLICY old_name ON t RENAME TO new_name; then ALTER POLICY new_name ON t [TO ...] [USING (...)] [WITH CHECK (...)] (two statements) | SAFE |
FOR command or AS PERMISSIVE/RESTRICTIVE differs | DROP POLICY name ON t; CREATE POLICY ... | SAFE |
| Policy removed | DROP POLICY name ON t | SAFE |
ALTER POLICY vs. drop-and-recreate: Real PostgreSQL’s ALTER POLICY can change TO/USING/WITH CHECK in place, atomically —
FOR command and AS PERMISSIVE/RESTRICTIVE are fixed at creation
with no ALTER POLICY equivalent, forcing drop-and-recreate for
those two fields specifically. Preferring ALTER POLICY whenever
possible matters beyond avoiding an extra statement: a pure
USING/WITH CHECK/TO-role change is exactly the kind of edit
DROP POLICY; CREATE POLICY turns into a real (if brief) window
with zero active policy for that command — ALTER POLICY closes
that window entirely.
RENAMED FROM on a policy (like Constraint/Index RENAMED FROM, Sections 7.3/7.7) matches an existing policy by its old name
within the same table and emits ALTER POLICY old_name ON t RENAME TO new_name — SAFE, metadata-only — instead of the drop-and-
recreate a name-only difference would otherwise trigger under name-
based policy identity. Policy identity is (schema, table, policy_name); PostgreSQL provides no mechanism to move a policy to
a different table via rename, so — unlike Table/View/Function’s
cross-schema RENAMED FROM extension (Section 7.6) — there is no
schema- or table-qualified form here. Real PostgreSQL’s ALTER POLICY cannot rename and modify TO/USING/WITH CHECK in a
single statement (they are two distinct, mutually exclusive clause
forms), so when both change in the same migration the compiler
emits the RENAME TO first, then a second ALTER POLICY statement
under the new name for the remaining changes. When FOR command or
AS PERMISSIVE/RESTRICTIVE also differs, the existing drop-and-
recreate path already creates the policy under its final (new) name
directly, so no separate RENAME TO is emitted in that case.
Example:
TABLE orders ( ... )
{
ENABLE ROW LEVEL SECURITY;
FORCE ROW LEVEL SECURITY;
POLICIES {
view_own FOR SELECT
USING (user_id = auth.uid());
insert_own FOR INSERT
WITH CHECK (user_id = auth.uid());
update_own FOR UPDATE
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid() AND status != 'locked');
restrict_deleted AS RESTRICTIVE FOR ALL
USING (deleted_at IS NULL);
admin_all FOR ALL
TO admin_role
USING (true);
service_read FOR SELECT
TO service_role, readonly_role
USING (true);
}
}
7.9. Triggers
Triggers are declared in the TRIGGERS { } block inside a table’s
{ } block.
trigger-decl = trigger-name WSP timing WSP event-list
[ WSP "FROM" WSP table-ref ]
[ WSP deferrable-clause ]
[ WSP referencing-clause ]
WSP for-each
[ WSP "WHEN" WSP "(" expr ")" ]
WSP "EXECUTE FUNCTION" WSP func-ref "(" arg-list ")"
[ WSP trigger-enable-state ]
[ WSP depends-on-extension-dir ]
[ WSP "RENAMED FROM" WSP trigger-name ]
";"
timing = "BEFORE" / "AFTER" / "INSTEAD OF"
event-list = event *( "OR" WSP event )
event = "INSERT" / "UPDATE" [ "OF" col-list ] / "DELETE" / "TRUNCATE"
for-each = "FOR EACH ROW" / "FOR EACH STATEMENT"
referencing-clause = "REFERENCING"
( "OLD TABLE AS" identifier [ "NEW TABLE AS" identifier ]
/ "NEW TABLE AS" identifier [ "OLD TABLE AS" identifier ] )
; Real PostgreSQL has no such clause on CREATE TRIGGER itself — a new
; trigger is always ENABLED, and the other three states are ALTER-only.
; DPG models the state declaratively anyway (same reasoning as RLS's
; enable-dir/force-dir, Section 7.8), so a source file's trigger declaration is
; a complete description of desired state without a separate directive
; block. Omitting this clause means ENABLED — real PostgreSQL's default.
trigger-enable-state = "DISABLED" / "ENABLE REPLICA" / "ENABLE ALWAYS"
Diffing semantics:
| Change | DDL emitted | Safety |
|---|---|---|
| New trigger | CREATE [CONSTRAINT] TRIGGER name ... ON t ... | SAFE |
| Trigger changed | DROP TRIGGER name ON t; CREATE TRIGGER ... | SAFE |
Renamed only (RENAMED FROM, nothing else differs) | ALTER TRIGGER old_name ON t RENAME TO new_name | SAFE |
Enable state changed only (trigger-enable-state) | ALTER TABLE t ENABLE/DISABLE TRIGGER name or ALTER TABLE t ENABLE REPLICA/ALWAYS TRIGGER name | SAFE |
[NO] DEPENDS ON EXTENSION changed (depends-on-extension-dir, Section 9.1) | ALTER TRIGGER name ON t [NO] DEPENDS ON EXTENSION ext | SAFE |
| Trigger removed | DROP TRIGGER name ON t | SAFE |
Trigger identity is (schema, table, trigger_name). depends-on- extension-dir reuses the same production Function/Procedure already
define (Section 9.1) — real PostgreSQL’s ALTER TRIGGER ... DEPENDS ON EXTENSION has identical grammar to the function form, just a
different target object.
RENAMED FROM on a trigger (like Constraint/Index/Policy
RENAMED FROM) matches an existing trigger by its old name within
the same table and emits ALTER TRIGGER old_name ON t RENAME TO new_name — SAFE, metadata-only. PostgreSQL provides no mechanism
to move a trigger to a different table via rename, so — unlike
Table/View/Function’s cross-schema RENAMED FROM extension (Section
7.6) — there is no schema- or table-qualified form here. When any
other trigger property changes at the same time (forcing the drop-
and-recreate row above), the recreated trigger is already created
under its final (new) name directly, so no separate RENAME TO is
emitted in that case. When only the enable state also changes, the
two ops are independent statements (ALTER TABLE vs. ALTER TRIGGER) with no conflict. When only [NO] DEPENDS ON EXTENSION
also changes, real PostgreSQL’s ALTER TRIGGER cannot combine
RENAME TO and DEPENDS ON EXTENSION in a single statement — the
compiler emits RENAME TO first (referencing the old name), then
the DEPENDS ON EXTENSION change as a second ALTER TRIGGER
statement referencing the new name.
RULE has no DPG equivalent — PostgreSQL’s CREATE RULE is out
of scope entirely (Section 23), so the ENABLE/DISABLE RULE half of real
PostgreSQL’s enable-state model has nothing to attach to and is not
addressed by this section.
REFERENCING constraints (informative). DPG performs no
clause-combination validation of its own for REFERENCING — every
other trigger clause is likewise passed through verbatim and left
entirely to PostgreSQL’s own parser/executor to accept or reject
(see Section 7.9’s grammar above: WHEN placement, a CONSTRAINT
trigger’s clause set, and every other combination rule receive no
DPG-level check either). The following are PostgreSQL’s own real
constraints on REFERENCING, confirmed live against PostgreSQL 17,
noted here only so a rejected trigger’s error is understandable —
none of them is enforced by the compiler:
- Only an
AFTERtrigger may specify transition tables —BEFOREandINSTEAD OFare rejected (“transition table name can only be specified for an AFTER trigger”). - A
CONSTRAINTtrigger cannot useREFERENCINGat all — real PostgreSQL’s grammar rejects the combination outright (a syntax error, not a semantic one). - Valid for both
FOR EACH ROWandFOR EACH STATEMENT. - A trigger on a view or a foreign table cannot have transition tables (“Triggers on views/foreign tables cannot have transition tables”).
- A
TRUNCATEtrigger cannot have transition tables (“TRUNCATE triggers with transition tables are not supported”).
Example:
TABLE users ( ... )
{
TRIGGERS {
before_insert BEFORE INSERT
FOR EACH ROW
EXECUTE FUNCTION set_defaults();
after_email_change AFTER UPDATE OF email
FOR EACH ROW
WHEN (OLD.email IS DISTINCT FROM NEW.email)
EXECUTE FUNCTION notify_email_change();
audit_changes AFTER INSERT OR UPDATE OR DELETE
REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows
FOR EACH STATEMENT
EXECUTE FUNCTION audit_table_changes();
check_ref CONSTRAINT AFTER INSERT OR UPDATE
FROM orders
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION check_ref_integrity();
}
}
7.10. Table-Level Grants and Revocations
Table-level grants and revocations are declared in GRANTS { } and
REVOCATIONS { } blocks inside the table’s { } block.
grants-block = "GRANTS" WSP "{" *( grant-entry ";" ) "}"
revocations-block = "REVOCATIONS" WSP "{" *( revoke-entry ";" ) "}"
grant-entry = privilege-list WSP "TO" WSP role-list
[ WSP "WITH GRANT OPTION" ]
[ WSP "GRANTED BY" WSP role-spec ]
revoke-entry = ( privilege-list / "ALL PRIVILEGES" ) WSP
"FROM" WSP role-list
[ WSP "GRANTED BY" WSP role-spec ]
[ WSP "CASCADE" ]
privilege-list = privilege *( "," privilege )
privilege = "SELECT" / "INSERT" / "UPDATE" / "DELETE" /
"TRUNCATE" / "REFERENCES" / "TRIGGER" /
"USAGE" / "EXECUTE" / "CREATE" / "CONNECT" /
"TEMPORARY" / "MAINTAIN" / "ALL" / "ALL PRIVILEGES"
Grants follow the additive model (Section 11.2).
MAINTAIN (PostgreSQL 17+): controls VACUUM/ANALYZE/
CLUSTER/REINDEX/REFRESH MATERIALIZED VIEW access to the table
without granting broader privileges — real PostgreSQL’s own
privilege production accepts it only for tables/matviews/indexes;
USAGE/EXECUTE-only object kinds reusing this same shared
privilege production (Section 11.2) would have it rejected by
PostgreSQL’s own parser, the same passthrough-validation principle
already applied throughout this document.
GRANTED BY role (both grant-entry and revoke-entry):
real PostgreSQL accepts this clause but restricts the effective
grantor to current_user in practice — a SQL-standard-compatibility
clause with limited real-world effect, included here purely to close
the Tenet-1 expressiveness gap (the RFC’s own text sets no “practical
impact” threshold for what counts as a gap).
7.11. Table Lifecycle Directives
renamed-from-dir = "RENAMED FROM" WSP qual-name
protected-dir = "PROTECTED"
deprecated-dir = "DEPRECATED" WSP SQUOTE text SQUOTE
drop-cascade-dir = "DROP CASCADE"
owner-dir = "OWNER" WSP DQUOTE identifier DQUOTE
comment-dir = "COMMENT" WSP SQUOTE text SQUOTE
PROTECTED: The compiler MUST refuse to emit a DROP TABLE
for a protected table even when the table is absent from the desired
state. Removing a protected table requires first removing the
PROTECTED directive. Safety: any attempt to drop a protected
table is error DPG-E022.
DEPRECATED 'msg': The compiler emits a COMMENT ON TABLE
prefixed with [DEPRECATED] and the message text. The linter emits
a warning when any other object references a deprecated table
(if warn_on_deprecated = true).
DROP CASCADE: Overrides default_drop_behavior for this
specific object. The compiler emits DROP TABLE name CASCADE when
removing this table.
RENAMED FROM: See Section 7.6, including the cross-schema
SET SCHEMA behaviour a schema-qualified old name triggers.
Attaching a downstream naming convention: a NAME MAP/NAME MAPS directive can also appear in this block, independently of
RENAMED FROM — see Appendix D.10 (Name Maps) for the full feature (ten
rule keywords, [namemaps] dpg.toml config, block-layer
directives, and snapshot representation).
REPLICA IDENTITY/CLUSTER ON are declared the same way as RLS
(Section 7.8) — as block directives even though real PostgreSQL has no
CREATE TABLE-native clause for either, only an ALTER TABLE
statement:
replica-identity-dir = "REPLICA IDENTITY" WSP
( "DEFAULT" / "FULL" / "NOTHING"
/ "USING INDEX" WSP index-name )
cluster-dir = "CLUSTER ON" WSP index-name
REPLICA IDENTITY controls how much of an updated/deleted row
PostgreSQL’s logical replication can capture — directly gates what a
Publication (Section 13.1) can actually replicate for UPDATE/DELETE.
Omitting the directive means DEFAULT (the primary key only),
PostgreSQL’s own default; USING INDEX requires a UNIQUE index on
NOT NULL columns, left to PostgreSQL’s own parser to enforce.
Changing it emits ALTER TABLE t REPLICA IDENTITY ... — SAFE
(metadata-only, no rewrite).
CLUSTER ON records which index a future manual CLUSTER
(Section 23 — the CLUSTER command itself remains a runtime operation, out
of scope) would use; declaring it does not itself cluster the table.
Removing a previously-declared CLUSTER ON emits ALTER TABLE t SET WITHOUT CLUSTER — SAFE. PostgreSQL 17’s new CLUSTER (VERBOSE, ...) parenthesized-options syntax is a variant of the
CLUSTER command itself, so it stays out of scope for the same
reason — no DPG action needed either direction.
TABLE orders ( ... )
{
REPLICA IDENTITY FULL;
CLUSTER ON idx_orders_created_at;
}
OF type_name/NOT OF (post-creation): A table gaining, losing,
or switching its OF type_name association (Section 7.1) after creation is
an ALTER-only transition, distinct from the CREATE-time Form 2
already covered in Section 7.1: ALTER TABLE t OF type_name (gained or
switched to a different type) / ALTER TABLE t NOT OF (removed) —
CAUTION (PostgreSQL validates every column against the type’s
attributes at execution time).
SET ACCESS METHOD (post-creation): A change to a table’s
already-declared USING method clause (Section 7.1) after creation emits
ALTER TABLE t SET ACCESS METHOD method — CAUTION (rewrites the
table’s storage using the new method).
INHERIT/NO INHERIT (post-creation): A change to a table’s
INHERITS (...) list (Section 7.1) after creation — a parent added or
removed — emits ALTER TABLE child INHERIT parent /
ALTER TABLE child NO INHERIT parent per added/removed parent,
rather than requiring the whole table to be recreated — SAFE
(INHERIT briefly validates existing rows against the parent’s
constraints; no rewrite).
7.12. Unlogged and Foreign Tables
Unlogged tables:
UNLOGGED TABLE session_cache (
key TEXT NOT NULL PRIMARY KEY,
value JSONB
);
Emits CREATE UNLOGGED TABLE. Changing a regular table to unlogged
(or vice versa) emits ALTER TABLE name SET UNLOGGED /
ALTER TABLE name SET LOGGED — classified CAUTION (rewrites the
table’s storage in place; briefly holds ACCESS EXCLUSIVE, but
unlike the drop-and-recreate path this document previously
prescribed, no dependent FK/view/index is ever destroyed).
Temporary tables are session-scoped. DPG MUST NOT manage them.
A TEMPORARY TABLE keyword anywhere in a .dpg file is a compiler
error (DPG-E023).
Foreign tables: SERVER and OPTIONS are Part 1 clauses
appearing after ) per Tenet 5. They MUST NOT be moved to the
{ } block.
FOREIGN TABLE remote_events (
id BIGINT,
payload JSONB,
created_at TIMESTAMPTZ
) SERVER log_server OPTIONS (table_name 'events', schema_name 'public')
{
COLUMN id { COMMENT 'Remote event primary key'; }
GRANTS { SELECT TO app_readonly; }
}
Diffing semantics: A foreign table reuses the same TABLE
diffing machinery as a regular table (Section 21) — columns, owner,
comment, grants/revocations, security labels, rename — with two
foreign-specific rules:
| Change | DDL emitted | Safety |
|---|---|---|
OPTIONS changed | ALTER FOREIGN TABLE t OPTIONS (ADD/SET/DROP key 'value', ...) | SAFE |
SERVER changed | DROP FOREIGN TABLE t; CREATE FOREIGN TABLE t ... SERVER new_server ... | DESTRUCTIVE |
SERVER cannot be changed via ALTER FOREIGN TABLE — real
PostgreSQL has no such clause — so a SERVER change always requires
the full drop-and-recreate path, unlike OPTIONS, which real
PostgreSQL supports altering in place. Column add/drop/type-change
follow Section 21’s TABLE rules unchanged (a foreign table’s columns
describe the remote shape, not local storage, but the diffing model
itself does not distinguish the two).
TRUNCATE triggers on foreign tables (PostgreSQL 16+) and
NOT NULL on foreign table columns (PostgreSQL 18+) were both
previously rejected by PostgreSQL. Neither restriction was ever
encoded in this document’s grammar — trigger-decl’s event
alternatives (Section 7.9, including TRUNCATE) and col-constraint’s
NOT NULL (Section 7.2) already applied unconditionally to TRIGGERS { }/
column lists on any table, foreign or not — so both versions'
restrictions and their later lifting are handled entirely by
PostgreSQL’s own version-aware parser, with no DPG grammar change
needed either direction, same as the partitioned-table EXCLUDE/
identity-column confirmations above (Section 7.3).
7.13. Partitioned Tables
Partitioning uses the PARTITION BY clause in Part 1 (after ),
per Tenet 5).
partition-by-clause = "PARTITION BY" WSP partition-strategy
WSP "(" partition-col-list ")"
partition-strategy = "RANGE" / "LIST" / "HASH"
partition-col-list = partition-col *( "," partition-col )
partition-col = col-name / "(" expr ")"
Partitions are declared in the PARTITIONS { } block inside the
table’s { } block.
partition-decl = [ "FOREIGN" WSP ] partition-name WSP
( "FOR VALUES" WSP bounds-clause / "DEFAULT" )
[ WSP "SERVER" WSP identifier
[ WSP "OPTIONS" WSP "(" option-list ")" ] ]
[ WSP "RENAMED FROM" WSP partition-name ]
[ WSP partition-by-clause WSP
"{" "PARTITIONS" WSP "{" *partition-decl "}" "}" ]
";"
bounds-clause = "FROM" WSP "(" literal-list ")"
"TO" WSP "(" literal-list ")" -- RANGE
/ "IN" WSP "(" literal-list ")" -- LIST
/ "WITH" WSP "(" modulus-remainder ")" -- HASH
Formalizing sub-partitioning in the grammar: the recursive
[ WSP partition-by-clause WSP "{" "PARTITIONS" WSP "{" *partition-decl "}" "}" ] suffix above makes explicit what the sub-partitioning
example further below already demonstrated informally — a partition
entry MAY itself be further partitioned to arbitrary depth, reusing
partition-decl recursively.
Foreign table partitions: FOREIGN partition-name ... SERVER server_name [OPTIONS (...)] makes one partition a foreign table
instead of a regular one — real PostgreSQL’s own sharding pattern,
where each partition’s data actually lives on a different remote
server (postgres_fdw, etc.). Column definitions are never
restated — a partition, foreign or not, always inherits its parent’s
columns. Emits CREATE FOREIGN TABLE name PARTITION OF parent FOR VALUES ... SERVER server_name [OPTIONS (...)].
TABLE events ( id BIGINT, region TEXT, payload JSONB )
PARTITION BY LIST (region)
{
PARTITIONS {
events_us FOR VALUES IN ('us-east', 'us-west');
FOREIGN events_archive DEFAULT
SERVER archive_server OPTIONS (table_name 'events_archive');
}
}
Sub-partitioning: A partition entry MAY have its own
PARTITION BY sub-clause and a nested { PARTITIONS { ... } }
block:
TABLE events ( ... ) PARTITION BY RANGE (created_at)
{
PARTITIONS {
events_2024 FOR VALUES FROM ('2024-01-01') TO ('2025-01-01')
PARTITION BY LIST (region) {
PARTITIONS {
events_2024_us FOR VALUES IN ('us-east', 'us-west');
events_2024_eu FOR VALUES IN ('eu-west', 'eu-central');
}
};
}
}
Attaching/detaching an existing standalone table: Declaring a
partition from scratch (above) always emits CREATE TABLE ... PARTITION OF .... To instead convert an already-existing standalone table
into a partition — or a partition back into a standalone table —
without dropping and recreating it (and its data), DPG offers a
symmetric pair of directives mirroring RENAMED FROM’s “point at a
prior identity” shape:
; PARTITIONS { } entry form — attaches an existing standalone table
attached-partition-decl = "ATTACHED FROM" WSP table-ref WSP
"FOR VALUES" WSP bounds-clause ";"
/ "ATTACHED FROM" WSP table-ref WSP "DEFAULT" ";"
; Standalone TABLE { } block directive — detaches a former partition
detached-from-dir = "DETACHED FROM" WSP table-ref [ WSP "CONCURRENTLY" ] ";"
- A
PARTITIONS { }entry written asATTACHED FROM existing_table FOR VALUES ...— whereexisting_tableis a standalone table already present in the snapshot — emitsALTER TABLE parent ATTACH PARTITION existing_table FOR VALUES ...instead ofCREATE TABLE ... PARTITION OF .... Real PostgreSQL validates the existing table’s structure and anyCHECKconstraints against the target partition bound at attach time; DPG performs no additional validation of its own (passthrough principle, as elsewhere in this document).ATTACH PARTITIONhas noCONCURRENTLYvariant in real PostgreSQL — none is offered here either. - A standalone
TABLEdeclaration carryingDETACHED FROM parent_table [CONCURRENTLY]in its{ }block — where the table is currently a partition ofparent_tableper the snapshot — emitsALTER TABLE parent_table DETACH PARTITION name [CONCURRENTLY]instead of the drop this document would otherwise prescribe for “declared table disappeared from its parent’sPARTITIONS { }block.”CONCURRENTLYruns the detach in two non-blocking steps (matching real PostgreSQL); omitting it holds a briefACCESS EXCLUSIVEon the parent.
Diffing semantics:
| Change | DDL emitted | Safety |
|---|---|---|
| New partition | CREATE TABLE <name> PARTITION OF <parent> FOR VALUES ... | SAFE |
Partition renamed only (RENAMED FROM, bound/strategy unchanged) | ALTER TABLE old_name RENAME TO new_name | CAUTION |
Partition attached (ATTACHED FROM, existing table) | ALTER TABLE parent ATTACH PARTITION name FOR VALUES ... | CAUTION |
Partition detached (DETACHED FROM) | ALTER TABLE parent DETACH PARTITION name [CONCURRENTLY] | MANUAL if CONCURRENTLY written, else CAUTION |
| Partition removed (absent, not detached) | DROP TABLE <name> | DESTRUCTIVE |
| Partition strategy change | Requires --approve-partition-rebuild | MANUAL |
| Parent constraint dropped, retained locally by a direct partition (PG18+, Section 7.3) | ALTER TABLE ONLY parent DROP CONSTRAINT name | SAFE |
| Partition’s own local constraint added (Section 7.3) | ALTER TABLE partition ADD CONSTRAINT name ... | CAUTION |
| Partition’s own local constraint dropped (Section 7.3) | ALTER TABLE partition DROP CONSTRAINT name | DESTRUCTIVE |
RENAMED FROM on a partition matches an existing partition —
which MUST already be attached as a partition of this same parent
table per the snapshot; converting an unrelated standalone table
into a partition is ATTACHED FROM’s job, above, not this one’s —
by its old name and emits ALTER TABLE old_name RENAME TO new_name,
the identical mechanism and safety classification as a plain table
rename (Section 7.6), since a partition is an ordinary table under
the hood: real PostgreSQL confirms renaming a partition has no
effect on its partition attachment, constraints, or stored data.
Because DPG does not model an independent schema for a partition (a
partition’s schema is always its parent table’s), the target here is
a bare partition-name, not the qual-name cross-schema form
Table/View/Function use — no SET SCHEMA variant applies. Since the
recursive PARTITION BY { PARTITIONS { ... } } suffix reuses this
same partition-decl production at every nesting depth, RENAMED FROM is available identically on a sub-partition at any depth, with
no special-casing required. Combining RENAMED FROM with ATTACHED FROM/DETACHED FROM on the same entry is out of scope — converting
a standalone table into a partition under a different name (or vice
versa) requires two separate migrations.
Partition strategy change procedure (requires
--approve-partition-rebuild):
- Create new partitioned table with target strategy.
- Create all declared partitions.
INSERT INTO new_table SELECT * FROM old_table.- Verify row counts match.
DROP TABLE old_table.ALTER TABLE new_table RENAME TO old_table.- Recreate all indexes, constraints, grants, and policies.
This sequence is emitted as a mix of SAFE and MANUAL steps
with inline human-readable comments explaining each step.
Indexes declared at the parent partitioned table level are
automatically inherited by all partitions. The compiler MUST NOT
emit duplicate CREATE INDEX statements on partitions that already
inherit a parent-level index.
8. Views
8.1. Regular Views
Views use AS <query> per PostgreSQL’s CREATE VIEW syntax. The
query text is Part 1, terminated with ; per Rule T3. An optional
{ } block holds grants, a comment, owner, and other externally-
attachable concerns.
Temporary views are session-scoped for the same reason temporary
tables are (Section 7.12) and are excluded on the same terms: DPG MUST NOT
manage them, and a TEMPORARY VIEW (or TEMP VIEW) keyword anywhere
in a .dpg file is a compiler error (DPG-E023).
PG equivalent:
CREATE [OR REPLACE] VIEW name [(col-list)] [WITH (options)] AS query [WITH CHECK OPTION]
view-decl = "VIEW" WSP schema-view-name
[ WSP "(" col-name-list ")" ]
[ WSP "WITH" WSP "(" view-options ")" ]
WSP "AS" WSP query ";"
[ "{" view-block "}" ]
view-block = *( view-directive ";" )
view-directive = owner-dir / comment-dir / renamed-from-dir
/ deprecated-dir / grants-block / revocations-block
The WITH CHECK OPTION (and WITH LOCAL CHECK OPTION) clause
MUST appear at the END of the query text, immediately before the
terminating ;, per PostgreSQL syntax.
Examples:
SCHEMA public {
-- Minimal view
VIEW active_users AS
SELECT id, email, created_at
FROM users
WHERE status = 'active' AND deleted_at IS NULL;
-- With named column list
VIEW user_summary (user_id, email, order_count) AS
SELECT u.id, u.email, COUNT(o.id)
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.email;
-- With security_barrier option
VIEW secure_view WITH (security_barrier = true) AS
SELECT id, email FROM users WHERE tenant_id = current_tenant();
-- With check option
VIEW active_orders AS
SELECT * FROM orders WHERE status != 'cancelled'
WITH LOCAL CHECK OPTION;
-- With { } block
VIEW admin_summary AS
SELECT id, email, created_at FROM users WHERE role = 'admin';
{
COMMENT 'Admin user summary view';
GRANTS { SELECT TO app_readonly; }
}
}
Diffing semantics:
| Change | DDL emitted | Safety |
|---|---|---|
| New view | CREATE VIEW ... | SAFE |
| Query changed, same output column list | CREATE OR REPLACE VIEW ... | SAFE |
| Output column list changed (any way) | DROP VIEW CASCADE; CREATE VIEW | DESTRUCTIVE |
| Owner changed | ALTER VIEW name OWNER TO role | SAFE |
| Comment changed | COMMENT ON VIEW name IS '...' | SAFE |
View renamed (RENAMED FROM) | ALTER VIEW old RENAME TO new | CAUTION |
View moved to another schema (RENAMED FROM schema-qualified, Section 7.6) | ALTER VIEW old_schema.name SET SCHEMA new_schema | SAFE |
| View removed | DROP VIEW name [CASCADE] | DESTRUCTIVE |
Output column list comparison: The compiler compares the columns
produced by the view query by name and ordinal position. If
either the name or the position of any output column differs, the
change is classified as DESTRUCTIVE.
8.2. Materialized Views
Materialized views use the same syntax as regular views, prefixed
with MATERIALIZED.
PG equivalent:
CREATE MATERIALIZED VIEW [IF NOT EXISTS] name [WITH (options)] [TABLESPACE ts] AS query [WITH NO DATA]
matview-decl = "MATERIALIZED VIEW" WSP schema-view-name
[ WSP "WITH" WSP "(" storage-params ")" ]
[ WSP "TABLESPACE" WSP identifier ]
WSP "AS" WSP query
[ WSP "WITH NO DATA" ]
";"
[ "{" matview-block "}" ]
matview-block = *( matview-directive ";" )
matview-directive = owner-dir / comment-dir / indices-block
/ grants-block / revocations-block
/ renamed-from-dir / deprecated-dir
The { } block of a materialized view MAY contain INDICES { }
to declare indexes on the materialized view.
Example:
SCHEMA analytics {
MATERIALIZED VIEW daily_revenue AS
SELECT
date_trunc('day', created_at) AS day,
SUM(total_amount) AS revenue,
COUNT(*) AS order_count
FROM orders
WHERE status = 'completed'
GROUP BY 1;
MATERIALIZED VIEW product_stats
WITH (fillfactor = 90)
TABLESPACE analytics_space AS
SELECT product_id, COUNT(*) AS purchases, AVG(price) AS avg_price
FROM order_items
GROUP BY product_id
WITH NO DATA;
{
INDICES { idx_product_stats_id (product_id); }
GRANTS { SELECT TO app_readonly; }
}
}
Diffing semantics: Any change to the query text of a
materialized view requires DROP MATERIALIZED VIEW followed by
CREATE MATERIALIZED VIEW — classified as DESTRUCTIVE.
REFRESH MATERIALIZED VIEW is a runtime operation and is out of
scope for DPG (Section 23). RENAMED FROM (Section 7.6) and OWNER/
COMMENT changes follow the same rules as regular views: renaming
emits ALTER MATERIALIZED VIEW old RENAME TO new (CAUTION); a
schema-qualified RENAMED FROM emits ALTER MATERIALIZED VIEW old_schema.name SET SCHEMA new_schema (SAFE) alongside it when the
schema also changed. A TABLESPACE change emits ALTER MATERIALIZED VIEW name SET TABLESPACE ts (SAFE); a WITH (...) storage-param
change emits targeted ALTER MATERIALIZED VIEW name SET (...)/
RESET (...) clauses (SAFE) — the same treatment as a table’s
identical TABLESPACE/WITH (...) diffing (Section 7.11).
8.3. Recursive Views
Recursive views use the RECURSIVE keyword and require a column
name list.
PG equivalent:
CREATE RECURSIVE VIEW name (col1, ...) AS query
SCHEMA public {
RECURSIVE VIEW org_tree (id, parent_id, depth, path) AS
SELECT id, parent_id, 0, ARRAY[id]
FROM departments WHERE parent_id IS NULL
UNION ALL
SELECT d.id, d.parent_id, t.depth + 1, t.path || d.id
FROM departments d JOIN org_tree t ON d.parent_id = t.id;
}
Diffing semantics identical to regular views.
9. Functions and Procedures
9.1. Function Declaration Syntax
Functions are written in complete, unmodified PostgreSQL SQL syntax
with only the CREATE OR REPLACE verb removed. The dollar-quoted
body is Part 1 (terminated with $$; or $tag$; per Rule T2).
The optional { } block is Part 2.
function-decl = "FUNCTION" WSP schema-func-name "(" [ arg-list ] ")"
WSP return-clause
*( func-attribute )
func-body
";"
[ "{" func-block "}" ]
; The dollar-quoted form is overwhelmingly the common case (PL/pgSQL,
; SQL, and every other procedural-language extension). LANGUAGE C and
; LANGUAGE internal use a different, string-literal AS form instead —
; DPG does not validate which LANGUAGE pairs with which func-body form,
; left to PostgreSQL's own parser (passthrough principle, as elsewhere
; in this document). BEGIN ATOMIC is PG14+'s standard-SQL-conformant
; alternative to the whole "AS dollar-string" shape, for LANGUAGE SQL
; only — see the BEGIN ATOMIC note below.
func-body = WSP "AS" WSP dollar-string
/ WSP "AS" WSP string-literal [ WSP "," WSP string-literal ]
/ WSP "BEGIN ATOMIC" WSP sql-stmt-list WSP "END"
; One or more semicolon-terminated SQL statements, real PostgreSQL's
; own BEGIN ATOMIC body — each statement diffed the same as a
; LANGUAGE SQL body (Section 9.5's SQL canonicalisation applies here too).
sql-stmt-list = 1*( <SQL statement> ";" )
return-clause = "RETURNS" WSP return-type
/ "RETURNS TABLE" WSP "(" col-def-list ")"
/ "RETURNS SETOF" WSP type-ref
func-attribute = "LANGUAGE" WSP lang-name
/ "VOLATILE" / "STABLE" / "IMMUTABLE"
/ "CALLED ON NULL INPUT" / "RETURNS NULL ON NULL INPUT"
/ "STRICT"
/ "SECURITY DEFINER" / "SECURITY INVOKER"
/ "LEAKPROOF" / "NOT LEAKPROOF"
/ "PARALLEL UNSAFE" / "PARALLEL RESTRICTED" / "PARALLEL SAFE"
/ "COST" WSP number
/ "ROWS" WSP number
/ "SUPPORT" WSP func-ref
/ "WINDOW"
/ "SET" WSP identifier WSP "=" WSP expr
/ "SET" WSP identifier WSP "FROM CURRENT"
/ "TRANSFORM" WSP transform-for-type-list
transform-for-type-list = "FOR TYPE" WSP type-ref
*( "," WSP "FOR TYPE" WSP type-ref )
func-block = *( func-directive ";" )
func-directive = comment-dir / owner-dir / grants-block / revocations-block
/ deprecated-dir / renamed-from-dir / depends-on-extension-dir
; Applies to Function/Procedure only — real PostgreSQL's ALTER AGGREGATE
; has no DEPENDS ON EXTENSION clause, even though Aggregate shares this
; same func-block production. DPG does not reject it on Aggregate itself
; (passthrough principle); PostgreSQL's own parser does.
depends-on-extension-dir = [ "NO" WSP ] "DEPENDS ON EXTENSION" WSP identifier
Function attributes MUST appear in PostgreSQL’s own documented ordering. The compiler does not reorder them.
All attributes listed in func-attribute above correspond exactly
to options accepted by CREATE FUNCTION in PostgreSQL 14+. The
compiler passes them through verbatim when reconstructing the CREATE OR REPLACE FUNCTION statement.
AS 'obj_file', 'link_symbol' / AS 'name': LANGUAGE C
functions load a symbol from a shared object file — 'link_symbol'
defaults to the SQL function name when omitted. LANGUAGE internal
functions reference an already-compiled function built into the
server by its C name. Both use func-body’s string-literal form
instead of a dollar-quoted body — no procedural code of DPG’s own to
store, so it is diffed the same way any other function attribute
change is (Section 9.5’s generic “signature/attribute changed” path), not
via body-hash. This closes the same gap TYPE ... (INPUT = ...)
base types (Section 5.5) depend on: their INPUT/OUTPUT/etc. support
functions are routinely LANGUAGE C functions using exactly this
form.
BEGIN ATOMIC ... END: PostgreSQL 14+’s standard-SQL-conformant
alternative to the dollar-quoted AS body, LANGUAGE SQL only — see
func-body above. Where the plain dollar-quoted form is the
PostgreSQL-specific idiom, BEGIN ATOMIC is the form the SQL
standard itself defines, directly relevant to Tenet 3 (Standard SQL
vs. PostgreSQL-specific): a source file may choose either without
DPG preferring one. Diffed identically to a dollar-quoted
LANGUAGE SQL body (Section 9.5’s SQL canonicalisation applies verbatim,
since the body content is ordinary SQL statements either way).
TRANSFORM FOR TYPE type_name [, FOR TYPE type_name ...]:
References a CREATE TRANSFORM-registered marshaling pair for the
named type(s) — e.g. a PL/Python function accepting/returning
hstore via hstore_plpython’s transform. This references an
already-installed transform; it does not declare one — CREATE TRANSFORM itself remains correctly out of scope (Section 23), the same
distinction access-method USING (Section 7.1) draws against CREATE ACCESS METHOD.
[NO] DEPENDS ON EXTENSION extension_name (Function/Procedure
only, func-block): declares (or removes) an auto-drop dependency
between the function and an extension, so the function is dropped
automatically if the extension is — PostgreSQL’s mechanism for a
function that logically belongs to an extension without itself being
a member object of it. Diffed as a set: an added entry emits ALTER FUNCTION name(...) DEPENDS ON EXTENSION ext (SAFE); a removed one
emits ALTER FUNCTION name(...) NO DEPENDS ON EXTENSION ext
(SAFE).
OWNER/RENAMED FROM (func-block) follow the standard rules
(Section 7.6): ALTER FUNCTION name(...) OWNER TO role (SAFE); ALTER FUNCTION old(...) RENAME TO new (CAUTION), plus SET SCHEMA when
schema-qualified (SAFE) — the same generic renamed-from-dir
extension Section 7.6 introduced. Significant for Aggregate specifically
(Section 9.4, which reuses func-block): real PostgreSQL’s ALTER AGGREGATE supports only RENAME TO/OWNER TO/SET SCHEMA —
every other change requires drop and recreate — so this closes a
full third of Aggregate’s entire incremental-ALTER surface, not
just Function/Procedure’s.
REVOCATIONS { } (func-block, Section 11.3’s revoke-entry grammar)
was already implemented in core/ but had no grammar slot in this
document — func-directive previously listed only grants-block,
silently discarding any declared Function/Procedure/Aggregate
revocation at the spec level even though the compiler itself already
supported it.
9.2. Function Attributes Reference
| Attribute | Meaning |
|---|---|
VOLATILE | Default. May modify DB; result may differ per call. |
STABLE | Constant within a single transaction for given inputs. Cannot modify DB. |
IMMUTABLE | Constant for all time for given inputs. Index-eligible. |
STRICT | Alias for RETURNS NULL ON NULL INPUT. Returns NULL if any argument is NULL. |
SECURITY DEFINER | Executes with the privileges of the function owner. |
SECURITY INVOKER | Default. Executes with the privileges of the calling role. |
LEAKPROOF | Never leaks argument data via errors/side channels — governs whether the planner may push a qual through an RLS-restricted view. |
NOT LEAKPROOF | Default. May leak argument data. |
PARALLEL SAFE | Safe for parallel execution in any worker. |
PARALLEL RESTRICTED | Parallel-safe but must run in the leader process. |
PARALLEL UNSAFE | Default. Cannot run in parallel. |
COST n | Estimated execution cost in cpu_operator_cost units. |
ROWS n | Estimated number of rows returned (set-returning functions only). |
SUPPORT func | Planner support function (PostgreSQL 12+). |
SET param = value | Sets the named GUC to value for the duration of the call. |
SET param FROM CURRENT | Sets the named GUC from its current session value. |
WINDOW | Declares the function as a window function. |
TRANSFORM FOR TYPE t [, ...] | References an already-installed transform for marshaling type t. |
SECURITY DEFINER + search_path: Functions declared with
SECURITY DEFINER SHOULD include SET search_path = schema [, ...]
to prevent search path injection. The linter SHOULD warn when a
SECURITY DEFINER function lacks an explicit search_path setting
(rule: security-definer-search-path).
Examples:
SCHEMA public {
-- Simple SQL function
FUNCTION active_user_count() RETURNS BIGINT
LANGUAGE sql STABLE PARALLEL SAFE
AS $$
SELECT COUNT(*) FROM users WHERE status = 'active';
$$;
-- PL/pgSQL function
FUNCTION get_user(p_email TEXT) RETURNS users
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE v_user users;
BEGIN
SELECT * INTO STRICT v_user FROM users WHERE email = p_email;
RETURN v_user;
EXCEPTION
WHEN NO_DATA_FOUND THEN
RAISE EXCEPTION 'User not found: %', p_email;
END;
$$;
{
COMMENT 'Fetch a user record by verified email address';
GRANTS { EXECUTE TO app_service; }
}
-- Named dollar-quote (avoids conflict when body contains $$)
FUNCTION format_price(p_amount NUMERIC) RETURNS TEXT
LANGUAGE plpgsql IMMUTABLE STRICT
AS $func$
BEGIN
RETURN '$' || TO_CHAR(p_amount, 'FM999,999,990.00');
END;
$func$;
{
GRANTS { EXECUTE TO app_readonly, app_service; }
}
}
9.3. Procedures
Procedures follow the same model as functions. They omit the
RETURNS clause. Procedures MAY issue COMMIT mid-execution.
PG equivalent: CREATE [OR REPLACE] PROCEDURE name (...) LANGUAGE lang AS $$...$$
SCHEMA public {
PROCEDURE process_settlements()
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE v_id settlements.id%TYPE;
BEGIN
FOR v_id IN SELECT id FROM settlements WHERE processed = false LOOP
PERFORM settle_order(v_id);
COMMIT;
END LOOP;
END;
$$;
{
GRANTS { EXECUTE TO scheduler_role; }
}
}
Procedure diffing semantics are identical to function diffing
(Section 9.5). Procedure identity is (schema, name, arg_types)
where OUT and TABLE mode parameters are excluded from the type
key per PostgreSQL’s overloading rules.
9.4. Aggregate Functions
Aggregates use two ( ) groups per PostgreSQL’s CREATE AGGREGATE
syntax — both are Part 1 per Tenet 5.
PG equivalent:
CREATE [OR REPLACE] AGGREGATE name (input_types) (SFUNC = ..., STYPE = ..., ...)
aggregate-decl = "AGGREGATE" WSP schema-func-name
WSP "(" agg-input-list ")"
WSP "(" agg-options ")"
[ "{" func-block "}" ]
agg-input-list = "*"
/ [ mode WSP ] type-ref *( "," [ mode WSP ] type-ref )
/ ordered-set-sig
ordered-set-sig = type-ref *( "," type-ref )
WSP "ORDER BY" WSP type-ref *( "," type-ref )
; Every option real PostgreSQL's CREATE AGGREGATE accepts (normal-
; aggregate form) — previously referenced but never enumerated here.
agg-options = agg-option *( "," WSP agg-option )
agg-option = "SFUNC" WSP "=" WSP func-ref
/ "STYPE" WSP "=" WSP type-ref
/ "SSPACE" WSP "=" WSP integer
/ "FINALFUNC" WSP "=" WSP func-ref
/ "FINALFUNC_EXTRA"
/ "FINALFUNC_MODIFY" WSP "=" WSP modify-mode
/ "COMBINEFUNC" WSP "=" WSP func-ref
/ "SERIALFUNC" WSP "=" WSP func-ref
/ "DESERIALFUNC" WSP "=" WSP func-ref
/ "INITCOND" WSP "=" WSP string-literal
/ "MSFUNC" WSP "=" WSP func-ref
/ "MINVFUNC" WSP "=" WSP func-ref
/ "MSTYPE" WSP "=" WSP type-ref
/ "MSSPACE" WSP "=" WSP integer
/ "MFINALFUNC" WSP "=" WSP func-ref
/ "MFINALFUNC_EXTRA"
/ "MFINALFUNC_MODIFY" WSP "=" WSP modify-mode
/ "MINITCOND" WSP "=" WSP string-literal
/ "SORTOP" WSP "=" WSP operator-symbol
/ "PARALLEL" WSP "=" WSP ( "SAFE" / "RESTRICTED" / "UNSAFE" )
/ "HYPOTHETICAL"
modify-mode = "READ_ONLY" / "SHAREABLE" / "READ_WRITE"
Example:
SCHEMA public {
AGGREGATE product (DOUBLE PRECISION) (
SFUNC = float8mul,
STYPE = DOUBLE PRECISION,
INITCOND = '1'
)
{
COMMENT 'Multiplicative aggregate over DOUBLE PRECISION values';
GRANTS { EXECUTE TO app_service; }
}
}
Diffing semantics: Aggregate identity is (schema, name, input_types).
A change to any agg-option above — not only the six previously
named here (SFUNC/STYPE/INITCOND/FINALFUNC/COMBINEFUNC/
SERIALFUNC) but every field the declaration grammar can express
(SSPACE/DESERIALFUNC/MSFUNC/MINVFUNC/MSTYPE/MSSPACE/
MFINALFUNC/MINITCOND/SORTOP/PARALLEL/HYPOTHETICAL/
FINALFUNC_EXTRA/FINALFUNC_MODIFY/MFINALFUNC_EXTRA/
MFINALFUNC_MODIFY) — requires DROP AGGREGATE CASCADE followed by
CREATE AGGREGATE, classified as DESTRUCTIVE. Real PostgreSQL’s
ALTER AGGREGATE supports only three operations, all metadata-only:
- Renamed (
RENAMED FROM,func-block):ALTER AGGREGATE name (input_types) RENAME TO new_name—CAUTION; schema-qualified additionally emitsALTER AGGREGATE ... SET SCHEMA new_schema(SAFE), the samerenamed-from-dirextension as every other kind (Section 7.6). - Owner changed (
owner-dir,func-block):ALTER AGGREGATE name (input_types) OWNER TO role—SAFE. SET SCHEMA— covered by the schema-qualified rename above; not independently expressible without a rename in real PostgreSQL’s own grammar either.
Every agg-option change remains DESTRUCTIVE as above; only these
three metadata operations bypass drop-and-recreate.
9.5. Function Body Diffing Semantics
The compiler stores a SHA-256 hash of the normalised function body
in the snapshot (see Section 16.3). Normalisation depends on the
function’s LANGUAGE:
LANGUAGE SQL: the body is parsed and re-deparsed via the same PostgreSQL-grammar parser the compiler uses for every other statement, then hashed. This canonicalisation absorbs whitespace, quote-style, and clause-formatting differences — the same mechanism already relied on for reconstructing the opaque-tier object kinds (Tablespace, FDW, Collation, etc. — see Section 25). On any parse failure (a body the parser rejects for any reason), the compiler falls back to the plain normalisation below rather than erroring. ABEGIN ATOMIC ... ENDbody (Section 9.1) is canonicalised identically — each statement betweenBEGIN ATOMICandENDis ordinary SQL, parsed/re-deparsed/hashed the same way a dollar-quotedLANGUAGE SQLbody is; the two forms are interchangeable from the differ’s perspective, only their Part 1 spelling differs.LANGUAGE plpgsql: the body is compiled through the real PL/pgSQL compiler (vialibpg_query’s PL/pgSQL parse-to-JSON entry point, fed a full, argument-accurateCREATE FUNCTION/PROCEDUREstatement — a bare body string is not sufficient: the PL/pgSQL compiler resolves the body’s own parameter references, e.g. an assignment target, against the declared argument list at compile time), and the resulting parse tree is stripped of its one confirmed source-position field (lineno) before hashing. This absorbs whitespace, blank-line, and comment differences in the outer control-flow shape (statement ordering/nesting, declarations, labels, flags). Every embedded expression fragment (a condition, an assignment’s left- and right-hand sides, aRETURNexpression, aRAISEparameter, an embedded SQL statement) is also independently re-parsed and re-deparsed before hashing, using the exact raw parser modelibpg_queryitself records for that fragment (RAW_PARSE_PLPGSQL_EXPRfor a bare expression,RAW_PARSE_PLPGSQL_ASSIGN1/2/3for a one-, two-, or three-part dotted assignment target, or the ordinary statement parser for a fragment that is already a complete standalone statement) — the same modes PostgreSQL’s own PL/pgSQL compiler uses for these fragments, exposed via a small, purely additive patch tolibpg_query’s Go bindings (these raw-parse-mode entry points already existed as public, stable C functions; they had simply never been wrapped in Go). This closes the previous gap where only the outer control-flow shape was canonicalised: a whitespace-only change inside an embedded fragment (e.g.a||breformatted toa || b) is now absorbed too, for every fragment shape the currentlibpg_queryversion actually produces. On any failure at any step — a body the PL/pgSQL compiler rejects, a compile error from a mismatched shim, or an individual fragment that fails to re-parse — the affected fragment (or, if the failure is at the whole-body level, the entire body) falls back to the plain normalisation below rather than erroring; a hashing nicety must never block a build.- Every other language (
c,internal, and any procedural- language extension other thanplpgsql): plain text normalisation only —- Stripping leading and trailing whitespace from the body text.
- Collapsing all internal runs of whitespace (spaces, tabs, newlines) to a single space character.
Any change to the body that survives normalisation — including, for languages with no canonicalisation, changes that are cosmetic but not reducible to whitespace alone (comment wording, quote style, capitalisation) — changes the hash and causes the compiler to emit:
CREATE OR REPLACE FUNCTION schema.name(...) RETURNS ... AS $$...$$;
No semantic analysis of procedural code is performed for any
language. For LANGUAGE SQL, syntactic reformulations that
re-deparse identically are absorbed by the canonicalisation above;
genuinely equivalent but differently-structured SQL (e.g. a
rewritten join order) is still detected as changed, by design — this
is syntactic canonicalisation, not semantic equivalence. For
LANGUAGE plpgsql, both the outer control-flow shape and every
embedded expression fragment are canonicalised, on the same
syntactic (not semantic) basis: a genuinely equivalent but
differently-structured fragment (e.g. rewriting a OR b to the
logically equivalent NOT (NOT a AND NOT b)) is still detected as
changed, by design. This canonicalisation covers every fragment shape
the current libpg_query version’s PL/pgSQL parser actually
produces (verified directly against the grammar source, not assumed)
— a future libpg_query version introducing a new fragment shape
would fail open (that fragment’s raw text retained, same as before
this fix) rather than error. For every other language, no
canonicalisation is performed at all: the compiler does not detect
semantically (or even cosmetically, beyond whitespace) equivalent
reformulations.
Function signature changes (argument types, return type,
language, SECURITY DEFINER, STRICT, any attribute) are handled
as follows:
Changes to attributes that
CREATE OR REPLACE FUNCTIONcan update (SECURITY DEFINER,STRICT,VOLATILE/STABLE/IMMUTABLE,LEAKPROOF,PARALLEL,COST,ROWS,SEToptions): emitCREATE OR REPLACE FUNCTION— SafetySAFE. (SettingLEAKPROOFrequires superuser privilege at apply time — a PostgreSQL-level restriction, not a DPG one.)Changes to the argument list or return type: PostgreSQL does not support
CREATE OR REPLACEfor these. The compiler emitsDROP FUNCTION CASCADEfollowed byCREATE FUNCTION— classified asDESTRUCTIVE.
10. Sequences
Sequences are schema-level objects used for auto-incrementing values
not backed by GENERATED AS IDENTITY or SERIAL.
PG equivalent:
CREATE [UNLOGGED] SEQUENCE name [AS type] [INCREMENT BY n] [MINVALUE n] [MAXVALUE n] [START WITH n] [CACHE n] [CYCLE|NO CYCLE] [OWNED BY {table.col|NONE}]
sequence-decl = [ "UNLOGGED" WSP ] "SEQUENCE" WSP schema-name
*( sequence-option )
";"
[ "{" sequence-block "}" ]
sequence-option = "AS" WSP seq-type
/ "INCREMENT BY" WSP integer
/ "MINVALUE" WSP integer / "NO MINVALUE"
/ "MAXVALUE" WSP integer / "NO MAXVALUE"
/ "START WITH" WSP integer
/ "CACHE" WSP integer
/ "CYCLE" / "NO CYCLE"
/ "OWNED BY" WSP ( table-col-ref / "NONE" )
/ "RESTART" [ WSP "WITH" WSP integer ]
seq-type = "SMALLINT" / "INTEGER" / "BIGINT"
sequence-block = *( ( owner-dir / comment-dir / grants-block
/ revocations-block ) ";" )
UNLOGGED: Like UNLOGGED TABLE (Section 7.12), trades crash-safety
(the sequence’s current value is not WAL-logged) for reduced write
overhead — a genuinely different tradeoff axis from the temp-table
session-scoping exclusion (Section 7.12), and available on any PostgreSQL
version this document targets (Section 1.4). Toggling it after creation
follows the same ALTER SEQUENCE name SET LOGGED/UNLOGGED path as
tables, CAUTION.
OWNED BY NONE: Explicitly detaches the sequence from any
column, distinct from never having declared OWNED BY at all (which
simply leaves the sequence’s existing ownership, if any, untouched).
Diffed like any other option change — see below.
RESTART [WITH n]: Unlike every other sequence-option, this
does not describe persistent, comparable state — real PostgreSQL’s
RESTART is an imperative action (reset the sequence’s current
value now) that leaves no queryable “current RESTART value” for a
later plan to diff against; nextval() calls immediately begin
moving the value away from n again. The compiler therefore does
NOT persist RESTART’s argument in the snapshot the way it persists
START WITH/INCREMENT BY/etc. Instead, RESTART’s mere presence
in the desired source unconditionally emits ALTER SEQUENCE name RESTART [WITH n] on every plan/apply for as long as it remains
declared — Safety MANUAL, and the compiler emits an inline comment
recommending the directive be removed from source once the reset has
been applied, the same one-shot-then-remove usage pattern real
PostgreSQL’s own documentation recommends for RESTART itself.
Sequences gain a REVOCATIONS { } block (Section 11.3’s revoke-entry
grammar) identical in shape to Table’s (Section 7.10) — previously absent
from sequence-block even though GRANTS { } was already present,
which silently discarded any declared sequence revocation with no
error and no DDL emitted.
Rule: Sequences backing GENERATED AS IDENTITY or SERIAL
columns are managed automatically by PostgreSQL and MUST NOT be
declared in DPG. The compiler MUST emit a warning (lint rule
serial_sequence_declared) if a declared sequence name matches
the pattern <table>_<col>_seq and a column with that identity
pattern exists.
Example:
SCHEMA public {
SEQUENCE order_number_seq
AS BIGINT
START WITH 10000
INCREMENT BY 1
MINVALUE 10000
MAXVALUE 99999999
CACHE 50
NO CYCLE
OWNED BY orders.order_number;
}
Diffing semantics:
| Change | DDL emitted | Safety |
|---|---|---|
| New sequence | CREATE SEQUENCE ... | SAFE |
| Increment/min/max/cache/cycle changed | ALTER SEQUENCE name ... | SAFE |
OWNED BY changed (incl. to/from NONE) | ALTER SEQUENCE name OWNED BY {table.col|NONE} | SAFE |
RESTART [WITH n] present in source | ALTER SEQUENCE name RESTART [WITH n] (re-emitted every plan/apply while present, see above) | MANUAL |
UNLOGGED toggled | ALTER SEQUENCE name SET LOGGED/UNLOGGED | CAUTION |
AS type changed | DROP SEQUENCE; CREATE SEQUENCE | DESTRUCTIVE |
| Sequence removed | DROP SEQUENCE name | DESTRUCTIVE |
11. Access Control
11.1. Roles
Roles are cluster-level objects declared in .dpg files inside the
cluster objects directory. Every attribute below is native PostgreSQL
CREATE ROLE/ALTER ROLE grammar, parsed directly by the real PG
parser (CreateRoleStmt.Options) — not a DPG-invented reformulation.
This corrects an earlier draft of this section, which wrapped every
attribute in a DPG-only { } block (with SUPERUSER/CREATEDB/
CREATEROLE/REPLICATION/BYPASSRLS incorrectly shown taking a
boolean argument — real PG has no such form; they’re toggle-pairs, e.g.
SUPERUSER/NOSUPERUSER, no argument at all). The { } block is
reserved for genuinely DPG-only additions — currently just COMMENT.
PG equivalent:
CREATE ROLE name [option [...]]
role-decl = "ROLE" WSP identifier
*( WSP role-option )
";"
[ "{" role-block "}" ]
role-option = "LOGIN" / "NOLOGIN"
/ "SUPERUSER" / "NOSUPERUSER"
/ "CREATEDB" / "NOCREATEDB"
/ "CREATEROLE" / "NOCREATEROLE"
/ "INHERIT" / "NOINHERIT"
/ "REPLICATION" / "NOREPLICATION"
/ "BYPASSRLS" / "NOBYPASSRLS"
/ "CONNECTION" WSP "LIMIT" WSP integer
/ "PASSWORD" WSP SQUOTE password-literal SQUOTE
/ "VALID" WSP "UNTIL" WSP SQUOTE timestamp SQUOTE
/ "IN" WSP "ROLE" WSP membership-list
/ "ROLE" WSP membership-list
/ "ADMIN" WSP membership-list
; PostgreSQL 16+ per-membership modifiers — previously only WITH ADMIN
; OPTION (the legacy pre-16 boolean-flag spelling) was representable at
; all; INHERIT/SET govern whether the member actually inherits the
; granted role's privileges and whether it may SET ROLE to it,
; independently of ADMIN (delegation-of-grant) — not cosmetic.
membership-list = membership-item *( "," WSP membership-item )
membership-item = identifier *( WSP "WITH" WSP membership-opt )
membership-opt = "ADMIN" WSP ( "OPTION" / boolean )
/ "INHERIT" WSP boolean
/ "SET" WSP boolean
role-block = *( ( comment-dir / renamed-from-dir
/ role-config-dir ) ";" )
; ALTER ROLE ... [IN DATABASE db] SET param {TO|=} value / SET param
; FROM CURRENT / RESET param / RESET ALL — a real, common need (e.g.
; per-role statement_timeout) with no prior grammar slot at all.
role-config-dir = "SET" WSP identifier WSP ( "TO" / "=" ) WSP config-value
[ WSP "IN DATABASE" WSP identifier ]
/ "SET" WSP identifier WSP "FROM CURRENT"
[ WSP "IN DATABASE" WSP identifier ]
/ "RESET" WSP ( identifier / "ALL" )
[ WSP "IN DATABASE" WSP identifier ]
config-value = string-literal / integer / boolean / identifier
Any option a declaration omits is simply not managed by DPG for that role — offline diffing only ever compares options the source explicitly sets (the same “declared, so managed” convention already used elsewhere, e.g. Sequence’s optional params), never PostgreSQL’s own defaults for whatever was left unstated.
password-literal is an ordinary string, optionally containing one or
more {{<secret-uri>}} placeholders — the exact same mechanism as
SUBSCRIPTION CONNECTION (Section 13.2, Appendix D.5): each placeholder is resolved
independently at apply time via pipeline.ResolveTemplate and
substituted in place (the whole value, or just a fragment); a literal
with no {{...}} at all never touches the resolver. Resolution happens
once, immediately before the CREATE ROLE/ALTER ROLE ... PASSWORD
statement executes — never during plan/diff.
Hardcoded passwords: The linter MUST emit an error (not a warning)
when PASSWORD is set to a value with no {{...}} placeholder at all
and forbid_hardcoded_passwords is enabled (default: true). This
supersedes an earlier draft of this rule, which named env:VAR_NAME
specifically — the check is scheme-agnostic now that five backends
exist (Appendix D.5), not tied to one.
Password drift detection: the snapshot stores a hash of the
declared PASSWORD text (the literal or {{...}} reference exactly
as written, trimmed, hashed — never the resolved value), the same
pattern SUBSCRIPTION CONNECTION’s body-hash diffing already uses. This
supersedes an earlier draft of this section, which specified storing
only a boolean has_password — under that design DPG could never
detect a rotated secret reference or changed literal, only whether a
password existed at all. Hashing the declared text instead is no less
safe (a literal password, if used despite the lint warning above, is
already sitting in cleartext in the .dpg source file itself — hashing
it into the snapshot exposes nothing the source didn’t already) and
enables real rotation detection: editing the {{...}} reference (or a
literal) re-diffs as a genuine change.
PASSWORD cannot be live-introspected at all: PostgreSQL restricts
pg_authid (where role passwords, hashed, actually live) to superuser
— confirmed live, pg_roles.rolpassword itself returns the fixed
placeholder string '********' for any non-superuser caller regardless
of whether a password is actually set, so rolpassword IS NOT NULL is
unusable as a proxy. Every other attribute below introspects normally
(plain, unrestricted pg_roles/pg_auth_members columns).
Example:
-- production/cluster/roles.dpg
ROLE app_readonly NOLOGIN
{
COMMENT 'Read-only access for reporting tools';
}
ROLE app_service
LOGIN
PASSWORD '{{vault:secret/roles/app_service#pw}}'
CONNECTION LIMIT 20
VALID UNTIL '2030-01-01';
ROLE app_admin
LOGIN
NOSUPERUSER
NOCREATEDB
NOCREATEROLE
INHERIT
IN ROLE pg_read_all_stats;
Diffing semantics:
| Change | DDL emitted | Safety |
|---|---|---|
| New role | CREATE ROLE name <options> | SAFE |
LOGIN/SUPERUSER/CREATEDB/CREATEROLE/INHERIT/REPLICATION/BYPASSRLS/CONNECTION LIMIT/VALID UNTIL changed | ALTER ROLE name <changed-options> | SAFE |
PASSWORD changed | ALTER ROLE name PASSWORD '<resolved>' | CAUTION (invalidates existing sessions/connections relying on the old password) |
IN ROLE/ROLE/ADMIN membership added | GRANT role TO member [WITH ADMIN OPTION] [WITH INHERIT bool] [WITH SET bool] | SAFE |
Membership WITH option changed, membership itself unchanged | GRANT role TO member WITH <changed-option> (PostgreSQL treats a repeated GRANT as updating the option) | SAFE |
IN ROLE/ROLE/ADMIN membership removed | REVOKE role FROM member | CAUTION (may remove access something else depends on) |
SET/RESET config parameter changed (role-config-dir) | ALTER ROLE name [IN DATABASE db] SET param {TO|=} value / RESET param / RESET ALL | SAFE |
Renamed (RENAMED FROM) | ALTER ROLE old RENAME TO new | CAUTION |
| Role removed | DROP ROLE name | DESTRUCTIVE |
RENAMED FROM on a role is schema-agnostic — roles are cluster-
level, not schema-scoped, so renamed-from-dir’s schema-qualification
extension (Section 7.6) never applies here; only the bare RENAME TO form
is meaningful. This closes a gap that could otherwise be genuinely
impossible to work around: PostgreSQL refuses to DROP ROLE a
role that still owns any object, so the drop-and-recreate fallback
this document uses for kinds without a rename mechanism cannot
substitute for a real rename here the way it can elsewhere.
ALTER GROUP ... ADD USER ... [ADMIN OPTION] (PostgreSQL 16
added ADMIN OPTION to it) is ALTER GROUP’s own legacy spelling of
role-membership GRANT/REVOKE — real PostgreSQL treats ROLE,
USER, and GROUP as fully interchangeable since 8.1, and ALTER GROUP name ADD USER member [WITH ADMIN OPTION] is documented as
exactly equivalent to GRANT name TO member [WITH ADMIN OPTION].
membership-list’s WITH ADMIN/INHERIT/SET grammar above
already covers everything ALTER GROUP can express — confirmed not
a gap, not merely assumed.
IN ROLE/ROLE/ADMIN are create-time-only PostgreSQL grammar — a
later membership change has no ALTER ROLE equivalent, so it’s diffed
as GRANT/REVOKE (PostgreSQL’s own mechanism for changing membership
after creation), matching how DPG already diffs object-level privilege
grants (Section 11.2) rather than inventing new DDL shape for this.
11.2. Grants — The Additive Model
DPG follows PostgreSQL’s own additive privilege model:
Declaring a grant emits a GRANT statement.
Removing a grant declaration emits nothing. If revocation is
intended, an explicit REVOCATIONS { } entry MUST be added.
This is a deliberate design choice. DPG does not attempt to manage the full privilege graph. It only tracks the grants it declared and ensures they are present. Grants applied by other means (e.g., by a DBA directly) are not disturbed.
dpg verify reports as drift any DPG-declared grant that is absent
from the live catalog. It does NOT report extra grants present in
the live catalog but absent from DPG source.
WITH GRANT OPTION: Including WITH GRANT OPTION in a
GRANTS { } entry causes the compiler to emit
GRANT ... TO role WITH GRANT OPTION. Removing WITH GRANT OPTION
while keeping the grant emits nothing (removing the grant option
requires an explicit revoke-and-regrant cycle which the operator
SHOULD perform manually).
Privilege list is untyped, by design — an accepted offline
limitation: privilege (Appendix A) is a single flat union shared
verbatim across every object kind’s GRANTS { }/REVOCATIONS { }
block, not PostgreSQL’s real per-object-type privilege subsets
(e.g. TABLE accepts SELECT/INSERT/UPDATE/DELETE/
TRUNCATE/REFERENCES/TRIGGER/MAINTAIN; SEQUENCE accepts only
USAGE/SELECT/UPDATE; SCHEMA only CREATE/USAGE; FUNCTION/
PROCEDURE only EXECUTE; and so on). Every real privilege word
PostgreSQL has is present in the union, so this is not a Tenet-1
expressiveness gap — nothing is unexpressable — but DPG offers no
offline validation contract catching “wrong privilege for this
object kind” (e.g. EXECUTE ON TABLE) at plan time; such a
mistake surfaces only as a PostgreSQL parse/grammar error at apply
time, the same passthrough-to-PostgreSQL’s-own-parser principle used
throughout this document for clause-combination validation generally
(e.g. Section 7.9’s REFERENCING note). Stated here explicitly per
Tenet 3, rather than left as a silent gap.
11.3. Revocations
Explicit revocations are declared in REVOCATIONS { } blocks and
cause the compiler to emit REVOKE statements.
REVOKE on an already-absent privilege is a no-op in PostgreSQL —
it succeeds without error, unlike a grant declaration’s removal
(Section 11.2), which is the additive model’s deliberate no-op. PostgreSQL’s
REVOKE grammar has no IF EXISTS clause at all, so the compiler
emits plain REVOKE ... FROM role; there is no guard to add or omit.
The one revocation failure mode that does error is a target role
that does not exist — the compiler does not create roles referenced
only in a REVOCATIONS { } block, so a typo’d or since-dropped role
name surfaces as a normal PostgreSQL error at apply time.
However, the compiler SHOULD check the snapshot: if the
revocation targets a role that was never granted the privilege by
DPG, it MAY emit a warning (lint rule: unnecessary_revocation) —
useful as a “did you mean to declare this” signal, independent of
whether re-running it would error.
11.4. Default Privileges
Default privileges apply to future objects created by a role.
PG equivalent:
ALTER DEFAULT PRIVILEGES [FOR ROLE role] [IN SCHEMA schema] GRANT ... / REVOKE ...
default-privileges-decl =
"DEFAULT PRIVILEGES"
[ WSP "FOR ROLE" WSP identifier ]
[ WSP "IN SCHEMA" WSP identifier ]
"{" dp-block "}"
dp-block = *( ( grants-block / revocations-block ) ";" )
Inside the GRANTS { } / REVOCATIONS { } sub-blocks, the object
type is specified with an ON clause:
GRANTS {
SELECT ON TABLES TO app_readonly;
EXECUTE ON FUNCTIONS TO app_service;
USAGE ON SEQUENCES TO app_service;
}
Example:
SCHEMA public {
DEFAULT PRIVILEGES FOR ROLE app_admin {
GRANTS {
SELECT ON TABLES TO app_readonly;
EXECUTE ON FUNCTIONS TO app_service;
USAGE ON SEQUENCES TO app_service;
}
}
}
Emits:
ALTER DEFAULT PRIVILEGES FOR ROLE app_admin IN SCHEMA public
GRANT SELECT ON TABLES TO app_readonly;
ALTER DEFAULT PRIVILEGES FOR ROLE app_admin IN SCHEMA public
GRANT EXECUTE ON FUNCTIONS TO app_service;
ALTER DEFAULT PRIVILEGES FOR ROLE app_admin IN SCHEMA public
GRANT USAGE ON SEQUENCES TO app_service;
11.5. Owner Impersonation at Object Creation
PostgreSQL attributes default-privilege eligibility (Section 11.4) to
whichever role actually executed the CREATE statement — checked
against pg_default_acl via current_user/current_role — not to
an object’s final OWNER. A declared OWNER (owner-dir; see
Section 7.11 for Table’s copy of this directive — every object kind that
supports OWNER defines its own local copy of the same grammar
rather than sharing one central production) that is applied only
after creation, via a trailing ALTER ... OWNER TO, therefore
never satisfies a matching DEFAULT PRIVILEGES FOR ROLE block: the
role named in FOR ROLE never itself ran CREATE, so PostgreSQL
never consults its default-ACL entries for that object.
To make OWNER and DEFAULT PRIVILEGES FOR ROLE compose correctly,
the compiler MUST create every object that declares an OWNER
(owner-dir, per object kind — see the note above) as that role,
not as the connecting role. This is universal — it
applies to any object with a declared OWNER, not only objects
covered by a matching DEFAULT PRIVILEGES FOR ROLE block — because it
simply matches real PostgreSQL creator semantics: the role that
creates an object is that object’s owner.
Execution model. Each such object’s CREATE statement (and, for
an object whose CREATE MUST run outside a transaction — e.g.
TABLESPACE, Section 14.7 — the standalone statement) is wrapped as:
SET ROLE app_admin;
CREATE TABLE ...;
RESET ROLE;
SET ROLE is used rather than SET SESSION AUTHORIZATION: it only
requires the connecting role to be a member of the target role (not
superuser), and it does not change session_user — the real
connecting identity is preserved for the audit trail, and only the
privilege-checking current_user changes for the wrapped statement.
Once an object exists, reassigning its owner continues to use
ALTER ... OWNER TO exactly as before (Section 11.1, and per-object-kind
diffing sections) — an existing object’s ownership change does not
affect default-privilege attribution for objects already created, so
it needs no SET ROLE wrapping.
Pre-flight membership validation. Before the apply transaction
opens any DDL, the compiler MUST check, for every distinct role named
in an OWNER directive anywhere in the pending migration, that the
connecting role is a member of it (pg_has_role(current_user, owner, 'MEMBER')). If any declared OWNER fails this check, apply MUST
abort with error DPG-E036 before executing any statement in the
migration, naming every role that failed the check in a single error
— not a bare PostgreSQL “permission denied to set role” surfacing
mid-transaction on whichever object happens to hit it first.
11.6. Parameter Privileges
PostgreSQL 15+ has a distinct grantable-privilege object type for
individual GUC configuration parameters, letting an admin delegate
the ability to change a specific setting without granting broader
(superuser-adjacent) rights. This is a separate concern from ALTER SYSTEM the command remaining out of scope (Section 23): DPG never emits
ALTER SYSTEM itself, but the privilege to run it on a named
parameter is an ordinary grantable object like any other, and this
document’s own reasoning for excluding ALTER SYSTEM (a cluster-
configuration action, not a schema object) does not extend to who is
permitted to run it — that permission is exactly the kind of
access-control fact this section already manages for every other
object kind.
PG equivalent:
GRANT {SET | ALTER SYSTEM} ON PARAMETER param_name [, ...] TO role [, ...] [WITH GRANT OPTION]
parameter-privileges-decl =
"PARAMETER PRIVILEGES"
"{" pp-block "}"
pp-block = *( ( pp-grants-block / pp-revocations-block ) ";" )
pp-grants-block = "GRANTS" WSP "{" *( pp-grant-entry ";" ) "}"
pp-revocations-block = "REVOCATIONS" WSP "{" *( pp-revoke-entry ";" ) "}"
pp-grant-entry = pp-privilege-list WSP "ON PARAMETER" WSP identifier-list
WSP "TO" WSP role-list
[ WSP "WITH GRANT OPTION" ]
pp-revoke-entry = ( pp-privilege-list / "ALL PRIVILEGES" ) WSP
"ON PARAMETER" WSP identifier-list
WSP "FROM" WSP role-list
[ WSP "CASCADE" ]
pp-privilege-list = pp-privilege *( "," WSP pp-privilege )
pp-privilege = "SET" / "ALTER SYSTEM" / "ALL" / "ALL PRIVILEGES"
A top-level (cluster-scoped, not schema-scoped) block — configuration parameters have no schema.
-- production/cluster/parameter_privileges.dpg
PARAMETER PRIVILEGES {
GRANTS {
SET ON PARAMETER work_mem, statement_timeout TO app_admin;
}
}
Emits GRANT SET ON PARAMETER work_mem, statement_timeout TO app_admin.
Diffing semantics: Same additive model as Table-level grants
(Section 11.2) — a declared grant emits GRANT; removing the declaration
emits nothing (an explicit REVOCATIONS { } entry is required to
actually REVOKE). Safety SAFE for both directions — this is
metadata governing who may run a command, not an object with data
of its own.
12. Full-Text Search Objects
12.1. Text Search Configurations
Text search configurations define how documents are parsed and
tokenised. The MAPPING FOR sub-block is a declarative way to
set token mappings; the compiler emits
ALTER TEXT SEARCH CONFIGURATION ... ALTER MAPPING FOR ....
PG equivalent:
CREATE TEXT SEARCH CONFIGURATION name (COPY = source [, PARSER = parser])
tsconfig-decl = "TEXT SEARCH CONFIGURATION" WSP schema-name
WSP "(" tsconfig-opts ")"
[ "{" tsconfig-block "}" ]
";"
tsconfig-opts = "COPY" WSP "=" WSP qual-name
/ "PARSER" WSP "=" WSP qual-name
tsconfig-block = *( ( comment-dir / owner-dir / renamed-from-dir / mapping-dir ) ";" )
mapping-dir = "MAPPING FOR" WSP token-type-list
WSP "WITH" WSP dict-list
/ "MAPPING FOR" WSP token-type-list
WSP "REPLACE" WSP qual-name WSP "WITH" WSP qual-name
/ "MAPPING" WSP "REPLACE" WSP qual-name WSP "WITH" WSP qual-name
REPLACE substitutes one dictionary for another within a
mapping without restating the rest of that mapping’s dictionary
chain — MAPPING FOR token-types REPLACE old WITH new scopes the
substitution to the named token types; bare MAPPING REPLACE old WITH new (no FOR) applies it across every token type’s mapping at
once, matching real PostgreSQL’s two ALTER ... ALTER MAPPING
forms exactly. Functionally reachable via the plain WITH dict-list
form too (fully restating the desired dictionary chain already lets
the differ compute the same end state) — REPLACE exists as the
more targeted native PostgreSQL statement for the common “swap one
dictionary everywhere” case.
RENAMED FROM (renamed-from-dir, Section 7.6): a Configuration
MAY carry the same generic cross-schema SET SCHEMA extension as
every other schema-scoped kind — real PostgreSQL supports ALTER TEXT SEARCH CONFIGURATION ... RENAME TO/SET SCHEMA identically to
Dictionary (Section 12.2). This was previously missing from this
section’s grammar despite Dictionary/Parser/Template (Sections
12.2-12.4) already having it — Configuration is the fourth Full-Text-
Search kind, now consistent with its siblings.
Example:
SCHEMA public {
TEXT SEARCH CONFIGURATION english_unaccented (COPY = pg_catalog.english) {
MAPPING FOR hword, hword_part, word
WITH unaccent, english_stem;
}
}
Emits:
CREATE TEXT SEARCH CONFIGURATION public.english_unaccented (COPY = pg_catalog.english);
ALTER TEXT SEARCH CONFIGURATION public.english_unaccented
ALTER MAPPING FOR hword, hword_part, word WITH unaccent, english_stem;
Diffing semantics:
| Change | DDL emitted | Safety |
|---|---|---|
| New config | CREATE TEXT SEARCH CONFIGURATION ... | SAFE |
| Mapping added/changed | ALTER TEXT SEARCH CONFIGURATION ... ALTER MAPPING FOR ... | SAFE |
| Mapping removed | ALTER TEXT SEARCH CONFIGURATION ... DROP MAPPING FOR ... | SAFE |
REPLACE form declared | ALTER TEXT SEARCH CONFIGURATION ... ALTER MAPPING [FOR token-types] REPLACE old WITH new | SAFE |
| Owner changed | ALTER TEXT SEARCH CONFIGURATION name OWNER TO role | SAFE |
Renamed (RENAMED FROM) | ALTER TEXT SEARCH CONFIGURATION old RENAME TO new | CAUTION |
Moved to another schema (RENAMED FROM schema-qualified, Section 7.6) | ALTER TEXT SEARCH CONFIGURATION old_schema.name SET SCHEMA new_schema | SAFE |
| Config removed | DROP TEXT SEARCH CONFIGURATION name | DESTRUCTIVE |
12.2. Text Search Dictionaries
PG equivalent:
CREATE TEXT SEARCH DICTIONARY name (TEMPLATE = tmpl, [options])
SCHEMA public {
TEXT SEARCH DICTIONARY english_ispell (
TEMPLATE = ispell,
DictFile = english,
AffFile = english,
StopWords = english
)
{
OWNER "search_admin";
COMMENT 'Ispell-based English dictionary';
}
}
Unlike Text Search Parser/Template (Sections 12.3/12.4), a Dictionary MAY
carry an optional { } block (owner-dir/comment-dir/
renamed-from-dir, the same generic cross-schema SET SCHEMA
extension as every other kind, Section 7.6) — real PostgreSQL supports
ALTER TEXT SEARCH DICTIONARY ... OWNER TO/RENAME TO/SET SCHEMA
for Dictionary but has no OWNER concept at all for Parser/Template.
Diffing semantics:
| Change | DDL emitted | Safety |
|---|---|---|
Option (non-TEMPLATE) added/changed/removed | ALTER TEXT SEARCH DICTIONARY name (key [= value], ...) | SAFE |
TEMPLATE changed | DROP TEXT SEARCH DICTIONARY + recreate | DESTRUCTIVE |
| Owner changed | ALTER TEXT SEARCH DICTIONARY name OWNER TO role | SAFE |
Renamed (RENAMED FROM) | ALTER TEXT SEARCH DICTIONARY old RENAME TO new | CAUTION |
| Dictionary removed | DROP TEXT SEARCH DICTIONARY name | DESTRUCTIVE (if in use) |
TEMPLATE is fixed at creation, same as a base type’s core
properties (Section 5.5) — real PostgreSQL’s ALTER TEXT SEARCH DICTIONARY
can change any other option in place (add, change, or remove —
DROP an option by naming it with no value) without a rebuild; only
a TEMPLATE change forces drop-and-recreate.
12.3. Text Search Parsers
Text search parsers are low-level objects, typically installed via an extension. Explicit declaration is provided for completeness.
PG equivalent:
CREATE TEXT SEARCH PARSER name (START = func, GETTOKEN = func, END = func, LEXTYPES = func [, HEADLINE = func])
SCHEMA public {
TEXT SEARCH PARSER my_parser (
START = prsd_start,
GETTOKEN = prsd_nexttoken,
END = prsd_end,
LEXTYPES = prsd_lextype,
HEADLINE = prsd_headline
)
{
COMMENT 'Custom document parser';
}
}
Real PostgreSQL has no OWNER concept for Parser at all — the { }
block accepts only comment-dir/renamed-from-dir. SET SCHEMA is
unrelated to ownership and still applies via a schema-qualified
RENAMED FROM (Section 7.6) — real PostgreSQL supports moving a Parser
between schemas despite it having no owner-based ACL surface at all.
Any change to a parser requires drop + recreate (DESTRUCTIVE).
COMMENT/RENAMED FROM changes are SAFE/CAUTION respectively.
12.4. Text Search Templates
PG equivalent:
CREATE TEXT SEARCH TEMPLATE name ([INIT = func,] LEXIZE = func)
SCHEMA public {
TEXT SEARCH TEMPLATE ispell_template (
LEXIZE = dispell_lexize,
INIT = dispell_init
)
{
COMMENT 'Ispell-family dictionary template';
}
}
Same { } block rules as Parser above (comment-dir/
renamed-from-dir, no owner-dir — real PostgreSQL has no OWNER
concept for Template either).
Any change to a template requires drop + recreate (DESTRUCTIVE).
13. Logical Replication
13.1. Publications
Publications are database-level objects. The Part 1 body follows
PostgreSQL’s CREATE PUBLICATION syntax exactly.
PG equivalent:
CREATE PUBLICATION name [FOR TABLE table[, ...] | FOR ALL TABLES | FOR TABLES IN SCHEMA schema[, ...]] [WITH (options)]
publication-decl = "PUBLICATION" WSP identifier
WSP publication-scope
[ WSP "WITH" WSP "(" pub-options ")" ]
";"
[ "{" pub-block "}" ]
publication-scope = "FOR ALL TABLES"
/ "FOR TABLE" WSP pub-table-list
/ "FOR TABLES IN SCHEMA" WSP schema-list
pub-table-list = pub-table *( "," WSP pub-table )
pub-table = schema-table-name
[ "(" col-list ")" ]
[ "WHERE" WSP "(" predicate ")" ]
; pub-options (defined in Appendix A alongside storage-params/sub-options)
; mirrors real CREATE PUBLICATION's WITH (...) clause verbatim — an
; opaque key=value option list handed through as native PG text per
; Tenet 2/5, so e.g. publish, publish_via_partition_root, and any later
; PostgreSQL version's additions such as PG18's
; publish_generated_columns are automatically expressible with zero
; grammar changes here, the same passthrough pattern Subscription's
; WITH options (Section 13.2) already uses.
pub-block = *( ( comment-dir / owner-dir / renamed-from-dir
/ grants-block ) ";" )
owner-dir/renamed-from-dir follow the standard rules (Section 7.6):
ALTER PUBLICATION name OWNER TO role (SAFE); ALTER PUBLICATION old RENAME TO new (CAUTION) — Publication is database-level, not
schema-scoped (like Role and Event Trigger, Sections 11.1/14.1), so the
cross-schema SET SCHEMA half of the generic extension never
applies here.
Examples:
PUBLICATION user_data
FOR TABLE users, profiles
WITH (publish = 'insert, update, delete');
{
COMMENT 'Primary replication stream for user data';
}
PUBLICATION all_tables FOR ALL TABLES;
PUBLICATION filtered_orders
FOR TABLE orders (id, customer_id, status, total)
WHERE (status != 'draft');
Diffing semantics:
| Change | DDL emitted | Safety |
|---|---|---|
| New publication | CREATE PUBLICATION ... | SAFE |
| Table list changed | ALTER PUBLICATION name SET TABLE ... | SAFE |
| Options changed | ALTER PUBLICATION name SET (...) | SAFE |
| Owner changed | ALTER PUBLICATION name OWNER TO role | SAFE |
Renamed (RENAMED FROM) | ALTER PUBLICATION old RENAME TO new | CAUTION |
| Publication removed | DROP PUBLICATION name | DESTRUCTIVE |
13.2. Subscriptions
Subscriptions are database-level objects, declared and diffed as an
opaque body (byte-for-byte source text, not per-field) — the same tier
as Tablespace/FDW/Server/Publication/etc. CONNECTION is the one part
of that body DPG treats specially, to support a secret reference
instead of a literal credential (see below and Appendix D.5); COMMENT (in the
{ } block) is diffed and applied like every other Comment-bearing
object.
dump/verify/plan --live see every Subscription attribute except
CONNECTION itself: pg_subscription.subconninfo has no default grant
to PUBLIC (PostgreSQL revokes it from a normal caller outright), and
even a privileged caller who can read it has no way to recover
whatever {{secret-uri}} the original CONNECTION clause held, if
any — the same inherent limitation User Mapping OPTIONS (Section 14.10, Section 24)
has on recovering its original reference, though User Mapping’s
redaction works field-by-field rather than by omitting a whole column,
since OPTIONS also carries non-sensitive keys dump must still
reconstruct. An introspected Subscription’s CONNECTION is rendered
as a fixed,
syntactically-valid-but-inert placeholder conninfo instead (with
connect = false, create_slot = false, and enabled = false always
forced in its WITH clause, since the placeholder can never actually
be dialed); a dump’d Subscription must have its CONNECTION
hand-edited back to a real value before it does anything. Because the
reconstructed body’s BodyHash is never stored (same as every other
reconstructed opaque kind, Section 25), this placeholder never causes a
spurious DROP SUBSCRIPTION + CREATE SUBSCRIPTION loop on
verify/plan --live — introspecting at all is what makes an
already-applied Subscription visible as existing, rather than
plan --live proposing a spurious re-CREATE for one that’s already
there (which would then error on apply).
PG equivalent:
CREATE SUBSCRIPTION name CONNECTION 'connstr' PUBLICATION pub [, ...] [WITH (options)]
subscription-decl = "SUBSCRIPTION" WSP identifier
WSP "CONNECTION" WSP SQUOTE conn-literal SQUOTE
WSP "PUBLICATION" WSP identifier-list
[ WSP "WITH" WSP "(" sub-options ")" ]
";"
[ "{" comment-dir ";" "}" ]
conn-literal is an ordinary libpq conninfo string, used exactly as
written, optionally with one or more {{<secret-uri>}} placeholders
embedded in it — each resolved independently at apply time and
substituted in place (e.g. only the password comes from a secret, the
rest stays literal; or the whole value is one placeholder). {{...}} is
the only mechanism that ever triggers resolution: a real conninfo
string may itself contain a : (a postgresql://user:pass@host/db URI
is valid libpq syntax), so nothing else is ever read as a candidate
reference — a literal with no {{...}} at all never touches the
resolver.
Resolution happens once, immediately before executing the CREATE SUBSCRIPTION statement — never during plan/diff (offline-first is
unaffected: a {{...}} reference is compared as literal text, same as
any other body change) and never written anywhere after that: the
snapshot, an archived migration file, and any error message all show the
placeholder/reference form, not the resolved value. See Appendix D.5 for the
underlying SecretResolver/ResolveTemplate mechanism, shared with
Role PASSWORD (Section 11.1) and User Mapping OPTIONS (Section 14.10, Section 24).
Examples:
SUBSCRIPTION replica_users
CONNECTION 'host=primary.db.internal dbname=myapp user=replicator'
PUBLICATION user_data
WITH (enabled = true, copy_data = true);
SUBSCRIPTION replica_orders
CONNECTION 'host=primary.db.internal dbname=myapp user=replicator password={{vault:secret/repl/orders#pw}}'
PUBLICATION order_data;
SUBSCRIPTION replica_billing
CONNECTION '{{vault:secret/repl/billing#conninfo}}'
PUBLICATION billing_data
{
COMMENT 'Billing replica, syncs nightly';
}
Diffing semantics:
The Part 1 body (CONNECTION, PUBLICATION, WITH options) is diffed
as an opaque whole: any change is a full DROP SUBSCRIPTION +
CREATE SUBSCRIPTION, not a targeted ALTER SUBSCRIPTION, matching
every other opaque-tier object kind (Section 25). COMMENT is diffed
separately at the field level — a comment-only edit emits a plain
COMMENT ON SUBSCRIPTION, not a drop-and-recreate, so it never
interrupts an already-syncing subscription.
Both CREATE SUBSCRIPTION (with its default WITH (create_slot = true)) and DROP SUBSCRIPTION error “cannot run inside a transaction
block” in real PostgreSQL — confirmed live — so both run outside DPG’s
normal transactional migration block, unlike most DDL. A create-time
COMMENT ON SUBSCRIPTION (paired with a fresh CREATE) is likewise
emitted non-transactionally, so it executes strictly after the CREATE
it depends on rather than being reordered ahead of it by the
transactional/non-transactional split — also confirmed live.
| Change | DDL emitted | Safety |
|---|---|---|
| New subscription | CREATE SUBSCRIPTION ... [+ COMMENT ON SUBSCRIPTION ... if set] | MANUAL (non-transactional; still auto-executed, not an operator instruction) |
Body change (CONNECTION, PUBLICATION, or WITH) | DROP SUBSCRIPTION ... + CREATE SUBSCRIPTION ... [+ COMMENT ...] | DESTRUCTIVE then MANUAL |
| Comment-only change | COMMENT ON SUBSCRIPTION ... IS ... (or IS NULL) | SAFE |
| Subscription removed | DROP SUBSCRIPTION name | DESTRUCTIVE (non-transactional) |
14. Advanced PostgreSQL Objects
14.1. Event Triggers
Event triggers fire on DDL events cluster-wide. They are database- level objects (not cluster-level).
PG equivalent:
CREATE EVENT TRIGGER name ON event [WHEN TAG IN ('tag', ...)] EXECUTE FUNCTION func()
event-trigger-decl = "EVENT TRIGGER" WSP identifier
WSP "ON" WSP event-type
[ WSP "WHEN TAG IN" WSP "(" tag-list ")" ]
WSP "EXECUTE FUNCTION" WSP func-ref "()"
[ WSP trigger-enable-state ]
";"
[ "{" event-trigger-block "}" ]
event-type = "login" / "ddl_command_start" / "ddl_command_end" /
"table_rewrite" / "sql_drop"
; trigger-enable-state (DISABLED/ENABLE REPLICA/ENABLE ALWAYS, Section 7.9) is
; reused verbatim — real PostgreSQL's ALTER EVENT TRIGGER enable-state
; grammar is identical to ALTER TABLE ... ENABLE/DISABLE TRIGGER's,
; just against a different target object (no table name, since event
; triggers are database-level, not per-table).
event-trigger-block = *( ( comment-dir / owner-dir / renamed-from-dir ) ";" )
login (PostgreSQL 17+): fires once per authenticated
connection, before the session’s first query — confirmed against
PostgreSQL 17’s own documentation (event-trigger-definition.html):
“Currently, the only supported events are login,
ddl_command_start, ddl_command_end, table_rewrite and
sql_drop.” WHEN TAG IN (...) does not apply to login (there is
no command tag to filter on) — left to PostgreSQL’s own parser to
reject the combination, per the passthrough principle used
throughout this document.
REINDEX command-tag coverage (PostgreSQL 17+): REINDEX now
fires ddl_command_start/ddl_command_end (and sql_drop where
applicable) the same as any other DDL command — no DPG grammar
change needed, since WHEN TAG IN (...) already accepts any command
tag as an opaque string literal (tag-list, Appendix A); a
'REINDEX' tag simply becomes valid to write once running against
PostgreSQL 17+, the same way any future PostgreSQL version’s new
command tags are automatically expressible.
Example:
EVENT TRIGGER prevent_drop_table
ON sql_drop
WHEN TAG IN ('DROP TABLE', 'DROP SCHEMA')
EXECUTE FUNCTION abort_drop();
EVENT TRIGGER audit_ddl
ON ddl_command_end
EXECUTE FUNCTION log_ddl_command()
ENABLE REPLICA
{
OWNER "audit_admin";
}
Diffing semantics:
| Change | DDL emitted | Safety |
|---|---|---|
| New event trigger | CREATE EVENT TRIGGER ... | SAFE |
Event/WHEN TAG IN/function changed | DROP EVENT TRIGGER + CREATE EVENT TRIGGER | SAFE (no data involved) |
Enable state changed only (trigger-enable-state) | ALTER EVENT TRIGGER name ENABLE/DISABLE (or ENABLE REPLICA/ENABLE ALWAYS) | SAFE |
| Owner changed | ALTER EVENT TRIGGER name OWNER TO role | SAFE |
Renamed (RENAMED FROM) | ALTER EVENT TRIGGER old RENAME TO new | CAUTION |
| Event trigger dropped | DROP EVENT TRIGGER name | SAFE |
Event triggers are database-level, not schema-scoped — renamed- from-dir’s generic cross-schema SET SCHEMA extension (Section 7.6) never
applies here, since there is no schema to move between; only the
bare (unqualified) rename form is meaningful, same as Role (Section 11.1).
14.2. Collations
PG equivalent:
CREATE COLLATION [IF NOT EXISTS] name { (LOCALE = locale | LC_COLLATE = lc, LC_CTYPE = lc | PROVIDER = provider [, DETERMINISTIC = bool] [, RULES = rules]) | FROM existing_collation }
SCHEMA public {
COLLATION case_insensitive (
PROVIDER = icu,
LOCALE = 'und-u-ks-level2',
DETERMINISTIC = false
);
-- FROM copies an existing collation's definition under a new name
-- (e.g. a differently-cased alias of a system-provided collation).
COLLATION case_insensitive_alias FROM case_insensitive;
-- RULES (PostgreSQL 16+, ICU provider only): custom tailoring rules
-- layered on top of the base locale.
COLLATION custom_digits_first (
PROVIDER = icu,
LOCALE = 'en-u-kn-true',
RULES = '&0 < a-z'
)
{
OWNER "search_admin";
COMMENT 'Numeric-aware sort with a custom tailoring rule';
}
}
Since Collation is an opaque-object-decl kind (Part 1 passthrough,
Appendix A), both RULES = rules and FROM existing_collation
require no DPG-side grammar of their own — real PostgreSQL CREATE COLLATION syntax, handed to pg_query verbatim like every other
property form above.
Collation MAY carry an optional { } block (comment-dir/
owner-dir/renamed-from-dir/refresh-version-dir) — previously
entirely absent, silently sweeping an owner change into the generic
“any property change → DROP+CREATE” path even though real PostgreSQL
supports OWNER TO/RENAME TO/SET SCHEMA for Collation without a
rebuild.
; Collation-only: unlike RESTART (Sequence, Section 10), a bare presence
; keyword with no argument — REFRESH VERSION has nothing to parametrise,
; it just re-reads the OS/ICU library's *current* collation version and
; records it, the same non-persistent-target-value shape as RESTART.
refresh-version-dir = "REFRESH VERSION"
REFRESH VERSION (PostgreSQL 15+): updates the catalog’s
recorded collation version to match the operating system’s/ICU’s
current version, acknowledging an OS/library upgrade that may have
silently changed sort order — real PostgreSQL’s mitigation for a
genuine data-corruption risk (an index built under the old collation
version can silently misbehave after the OS collation library
changes underneath it). Like RESTART (Section 10), this describes an
imperative action, not comparable target state: its mere presence in
source unconditionally emits ALTER COLLATION name REFRESH VERSION
on every plan/apply for as long as it remains declared — Safety
MANUAL, with the compiler recommending removal from source once
applied, the same usage pattern as RESTART.
Diffing semantics:
| Change | DDL emitted | Safety |
|---|---|---|
| Owner changed | ALTER COLLATION name OWNER TO role | SAFE |
Renamed (RENAMED FROM) | ALTER COLLATION old RENAME TO new | CAUTION; schema-qualified additionally emits ALTER COLLATION old_schema.name SET SCHEMA new_schema (SAFE) |
REFRESH VERSION present in source | ALTER COLLATION name REFRESH VERSION (re-emitted every apply while present, see above) | MANUAL |
Any other property change (LOCALE/PROVIDER/RULES/FROM target/etc.) | DROP COLLATION + CREATE COLLATION | DESTRUCTIVE (dependent objects must be dropped and recreated) |
The DESTRUCTIVE row applies identically to the FROM existing_collation form: since the body is diffed as opaque text
(Section 14’s opaque-object-decl note), changing which collation a FROM
clause points at is itself a property change.
14.3. Operators
PG equivalent:
CREATE OPERATOR symbol (LEFTARG = t, RIGHTARG = t, FUNCTION = func, [COMMUTATOR = op, NEGATOR = op, RESTRICT = func, JOIN = func, HASHES, MERGES])
SCHEMA public {
OPERATOR === (
LEFTARG = complex,
RIGHTARG = complex,
PROCEDURE = complex_eq,
COMMUTATOR = ===,
NEGATOR = !==,
RESTRICT = eqsel,
JOIN = eqjoinsel,
HASHES,
MERGES
);
}
Operator identity: (schema, symbol, leftarg_type, rightarg_type).
Diffing semantics:
| Change | DDL emitted | Safety |
|---|---|---|
| New operator | CREATE OPERATOR ... | SAFE |
PROCEDURE/FUNCTION changed | DROP OPERATOR; CREATE OPERATOR | DESTRUCTIVE |
Optimizer hint changes (RESTRICT, JOIN, COMMUTATOR, NEGATOR, HASHES, MERGES) | ALTER OPERATOR ... (...) | SAFE |
| Operator removed | DROP OPERATOR symbol (leftarg, rightarg) | DESTRUCTIVE |
14.4. Operator Classes and Families
PG equivalent (family):
CREATE OPERATOR FAMILY name USING access_method
PG equivalent (class):
CREATE OPERATOR CLASS name [DEFAULT] FOR TYPE type USING access_method [FAMILY family] AS ...
SCHEMA public {
OPERATOR FAMILY my_family USING btree;
OPERATOR CLASS my_ops FOR TYPE mytype USING btree FAMILY my_family AS
OPERATOR 1 <,
OPERATOR 2 <=,
OPERATOR 3 =,
OPERATOR 4 >=,
OPERATOR 5 >,
FUNCTION 1 mytype_cmp(mytype, mytype);
}
Diffing (operator class): Identity is (schema, name, access_method).
The body (its AS member list) is diffed as normalised text
(passthrough). Any change to the member list requires drop + recreate
(DESTRUCTIVE) — PostgreSQL provides no incremental ALTER OPERATOR CLASS for its AS members at all, so there is no safer alternative.
Loose family members. PostgreSQL separately lets an operator family
carry members that belong to the family directly rather than to any one
of its operator classes — most often used for cross-type comparisons
(e.g. an int4 family also handling int4 vs. int8), added via
ALTER OPERATOR FAMILY name USING access_method ADD OPERATOR strategy_num operator_name (left_type, right_type) [FOR SEARCH | FOR ORDER BY sort_family_name], FUNCTION support_num [(left_type, right_type)] function_name (argument_types), .... DPG never exposes
ALTER as source syntax (declarations describe desired state, not
commands — the same reason table constraints never require ALTER TABLE ADD CONSTRAINT boilerplate), so these are declared in the family’s { }
block instead, using the exact same member grammar ALTER OPERATOR FAMILY ... ADD uses, just without repeating the ALTER/family header:
SCHEMA public {
OPERATOR FAMILY my_family USING btree {
OPERATOR 1 <(int4, int8),
OPERATOR 3 =(int4, int8),
FUNCTION 1 (int4, int8) btint48cmp(int4, int8)
};
}
FOR ORDER BY sort_family_name (used for GiST/SP-GiST “KNN” support —
btree itself rejects it, since it has no notion of ordering operators
distinct from its own strategies) always names a btree family:
PostgreSQL requires sort_family_name to reference one, so it is never
ambiguous even though the syntax itself doesn’t repeat USING btree.
A member’s identity is (kind, strategy/support number, left_type, right_type) — matching PostgreSQL’s own pg_amop/pg_amproc unique
indexes. Op-types are always compared in their canonical form (e.g.
integer, never int4). A FUNCTION item’s (left_type, right_type)
may be omitted only when the function takes exactly two arguments (then
defaulting to its own argument types, PostgreSQL’s own documented
default); any other arity requires them explicitly, since the correct
default (the owning operator class’s input type) cannot be derived from
the function’s signature alone.
Diffing (loose family members): unlike an operator class’s AS
list, PostgreSQL genuinely supports incremental member changes at the
family level, so these are diffed per-member rather than forcing the
whole family to drop and recreate:
| Change | DDL emitted | Safety |
|---|---|---|
| Member added | ALTER OPERATOR FAMILY f USING am ADD OPERATOR n op(lt,rt) [FOR SEARCH|FOR ORDER BY sf] / ... ADD FUNCTION n (lt,rt) fn(args) | SAFE |
| Member changed in place (same slot, different operator/function/sort target) | ALTER ... DROP ...; then ALTER ... ADD ... | DESTRUCTIVE |
| Member removed | ALTER OPERATOR FAMILY f USING am DROP OPERATOR n (lt,rt) / DROP FUNCTION n (lt,rt) | DESTRUCTIVE |
| Family itself dropped and recreated (body or access method changed) | every declared member re-added in full after the CREATE | inherits the family’s own classification |
Removal is classified DESTRUCTIVE even though it destroys no data:
dropping a cross-type member silently changes query-plan shape for any
query that relied on it (an index scan degrading to a sequential scan,
a merge join becoming a hash join) with no PostgreSQL error anywhere,
and the common real-world cause of “a declared member is gone” is an
accidental source omission, not an intentional removal —
--allow-destructive is the right point to require confirmation.
A member already owned by one of the family’s operator classes (i.e.
already present in some class’s own AS list) must never be
re-declared in the family’s { } block — PostgreSQL rejects the
resulting ALTER ... ADD as a duplicate. A family that has no
declaration of its own (relying entirely on PostgreSQL’s same-name
auto-creation from an unqualified CREATE OPERATOR CLASS) has nowhere
to attach loose members; declare the family explicitly first.
14.5. Casts
PG equivalent:
CREATE CAST (source AS target) (WITH FUNCTION func | WITHOUT FUNCTION | WITH INOUT) [AS IMPLICIT | AS ASSIGNMENT]
SCHEMA public {
CAST (mytype AS TEXT)
WITH FUNCTION mytype_to_text(mytype)
AS IMPLICIT;
CAST (TEXT AS mytype)
WITH FUNCTION text_to_mytype(TEXT);
}
Cast identity: (source_type, target_type).
Diffing semantics: PostgreSQL provides no ALTER CAST. Any
change to a cast requires DROP CAST followed by CREATE CAST —
classified as DESTRUCTIVE. The compiler MUST check for dependent
objects before emitting DROP CAST.
14.6. Extended Statistics Objects
PG equivalent:
CREATE STATISTICS [IF NOT EXISTS] name [(kinds)] ON { column | (expression) } [, ...] FROM table
SCHEMA public {
STATISTICS orders_stats (dependencies, ndistinct, mcv)
ON customer_id, created_at
FROM orders;
-- Extended statistics on an expression (PostgreSQL 14+), not just
-- plain columns.
STATISTICS orders_date_stats (ndistinct)
ON (date_trunc('month', created_at)), customer_id
FROM orders;
}
Extended Statistics is an opaque-object-decl kind (Appendix A) —
the whole ON ... FROM ... clause, expressions included, is Part 1
passthrough handed to pg_query verbatim, the same as every other
property shown above. Confirmed: DPG performs no column-list parsing
of its own here, so a (expression) entry needs no DPG-side grammar
and works today exactly like the plain-column form.
Diffing semantics:
| Change | DDL emitted | Safety |
|---|---|---|
| New statistics object | CREATE STATISTICS ... | SAFE |
statistics_target changed | ALTER STATISTICS name SET STATISTICS n | SAFE |
| Column list or kinds changed | DROP STATISTICS; CREATE STATISTICS | DESTRUCTIVE |
| Object removed | DROP STATISTICS name | SAFE |
14.7. Tablespaces
Tablespaces are cluster-level objects.
PG equivalent:
CREATE TABLESPACE name [OWNER owner] LOCATION 'path' [WITH (tablespace_option = value, ...)]
tablespace-decl = "TABLESPACE" WSP identifier
[ WSP "OWNER" WSP role-spec ]
WSP "LOCATION" WSP SQUOTE <text> SQUOTE
[ WSP "WITH" WSP "(" storage-params ")" ]
";"
[ "{" tablespace-block "}" ]
role-spec = identifier / "CURRENT_ROLE" / "CURRENT_USER" / "SESSION_USER"
tablespace-block = *( ( comment-dir / renamed-from-dir ) ";" )
OWNER is inline in Part 1, matching real PostgreSQL’s own
CREATE TABLESPACE grammar exactly (Tenet 2/5) rather than a { }
block directive — unlike Table/View/etc., where OWNER has no
place in the native CREATE statement and is therefore a block
directive instead. WITH (tablespace_option = value, ...) (e.g.
seq_page_cost, random_page_cost) follows the same trailing
position real PostgreSQL uses, after LOCATION. A change to the
declared inline OWNER post-creation emits ALTER TABLESPACE name OWNER TO role, and renamed-from-dir in the { } block emits
ALTER TABLESPACE old RENAME TO new — Tablespace is cluster-level,
not schema-scoped, so the generic extension’s cross-schema SET SCHEMA half never applies (same as Publication/Server/Role above).
A WITH (...) option change emits a targeted
ALTER TABLESPACE name SET (...)/RESET (...) (real PostgreSQL’s
identical two-clause grammar to ALTER TABLE’s own SET/RESET) —
CAUTION, since reloptions like random_page_cost/seq_page_cost
alter planning/maintenance behavior, not pure metadata.
-- production/cluster/tablespaces.dpg
TABLESPACE fast_ssd LOCATION '/mnt/nvme/pg_data'
WITH (random_page_cost = 1.1, seq_page_cost = 1.0);
TABLESPACE archive LOCATION '/mnt/hdd/pg_archive';
Diffing semantics:
| Change | DDL emitted | Safety |
|---|---|---|
| Owner changed | ALTER TABLESPACE name OWNER TO role | SAFE |
Renamed (RENAMED FROM) | ALTER TABLESPACE old RENAME TO new | CAUTION |
WITH (...) option changed | ALTER TABLESPACE name SET (...)/RESET (...) | CAUTION |
LOCATION changed | DROP TABLESPACE + CREATE TABLESPACE | DESTRUCTIVE |
| Tablespace removed | DROP TABLESPACE name | DESTRUCTIVE |
LOCATION cannot be changed after creation — any location change
requires the full drop-and-recreate path. Dropping a non-empty
tablespace fails at the PostgreSQL level; the compiler classifies it
as DESTRUCTIVE and additionally emits a warning comment noting
that it will fail if any objects reside in the tablespace.
14.8. Foreign Data Wrappers
In the common case FDWs are installed via extension. The explicit declaration is reserved for custom C-implemented FDWs and is placed in the cluster objects directory.
PG equivalent:
CREATE FOREIGN DATA WRAPPER name [HANDLER func] [VALIDATOR func] [OPTIONS (...)]
FOREIGN DATA WRAPPER myfdw
HANDLER myfdw_handler
VALIDATOR myfdw_validator;
Any change to a FDW requires drop + recreate (DESTRUCTIVE).
14.9. Foreign Servers
Foreign servers are database-level objects.
PG equivalent:
CREATE SERVER [IF NOT EXISTS] name [TYPE 'type'] [VERSION 'version'] FOREIGN DATA WRAPPER fdw [OPTIONS (...)]
SERVER analytics_warehouse
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host 'warehouse.internal', dbname 'analytics', port '5432')
{
OWNER "fdw_admin";
COMMENT 'Read replica warehouse connection';
}
SERVER MAY carry an optional { } block (comment-dir/
owner-dir/renamed-from-dir) — same standard rules as every other
kind (Section 7.6). Server is database-level, not schema-scoped, so the
cross-schema SET SCHEMA half of the generic extension never applies
here (same as Publication above).
Diffing semantics:
| Change | DDL emitted | Safety |
|---|---|---|
| New server | CREATE SERVER ... | SAFE |
| OPTIONS changed | ALTER SERVER name OPTIONS (SET key 'value', ...) | SAFE |
VERSION changed only, OPTIONS/FDW unchanged | ALTER SERVER name VERSION 'new_version' | SAFE |
| Owner changed | ALTER SERVER name OWNER TO role | SAFE |
Renamed (RENAMED FROM) | ALTER SERVER old RENAME TO new | CAUTION |
| FDW changed | Drop + recreate | DESTRUCTIVE |
| Server removed | DROP SERVER name [CASCADE] | DESTRUCTIVE |
14.10. User Mappings
User mappings associate a local PostgreSQL role with credentials for a foreign server. Declared and diffed as an opaque body (byte-for-byte source text, not per-field) — the same tier as Tablespace/FDW/Server/ Publication/etc.
PG equivalent:
CREATE USER MAPPING [IF NOT EXISTS] FOR user SERVER server [OPTIONS (...)]
USER MAPPING FOR app_service
SERVER analytics_warehouse
OPTIONS (user 'fdw_user', password '{{vault:secret/fdw/analytics#password}}');
Any OPTIONS value MAY hold one or more {{<secret-uri>}} placeholders
(Appendix D.5) — the same mechanism as SUBSCRIPTION CONNECTION (Section 13.2) and
Role PASSWORD (Section 11.1), and, like both of those, the only thing that
ever triggers resolution: a literal option value with no {{...}} at
all never touches the resolver. Unlike CONNECTION/PASSWORD, DPG
doesn’t isolate one specific option key to resolve — OPTIONS keys are
foreign-data-wrapper-specific, not fixed by DPG, so resolution runs over
the entire statement text, substituting whichever placeholders it finds
regardless of which key they’re under. Resolution happens once,
immediately before CREATE USER MAPPING executes — never during
plan/diff; the snapshot, an archived migration file, and any error
message all show the placeholder/reference form, not the resolved
value. This supersedes an earlier draft of this section, which required
env:VAR_NAME specifically — scheme-agnostic now that five backends
exist (Appendix D.5), matching the same correction already made to Role
PASSWORD’s hardcoded-password rule.
Hardcoded passwords (a literal value with no {{...}} placeholder
under any password-like OPTIONS key — password/passwd/pwd/
secret/passphrase) are rejected by the linter when
forbid_hardcoded_passwords is enabled (default true) — implemented
as hardcoded-fdw-password in the reference linter (Section 19.1’s own table
still names several rules with this document’s snake_case rather than
the actual kebab-case rule identifiers in code; see Appendix D.3 for
the corrected, authoritative rule ID table).
Diffing semantics: any change to the mapping is a full
DROP USER MAPPING + CREATE USER MAPPING, not a targeted
ALTER USER MAPPING, matching every other opaque-tier object kind
(Section 25) — this corrects an earlier draft of this table, which described a
targeted ALTER that was never actually implemented (User Mappings
have always been diffed via the generic opaque body-hash mechanism, the
same one Subscription used before its own Section 13.2 secret-reference work).
| Change | DDL emitted | Safety |
|---|---|---|
| New mapping | CREATE USER MAPPING ... | SAFE |
| OPTIONS changed | DROP USER MAPPING ... + CREATE USER MAPPING ... | DESTRUCTIVE then SAFE |
| Mapping removed | DROP USER MAPPING FOR user SERVER server | DESTRUCTIVE |
14.11. Security Labels
A security label attaches a provider-specific classification tag to
an object — meaningful only when a label provider is loaded on the
server (e.g. sepgsql for SELinux-integrated deployments; without
one, any SECURITY LABEL statement errors “no security label
providers have been loaded”, confirmed live). DPG’s directive
mirrors real PostgreSQL’s own statement directly, unlike COMMENT
(a single nilable value): a block MAY declare several SECURITY LABEL entries, one per provider, since PostgreSQL genuinely lets
multiple independent providers label the same object at once.
PG equivalent:
SECURITY LABEL [FOR provider] ON object_type object_name IS { string | NULL }
security-label-decl =
"SECURITY" WSP "LABEL" [ WSP "FOR" WSP identifier ] WSP string-literal ";"
The DPG form omits ON object_type object_name — implicit from the
enclosing declaration, the same convention COMMENT/GRANTS/
REVOCATIONS already use — and the provider identifier: omitted,
PostgreSQL resolves it to the sole loaded provider, erroring if zero
or more than one is loaded.
TABLE orders (
id BIGINT PRIMARY KEY
) {
SECURITY LABEL FOR sepgsql 'system_u:object_r:sepgsql_table_t:s0';
}
COLUMN ssn {
SECURITY LABEL FOR sepgsql 'system_u:object_r:sepgsql_secret_table_t:s0';
}
Supported object kinds: every kind PostgreSQL’s own SECURITY LABEL grammar supports that DPG models at all — Tables (incl.
Foreign Tables), Columns, Views, Materialized Views, Functions,
Procedures, Aggregates (rendered as ON FUNCTION, matching real
PostgreSQL grammar — confirmed live, not ON AGGREGATE), Domains
(ON DOMAIN) and every other Type variant (ON TYPE), Schemas,
Sequences, Roles, Tablespaces, Publications, Subscriptions, and
Event Triggers. DATABASE and LARGE OBJECT are excluded — DPG
doesn’t model either as an object kind at all (Section 23); [PROCEDURAL] LANGUAGE is excluded because DPG doesn’t support raw CREATE LANGUAGE either (Section 23) — every real language install goes through
CREATE EXTENSION, which already has its own COMMENT/SECURITY LABEL coverage as an ordinary opaque-tier kind.
Storage note (introspection): PostgreSQL splits SECURITY LABEL storage across two system catalogs along the same
per-database/shared boundary COMMENT’s obj_description/
shobj_description split already follows — pg_seclabel for every
per-database kind above, pg_shseclabel for the three cluster-wide
kinds specifically (Role, Tablespace, Subscription — confirmed live
via pg_class.relisshared, not assumed from their per-database-
looking CREATE syntax). An implementation introspecting labels
must query the correct catalog per kind; this has no bearing on the
DPG source syntax or emitted DDL, which are identical either way.
Diffing semantics: keyed by provider, not by (privilege, role)
the way GRANTS/REVOCATIONS are — two entries for different
providers are independent catalog rows, never in conflict. Unlike
GRANTS’ additive model, a removed entry emits an explicit IS NULL (SECURITY LABEL has no separate revoke-shaped statement;
NULL is PostgreSQL’s own documented way to clear a label). Every
emitted statement is SAFE — SECURITY LABEL never touches data,
only catalog metadata, the same classification COMMENT ON already
gets throughout this document.
| Change | DDL emitted | Safety |
|---|---|---|
| Label added (new provider) | SECURITY LABEL [FOR provider] ON ... IS '...' | SAFE |
| Label changed (same provider) | SECURITY LABEL [FOR provider] ON ... IS '...' (re-set) | SAFE |
| Label removed | SECURITY LABEL [FOR provider] ON ... IS NULL | SAFE |
15. Compilation Pipeline
15.1. Phases Overview
The DPG compilation pipeline processes source files through ten
sequential phases. Each phase is an independently composable
component. The reference implementation registers each phase
implementation in internal/pipeline.Registry.
Phase 1: File Discovery
Phase 2: Macro Preprocessing
Phase 3: Tokenization (Tokenizer)
Phase 4a: PG SQL Parsing (PGSQLParser)
Phase 4b: Block Parsing (BlockParser)
Phase 5: IR Construction (IRBuilder)
Phase 6: Merging (Merger)
Phase 7: Dependency Resolution (DependencyResolver)
Phase 8: Linting (Linter) [dpg validate / dpg plan / dpg apply only]
Phase 9: Differencing (Differ)
Phase 10: Emission (Emitter)
Phases 4a and 4b operate in parallel on each raw object. Phase 8
(Linting) runs after Dependency Resolution (Phase 7), against the
fully-resolved object list; its diagnostics are advisory by default
(warnings) on all three commands that run it, with --strict (on
dpg validate/dpg apply only — dpg plan has no --strict flag,
see Section 15.10) promoting them to hard errors.
15.2. Phase 1 — File Discovery
Implements Section 3.6. The output is an ordered list of
(cluster, database, []filepath) tuples. Files within a single
database tuple are sorted lexicographically by full path.
15.3. Phase 2 — Macro Preprocessing
Macro preprocessing runs in three passes across the full file set:
Pre-pass — Global Collection: Before processing any individual
file, the compiler iterates over every source file and collects all
MACRO declarations into a shared global store. This pass is
read-only; it does not modify any file and does not expand spreads.
If the Tokenizer implements the optional GlobalMacroSeeder
interface, the compiler delegates this pre-pass to the tokenizer via
AddGlobalMacros(src []byte).
Pass 1 — Per-file Collection: For each source file, scan for
MACRO name (body) and MACRO name {body} declarations. Merge
the file-local definitions with the global store; file-local
definitions take precedence on name conflicts. Emit error DPG-E007
if a macro declaration is found inside a block. Emit error DPG-E011
if a name is redeclared within the same file. Remove all MACRO
declarations from the file’s output text.
Pass 2 — Expansion: Scan for ...name spread operators.
Expand each spread inline by substituting the recorded body text
from the merged store. Emit error DPG-E010 if a name is not found.
Emit error DPG-E008 or DPG-E009 if the body type does not match
the context.
Pass 2 is applied iteratively until no ...name tokens remain, to
handle macros whose bodies contain other spread operators. If
iteration does not converge (circular reference), emit DPG-E012.
The output is a set of source files with all macros resolved and
MACRO declarations removed.
15.4. Phase 3 — Tokenization
The Tokenizer (interface pipeline.Tokenizer) scans the pre-
processed source text and splits each complete DPG declaration into
a pipeline.RawObject value containing:
Kind— thepipeline.ObjectKindidentified from the leading keyword(s).Part1— the raw Part 1 text, with the leading DPG keyword(s) stripped. The PGSQLParser prepends the correctCREATEverb.Part2— the raw Part 2{ }block text (the braces are stripped), or the empty string if absent.Schema— the name of the enclosingSCHEMAblock if this declaration is nested inside one; otherwise empty.Pos— theSourcePosof the first token.
The tokenizer MUST handle:
Comments (
--and/* ... */): stripped before keyword detection. Line numbers MUST be preserved forSourcePosaccuracy.Dollar-quoted strings: per the algorithm in Section 4.6. The tokenizer MUST NOT interpret content inside dollar-quoted regions.
Nested
{ }blocks: the tokenizer MUST count brace depth correctly so that nested blocks (e.g.,MIGRATE REMOVE { }, sub-partitions) are included in the correct Part 2.Schema blocks: a
SCHEMA name { ... }block is tokenized as a container. The objects inside it are tokenized withSchemaset to the enclosing schema name.
The tokenizer MUST emit error DPG-E006 upon encountering CREATE,
ALTER, or DROP at brace depth 0 outside a dollar-quoted region.
15.5. Phase 4a — PG SQL Parsing
The PGSQLParser (interface pipeline.PGSQLParser) takes a
RawObject.Part1 text and the ObjectKind, prepends the
appropriate CREATE [OR REPLACE] verb, and invokes the PostgreSQL
parser (via github.com/pganalyze/pg_query_go/v5, which wraps
libpg_query — the same parser used by PostgreSQL itself).
The result is a pipeline.PGParseResult holding the pg_query parse
tree (a *pg_query.ParseResult).
Special cases:
VIRTUAL TYPEandMACROare DPG-native and have no PostgreSQLCREATEequivalent. The PGSQLParser returns aPGParseResultwithKindset to the appropriate object kind andRawset to the raw Part1 text string (not a parse tree).The
SchemaContextfield of the returnedPGParseResultis populated fromRawObject.Schemafor use by the IR Builder.
The PGSQLParser MUST NOT modify the Part1 text in any way beyond
prepending the verb. If the PostgreSQL parser rejects the input,
the PGSQLParser MUST propagate the parser error as a CompilerError
at the source position.
Alternative parser: A NativeParser (no CGo dependency) is
provided for environments where libpg_query cannot be compiled.
It supports a reduced feature set; use of the native parser MAY
produce less accurate error messages and MUST be documented as
a reduced-capability mode.
15.6. Phase 4b — Block Parsing
The BlockParser (interface pipeline.BlockParser) takes a
RawObject.Part2 text and parses it into a pipeline.BlockAST.
The BlockAST is a structured representation of all the directives
in the { } block.
The block parser handles:
- All directives listed in the grammar for each object kind.
- Nested blocks (
INDICES { },POLICIES { },TRIGGERS { },GRANTS { },REVOCATIONS { },PARTITIONS { },COLUMNS { },MIGRATE REMOVE { },DEFAULT PRIVILEGES { }). - Spread operators (
...name) that were not resolved in Phase 2 (this should not happen; the preprocessor guarantees resolution, but the block parser MUST emit DPG-E010 if encountered). - Unknown directives: emits error DPG-E024 (unknown block directive for this object kind).
15.7. Phase 5 — IR Construction
The IRBuilder (interface pipeline.IRBuilder) takes a
(PGParseResult, BlockAST) pair and produces a fully-resolved
pipeline.IRObject.
IR construction includes:
Extracting all fields from the pg_query parse tree into the strongly-typed IR structs defined in
internal/ir/types.go.Applying schema context: if a column type or a foreign key reference uses an unqualified name, the IR builder resolves it against the enclosing schema context established by
SchemaContextor the database’sdefault_schema.Applying PRIMARY KEY → NOT NULL inference (Section 7.2).
Normalising constraint forms: inline single-column constraints are converted to their named table-level equivalents internally. The emitter converts them back to inline form in the output DDL.
Computing function body hashes (SHA-256 of normalised body text, Section 9.5).
Attaching
SourcePosto every sub-object.
The IR Builder MUST emit error DPG-E018 if a COLUMN name { }
block references a column not present in the ( ) list.
15.8. Phase 6 — Merging
The Merger (interface pipeline.Merger) accumulates all IRObject
instances for the same database and merges declarations of the same
logical object (same qualified name and kind) per the rules of
Section 3.7.
The Merger produces a flat, deduplicated list of IRObject values
where each qualified name appears exactly once.
15.9. Phase 7 — Dependency Resolution
The DependencyResolver (interface pipeline.DependencyResolver)
performs a topological sort of the merged IR object list.
Edge creation rules (object A depends on object B if):
- A column of A has a type defined by B (a user-defined type or domain in B’s schema).
- A column of A has a
REFERENCESconstraint to table B. - A view’s query text references table or view B.
- A function’s body references table or view B (if extractable).
- An index on A uses an operator class defined in B.
- A partition of A specifies B as its parent.
Circular dependency resolution:
When a cycle is detected:
If every FK in the cycle is
DEFERRABLE, the resolver emits the tables in any order (all tables first, then circular FKs asALTER TABLE ADD CONSTRAINT ... DEFERRABLE INITIALLY DEFERREDstatements).If any FK in the cycle is NOT
DEFERRABLE, the resolver emits error DPG-E017 with the complete cycle path listed.
The output is an ordered []IRObject slice such that every object
appears after all objects it depends on.
15.10. Phase 8 — Linting
The Linter (interface pipeline.Linter), when registered, runs
against the fully-resolved desired []IRObject — Phase 7’s
output — on dpg validate, dpg plan, and dpg apply only (not
dpg dump, dpg diff, or dpg verify). It implements the rules
in Section 19 and returns []LintDiagnostic, each carrying
IsError.
By default every diagnostic is advisory (a warning; never blocking).
dpg validate and dpg apply accept --strict, which promotes
every diagnostic’s IsError to true — turning all warnings into
hard errors that produce a non-zero exit (validate) or abort the
apply (apply). dpg plan has no --strict flag; its lint
diagnostics are always advisory-only. Linting is purely
advisory-by-default analysis of already-resolved IR — it never
mutates desired or influences Phase 9’s diffing.
15.11. Phase 9 — Differencing
The Differ (interface pipeline.Differ) takes:
desired []IRObject— the output of Phase 7.*Snapshot— the committed snapshot (Section 16).
It produces an ordered []DiffOp representing the minimal set of
DDL changes needed to transition the current state (snapshot) to the
desired state.
The differ performs three passes:
Pass 1 — Rename detection: For each desired object with a
non-empty RenamedFrom field, apply the rename resolution algorithm
(Section 7.6, generalised to all renameable objects). Renamed
objects are removed from the snapshot under the old key and inserted
under the new key for the purpose of subsequent diff passes.
Pass 2 — Object-level diff: For each desired object:
- If absent from the snapshot: emit
CREATE ...ops. - If present in the snapshot: compare field-by-field and emit
ALTER ...ops for each changed property. Per-object diff algorithms are specified in Section 21.
Pass 3 — Deletion: For each snapshot object absent from the
desired state (and not consumed by a rename in Pass 1): emit
DROP ... ops. Objects with PROTECTED = true in their snapshot
record are skipped with error DPG-E022 emitted instead.
DiffOps are appended in the topological order established by Phase 7, with DELETE ops appended after all CREATE/ALTER ops in reverse topological order (dependents dropped before their dependencies).
15.11. Phase 10 — Emission
The Emitter (interface pipeline.Emitter) splits the []DiffOp
into two groups:
Transactional:
op.Transactional() == true— wrapped inBEGIN; ... COMMIT;by the executor.Non-transactional:
op.Transactional() == false— emitted afterCOMMITand executed without a wrapping transaction.
The Emitter returns a pipeline.Migration value (Section 17).
16. Snapshot Format
16.1. Purpose and Placement
The snapshot is a committed JSON file that represents the compiler’s
normalised view of the database state after the most recent
successful dpg apply. It is the “current state” input to the
Differ.
The snapshot MUST be committed to version control. Snapshots are not secrets (they contain no plaintext passwords; see Sections 11.1 and 14.10).
Path: .dpg/snapshots/<cluster-name>/<database-name>.json
The snapshot directory is configurable via [snapshots] directory
in the root dpg.toml (Section 3.2).
16.2. Top-Level Fields
{
"dpg_version": "0.8.1",
"cluster": "production",
"database": "myapp",
"applied_at": "2025-09-15T14:32:00Z",
"source_revision": "a3f7c91",
"objects": { ... }
}
| Field | Type | Description |
|---|---|---|
dpg_version | string | The DPG version that wrote this snapshot. |
cluster | string | The cluster name from the cluster dpg.toml. |
database | string | The database name from the database dpg.toml. Absent for cluster-level snapshots. |
applied_at | RFC 3339 string | UTC timestamp of the last successful dpg apply. |
source_revision | string | The git commit hash at apply time, if available. Empty if git is unavailable. |
objects | object | Map from QualifiedName() to per-object snapshot record. |
16.3. Per-Object Snapshot Schema
Each entry in objects maps the object’s QualifiedName() to a
SnapObject — a discriminated union wrapper, not a flat record.
Its kind field (REQUIRED) selects which sub-object field is
populated:
{
"public.users": {
"kind": "table",
"table": { <SnapTable fields, shown below> }
},
"public.get_user(text)": {
"kind": "function",
"function": { <SnapFunction fields, shown below> }
},
"public.user_state": {
"kind": "virtual_type",
"virtual_type": { <SnapVirtualType fields, shown below> }
}
}
The kind string values and their corresponding sub-object fields:
kind value | Sub-object field | Object types covered |
|---|---|---|
"table" | table | TABLE, UNLOGGED TABLE, FOREIGN TABLE |
"view" | view | VIEW, MATERIALIZED VIEW, RECURSIVE VIEW |
"function" | function | FUNCTION |
"type" | type | ENUM, COMPOSITE, RANGE, DOMAIN, BASE |
"schema" | schema | SCHEMA |
"extension" | extension | EXTENSION |
"sequence" | sequence | SEQUENCE |
"role" | role | ROLE |
"virtual_type" | virtual_type | VIRTUAL TYPE |
"procedure", "aggregate", "tablespace", "fdw", "server", "user_mapping", "publication", "subscription", "event_trigger", "collation", "operator", "operator_class", "operator_family", "cast", "statistics", "ts_config", "ts_dict", "ts_parser", "ts_template" | opaque | All passthrough objects (Section 16.3.1) |
SnapTable:
{
"schema": "public",
"name": "users",
"owner": "app_role",
"comment": "Primary identity store",
"rls_enabled": true,
"rls_forced": false,
"protected": false,
"drop_cascade": false,
"unlogged": false,
"columns": [ ... ],
"constraints": [ ... ],
"indexes": [ ... ],
"policies": [ ... ],
"triggers": [ ... ],
"grants": [ ... ]
}
columns, constraints, indexes, policies, triggers, and
grants are all ordered slices (arrays), never maps — each
element’s own name field identifies it.
Column snapshot record:
{
"name": "email",
"type": "text",
"not_null": true,
"default": null,
"identity": null,
"generated": null,
"comment": "Verified email address",
"statistics": 300,
"compression": null,
"storage": null,
"deprecated": null,
"renamed_from": null,
"grants": []
}
not_null is true when the column IS NOT NULL — note the
inverted sense relative to a nullable field. identity holds the
string "ALWAYS" or "BY DEFAULT" (or null), not a nested object.
Constraint snapshot record:
{
"name": "pk_users",
"type": "PRIMARY KEY",
"expr": "(id)",
"not_valid": false,
"deferrable": false
}
There is no top-level columns array or initially_deferred
field; expr is the raw constraint expression/definition text.
Index snapshot record:
{
"name": "idx_users_email",
"unique": false,
"method": "btree",
"columns": "email",
"where": null
}
columns is a single comma-separated string, not an array of
{name, direction, nulls} objects.
Policy snapshot record:
{
"name": "view_own",
"command": "SELECT",
"permissive": true,
"using": "user_id = auth.uid()",
"with_check": null,
"roles": []
}
Trigger snapshot record:
{
"name": "after_email_change",
"when": "AFTER",
"events": "UPDATE",
"for_each": "ROW",
"update_of_columns": "email",
"old_transition_name": "",
"new_transition_name": "",
"function": "public.notify_email_change",
"condition": "OLD.email IS DISTINCT FROM NEW.email"
}
SnapTrigger is simplified relative to ir.Trigger: it has no
args field, and events is a single comma-separated string, not
an array. update_of_columns (comma-separated) and condition
ARE present as flat string fields, unlike an earlier draft of this
record claimed — both are populated once present in source.
old_transition_name/new_transition_name hold the REFERENCING OLD TABLE AS .../NEW TABLE AS ... names (Section 7.9); empty string
means that side of REFERENCING wasn’t present, the same
empty-means-unspecified convention condition itself already uses.
Function snapshot record:
{
"schema": "public",
"name": "get_user",
"args": [{ "name": "p_email", "type": "text", "mode": "IN" }],
"return_type": "public.users",
"language": "plpgsql",
"volatility": "STABLE",
"strict": false,
"security_definer": true,
"parallel": "UNSAFE",
"body_hash": "sha256:a3f7c91d...",
"comment": "Fetch a user record by verified email address",
"grants": [{ "privileges": ["EXECUTE"], "roles": ["app_service"], "with_grant": false }]
}
Virtual type snapshot record:
{
"schema": "public",
"name": "user_state",
"body": "\"active\" | \"suspended\" | \"deleted\"",
"comment": null
}
Grant record (used in all per-object grant arrays):
{ "privileges": ["SELECT"], "roles": ["app_readonly", "app_readonly2"], "with_grant": false }
roles is an array of role names; there is no singular grantee
field.
Function body hash: The body_hash field is the string
"sha256:" followed by the lowercase hex-encoded SHA-256 digest of
the normalised function body (Section 9.5). The full body text is
NOT stored in the snapshot; it lives in the .dpg source files.
16.3.1. SnapOpaque — Passthrough Object Records
Objects whose diff is body-text based (procedures, aggregates,
tablespaces, FDWs, servers, user mappings, publications,
subscriptions, event triggers, collations, operators, operator
classes, operator families, casts, statistics objects, and all four
text search object types) are stored as SnapOpaque:
{
"kind": "procedure",
"opaque": {
"kind": "procedure",
"schema": "public",
"name": "process_settlements",
"args": "",
"body_hash": "sha256:b4f2a1...",
"comment": null,
"grants": []
}
}
Fields:
| Field | Type | Description |
|---|---|---|
kind | string | Object kind (same as outer kind). |
schema | string | Schema name; empty for cluster-level objects. |
name | string | Object name. |
args | string | Type-only argument key for overloaded objects (procedures, aggregates). Empty for non-overloaded. |
body_hash | string | "sha256:<hex>" of the normalised Part 1 body text. Empty string means body was empty. |
comment | string|null | Comment text if any. |
grants | array | Grant records (for aggregate and procedure grants). |
The differ compares body_hash for changes. Any change to the body
hash causes the compiler to emit DROP ... CASCADE + CREATE ...
for the object (Safety class per Section 17.2 for each type).
See Appendix D.1.8/D.1.9 for the complete SnapSchema, SnapExtension,
SnapType, SnapSequence, SnapRole, and cluster-level snapshot
file records — not repeated here since they follow the same shape
conventions established above.
16.4. Versioning
The dpg_version field in the snapshot records the compiler version
that wrote it. Future major versions of DPG that change the snapshot
schema MUST provide a migration path. Minor-version changes MUST be
backward compatible: a newer compiler MUST be able to read a snapshot
written by an older minor version without data loss.
The compiler MUST emit a warning when reading a snapshot whose
dpg_version major component differs from the running compiler.
17. Migration Output Format
17.1. Output Structure
The migration output is a plain-text SQL file (or stream) in a standardised format. It consists of:
- A header block with metadata comments.
- A transactional section wrapped in
BEGIN;/COMMIT;. - Optionally a non-transactional section outside any transaction block.
When there are no changes, the output is the header block followed
by -- (no changes).
Header format:
-- DPG Migration
-- Generated: <RFC 3339 UTC timestamp>
-- Source revision: <git SHA or empty>
-- Cluster: <cluster name>
-- Database: <database name>
Per-operation annotations:
Each DiffOp is preceded by an annotation comment line:
-- [source: <file>:<line>[, safety: <class>]]
The source: annotation is always present when the source position
is known. The safety: annotation is omitted for SAFE ops
(since that is the expected normal case) and present for CAUTION,
DESTRUCTIVE, and MANUAL ops.
Full example output:
-- DPG Migration
-- Generated: 2025-09-15T14:32:00Z
-- Source revision: a3f7c91
-- Cluster: production
-- Database: myapp
-- transactional
BEGIN;
-- source: schemas/public/tables/users.dpg:4
CREATE TABLE public.users (
id BIGINT GENERATED ALWAYS AS IDENTITY CONSTRAINT "pk_users" PRIMARY KEY,
email TEXT NOT NULL CONSTRAINT "uq_users_email" UNIQUE
);
-- source: schemas/public/tables/users.dpg:4
COMMENT ON TABLE public.users IS 'Primary identity store';
-- source: schemas/public/tables/users.dpg:12
COMMENT ON COLUMN public.users.email IS 'Verified email address';
-- source: schemas/public/tables/users.dpg:13
ALTER TABLE public.users ALTER COLUMN email SET STATISTICS 300;
-- source: schemas/public/tables/users.dpg:8
GRANT SELECT ON TABLE public.users TO app_readonly;
-- source: schemas/public/tables/users.dpg:8
GRANT SELECT (email) ON TABLE public.users TO reporting_role;
COMMIT;
--------
-- non-transactional
-- source: schemas/public/tables/users.dpg:20, safety: MANUAL
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON public.users (email);
Multi-database output (RenderAll):
When a single dpg plan or dpg apply targets multiple databases
in the same cluster, the transactional section wraps ALL databases
in a single BEGIN/COMMIT pair, with each database’s ops
introduced by a -- Database: <name> label. The non-transactional
section is structured the same way.
17.2. Safety Classification
Every DiffOp MUST be assigned exactly one safety class.
| Class | Criteria | Default behaviour |
|---|---|---|
SAFE | No data loss possible; no excessive locking. | Applied automatically. |
CAUTION | Acquires ACCESS EXCLUSIVE lock for non-trivial duration; or reorders data; or may affect query plans. | Applied with a warning logged to stderr. |
DESTRUCTIVE | Data loss is possible (DROP TABLE, DROP COLUMN, type change without USING, etc.). | Blocked unless --allow-destructive is passed to dpg apply. |
MANUAL | Cannot run inside a transaction (e.g., CREATE INDEX CONCURRENTLY); or requires a human-operator step (partition strategy change instruction). | Executable MANUAL ops are emitted after COMMIT in the non-transactional section. Instruction-only MANUAL ops (prefixed with --) are displayed in the plan but never executed. --approve-partition-rebuild is required to acknowledge instruction-only MANUAL ops. |
The full per-operation safety classification table is:
| Operation | Safety |
|---|---|
CREATE TABLE | SAFE |
CREATE TABLE ... PARTITION OF ... | SAFE |
ALTER TABLE ADD COLUMN ... [NOT NULL] [DEFAULT ...] | SAFE (PG 11+ DDL-only NOT NULL) |
ALTER TABLE ALTER COLUMN TYPE (implicit cast) | CAUTION |
ALTER TABLE ALTER COLUMN TYPE ... USING | CAUTION |
ALTER TABLE ALTER COLUMN TYPE (no implicit cast, no USING) | DESTRUCTIVE |
ALTER TABLE DROP COLUMN | DESTRUCTIVE |
ALTER TABLE ADD CONSTRAINT ... NOT VALID | CAUTION |
ALTER TABLE VALIDATE CONSTRAINT | CAUTION |
ALTER TABLE RENAME COLUMN | CAUTION |
ALTER TABLE RENAME TO | CAUTION |
ALTER TABLE ENABLE ROW LEVEL SECURITY | SAFE |
ALTER TABLE DISABLE ROW LEVEL SECURITY | SAFE |
DROP TABLE | DESTRUCTIVE |
CREATE INDEX (new table) | SAFE |
CREATE INDEX (existing table, not concurrent) | CAUTION |
CREATE INDEX CONCURRENTLY | MANUAL |
DROP INDEX | CAUTION |
CREATE VIEW | SAFE |
CREATE OR REPLACE VIEW | SAFE |
DROP VIEW CASCADE | DESTRUCTIVE |
CREATE MATERIALIZED VIEW | SAFE |
DROP MATERIALIZED VIEW CASCADE | DESTRUCTIVE |
CREATE FUNCTION / CREATE OR REPLACE FUNCTION | SAFE |
DROP FUNCTION CASCADE | DESTRUCTIVE |
ALTER TYPE ... ADD VALUE | SAFE (Section 1.4’s version floor of 14 is past PostgreSQL 12’s transaction-block restriction) |
DROP TYPE CASCADE | DESTRUCTIVE |
CREATE POLICY | SAFE |
DROP POLICY | SAFE |
CREATE TRIGGER | SAFE |
DROP TRIGGER | SAFE |
GRANT ... | SAFE |
REVOKE ... | SAFE |
CREATE SEQUENCE | SAFE |
DROP SEQUENCE | DESTRUCTIVE |
ALTER SCHEMA RENAME TO | CAUTION |
DROP SCHEMA CASCADE | DESTRUCTIVE |
DROP EXTENSION CASCADE | DESTRUCTIVE |
DROP ROLE | DESTRUCTIVE |
17.3. Transactional vs Non-Transactional Steps
A DiffOp is non-transactional (Transactional() == false) when:
- Its SQL is
CREATE INDEX CONCURRENTLY ...(cannot run in a transaction block). - Its SQL is
DROP INDEX CONCURRENTLY .... - Its safety class is
MANUALand it requires being outside a transaction.
ALTER TYPE ... ADD VALUE is deliberately NOT in this list — see
Section 5.1.1: it requires non-transactional execution only on PostgreSQL
versions below 12, already excluded by Section 1.4’s version floor.
All other ops are transactional.
The executor MUST execute all transactional ops within a single transaction per database. If any transactional op fails, the entire transaction is rolled back.
Non-transactional ops are executed sequentially after COMMIT,
each as an individual statement outside any transaction. If a
non-transactional op fails, it does NOT roll back the already-
committed transactional ops. The operator MUST manually handle the
partial failure.
17.4. Idempotency Requirement
The idempotency guarantee is:
Running
dpg applyon a database that already exactly matches the desired state MUST produce zero SQL statements.
Any violation of this guarantee is a defect in the DPG compiler.
Idempotency is enforced through:
- The snapshot accurately reflecting the post-apply state.
- The Differ emitting ops only when the snapshot and desired state genuinely differ.
CREATE INDEX IF NOT EXISTSin concurrent index creation.CREATE EXTENSION IF NOT EXISTSfor extensions.
If a dpg apply is interrupted partway through, the snapshot will
not have been updated (the snapshot is written only upon full
success). On the next dpg apply, the transactional ops will be
re-attempted. The already-executed non-transactional ops (e.g.,
concurrent index creations) will result in IF NOT EXISTS no-ops.
18. CLI Commands
Global options, accepted before the subcommand name by every command below:
dpg [global options] <command> ...
Global options:
--dir <path>, -C <path> Project root directory (default: current
working directory).
--env <path> Path to a .env file used to resolve link =
connection strings (default:
<project-root>/.env, if present; a missing
file is not an error). Only consulted for
commands that may need a live database
connection: dpg apply, dpg verify, dpg dump,
and dpg plan --live.
.env loading rules:
- Loading is only performed when at least one cluster uses a
linkconnection string; clusters using an inlineurl =never trigger it. - Path resolution order: the path given by
--env <path>, if provided; otherwise<project-root>/.env, if it exists. - Existing process environment variables are NEVER overwritten — the file only sets variables not already present in the process environment (“process env wins”).
- File format: blank lines and lines starting with
#are ignored; a leadingexportis stripped; entries areKEY=VALUEorKEY = VALUE; single/double-quoted values have the quotes stripped. - A missing
.envfile is not an error — the command proceeds using only the process environment.
18.1. dpg plan
Computes the migration that would be applied and prints it to stdout. No database connection is required by default.
dpg plan [options] [<cluster>[/<database>]]
Options:
--live Diff against the live catalog instead of the
committed snapshot. Requires a database connection.
--format <fmt> Output format: text (default), json. The text
format is the emitted SQL migration itself.
--watch Re-run whenever source files change (polls every 500ms).
--cluster <name> Target a specific cluster (default: all clusters).
--database <name> Target a specific database (default: all databases).
dpg plan always shows the full computed migration, including any
DESTRUCTIVE operations — there is no --allow-destructive or
--no-color flag on plan; those only gate dpg apply (Section 18.2),
since plan never executes anything. dpg plan runs the linter
(Phase 8, Section 15.10) but has no --strict flag either — its lint
diagnostics are always advisory-only, never blocking; use
dpg validate --strict or dpg apply --strict to promote them to
errors.
Exit codes: 0 = success (no changes); 1 = changes computed; 2 = error.
With --format json, each targeted database’s plan is serialised to
stdout as one JSON object:
{
"cluster": "production",
"database": "myapp",
"generated_at": "2026-05-13T14:32:00Z",
"source_revision": "a3f7c91",
"ops": [
{
"sql": "CREATE TABLE public.users (...);",
"safety": "SAFE",
"file": "schemas/public/tables/users.dpg",
"line": 4
}
],
"empty": false
}
| Field | Type | Description |
|---|---|---|
cluster | string | Cluster name. |
database | string | Database name; empty for cluster-level plans. |
generated_at | RFC 3339 string | UTC timestamp of plan generation. |
source_revision | string | Git short SHA, or empty if unavailable. |
ops | array | Ordered list of DiffOp objects. |
ops[].sql | string | The SQL statement text. |
ops[].safety | string | One of "SAFE", "CAUTION", "DESTRUCTIVE", "MANUAL". |
ops[].file | string | Source file path relative to project root, or omitted if unknown. |
ops[].line | integer | 1-based source line number, or omitted if unknown. |
empty | boolean | true when ops is empty (no changes). |
When targeting multiple databases in one run, each produces its own complete JSON object; multiple objects are printed sequentially, separated by newlines (NDJSON, [NDJSON]).
18.2. dpg apply
Runs the linter, computes the migration, prompts for operator approval, executes the SQL, and updates the snapshot.
dpg apply [options] [<cluster>[/<database>]]
Options:
--allow-destructive Allow DESTRUCTIVE operations (required if any
exist; operator must confirm interactively unless
--yes is also set).
--approve-partition-rebuild Acknowledge MANUAL partition-rebuild steps.
--yes / -y Skip interactive approval prompt (non-interactive
mode; implies --allow-destructive is acknowledged).
--dry-run Compute and print the migration but do not execute.
--no-snapshot Do not update the snapshot after apply.
--strict Promote linter warnings to errors. Apply is
blocked if any lint errors exist.
--cluster <name>
--database <name>
Apply procedure:
- Run the linter. If any error-level diagnostics exist (or
any warnings with
--strict), abort and print diagnostics. - Compute the migration (
dpg plan). - If
--dry-run, print and exit. - If the migration contains
DESTRUCTIVEops and--allow-destructiveis absent, abort with error DPG-E025. - Print the migration SQL.
- Unless
--yes, prompt:Apply this migration? [y/N]. Abort if the operator does not confirm. - Execute transactional ops in a single
BEGIN/COMMITper database. - Execute non-transactional ops sequentially.
- Update the snapshot atomically (write to a temp file, rename).
- Print
Migration applied successfully.to stdout.
If step 7 fails, the transaction is rolled back. The snapshot is not updated. The error from the database is printed with the failing SQL statement highlighted.
18.3. dpg verify
Introspects the live database catalog and reports drift: any divergence between the snapshot and the live state.
dpg verify [options] [<cluster>[/<database>]]
Options:
--cluster <name>
--database <name>
--format <fmt> sql (default), json, text
Drift detection model:
Reports: any DPG-declared object property that is absent from or differs in the live catalog.
Reports: any DPG-declared grant that is absent from the live catalog.
Does NOT report: extra grants present in the live catalog but not declared in DPG source (additive model, Section 11.2).
Does NOT report: objects present in the live catalog but absent from DPG source (unmanaged objects).
Exit codes: 0 = no drift; 1 = drift detected; 2 = connection error.
18.4. dpg dump
Introspects a live database catalog and produces an initial .dpg
source tree and snapshot, suitable for bootstrapping a new DPG
project from an existing database.
dpg dump [options]
Options:
--cluster <name> REQUIRED. The cluster to introspect.
--database <name> REQUIRED. The database to introspect.
--output / -o <dir> Output directory for .dpg files
(default: <cluster>/<database>/ within project root).
When set, sandboxes ALL output under this directory —
including cluster-level roles.dpg and the snapshot,
not just the per-database source files — so a dump
run this way never touches the real project.
--yes / -y Skip the interactive overwrite confirmation below
(for scripts/CI). The warning still always prints.
Overwrite protection: if any target .dpg file already exists on disk,
dpg dump refuses to proceed silently. It prints a warning naming
every file that would be overwritten, then requires two separate
confirmations before continuing — an initial [y/N] prompt, followed
by typing the literal word overwrite — matching the type-the-word
confirmation pattern other tools use for an action this hard to
reverse. --yes skips both prompts for non-interactive use, but the
warning listing the affected files is still always printed first,
regardless. When nothing targeted by this dump already exists (a
brand-new project, or a fresh -o directory), there is no warning and
no prompt at all — re-running dpg dump is exactly as safe as before
in that case. The generated snapshot is deliberately excluded from
this check: it is a derived/managed artifact dump is always expected
to refresh, not hand-authored source.
--output/-o sandboxing detail: without -o, cluster-scoped output
(roles.dpg) and the snapshot are written to their real, permanent
project locations — the cluster’s own objects directory and the
registered snapshot store — exactly as if dpg dump were run with no
flags at all. With -o <dir> set, both are redirected under <dir>
instead: roles.dpg to <dir>/<cluster-name>/cluster/roles.dpg
(namespaced by cluster name so a single -o value spanning multiple
clusters in one invocation cannot mix their roles together), and the
snapshot to <dir>/.dpg/snapshots/, mirroring the real project’s own
layout convention. Earlier versions of dpg dump only ever redirected
the per-database schema/objects.dpg files — roles.dpg and the
snapshot silently ignored -o and always wrote to the real project
regardless, a real, confirmed bug (not the intended behavior of -o,
whose whole purpose is to let a dump be inspected without touching the
real project) — fixed.
The dump output is a best-effort conversion. Objects whose DDL
cannot be cleanly reconstructed from catalog information are emitted
as comments with a -- dpg:manual marker.
18.5. dpg diff
Diffs two DPG source directories targeting the same logical database, without requiring a snapshot or a live database connection.
dpg diff --from <dir> --to <dir> [options]
Options:
--from <dir> REQUIRED. "Before" source directory.
--to <dir> REQUIRED. "After" source directory.
--format <fmt> sql (default), json.
--allow-destructive
Both directories MUST contain a dpg.toml with a [database]
section identifying the same logical database.
18.6. dpg validate
Compiles and lints .dpg source files offline. No snapshot or
database connection required.
dpg validate [file...] [options]
Options:
--cluster <name> cluster to validate (default: all)
--database <name> database to validate (default: all)
--format <fmt> output format: text or json (default: text)
--strict promote all lint warnings to errors (non-zero exit)
When one or more .dpg files are given as arguments, only those
files are validated, with no project discovery — this mode is what
an editor/LSP integration uses to validate a single file or buffer.
Exit codes: 0 = no errors; non-zero = errors found or internal error.
With --format json the output is a single JSON object per
cluster/database scope, not an array — multiple scopes each emit
a separate JSON object (one line per scope is NOT guaranteed; each
is a complete JSON object):
{
"cluster": "production",
"database": "myapp",
"objects": 42,
"errors": [
{
"rule": "hardcoded-password",
"message": "column 'password' has a hardcoded string default",
"file": "schemas/public/tables/users.dpg",
"line": 12,
"col": 5
}
],
"warnings": []
}
Fields:
| Field | Type | Description |
|---|---|---|
cluster | string | Cluster name. |
database | string | Database name; "(cluster)" for cluster-level objects. |
objects | integer | Number of IR objects successfully compiled. |
errors | array | Diagnostics with IsError = true. Empty array if none. |
warnings | array | Diagnostics with IsError = false. Empty array if none. |
Each diagnostic object has rule, message, file, line, col.
rule uses hyphen-separated IDs (e.g., "hardcoded-password"), per
Appendix D.3.
18.7. dpg fmt
Reformats .dpg source files in place according to a canonical style.
dpg fmt [options] [<file> ...]
Options:
--check Exit with code 1 if any file would be reformatted.
Does not modify files. Useful as a CI gate.
--diff Print a unified diff of the proposed reformatting.
Does not modify files.
--stdin Read source from stdin, write formatted output to stdout.
Ignores any <file> arguments and --check/--diff; for editor
integrations that pipe a buffer's content through the
formatter rather than operating on a file on disk.
Configured via the root dpg.toml [fmt] section (Section 3.2); the
TOML keys are indent (default 4) and keyword_case ("upper" or
"lower", default "upper").
Canonical style rules:
- Indentation: configurable (default 4 spaces).
- Keyword casing:
"upper"uppercases DPG/PostgreSQL keywords;"lower"lowercases them. - Column alignment: column names and types are aligned in
( )lists. - Trailing whitespace: stripped.
- Blank lines: one blank line between top-level declarations.
- Comment style:
--for single-line,/* */for multi-line.
dpg fmt deliberately does NOT touch identifier casing — unlike
keyword casing, identifiers are never rewritten, in either
direction. This is intentional, not a gap: DPG’s macro
preprocessor (Section 4.7) spreads identifiers across files verbatim, and
PostgreSQL identifiers are case-sensitive once quoted, so a
formatter that normalized casing could silently change which
objects a macro-expanded reference resolves to. A case mismatch
that changes meaning is a compiler error (see the linter/compiler),
never something the formatter guesses at or silently corrects.
18.8. dpg portability
Reports all PostgreSQL-specific constructs in use with SQL standard alternatives noted where available.
dpg portability [options]
Options:
--cluster <name> cluster to analyze (default: all)
--database <name> database to analyze (default: all)
--format <fmt> output format: text or json (default: text)
--format json emits one JSON object per cluster/database analyzed
({"cluster", "database", "issues": [...]}, each issue carrying
construct/alternative/file/line/col) — suitable for CI or
other tooling, as an alternative to the default human-readable text
output.
This command is OPTIONAL; it MUST NOT be a compilation gate.
18.9. dpg init
Scaffolds a new project with the standard directory layout and
dpg.toml files.
dpg init [options] [<dir>]
Options:
--cluster <name> Cluster directory name (default: "production").
--database <name> Database directory name (default: "myapp").
--schema <name> Default schema name (default: "public").
--url <url> PostgreSQL connection URL (can be set later in dpg.toml).
Existing files are skipped (not overwritten). Directories are created unconditionally. Files created:
<dir>/dpg.toml root config
<dir>/<cluster>/dpg.toml cluster config
<dir>/<cluster>/<database>/dpg.toml database config
<dir>/<cluster>/cluster/ cluster objects dir (empty)
<dir>/<cluster>/<database>/schemas/<schema>/ schema source dir (empty)
<dir>/.dpg/snapshots/ snapshot storage
18.10. dpg completion
Generates shell completion scripts.
dpg completion <shell>
<shell>: bash | zsh | fish | powershell
19. The Linter
19.1. Built-in Rules
| Rule ID | Description | Default Level |
|---|---|---|
hardcoded_password | ROLE PASSWORD 'literal' detected. Use env:VAR_NAME. | Error |
hardcoded_fdw_password | USER MAPPING OPTIONS has a literal value under a password-like key (password/passwd/pwd/secret/passphrase, matched as a substring). | Error |
deprecated_reference | A non-deprecated object references a deprecated object or column. | Warning |
missing_column_comment | A column has no COMMENT and require_column_comments = true. | Warning (configurable to Error) |
column_count_exceeded | A table has more columns than max_columns_per_table. | Warning |
scalar_merge_conflict | Two files provide conflicting scalar values for the same object property. | Warning |
security_definer_search_path | A SECURITY DEFINER function has no explicit SET search_path. | Warning |
serial_sequence_declared | A sequence is declared with a name matching an auto-managed SERIAL/IDENTITY sequence. | Warning |
unnecessary_revocation | A revocation targets a role that was never granted the privilege by DPG. | Warning |
stale_renamed_from | RENAMED FROM directive references a name not in the snapshot (DPG-E021). | Error |
unguarded_enum_removal | An ENUM value is removed without a MIGRATE REMOVE block. | Error |
protected_drop_attempt | The diff would drop a PROTECTED object (DPG-E022). | Error |
19.2. Configuration
All linter rules are configurable in the root dpg.toml
[linter] section. The warn_on_deprecated, require_column_comments,
forbid_hardcoded_passwords, max_columns_per_table, and
warn_on_scalar_merge_conflict fields are described in Section 3.2.
Individual rules MAY be set to "error", "warning", or "off"
via [linter.rules]:
[linter.rules]
security_definer_search_path = "error"
serial_sequence_declared = "off"
20. Introspection Engine
20.1. Catalog Tables Read
The introspection engine (interface pipeline.Introspector)
connects to a live PostgreSQL 14+ catalog and reads the following
system tables and views:
| Catalog object | Used for |
|---|---|
pg_class | Tables, views, materialized views, sequences, indexes |
pg_attribute | Column definitions (incl. attstattarget, attcompression, attstorage) |
pg_constraint | Table constraints |
pg_index | Index definitions |
pg_proc | Functions, procedures, aggregates |
pg_trigger | Trigger definitions |
pg_policy | Row security policies |
pg_type | Types (ENUMs, composites, domains, ranges, base) |
pg_enum | ENUM values |
pg_namespace | Schemas |
pg_extension | Installed extensions |
pg_publication | Publications |
pg_subscription | Subscriptions (all attributes except subconninfo, Section 13.2) |
pg_foreign_table | Foreign tables |
pg_foreign_server | Foreign servers |
pg_user_mapping | User mappings |
pg_foreign_data_wrapper | Foreign data wrappers |
pg_statistic_ext | Extended statistics objects |
pg_event_trigger | Event triggers |
pg_collation | Collations |
pg_operator | Operators |
pg_opclass | Operator classes |
pg_opfamily | Operator families |
pg_cast | Casts |
pg_partitioned_table | Partitioning metadata |
pg_inherits | Table inheritance |
pg_sequence | Sequence parameters |
pg_ts_config | Text search configurations |
pg_ts_dict | Text search dictionaries |
pg_ts_parser | Text search parsers |
pg_ts_template | Text search templates |
information_schema.column_privileges | Column-level grants |
pg_roles (or pg_authid) | Roles and role memberships |
pg_tablespace | Tablespaces |
20.2. Drift Detection
The dpg verify command compares the snapshot with the live catalog:
- Introspect the live catalog → produce a
[]IRObjectof live state. - Load the committed snapshot → produce a
[]IRObjectof snapshot state. - Compute the diff between snapshot state (desired) and live state (current) — i.e., treat the snapshot as the “desired” input and the live catalog as the “snapshot” input to the Differ.
- Any non-empty DiffOps represent drift.
Grant drift: Report as drift any DPG-declared grant absent from the live catalog. Do NOT report extra grants present in the live catalog.
21. Per-Object Diff Algorithms
This section specifies, for each object type, the precise field comparison performed by the Differ and the DDL emitted for each type of change.
TABLE:
| Field | Change detection | DDL | Safety |
|---|---|---|---|
| Columns (added) | New col name not in snapshot | ALTER TABLE t ADD COLUMN c TYPE [DEFAULT ...] | SAFE |
| Columns (dropped) | Col name in snapshot, absent in desired | ALTER TABLE t DROP COLUMN c | DESTRUCTIVE |
| Column type changed | TypeRef.String() differs | ALTER TABLE t ALTER COLUMN c TYPE newtype [USING expr] | CAUTION or DESTRUCTIVE |
| Column NOT NULL added | nullable false→true (was true in snap) | ALTER TABLE t ALTER COLUMN c SET NOT NULL | CAUTION |
| Column NOT NULL removed | nullable true→false | ALTER TABLE t ALTER COLUMN c DROP NOT NULL | SAFE |
| Column DEFAULT added | default was nil | ALTER TABLE t ALTER COLUMN c SET DEFAULT expr | SAFE |
| Column DEFAULT changed | default text differs | ALTER TABLE t ALTER COLUMN c SET DEFAULT expr | SAFE |
| Column DEFAULT removed | default is nil | ALTER TABLE t ALTER COLUMN c DROP DEFAULT | SAFE |
| Column statistics | statistics_target differs | ALTER TABLE t ALTER COLUMN c SET STATISTICS n (or DEFAULT, PG17+) | SAFE |
| Column compression | compression differs | ALTER TABLE t ALTER COLUMN c SET COMPRESSION m | SAFE |
| Column storage | storage differs | ALTER TABLE t ALTER COLUMN c SET STORAGE s | SAFE |
| Column comment | comment differs | COMMENT ON COLUMN t.c IS '...' | SAFE |
| Constraint added | Name absent in snapshot | ALTER TABLE t ADD CONSTRAINT name ... | CAUTION |
| Constraint dropped | Name absent in desired | ALTER TABLE t DROP CONSTRAINT name | DESTRUCTIVE |
Constraint renamed only (RENAMED FROM, Section 7.3) | renamed_from set, body unchanged | ALTER TABLE t RENAME CONSTRAINT old TO new | SAFE |
| Constraint deferrability-only change (FK, Section 7.3) | Only DEFERRABLE/INITIALLY ... differs | ALTER TABLE t ALTER CONSTRAINT name ... | SAFE |
NOT NULL constraint inheritability-only change (PG18, Section 7.3) | Only NO INHERIT differs | ALTER TABLE t ALTER CONSTRAINT name [NO] INHERIT | SAFE |
| Constraint dropped from a partitioned parent while a direct partition declares it locally (PG18+, Section 7.3) | Name absent from parent’s desired, present in a direct partition’s own { } body | ALTER TABLE ONLY t DROP CONSTRAINT name | SAFE |
| Constraint changed | Body text differs | Drop + re-add | DESTRUCTIVE |
| NOT VALID removed | not_valid false in desired | ALTER TABLE t VALIDATE CONSTRAINT name | CAUTION |
| Generated-column expression added/changed/dropped (Section 7.4) | GENERATED ... AS (expr) differs | ADD GENERATED/SET EXPRESSION/DROP EXPRESSION (Section 7.4 table) | SAFE/CAUTION |
| Identity clause added/changed/dropped (Section 7.4) | identity-opts differ | ADD GENERATED AS IDENTITY/SET GENERATED/SET <option>/DROP IDENTITY (Section 7.4 table) | SAFE/CAUTION |
| Index added (existing table) | Name absent in snapshot | CREATE [UNIQUE] INDEX [CONCURRENTLY] ... | MANUAL or CAUTION |
| Index dropped | Name absent in desired | DROP INDEX [CONCURRENTLY] name | CAUTION |
Index renamed only (RENAMED FROM, Section 7.7) | Name changed, nothing else differs | ALTER INDEX old RENAME TO new | SAFE |
| Index changed | Any other field differs | Drop + recreate | CAUTION/MANUAL |
| RLS enabled | rls_enabled changed | ALTER TABLE t ENABLE ROW LEVEL SECURITY | SAFE |
| RLS disabled | rls_enabled changed | ALTER TABLE t DISABLE ROW LEVEL SECURITY | SAFE |
| RLS force removed (enable still set, Section 7.8) | rls_forced false in desired, rls_enabled still true | ALTER TABLE t NO FORCE ROW LEVEL SECURITY | SAFE |
| Policy added | Name absent in snapshot | CREATE POLICY name ON t ... | SAFE |
Policy TO/USING/WITH CHECK changed only (Section 7.8) | FOR/AS unchanged | ALTER POLICY name ON t ... | SAFE |
Policy FOR/AS changed | Either field differs | Drop + recreate | SAFE |
| Policy dropped | Name absent in desired | DROP POLICY name ON t | SAFE |
| Trigger added | Name absent in snapshot | CREATE TRIGGER name ... | SAFE |
| Trigger changed | Any field differs | Drop + recreate | SAFE |
| Trigger enable state changed only (Section 7.9) | trigger-enable-state differs, rest unchanged | ALTER TABLE t ENABLE/DISABLE TRIGGER name (or ENABLE REPLICA/ALWAYS TRIGGER) | SAFE |
| Trigger dropped | Name absent in desired | DROP TRIGGER name ON t | SAFE |
| Grant added | Not in snapshot grant list | GRANT privs ON TABLE t TO role | SAFE |
| Owner changed | owner differs | ALTER TABLE t OWNER TO role | SAFE |
| Comment changed | comment differs | COMMENT ON TABLE t IS '...' | SAFE |
| Table renamed | renamed_from set | ALTER TABLE old RENAME TO new | CAUTION |
Table moved to another schema (RENAMED FROM schema-qualified, Section 7.6) | Schema component of renamed_from differs | ALTER TABLE old_schema.name SET SCHEMA new_schema | SAFE |
| Tablespace changed | tablespace (Section 7.1’s TABLESPACE clause) differs | ALTER TABLE t SET TABLESPACE ts | CAUTION |
| Access method changed | USING method (Section 7.1) differs | ALTER TABLE t SET ACCESS METHOD method | CAUTION |
| Storage parameters changed | WITH (...) (Section 7.1) params differ | ALTER TABLE t SET (...) / ALTER TABLE t RESET (...) | SAFE |
| Parent added/removed | INHERITS (...) (Section 7.1) list differs | ALTER TABLE child INHERIT parent / ALTER TABLE child NO INHERIT parent | SAFE |
| Typed-table association added/switched/removed | OF type_name (Section 7.1) differs | ALTER TABLE t OF type_name / ALTER TABLE t NOT OF | CAUTION |
REPLICA IDENTITY changed | replica-identity-dir (Section 7.11) differs | ALTER TABLE t REPLICA IDENTITY ... | SAFE |
CLUSTER ON changed/removed | cluster-dir (Section 7.11) differs | ALTER TABLE t CLUSTER ON index / ALTER TABLE t SET WITHOUT CLUSTER | SAFE |
LOGGED/UNLOGGED toggled (Section 7.12) | UNLOGGED prefix differs | ALTER TABLE t SET LOGGED / ALTER TABLE t SET UNLOGGED | CAUTION |
| Partition attached/detached (Section 7.13) | ATTACHED FROM/DETACHED FROM present | ALTER TABLE parent ATTACH/DETACH PARTITION ... | CAUTION/MANUAL |
| Table dropped | Absent in desired, not PROTECTED | DROP TABLE t [CASCADE] | DESTRUCTIVE |
FUNCTION / PROCEDURE:
| Field | Change | DDL | Safety |
|---|---|---|---|
| New function | Absent in snapshot | CREATE OR REPLACE FUNCTION ... | SAFE |
| Body hash changed | body_hash differs | CREATE OR REPLACE FUNCTION ... | SAFE |
| Attribute changed (volatility, strict, security, leakproof, parallel, cost, rows, set options) | Field differs | CREATE OR REPLACE FUNCTION ... | SAFE |
| Argument list or return type changed | Type key differs | DROP FUNCTION CASCADE; CREATE FUNCTION | DESTRUCTIVE |
[NO] DEPENDS ON EXTENSION changed (Function/Procedure, Sections 9.1/9.2) | Extension-dependency set differs | ALTER FUNCTION ... [NO] DEPENDS ON EXTENSION ext | SAFE |
| Grant added | Not in snapshot | GRANT EXECUTE ON FUNCTION ... | SAFE |
| Revocation added | Not in snapshot | REVOKE EXECUTE ON FUNCTION ... FROM role | SAFE |
| Owner changed | owner differs | ALTER FUNCTION ... OWNER TO role | SAFE |
| Comment changed | comment differs | COMMENT ON FUNCTION ... | SAFE |
Renamed (RENAMED FROM) | renamed_from set | ALTER FUNCTION ... RENAME TO new | CAUTION |
Moved to another schema (RENAMED FROM schema-qualified) | Schema component differs | ALTER FUNCTION ... SET SCHEMA new_schema | SAFE |
| Function dropped | Absent in desired | DROP FUNCTION name(...) [CASCADE] | DESTRUCTIVE |
Aggregate reuses this same table for its three metadata-only
operations (rename/owner/schema-move, Section 9.4); every other Aggregate
field change is DESTRUCTIVE per Section 9.4’s own table, unlike Function/
Procedure’s mostly-SAFE attribute set above.
VIEW:
| Change | DDL | Safety |
|---|---|---|
| New view | CREATE VIEW ... | SAFE |
| Query changed, same column list | CREATE OR REPLACE VIEW ... | SAFE |
| Column list changed | DROP VIEW CASCADE; CREATE VIEW | DESTRUCTIVE |
View/Matview renamed (RENAMED FROM, Sections 8.1/8.2) | ALTER [MATERIALIZED] VIEW old RENAME TO new | CAUTION |
View/Matview moved to another schema (RENAMED FROM schema-qualified) | ALTER [MATERIALIZED] VIEW old_schema.name SET SCHEMA new_schema | SAFE |
| View dropped | DROP VIEW CASCADE | DESTRUCTIVE |
ENUM:
| Change | DDL | Safety |
|---|---|---|
| New value | ALTER TYPE name ADD VALUE 'v' [BEFORE/AFTER 'existing'] | SAFE |
Value renamed (RENAMED FROM, Section 5.1.1) | ALTER TYPE name RENAME VALUE 'old' TO 'new' | SAFE |
| Value removed (guarded) | MIGRATE REMOVE procedure (Section 5.1.2) | DESTRUCTIVE |
| Value removed (unguarded) | Error DPG-E014 (or with --allow-destructive) | DESTRUCTIVE |
| Comment changed | COMMENT ON TYPE name IS '...' | SAFE |
| Owner changed | ALTER TYPE name OWNER TO role | SAFE |
Renamed (RENAMED FROM on the type) | ALTER TYPE old RENAME TO new | CAUTION |
Moved to another schema (RENAMED FROM schema-qualified) | ALTER TYPE old_schema.name SET SCHEMA new_schema | SAFE |
Composite, Range, Domain, and Base types follow the same
Owner/Renamed/Moved-to-another-schema rows as ENUM above (Section 5.1’s
cross-reference) — not repeated per kind here; each kind’s own
section (Sections 5.2-5.5) documents any additional kind-specific rows
(Composite attribute rename, Domain constraint NOT VALID/rename,
etc.).
SEQUENCE:
| Change | DDL | Safety |
|---|---|---|
| New sequence | CREATE SEQUENCE ... | SAFE |
| Numeric parameters changed | ALTER SEQUENCE name [INCREMENT BY n] [MINVALUE n] ... | SAFE |
OWNED BY changed (incl. to/from NONE) | ALTER SEQUENCE name OWNED BY {table.col|NONE} | SAFE |
RESTART [WITH n] present in source (Section 10) | ALTER SEQUENCE name RESTART [WITH n] (re-emitted every apply while present) | MANUAL |
UNLOGGED toggled (Section 10) | ALTER SEQUENCE name SET LOGGED/UNLOGGED | CAUTION |
AS type changed | Drop + recreate | DESTRUCTIVE |
| Sequence dropped | DROP SEQUENCE name | DESTRUCTIVE |
ROLE:
| Change | DDL | Safety |
|---|---|---|
| New role | CREATE ROLE name WITH ... | SAFE |
| Any option changed | ALTER ROLE name WITH [options] | SAFE |
Membership added/changed (WITH ADMIN/INHERIT/SET, Section 11.1) | GRANT role TO member [WITH ...] | SAFE |
| Membership removed | REVOKE role FROM member | CAUTION |
SET/RESET config param changed (Section 11.1) | ALTER ROLE name [IN DATABASE db] SET/RESET ... | SAFE |
Renamed (RENAMED FROM, Section 11.1) | ALTER ROLE old RENAME TO new | CAUTION |
| Role dropped | DROP ROLE name | DESTRUCTIVE |
EVENT TRIGGER:
| Change | DDL | Safety |
|---|---|---|
| New event trigger | CREATE EVENT TRIGGER ... | SAFE |
| Event/tags/function changed | DROP EVENT TRIGGER + CREATE EVENT TRIGGER | SAFE |
| Enable state changed only (Section 14.1) | ALTER EVENT TRIGGER name ENABLE/DISABLE [REPLICA/ALWAYS] | SAFE |
| Owner changed | ALTER EVENT TRIGGER name OWNER TO role | SAFE |
Renamed (RENAMED FROM) | ALTER EVENT TRIGGER old RENAME TO new | CAUTION |
| Event trigger dropped | DROP EVENT TRIGGER name | SAFE |
22. Dependency Ordering
22.1. Topological Sort
The dependency resolver builds a directed acyclic graph (DAG) where
nodes are IRObject values and a directed edge from A to B means
“A depends on B” (B must be created before A).
Edge sources:
A table column whose type is a user-defined type or domain creates an edge from the table to the type/domain.
A
REFERENCESFK constraint creates an edge from the source table to the target table.A view’s query that references table or view B creates an edge from the view to B. This is real static analysis of the parsed query — every table/view reference reachable via
FROM,JOIN, a CTE, or a subquery — not a blanket “depends on every table in the object set” approximation; a view that never mentions B gets no edge to B.A function’s
search_pathorSECURITY DEFINERcontext creates an edge from the function to the schema.An index that uses a custom operator class creates an edge from the index (and transitively its table) to the operator class.
A trigger function reference creates an edge from the table to the trigger function.
A domain whose base type is a user-defined type creates an edge from the domain to the type.
A partition creates an edge from the partition to its parent partitioned table.
A
LANGUAGE sqlfunction/procedure body that statically references table or view B (aFROM/JOINclause, anINSERT/UPDATE/DELETEtarget, a CTE, or a subquery) creates an edge from the function/procedure to B. Dynamic SQL (anEXECUTEwhose argument is built at runtime rather than a literal query text) is invisible to this analysis — a known, accepted limitation matching real PostgreSQL’s own inability to validate dynamic SQL either. ALANGUAGE plpgsqlfunction/procedure body is deliberately not analysed for table references, even though it is analysed for embedded SQL when computing its body hash (Section 16.3) — see the note below this list for why. A function/procedure body in any other language is not analysed for table references.Why
plpgsqlis exempt: PostgreSQL compiles aplpgsqlbody lazily — the embedded SQL statements are not resolved against the catalog until the function is first called, so aplpgsqlfunction can be created referencing a table (or another function) that does not exist yet. Adding a function→table edge forplpgsqlbodies is therefore never required for a successfulapply, and doing so anyway can manufacture an unresolvable cycle for an entirely ordinary pattern: a validation or audit trigger function whose body queries the very table the trigger is attached to. That shape combines with edge source 6 (table→trigger-function) into a 2-node cycle with noFOREIGN KEYanywhere in it, which Section 22.2’s cycle-breaker cannot resolve (step 2a’sDEFERRABLEcheck has nothing to examine, since the cycle contains no FK edge at all). Exemptingplpgsqlhere mirrors the reasoning already used for function-calls-function edges: aLANGUAGE sqlbody calling another function creates an edge (sqlis validated eagerly), aplpgsqlbody calling another function does not.
The topological sort MUST use Kahn’s algorithm or an equivalent
O(V + E) algorithm. The sort is deterministic: among nodes with no
remaining incoming edges, the one with the lexicographically smallest
QualifiedName() is selected first.
22.2. Circular Dependency Resolution
When the dependency graph contains a cycle, the resolver applies the following procedure:
Identify all strongly connected components (SCCs) using Tarjan’s algorithm.
For each SCC with more than one node:
a. Verify that every FK edge within the SCC is
DEFERRABLE. If any non-deferrable FK is found, emit error DPG-E017 listing all nodes in the cycle.b. Remove the circular FK edges from the graph (these will be emitted as
ALTER TABLE ADD CONSTRAINT ... DEFERRABLEafter all tables in the SCC are created).c. Re-run the topological sort on the cycle-free graph.
After the topological sort, append
ALTER TABLE ADD CONSTRAINTops for all deferred circular FKs.
23. Deferred Features
The following features are formally out of scope for this version of the specification. They are documented here to establish the intended direction for future versions.
Rule (REWRITE) objects:
PostgreSQL CREATE RULE is a legacy feature superseded by triggers
and updatable views. DPG explicitly does not manage rules.
IMPORT FOREIGN SCHEMA:
Runtime discovery operation; not appropriate for declarative schema
management.
REFRESH MATERIALIZED VIEW:
Runtime DML operation; out of scope.
Temporary tables: Session-scoped; cannot be meaningfully managed by a schema tool.
Database management (CREATE DATABASE / ALTER DATABASE /
DROP DATABASE):
Permanently out of scope, not merely deferred. DPG’s project model
treats a database directory (Section 3.4) as proof the database already
exists — the directory’s presence is what makes a database
discoverable and in scope for diffing, not a declaration that DPG
should create it. Making DPG create-and-manage databases as objects
would require redefining what that directory means, or inventing a
second declaration surface alongside it; either way, database
creation/deletion is a cluster-provisioning operation, a tier below
the schema-management scope this specification defines, and is not
planned for any future version.
REASSIGN OWNED BY / DROP OWNED BY:
Out of scope. Both are one-shot cluster-maintenance commands, closer
in spirit to VACUUM than to a declared object — there is no
steady-state “desired” form of either to diff against, which is
exactly the same reason ALTER is structurally excluded from DPG’s
no-verb object model (Section 4). Not planned for any future version.
ALTER SYSTEM:
Out of scope. It writes a GUC override to postgresql.auto.conf, a
cluster-wide configuration file outside any database’s catalog —
nothing DPG introspects, diffs, or otherwise treats as schema state.
Even where a value it sets does persist (unlike REASSIGN OWNED BY/DROP OWNED BY above), the object it changes is server
configuration, not a schema object; many of the settings it touches
also require a server reload or restart to take effect, a cluster-
provisioning concern a tier below this specification’s scope — the
same distinction that puts CREATE/ALTER/DROP DATABASE out of
scope above. Not planned for any future version.
CREATE ACCESS METHOD:
Out of scope. The motivating real-world case — installing a custom
index access method such as pgvector’s hnsw/ivfflat or
bloom — never actually requires a bare CREATE ACCESS METHOD
statement in practice: the access method is registered as a side
effect of CREATE EXTENSION, which DPG already manages, and is
then only ever referenced by name (USING hnsw) on an index or
operator class, both already supported. A bare CREATE ACCESS METHOD only serves someone implementing a new access method in C
— extension authors, not DPG’s application-schema audience. Not
planned for any future version unless a concrete use case outside
the extension-install path emerges.
CREATE CONVERSION:
Out of scope. Character-set conversions were relevant when a
cluster mixed encodings; the near-universal use of UTF-8 end to end
today leaves no realistic scenario where an application schema
hand-declares one. Not planned for any future version.
CREATE [PROCEDURAL] LANGUAGE:
Out of scope. Every commonly used procedural language
(plpython3u, plperl, plv8, etc.) installs via CREATE EXTENSION, which DPG already manages, exactly as PostgreSQL’s own
documentation recommends; a bare CREATE LANGUAGE statement is
largely a pre-9.1 pattern predating the extension mechanism. Not
planned for any future version.
CREATE TRANSFORM:
Out of scope. Type transforms (e.g. for PL/Python) are always
bundled with a specific extension pairing (hstore_plpython,
ltree_plpython) and are never hand-declared independently of
installing that extension, which DPG already manages. Not planned
for any future version.
ALTER EXTENSION ADD/DROP member_object:
Out of scope. This reassigns which specific catalog objects (a
function, a type, an operator, etc.) an extension’s own internal
membership list claims — normally only relevant when packaging a new
extension version or working around a broken/partial installation,
both extension-authoring concerns rather than something an
application schema project hand-manages. Section 6.2 already manages
installing, updating, and removing an extension as a whole; picking
apart its internal member list is a tier below that scope, the same
distinction that excludes CREATE ACCESS METHOD/CREATE LANGUAGE/
CREATE TRANSFORM above. Not planned for any future version unless
a concrete use case outside extension-authoring emerges.
24. Security Considerations
Secret handling: DPG MUST NOT store plaintext secret values in any persisted file. This includes:
- Connection strings in
dpg.toml: iflink = "env:VAR"is used, the resolved value is never written to disk (viapipeline.SecretResolver/ChainResolver, Appendix D.5). Ifurl =is used, the connection string may contain embedded credentials and SHOULD NOT be committed to a public repository; this is the operator’s responsibility. - Subscription
CONNECTIONstrings (Section 13.2): MAY hold one or more{{secret-uri}}placeholders embedded in an otherwise-literal conninfo (or the whole value), resolved viapipeline.ResolveTemplate(Appendix D.5) immediately beforeCREATE SUBSCRIPTIONexecutes — never duringplan/diff, never written to the snapshot, an archived migration file, or any error message. ACONNECTIONvalue with no{{...}}at all is opaque literal text, same as before this existed — the same operator responsibility asurl =above. - Role
PASSWORD(Section 11.1): same{{secret-uri}}mechanism as SubscriptionCONNECTIONabove. The snapshot stores a hash of the declared text (never the resolved value), enabling rotation detection — not just a booleanhas_password, an earlier, less capable design this section once specified (see Section 11.1’s own note). - User Mapping
OPTIONS(Section 14.10): same{{secret-uri}}mechanism, applied to the entire opaque statement text rather than one isolated field —OPTIONSkeys are foreign-data-wrapper-specific, not fixed by DPG, so there’s no single clause to target the wayCONNECTION/PASSWORDare. The snapshot stores a hash of the whole declared body (never the resolved value), same as every other opaque-tier kind’s existing diffing.
FDW-level OPTIONS (as opposed to User Mapping’s) are not covered —
FDW connection details are typically non-sensitive (host, port,
dbname), with the credential living in the User Mapping specifically;
no case for a secret-bearing FDW-level option has come up.
Introspection-side redaction: dump/verify/plan --live read
the live catalog, not source, so the concern above (a resolved value
escaping into a persisted file) applies there too. Subscription
CONNECTION is handled fully: pg_subscription.subconninfo is never
selected at all (Section 13.2), so a resolved credential can never reach a
dumped .dpg file this way. User Mapping OPTIONS is handled the
same way it can be, given a structural difference from Subscription:
PostgreSQL redacts pg_user_mappings.umoptions to NULL for a
non-owner/non-superuser caller, but shows the real, already-resolved
value to a privileged one (the owner or a superuser) — and unlike
subconninfo, umoptions is a set of provider-specific key/value
pairs DPG must read individually to reconstruct valid OPTIONS (...)
syntax at all, so the column can’t simply be skipped the way
subconninfo is. Instead, dump redacts password-like keys
(password, passwd, pwd, secret, passphrase — matched
case-insensitively as a substring, same heuristic as the
hardcoded-fdw-password lint rule’s column-default check) to a fixed,
clearly-fake placeholder before writing OPTIONS into source; every
other key (user, dbname, host, etc.) is left untouched. The
placeholder deliberately contains no {{...}} marker, so if the
dumped file is planned or applied unmodified, the existing
hardcoded-fdw-password rule (see Appendix D.3 for the corrected
rule ID table) still hard-errors on any of the 5 password-like keys
and forces the operator to replace it with a real
{{secret-uri}} reference. What remains a genuine, inherent
limitation — not fixable by redaction — is narrower than the above:
PostgreSQL itself only ever stores the resolved credential, never the
original {{secret-uri}} reference (if any) that produced it, so
dump has no way to recover that reference; it can only stop the
live value from leaking into a persisted file.
SQL injection in generated DDL: All identifier names read from
source files are validated against PostgreSQL’s identifier rules
before being interpolated into generated SQL. The compiler MUST
quote all identifiers using PostgreSQL’s double-quote quoting
("identifier") in generated DDL to prevent injection via crafted
identifier names.
SECURITY DEFINER functions: The linter warns on SECURITY DEFINER functions lacking explicit SET search_path to mitigate
search path injection attacks (rule: security-definer-search-path).
Snapshot integrity: The snapshot is a plain JSON file. It MUST be committed to version control to prevent tampering. An attacker with write access to the snapshot could cause the differ to omit real changes or generate incorrect migrations. Snapshot integrity SHOULD be enforced via the same commit signing and branch protection mechanisms applied to source code.
Privilege escalation via DEFAULT PRIVILEGES: ALTER DEFAULT PRIVILEGES grants can confer privileges on future objects. Operators
SHOULD review all DEFAULT PRIVILEGES declarations carefully before
applying.
DROP ... CASCADE: The DROP CASCADE directive and the
default_drop_behavior = "cascade" setting cause the compiler to
emit DROP ... CASCADE, which silently drops all dependent objects.
Operators MUST review DESTRUCTIVE ops carefully. DPG’s safety
classification system exists precisely to make this review tractable.
25. Feature Coverage Matrix
Legend:
- Declared — DPG syntax fully specified in this document.
- Diffed — The compiler computes structured per-field changes and emits precise DDL.
- Passthrough — Treated as opaque text; diffed by text equality only.
- No SQL — DPG-native; generates no PostgreSQL DDL.
- Out of scope — Not managed by DPG.
- Deferred — Explicitly out of scope for this version; planned.
| Feature | Status | Notes |
|---|---|---|
| Tables (regular) | Declared, Diffed | Full per-field diff, incl. LIKE, typed (OF type_name), USING access method, REPLICA IDENTITY, CLUSTER ON, ATTACH/DETACH PARTITION, cheap constraint rename |
| Tables (unlogged) | Declared, Diffed | UNLOGGED prefix; toggling post-creation uses SET LOGGED/SET UNLOGGED, not drop+recreate |
| Tables (temporary) | Out of scope | Session-scoped |
| Columns — all built-in types | Declared, Diffed | In () list |
Columns — generated (ALWAYS AS ... STORED/VIRTUAL) | Declared, Diffed | In () list; VIRTUAL is PG18+ (Section 7.2) |
Columns — identity (AS IDENTITY) | Declared, Diffed | In () list |
Column COMPRESSION | Declared, Diffed | COLUMN c { COMPRESSION m; } |
Column STORAGE | Declared, Diffed | COLUMN c { STORAGE s; } |
| Column statistics targets | Declared, Diffed | COLUMN c { STATISTICS n | DEFAULT; } — DEFAULT is PG17+ |
| Column comments | Declared, Diffed | COLUMN c { COMMENT '...'; } |
Column DEPRECATED | Declared, Diffed | COLUMN c { DEPRECATED '...'; } |
Column USING (type change) | Declared, Diffed | COLUMN c { USING expr; } |
| Column renames | Declared, Diffed | COLUMN new { RENAMED FROM old; } |
| Column-level grants | Declared, Diffed | COLUMN c { GRANTS { ... } } |
| Column-level revocations | Declared, Diffed | COLUMN c { REVOCATIONS { ... } } |
Inline constraints (PK, UNIQUE, CHECK, FK, NOT NULL) | Declared, Diffed | Single-column emitted inline; table-level named NOT NULL is PG18+ (Section 7.3) |
Named constraints in () list | Declared, Diffed | Emitted inline for single-column |
Constraints in {} block | Declared, Diffed | NOT VALID required here |
EXCLUSION constraints | Declared, Diffed | In () or {} block; on partitioned tables since PG17 (Section 7.3, passthrough) |
NOT VALID / VALIDATE CONSTRAINT | Declared, Diffed | Multi-migration lifecycle; extended to NOT NULL constraints in PG18 (Section 7.3) |
ENFORCED/NOT ENFORCED (CHECK/FOREIGN KEY) | Declared, Diffed | PG18+ (Sections 7.2/7.3) |
Temporal keys (WITHOUT OVERLAPS/PERIOD) | Declared, Diffed | PG18+; references an existing range/multirange column, catalogued as an ordinary PRIMARY KEY/UNIQUE/FOREIGN KEY, not EXCLUDE (Section 7.3) |
FK ON DELETE/ON UPDATE SET NULL/SET DEFAULT (col-list) | Declared, Diffed | PG15+, column-scoped variant of the existing bare form (Section 7.2) |
| Indexes — all access methods | Declared, Diffed | btree, hash, gin, gist, brin, spgist, bloom |
| Indexes — partial | Declared, Diffed | WHERE predicate as text |
| Indexes — expression | Declared, Diffed | Expression as text |
Indexes — covering (INCLUDE) | Declared, Diffed | Drop + recreate on change |
| Indexes — concurrent creation | Declared, Manual | CREATE INDEX CONCURRENTLY |
Indexes — ON ONLY, opclass parameters, NULLS [NOT] DISTINCT, rename | Declared, Diffed | ONLY suppresses partition recursion; rename is metadata-only (Section 7.7) |
| ENUM types | Declared, Diffed | MIGRATE REMOVE for value removal; BEFORE/AFTER positional ADD VALUE, RENAME VALUE, OWNER, RENAMED FROM/SET SCHEMA |
| Composite types | Declared, Diffed | Attribute COLLATE; OWNER, RENAMED FROM/SET SCHEMA |
| Range types | Declared, Diffed | Any option change = DESTRUCTIVE; OWNER, RENAMED FROM/SET SCHEMA; multirange companion auto-created, never separately declared |
| Domain types | Declared, Diffed | COLLATE; NOT VALID/VALIDATE CONSTRAINT/RENAME CONSTRAINT lifecycle; OWNER, RENAMED FROM/SET SCHEMA |
| Base (shell) types | Declared, Passthrough | Bare forward-declaration shell + automatic cycle-breaking for self-referential support functions (Section 5.5); OWNER, RENAMED FROM/SET SCHEMA; support-function/storage property changes use non-destructive ALTER TYPE ... SET (...) where real PostgreSQL allows it |
| Virtual types | Declared, No SQL | DPG-native; snapshot only |
| Views | Declared, Diffed | Column list change = DESTRUCTIVE; RENAMED FROM and cross-schema SET SCHEMA supported (Section 7.6, Section 8.1) |
| Materialized views | Declared, Diffed | Query change = DESTRUCTIVE; RENAMED FROM/SET SCHEMA supported (Section 8.2) |
| Recursive views | Declared, Diffed | |
| Functions — all languages | Declared, Passthrough body | Body hash-diffed; LEAKPROOF, TRANSFORM FOR TYPE, C/internal AS forms, PG14+ BEGIN ATOMIC, [NO] DEPENDS ON EXTENSION, OWNER, REVOCATIONS, RENAMED FROM/SET SCHEMA |
| Procedures | Declared, Passthrough body | Same additions as Functions above ([NO] DEPENDS ON EXTENSION, OWNER, REVOCATIONS, RENAMED FROM/SET SCHEMA) |
| Aggregates | Declared, Diffed | Full agg-options set (Section 9.4) diffed; any option change = DESTRUCTIVE; RENAME TO/OWNER TO/SET SCHEMA are the only non-destructive ALTER operations, matching real PostgreSQL’s ALTER AGGREGATE surface |
| Window functions | Declared, Passthrough body | |
| Row Level Security | Declared, Diffed | TO/USING/WITH CHECK-only policy changes use non-destructive ALTER POLICY, avoiding a zero-active-policy window; policy RENAMED FROM also supported (Section 7.8) |
| Triggers | Declared, Diffed | RENAMED FROM supported, metadata-only via ALTER TRIGGER (Section 7.9) |
| Event triggers | Declared, Passthrough | Reconstructed from catalog; hash-diffed. Enable-state (DISABLED/ENABLE REPLICA/ENABLE ALWAYS), OWNER, RENAMED FROM diffed structurally, not part of the hash. login event (PG17+) also supported (Section 14.1) |
| Sequences | Declared, Diffed | UNLOGGED, OWNED BY NONE, REVOCATIONS, RESTART [WITH n] (Section 10) |
| Schemas | Declared, Diffed | |
| Extensions | Declared, Diffed | SCHEMA change uses non-destructive ALTER EXTENSION ... SET SCHEMA for relocatable extensions (Section 6.2) |
| Roles | Declared, Diffed | Cluster-level; PASSWORD (Section 11.1) never live-introspected (superuser-only in PG), diffed offline via a hash of the declared text. RENAMED FROM/RENAME TO and SET/RESET session-config-parameter grammar now supported (Section 11.1); membership WITH ADMIN/INHERIT/SET options (PG16+) also supported |
| Table-level grants | Declared, Diffed | Additive model; MAINTAIN privilege (PG17) and GRANTED BY role (Section 7.10) also supported |
| Parameter Privileges (Section 11.6) | Declared, Diffed | Cluster-level; GRANT {SET|ALTER SYSTEM} ON PARAMETER, additive model like Table-level grants |
| Column-level grants | Declared, Diffed | Additive model |
| Explicit revocations | Declared, Diffed | |
| Default Privileges | Declared, Diffed | |
| Security Labels (Section 14.11) | Declared, Diffed | Keyed by provider; every kind PostgreSQL’s own SECURITY LABEL grammar supports and DPG models |
| Tablespaces | Declared, Passthrough | Cluster-level; reconstructed from catalog, hash-diffed. CREATE-time WITH (...) storage params, OWNER TO, RENAME TO, and non-destructive SET/RESET on WITH (...) options are all supported (Section 14.7) |
| Foreign Data Wrappers | Declared, Passthrough | Cluster-level; reconstructed from catalog, hash-diffed |
| Foreign Servers | Declared, Passthrough | Reconstructed from catalog; hash-diffed. OWNER, RENAMED FROM, bare VERSION-only change also supported (Section 14.9) |
| User Mappings | Declared, Passthrough | Reconstructed from catalog; hash-diffed. OPTIONS may hold a {{secret-uri}} reference (Section 14.10, Appendix D.5), resolved only immediately before CREATE USER MAPPING executes |
| Foreign Tables | Declared, Diffed | SERVER/OPTIONS after ); Section 7.12 now documents the ALTER-semantics table directly (OPTIONS change is SAFE in place, SERVER change is DESTRUCTIVE drop+recreate, column add/drop follows regular TABLE rules) |
| Partitioned Tables | Declared, Diffed | Partition RENAMED FROM supported, same mechanism as a plain table rename (Section 7.13) |
| Sub-partitioning | Declared, Diffed | |
| Publications | Declared, Passthrough | Reconstructed from catalog; hash-diffed. OWNER, RENAMED FROM also supported (Section 13.1) |
| Subscriptions | Declared, Passthrough | Reconstructed from the catalog; hash-diffed. CONNECTION alone is never introspected (subconninfo has no PUBLIC grant, and even a privileged read can’t recover the original {{secret-uri}}) — reconstructed as a fixed placeholder instead, excluded from the drift comparison like every other reconstructed body (Section 13.2). CONNECTION may hold a {{secret-uri}} reference in source (Section 13.2, Appendix D.5), resolved only immediately before CREATE SUBSCRIPTION executes |
| Collations | Declared, Passthrough | Reconstructed from catalog, hash-diffed; property changes (LOCALE/PROVIDER/RULES/FROM target) = DESTRUCTIVE. FROM existing_collation and RULES (PG16+) forms, OWNER/RENAMED FROM/SET SCHEMA, and REFRESH VERSION (PG15+) also supported (Section 14.2) |
| Operators | Declared, Passthrough | Reconstructed from catalog, hash-diffed; any change = DESTRUCTIVE |
| Operator Classes | Declared, Passthrough | Reconstructed from catalog, hash-diffed; any AS member-list change = DESTRUCTIVE (PostgreSQL has no incremental ALTER OPERATOR CLASS) |
| Operator Families | Declared, Passthrough + Diffed | Header (name/access method) reconstructed from catalog, hash-diffed; loose members (Section 14.4, ALTER OPERATOR FAMILY ... ADD) are structured and diffed incrementally per member, live-path included — not gated on Reconstructed the way the bare header hash is |
| Casts | Declared, Passthrough | Reconstructed from catalog, hash-diffed; any change = DESTRUCTIVE |
| Extended Statistics Objects | Declared, Passthrough | Reconstructed from catalog; hash-diffed |
| Text Search Configurations | Declared, Passthrough | Reconstructed from catalog; hash-diffed. OWNER, RENAMED FROM/SET SCHEMA, ALTER MAPPING REPLACE (bulk and per-token-type) also supported (Section 12.1) |
| Text Search Dictionaries | Declared, Passthrough | Reconstructed from catalog; hash-diffed. OWNER/COMMENT/RENAMED FROM block now supported (Section 12.2) |
| Text Search Parsers | Declared, Passthrough | Reconstructed from catalog; hash-diffed. COMMENT/RENAMED FROM block now supported, no OWNER (real PostgreSQL has none for this kind, Section 12.3) |
| Text Search Templates | Declared, Passthrough | Reconstructed from catalog; hash-diffed. COMMENT/RENAMED FROM block now supported, no OWNER (Section 12.4) |
| Macro preprocessor | Declared, No SQL | Compile-time text expansion |
| Cross-file macro sharing | Declared, No SQL | Macros defined in any file in the compilation scope are available to all others |
| Rules (REWRITE) | Out of scope | Legacy |
IMPORT FOREIGN SCHEMA | Out of scope | Runtime discovery |
REFRESH MATERIALIZED VIEW | Out of scope | Runtime DML |
| Temporary tables | Out of scope | Session-scoped |
| Temporary views | Out of scope | Session-scoped, same reasoning as temporary tables (Section 8.1) |
| Inline data seeding | Out of scope | DPG is a schema tool; data management is outside its scope |
Database management (CREATE/ALTER/DROP DATABASE) | Out of scope | Cluster-provisioning, not schema management; see Section 23 — permanent, not deferred |
REASSIGN OWNED BY / DROP OWNED BY | Out of scope | One-shot maintenance command, not a declarable object; see Section 23 |
ALTER SYSTEM | Out of scope | Cluster-wide GUC configuration, not schema state; see Section 23 |
CREATE ACCESS METHOD | Out of scope | Covered by extension install (CREATE EXTENSION); see Section 23 |
CREATE CONVERSION | Out of scope | No realistic use case under UTF-8 dominance; see Section 23 |
CREATE [PROCEDURAL] LANGUAGE | Out of scope | Covered by extension install (CREATE EXTENSION); see Section 23 |
CREATE TRANSFORM | Out of scope | Always bundled with a specific extension pairing; see Section 23 |
ALTER EXTENSION ADD/DROP member_object | Out of scope | Extension-authoring concern, a tier below application-schema scope; see Section 23 |
Appendix A. ABNF Grammar Summary
The following is a consolidated summary of all ABNF productions defined throughout this document. Individual productions were specified inline in their respective sections. This appendix collects them for reference.
; Top-level source file
dpg-file = *( WSP / comment / macro-decl / top-level-decl )
; Every declaration kind DPG models, at the granularity a .dpg file
; actually admits at top level (schema-scoped kinds also nest inside a
; schema-decl's { } block via nested-object — both positions are legal,
; see Section 3.6 on directory-inferred schema context for top-level use
; without a wrapping SCHEMA block).
top-level-decl = schema-decl
/ extension-decl
/ role-decl
/ tablespace-decl
/ fdw-decl
/ server-decl
/ user-mapping-decl
/ publication-decl
/ subscription-decl
/ event-trigger-decl
/ default-privileges-decl
/ parameter-privileges-decl
/ opaque-object-decl
/ nested-object
; security-label-decl (Section 14.11) is NOT a top-level declaration — it is a
; { } block directive nested inside another object's own block (Table,
; Column, Function, Publication, etc.), the same position comment-dir/
; grants-block occupy.
; Schema-scoped object kinds — legal directly at top level (relying on
; directory-inferred schema, Section 3.6) or nested inside schema-block.
nested-object = table-decl
/ view-decl
/ matview-decl
/ function-decl
/ procedure-decl
/ aggregate-decl
/ sequence-decl
/ enum-decl
/ composite-decl
/ range-decl
/ domain-decl
/ base-type-decl
/ virtual-type-decl
/ tsconfig-decl
; server-decl / user-mapping-decl have no dedicated ABNF block in their
; own sections (Sections 14.9/14.10) any more than the opaque kinds below do —
; named here distinctly only because they're referenced from other
; productions (server-clause, terminator) that DO need a name to point
; at, unlike the opaque-object-decl kinds, which nothing else references.
server-decl = "SERVER" WSP identifier <PG CREATE SERVER syntax, Part 1 passthrough>
user-mapping-decl = "USER MAPPING FOR" WSP role-spec <PG CREATE USER MAPPING syntax, Part 1 passthrough>
; base-type-decl is BASE type's own production name (Section 5.5) — named here
; since it's referenced from nested-object above; its option list
; (INPUT, OUTPUT, INTERNALLENGTH, ALIGNMENT, etc.) is the same
; comma-separated key=value shape as storage-params, reused here rather
; than inventing a separate name for an identical shape.
base-type-decl = "TYPE" WSP schema-name WSP "(" storage-params ")" ";"
; opaque-object-decl covers every remaining kind whose Part 1 body is
; handed to pg_query as literal, unmodified native PostgreSQL DDL
; (Tenet 2/5's passthrough pattern — the same kind list Section 16.3.1's
; SnapOpaque table already enumerates for the snapshot side): Procedure
; (body only — its declaration head is function-decl's own grammar,
; Section 9.3), Foreign Data Wrapper, Collation, Operator, Operator Class,
; Operator Family, Cast, Extended Statistics Object, and three of the
; four Text Search object kinds (Dictionary, Parser, Template — Text
; Search Configuration is the exception, with real structured grammar
; of its own: tsconfig-decl, Section 12.1). None of these has (or needs) a
; DPG-specific ABNF production for its Part 1 body beyond its own
; section's worked "PG equivalent" line — inventing one would violate
; Tenet 2 by giving PostgreSQL's own syntax a second, redundant DPG-
; specific name. Several of them DO carry an optional trailing { }
; block of ordinary lifecycle directives (owner-dir/comment-dir/
; renamed-from-dir), same as every non-opaque kind — that block is DPG
; grammar for concepts with no place in the native CREATE statement, not
; a second name for the passthrough body, so it doesn't violate the
; same rule (Text Search Dictionary/Parser/Template, Sections 12.2-12.4).
opaque-object-decl = <the object kind's own native CREATE statement,
verbatim, per its section's "PG equivalent" line>
; Common terminals
WSP = *( SP / HTAB / CRLF / LF )
SQUOTE = %x27 ; single quote '
DQUOTE = %x22 ; double quote "
integer = [ "-" ] 1*DIGIT
boolean = "true" / "false"
expr = <arbitrary SQL expression text>
qual-name = identifier *( "." identifier )
schema-name = identifier *( "." identifier )
; Identifier
identifier = unquoted-id / quoted-id
unquoted-id = ( ALPHA / "_" ) *( ALPHA / DIGIT / "_" / "$" )
quoted-id = DQUOTE *( safe-char / DQUOTE DQUOTE ) DQUOTE
safe-char = <any Unicode character except DQUOTE>
; Common directives
owner-dir = "OWNER" WSP DQUOTE identifier DQUOTE
comment-dir = "COMMENT" WSP string-literal
; qual-name (not bare identifier) since Section 7.6 extends this production to
; cross-schema moves: a schema-qualified old name whose schema differs
; from the object's current one additionally triggers ALTER ... SET
; SCHEMA alongside RENAME TO.
renamed-from-dir = "RENAMED FROM" WSP qual-name
protected-dir = "PROTECTED"
deprecated-dir = "DEPRECATED" WSP string-literal
drop-cascade-dir = "DROP CASCADE"
; Dollar-quoted string
dollar-string = dollar-delim *<any byte> dollar-delim
dollar-delim = "$" *( ALPHA / DIGIT / "_" ) "$"
; Type reference
type-ref = qual-name [ "(" type-mods ")" ] *( "[]" )
type-mods = integer *( "," integer )
; String literal
string-literal = SQUOTE <text> SQUOTE
; Option lists — the "WITH ( name [= value] [, ...] )" shape shared
; verbatim by Table/Index/Sequence/Materialized-View storage params and
; by Publication/Subscription's own WITH options (real PostgreSQL uses
; the identical grammar in both places). A single shared production
; means a later PostgreSQL version's new option (e.g. PG18's
; publish_generated_columns) is automatically expressible with zero
; grammar changes, the same passthrough reasoning Subscription's fully
; opaque body already relies on.
storage-params = storage-param *( "," WSP storage-param )
storage-param = identifier [ WSP "=" WSP option-value ]
option-value = string-literal / integer / boolean / identifier
pub-options = storage-params
sub-options = storage-params
; Inline index parameters — real PostgreSQL's index_parameters clause,
; usable both on a standalone CREATE INDEX (index-decl, Section 7.7) and
; inline on a PRIMARY KEY/UNIQUE column or table constraint (col-
; constraint/table-constraint-body, Section 7.2), which is what conflict-clause
; exists to name for the latter, narrower position (no WHERE predicate
; or CONCURRENTLY there — those are CREATE-INDEX-only).
conflict-clause = [ WSP "INCLUDE" WSP "(" col-list ")" ]
[ WSP "WITH" WSP "(" storage-params ")" ]
[ WSP "USING INDEX TABLESPACE" WSP identifier ]
; EXCLUDE constraint element list (Section 7.2) — mirrors real PostgreSQL's
; exclude_element WITH operator repeating group exactly.
excl-list = excl-element *( "," WSP excl-element )
excl-element = ( identifier / "(" expr ")" )
[ WSP identifier [ "(" storage-params ")" ] ]
[ WSP ( "ASC" / "DESC" ) ]
[ WSP "NULLS" WSP ( "FIRST" / "LAST" ) ]
WSP "WITH" WSP operator-symbol
operator-symbol = <a PostgreSQL operator name/symbol, e.g. "=", "&&">
; CONSTRAINT TRIGGER deferrability (Section 7.9) — identical real-PostgreSQL
; shape to the table-constraint deferrable clause defined inline at
; Section 7.2, named here since Section 7.9 references it by name.
deferrable-clause = "NOT DEFERRABLE"
/ "DEFERRABLE" [ WSP ( "INITIALLY DEFERRED"
/ "INITIALLY IMMEDIATE" ) ]
; Bare function reference — a (possibly schema-qualified) function name
; with no argument list or parentheses of its own; each call site
; (Trigger's EXECUTE FUNCTION func-ref "(" arg-list ")", Event Trigger's
; EXECUTE FUNCTION func-ref "()", Function's SUPPORT func-ref) appends
; whatever parenthesization its own context requires.
func-ref = qual-name
; Text Search Configuration MAPPING FOR ... WITH ... (Section 12.1)
token-type-list = identifier *( "," WSP identifier )
dict-list = qual-name *( "," WSP qual-name )
; Event Trigger WHEN TAG IN (...) (Section 14.1) — command tag string list
tag-list = string-literal *( "," WSP string-literal )
; Table-level trailing TABLESPACE clause (Section 4.5's terminator rule) —
; same shape already used inline for table-clause's own TABLESPACE
; alternative (Section 7.1); named here since Section 4.5 references it by name.
tablespace-clause = "TABLESPACE" WSP identifier
; Grants
grants-block = "GRANTS" WSP "{" *( grant-entry ";" ) "}"
revocations-block = "REVOCATIONS" WSP "{" *( revoke-entry ";" ) "}"
grant-entry = privilege-list WSP "TO" WSP role-list
[ WSP "WITH GRANT OPTION" ]
[ WSP "GRANTED BY" WSP role-spec ]
revoke-entry = ( privilege-list / "ALL PRIVILEGES" ) WSP
"FROM" WSP role-list
[ WSP "GRANTED BY" WSP role-spec ]
[ WSP "CASCADE" ]
privilege-list = privilege *( "," WSP privilege )
privilege = "SELECT" / "INSERT" / "UPDATE" / "DELETE" / "TRUNCATE" /
"REFERENCES" / "TRIGGER" / "USAGE" / "EXECUTE" / "CREATE" /
"CONNECT" / "TEMPORARY" / "MAINTAIN" / "ALL" / "ALL PRIVILEGES"
role-list = identifier *( "," WSP identifier )
; role-spec is defined locally at its first use (Section 14.7, Tablespace
; OWNER) and reused verbatim by GRANTED BY above and by Role membership
; WITH clauses (Section 11.1) — not redefined per site.
role-spec = identifier / "CURRENT_ROLE" / "CURRENT_USER" / "SESSION_USER"
; Macro preprocessor
macro-decl = "MACRO" WSP identifier WSP ( paren-body / brace-body )
paren-body = "(" *column-def ")"
brace-body = "{" *( block-directive ";" ) "}"
spread = "..." identifier
; See individual sections for all other productions.
Appendix B. Complete Example Project
This appendix shows a complete, coherent DPG project for a multi-tenant SaaS application.
# dpg.toml
[compiler]
default_drop_behavior = "restrict"
[linter]
warn_on_deprecated = true
forbid_hardcoded_passwords = true
max_columns_per_table = 50
warn_on_scalar_merge_conflict = true
[snapshots]
directory = ".dpg/snapshots"
# production/dpg.toml
[cluster]
name = "production"
cluster_objects_dir = "cluster"
link = "env:PRODUCTION_DB_URL"
[cluster.options]
snapshot_on_apply = true
# production/myapp/dpg.toml
[database]
name = "myapp"
default_schema = "public"
-- production/cluster/roles.dpg
ROLE app_service
LOGIN
PASSWORD '{{vault:secret/roles/app_service#pw}}'
CONNECTION LIMIT 20;
ROLE app_readonly NOLOGIN;
ROLE app_admin
LOGIN
NOSUPERUSER
NOCREATEDB
INHERIT;
-- production/myapp/extensions.dpg
EXTENSION pgcrypto;
EXTENSION pg_trgm CASCADE;
-- production/myapp/schemas/public/types.dpg
SCHEMA public {
ENUM account_status ('trial', 'active', 'suspended', 'cancelled');
{
COMMENT 'Top-level account lifecycle states';
}
ENUM invoice_status ('draft', 'sent', 'paid', 'void', 'overdue');
{
COMMENT 'Billing lifecycle states for customer invoices';
}
DOMAIN positive_money AS NUMERIC(12, 2)
CONSTRAINT must_be_positive CHECK (VALUE >= 0);
}
-- production/myapp/schemas/public/tables/accounts.dpg
MACRO audit_timestamps (
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
)
SCHEMA public {
TABLE accounts (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
status account_status NOT NULL DEFAULT 'trial',
...audit_timestamps
)
{
COMMENT 'Top-level tenant accounts';
ENABLE ROW LEVEL SECURITY;
COLUMN status { STATISTICS 300; }
COLUMN created_at { STATISTICS 200; }
INDICES {
idx_accounts_status (status) WHERE (deleted_at IS NULL);
}
POLICIES {
isolate_tenants FOR ALL
USING (id = current_setting('app.account_id')::UUID);
}
GRANTS {
SELECT, INSERT, UPDATE TO app_service;
SELECT TO app_readonly;
}
REVOCATIONS {
ALL PRIVILEGES FROM PUBLIC;
}
}
}
-- production/myapp/schemas/public/tables/invoices.dpg
SCHEMA public {
TABLE invoices (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
account_id UUID NOT NULL,
status invoice_status NOT NULL DEFAULT 'draft',
total positive_money NOT NULL DEFAULT 0,
issued_at TIMESTAMPTZ,
due_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT fk_account FOREIGN KEY (account_id)
REFERENCES accounts (id) ON DELETE CASCADE,
CONSTRAINT ck_due_after_issued CHECK (due_at IS NULL OR due_at > issued_at)
)
{
COLUMN created_at { STATISTICS 200; }
INDICES {
idx_invoices_account (account_id);
idx_invoices_status (status) WHERE (status NOT IN ('paid', 'void'));
idx_invoices_due (due_at) WHERE (status = 'sent');
}
ENABLE ROW LEVEL SECURITY;
POLICIES {
isolate_tenants FOR ALL
USING (account_id = current_setting('app.account_id')::UUID);
}
TRIGGERS {
after_status_change AFTER UPDATE OF status
FOR EACH ROW
WHEN (OLD.status IS DISTINCT FROM NEW.status)
EXECUTE FUNCTION notify_invoice_status_change();
}
GRANTS { SELECT, INSERT, UPDATE TO app_service; }
}
}
-- production/myapp/schemas/public/functions.dpg
SCHEMA public {
FUNCTION notify_invoice_status_change() RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
PERFORM pg_notify(
'invoice_status_changed',
json_build_object(
'invoice_id', NEW.id,
'old_status', OLD.status,
'new_status', NEW.status
)::TEXT
);
RETURN NEW;
END;
$$;
MATERIALIZED VIEW account_billing_summary AS
SELECT
a.id,
a.name,
COUNT(i.id) AS total_invoices,
COALESCE(SUM(i.total) FILTER (WHERE i.status = 'paid'), 0) AS paid_total,
COALESCE(SUM(i.total) FILTER (WHERE i.status = 'overdue'),0) AS overdue_total
FROM accounts a
LEFT JOIN invoices i ON i.account_id = a.id
WHERE a.deleted_at IS NULL
GROUP BY a.id, a.name
WITH NO DATA;
{
GRANTS { SELECT TO app_readonly; }
}
}
Appendix C. Error Code Reference
| Code | Name | Description |
|---|---|---|
| DPG-E000 | unlabeled_compile_error | Generic fallback label for a compile-time error not yet mapped to one of the specific codes below (dpg validate --format json’s catch-all rule value). |
| DPG-E001 | unknown_config_key | Unknown key in dpg.toml. |
| DPG-E002 | ambiguous_connection | Both url and link set in cluster config. |
| DPG-E003 | no_connection_configured | Command requires a connection but neither url nor link is set. |
| DPG-E004 | reserved_name_conflict | A database directory name matches the cluster objects directory name. |
| DPG-E005 | conflicting_set_member | Same-named set-valued property has conflicting definitions across files. |
| DPG-E006 | forbidden_verb | CREATE, ALTER, or DROP at declaration level in a .dpg file. |
| DPG-E007 | macro_inside_block | MACRO declaration found inside a block. |
| DPG-E008 | paren_macro_in_block | Paren-body macro spread inside a { } block. |
| DPG-E009 | brace_macro_in_paren | Brace-body macro spread inside a ( ) list. |
| DPG-E010 | undefined_macro | Spread of undefined macro name. |
| DPG-E011 | duplicate_macro | Macro name redeclared in the same file. |
| DPG-E012 | circular_macro | Circular macro reference detected. |
| DPG-E013 | enum_migration_data_remains | Rows still hold a removed ENUM value after the MIGRATE REMOVE DML ran. |
| DPG-E014 | unguarded_enum_removal | ENUM value removed without a MIGRATE REMOVE block. |
| DPG-E015 | invalid_virtual_type_directive | VIRTUAL TYPE { } block contains a directive other than COMMENT. |
| DPG-E016 | not_valid_in_paren_list | NOT VALID used in a column or constraint inside the ( ) list. |
| DPG-E017 | unresolvable_cycle | Circular FK dependency with no DEFERRABLE FK. |
| DPG-E018 | unknown_column_reference | COLUMN name { } block references a column not in the ( ) list. |
| DPG-E019 | stale_column_name_in_index | Index or constraint references old column name after a rename. |
| DPG-E020 | statistics_target_out_of_range | STATISTICS n value is outside [-1, 10000]. |
| DPG-E021 | stale_renamed_from | RENAMED FROM references a name absent from both source and snapshot. |
| DPG-E022 | protected_drop_attempt | Diff would drop a PROTECTED object. |
| DPG-E023 | temporary_table_declared | TEMPORARY TABLE keyword found in a .dpg file. |
| DPG-E024 | unknown_block_directive | Unknown directive for this object kind in a { } block. |
| DPG-E025 | destructive_ops_blocked | Migration contains DESTRUCTIVE ops but --allow-destructive not passed. |
| DPG-E026 | multiple_clusters_no_flag | Multiple clusters found; --cluster required. |
| DPG-E027 | cluster_not_found | --cluster value does not match any cluster. |
| DPG-E028 | multiple_databases_no_flag | Multiple databases found; --database required. |
| DPG-E029 | database_not_found | --database value does not match any database. |
| DPG-E030 | invalid_namemap_rule | Unknown rule keyword in [namemaps] config or NAME MAP block directive. |
| DPG-E031 | duplicate_namemap_tool | Same tool key specified more than once at the same block level (warning only; last entry wins). |
| DPG-E032 | cluster_name_required | Cluster dpg.toml is missing a [cluster] name (Section 3.3). |
| DPG-E033 | database_name_required | Database dpg.toml is missing a [database] name (Section 3.4). |
| DPG-E034 | duplicate_cluster_name | Two cluster directories declare the same name (Section 3.6). |
| DPG-E035 | duplicate_database_name | Two database directories within the same cluster declare the same name (Section 3.6). |
| DPG-E036 | owner_role_not_a_member | Connecting role is not a member of one or more declared OWNER roles (Section 11.5). |
Appendix D. Corrections and Additions to Earlier Sections
This appendix records normative corrections and additions discovered by cross-referencing the reference implementation after the main document was written. These entries have the same normative weight as the sections they amend.
D.1. Snapshot Format — Actual Wire Schema (amends Section 16)
D.1.1. Corrections Integrated
Earlier drafts of Section 16.3 showed a flat per-object record with
several field names/shapes that didn’t match the reference
implementation (originally corrected here across seven subsections,
D.1.1–D.1.7: the SnapObject discriminated-union wrapper, the
SnapOpaque passthrough shape, and corrected SnapColumn/
SnapConstraint/SnapIndex/SnapTrigger/SnapGrant field names).
Those corrections are now integrated directly into Section 16.3 and
Section 16.3.1, which are the single normative source for the per-object
snapshot schema — this entry is kept only as a historical pointer.
D.1.8. SnapSchema, SnapExtension, SnapType, SnapSequence, SnapRole
Complete records for all named sub-object types:
SnapSchema:
{
"name": "analytics",
"owner": "analytics_role",
"comment": "Derived tables",
"renamed_from": null
}
SnapExtension:
{
"name": "pgcrypto",
"schema": null,
"version": null
}
SnapType (ENUM):
{
"schema": "public",
"name": "invoice_status",
"variant": "ENUM",
"values": ["draft", "sent", "paid", "void", "overdue"],
"comment": "Billing lifecycle states"
}
SnapType (COMPOSITE):
{
"schema": "public",
"name": "address",
"variant": "COMPOSITE",
"composite_attrs": [
{ "name": "street", "type": "text" },
{ "name": "city", "type": "text" }
]
}
SnapSequence:
{
"schema": "public",
"name": "order_number_seq",
"unlogged": false,
"owner": null,
"comment": null,
"grants": [],
"revocations": [],
"increment_by": 1,
"min_value": 10000,
"max_value": 99999999,
"no_min_value": false,
"no_max_value": false,
"start_value": 10000,
"cache": 50,
"cycle": false,
"as_type": "bigint",
"owned_by": null,
"security_labels": [],
"name_maps": []
}
SnapRole:
{
"name": "app_service",
"can_login": true,
"superuser": false,
"create_db": false,
"create_role": false,
"inherit": true,
"is_replication": false,
"bypass_rls": false,
"connection_limit": null,
"password_hash": "sha256:...",
"valid_until": null,
"comment": null,
"memberships": [],
"configs": [],
"security_labels": [],
"name_maps": []
}
Note: unlike Sequence’s other object-level fields above, Role
attributes (LOGIN, PASSWORD, CONNECTION LIMIT, etc.) ARE stored
in the snapshot, not just the name and comment — PasswordHash in
particular persists a hash of the declared PASSWORD text (literal
or {{secret-uri}} reference) so that password drift is detectable
offline, without a live connection. The differ compares each
attribute against this stored state, the same targeted-diff treatment
every other object kind gets, not name-presence-only.
D.1.9. Cluster-Level Snapshot File
Cluster-level objects (roles, tablespaces) are stored in a SEPARATE snapshot file from database-level objects. The path is:
.dpg/snapshots/<cluster-name>/_cluster.json
The database field in the top-level snapshot record is absent
(empty string / omitted) for cluster-level snapshots. The compiler
identifies a snapshot as cluster-level when database is empty.
D.2. dpg plan Corrections Integrated; Target Auto-Selection Rules (amends Section 18, Section 3.6)
D.2.1. dpg plan / dpg validate / –env — Corrections Integrated
The dpg plan flag corrections (--format text|json, not sql;
the --watch flag), the identical dpg validate --format
correction, and the --env flag / .env-loading algorithm are now
integrated directly into Section 18 (the global options intro) and Section 18.1 —
those are the single normative source; this entry is kept only as a
historical pointer.
D.2.2. Target Auto-Selection Rules
When --cluster or --database are not specified, the compiler
applies the following auto-selection algorithm:
Cluster auto-selection:
- If there is exactly one cluster in the project, it is selected
automatically. No
--clusterflag is required. - If there are multiple clusters and
--clusteris not set, the compiler MUST emit error DPG-E026 listing all available cluster names. - If
--clusteris set to a name that does not exist, the compiler MUST emit error DPG-E027 with the available cluster names.
Database auto-selection (within a selected cluster):
- If there is exactly one database in the cluster, it is selected automatically.
- If there are multiple databases and
--databaseis not set, the compiler MUST emit error DPG-E028 listing the available database names. - If
--databaseis set to a name that does not exist, the compiler MUST emit error DPG-E029 with the available database names.
These rules apply to: dpg plan, dpg apply, dpg verify,
dpg dump.
D.2.3. dpg plan –format json — Output Schema Integrated
The --format json output schema is now documented directly in
Section 18.1, which is the single normative source; this entry is kept
only as a historical pointer.
D.3. Linter Rule ID Corrections (amends Section 19)
The actual built-in linter rule identifiers use hyphens, NOT
underscores. Section 19.1’s table also predates two rule identifiers being
split apart and two others being renamed (both corrected below) — the
corrected, complete rule ID table, matching internal/linter/linter.go
exactly as of this writing:
| Rule ID (actual) | Description | Default Level |
|---|---|---|
hardcoded-password | A table column’s DEFAULT contains a hardcoded string, for a column whose name contains password, passwd, pwd, secret, or passphrase (case-insensitive). | Error |
hardcoded-role-password | A ROLE’s PASSWORD is a literal value with no {{secret-uri}} placeholder. A separate rule from hardcoded-password above — different check, different object kind — despite Section 19.1’s table conflating both under one hardcoded_password entry. | Error |
hardcoded-fdw-password | A USER MAPPING’s OPTIONS has a literal value with no {{secret-uri}} placeholder under a password-like key (password/passwd/pwd/secret/passphrase, matched case-insensitively as a substring — the same 5-key list as hardcoded-password above and dump’s own OPTIONS redaction, Section 14.10). | Error |
deprecated | Object or column is marked DEPRECATED. Applied to tables, columns, views, functions. A different check from deprecated-reference below (that object/column being deprecated, vs. something else referencing it) — see the note below. | Warning |
missing-column-comment | Column lacks a COMMENT when require_column_comments = true. Renamed from require-column-comments (Section 19.1 named this missing_column_comment; the actual code now matches that wording, kebab-cased). | Warning |
column-count-exceeded | Table exceeds max_columns_per_table columns. Renamed from max-columns (Section 19.1 named this column_count_exceeded; the actual code now matches that wording, kebab-cased). | Error |
security-definer-search-path | SECURITY DEFINER function body does not reference search_path. | Warning |
serial-sequence-declared | A hand-declared SEQUENCE collides with the name PostgreSQL auto-manages for a GENERATED ... AS IDENTITY column’s sequence, or for a SERIAL/BIGSERIAL/SMALLSERIAL column’s owned sequence (<table>_<column>_seq in both cases) in the same desired state. Renamed from Section 19.1’s serial_sequence_declared; originally scoped to IDENTITY only, extended to cover SERIAL once ir.Column.Serial was added — see the note below and Appendix D.11. | Warning |
unnecessary-revocation | A REVOCATIONS entry names a (role, privilege) pair with no matching GRANTS entry in the same object’s own declaration. Renamed from Section 19.1’s unnecessary_revocation; narrower in scope than that entry’s wording — see the note below. | Warning |
deprecated-reference | A non-deprecated FOREIGN KEY references a deprecated table/column, or a non-deprecated column/function-parameter/function-return-type references a deprecated custom TYPE. Renamed from Section 19.1’s deprecated_reference; deliberately narrower in scope than that entry’s wording — see the note below. | Warning |
scalar-merge-conflict | Two files declare the same object and provide different values for the same scalar property (e.g. two files each set TABLE t’s OWNER to a different role). Renamed from Section 19.1’s scalar_merge_conflict; the winning (alphabetically-last-file) value is applied regardless — this rule only adds visibility, per Section 3.7. See the note below for exact per-kind scope. | Warning |
min-pg-version | A declared construct requires a PostgreSQL major version newer than the project’s effective min_pg_version (Section 3.8). Not a rename or split of a Section 19.1 entry — a wholly new rule, added alongside min_pg_version itself, not present when Section 19.1 was first written. | Warning |
Implementation note on hardcoded-password vs. hardcoded-role-password:
the column rule checks a table column’s DEFAULT expression: if the
column name contains any of the substrings password, passwd, pwd,
secret, or passphrase (case-insensitive), AND the default
expression is a single-quoted string literal (starts with '), the
linter emits hardcoded-password as an error. The role rule is
unrelated: it checks ROLE ... PASSWORD directly (a structured field,
not a text-pattern match) for the absence of any {{...}} placeholder.
Note on the remaining Section 19.1 rules’ actual implementation status:
three of them — stale_renamed_from (DPG-E021), unguarded_enum_removal
(DPG-E014), and protected_drop_attempt (DPG-E022) — are fully
implemented and tested, but not as Linter.Lint-interface rules: each
is a hard error returned directly from Differ.Diff during the
desired-vs-snapshot comparison (internal/diff/differ.go), which aborts
the plan/apply outright rather than surfacing as a downgradable
diagnostic. This is a real, deliberate architectural difference from
the built-in linter rules, not a gap: stale_renamed_from and
unguarded_enum_removal both fundamentally need a desired-vs-snapshot
comparison to detect what they detect (a stale rename target, a
removed enum value), which the differ already does every run and the
linter — see Section 19.1’s own Linter.Lint(objects []IRObject, cfg LinterConfig) signature — does not have access to at all today.
(A prior revision of this note incorrectly stated these two had no
implementation anywhere; corrected after re-reading differ.go
directly.)
Of the remaining four, serial_sequence_declared,
unnecessary_revocation, deprecated_reference, and
scalar_merge_conflict are now all implemented — the first three as
Linter.Lint-interface rules (see below for their exact, and in
unnecessary_revocation’s and deprecated_reference’s cases
deliberately narrowed, scope). scalar_merge_conflict is architecturally
different: it is computed by pipeline.Merger.Merge itself (the merge
stage runs before Linter.Lint ever sees the objects, and the conflict
needs to see the pre-merge, per-file values Linter.Lint’s
already-merged []IRObject input has thrown away), not by
internal/linter’s own checkObject/checkCrossObjectRules dispatch —
see below for the full architecture.
serial_sequence_declared: warns when a hand-declared SEQUENCE
object’s name collides with the auto-managed sequence name PostgreSQL
generates for an identity or SERIAL-family column in the same desired
state (<table>_<column>_seq). Originally scoped to GENERATED ... AS IDENTITY columns only, using ir.Column.Identity; now also triggers on
ir.Column.Serial now that SERIAL/BIGSERIAL/SMALLSERIAL are
modeled as a distinct IR concept — see Appendix D.11 for the full
Column.Serial specification, including why this was previously
out of scope (SERIAL passed through as a literal type named "serial"
with no auto-sequence relationship recorded at all).
unnecessary_revocation: scoped to a single object’s own
declaration — warns when a REVOCATIONS entry names a
(role, privilege) pair with no matching GRANTS entry for that same
pair in the same object’s declaration. This is narrower than this
section’s literal wording (“a role that was never granted the
privilege by DPG”), which would require snapshot/grant-history access
the linter does not have (see above) — the within-object scope still
catches the common real mistake (a copy-pasted or typo’d revocation
with no corresponding grant) without requiring an interface change.
deprecated_reference: warns when a non-deprecated object
references a deprecated one. Deliberately narrow v1 scope, chosen
because the alternative — a lint-rule false positive — is a visible
user-facing warning, unlike an over-matching edge in internal/graph’s
dependency-ordering heuristics (harmless there; wrong here):
- A FOREIGN KEY on a non-deprecated table referencing a deprecated
table, or referencing a specific deprecated column of the target
table (via ir.Constraint.RefSchema/RefTable/RefColumns,
structured fields populated directly from the parsed REFERENCES
clause at build time — not a re-parse of the constraint’s rendered
SQL text).
- A non-deprecated column, FUNCTION parameter/return type, or
PROCEDURE parameter, whose declared type is a deprecated custom
TYPE. An unqualified type reference resolves against the
referencing object’s own schema, the same convention
ir.TypeRef.Schema uses everywhere else; a real PostgreSQL built-in
type never accidentally matches, since matching requires an actual
index hit against a table of currently-deprecated custom types.
- The gating gate is per-referencing-object: an already-deprecated
table’s own FOREIGN KEY to another deprecated table does not also
fire this rule (its own deprecated diagnostic already covers it),
and likewise for an already-deprecated column/function referencing
a deprecated type.
NOT covered in v1 — flagged as a real, explicit residual rather
than silently out of scope: a VIEW’s query referencing a deprecated
table/column, a function/procedure body referencing a deprecated
object, and a column DEFAULT expression referencing a deprecated
object/function. All three would need either genuine SQL-AST analysis
(no parser exists today for any of these opaque text blobs — view
queries, function bodies, and default expressions are all stored as
raw text) or a regex/text-scan heuristic, which risks exactly the
false-positive-warning problem this rule design otherwise avoids.
The [linter.rules] per-rule severity-override mechanism described in
Section 19.2 is now implemented, following the existing --strict promotion
pattern (LintDiagnostic.IsError) for "error", and a post-filter
step for "off".
scalar_merge_conflict: unlike every other rule in this table,
this one is computed inside pipeline.Merger.Merge (internal/merger),
not internal/linter’s object-dispatch. Merger.Merge returns a second
value, []LintDiagnostic, always populated regardless of
warn_on_scalar_merge_conflict — gating (the config flag, and any
[linter.rules] override) happens once, centrally, in
internal/linter.FilterMergeDiagnostics, mirroring how
ApplyRuleSeverityOverrides is the one place [linter.rules] gating
lives for Linter.Lint’s own diagnostics. Every CLI command that calls
compiler.Compile (plan, apply, validate) combines
FilterMergeDiagnostics’s output with Linter.Lint’s diagnostics into
one slice before printing/--strict-promoting, so a scalar-merge-conflict
warning behaves identically to any other lint warning from the user’s
perspective, including under --strict, despite the different
implementation location. pkg/dpg’s public Go API re-exports both
FilterMergeDiagnostics and ApplyRuleSeverityOverrides so external
consumers of dpg.Compile can replicate the same gating without
reaching into internal/linter, which they cannot import.
A conflictTracker (internal/merger/merger.go) is threaded through
every scalar field this package merges: for each property, it records
which file most recently supplied the property’s current value, and
emits a diagnostic whenever a later file supplies a genuinely different
value for the same property — while still always applying last-file-wins
regardless (Section 3.7: “the winning value is used regardless”). Tracking is
by last setter, not merely “compare to the immediately preceding
file”: if file A sets a property, file B reconfirms the same value, and
file C then disagrees, the diagnostic correctly attributes the conflict
to file B (the file that actually owns the current value), not file A.
Scope, mirroring exactly which fields each mergeX function already
treats as a last-file-wins scalar (Section 3.7’s own examples — owner,
comment, tablespace, RLS flags, PROTECTED, DEPRECATED,
DROP CASCADE, RENAMED FROM, drop behaviour — plus the equivalent
settable properties for kinds not in that list): TABLE, VIEW,
FUNCTION, SCHEMA, TYPE (including DOMAIN’s BaseType/Default
fields — NotNull is OR-merged like every other boolean flag, not
last-wins, so it is never conflict-checked), PROCEDURE, AGGREGATE,
SEQUENCE, EXTENSION, ROLE, TABLESPACE, FOREIGN DATA WRAPPER,
SERVER, and USER MAPPING. A USER MAPPING/FDW/SERVER’s OPTIONS
list is checked key-by-key (each key is its own scalar property,
addressed as options[key] in the diagnostic) rather than as one
opaque blob, so two files setting different, non-colliding keys never
conflict. Set-valued properties (indexes, constraints, grants,
ROLE’s IN ROLE/ROLE/ADMIN membership lists, etc.) are unaffected
— they already use Section 3.7’s union semantics, not last-wins, so there is
nothing to conflict-check.
NOT covered — the opaque/reconstruction-tier object kinds
(PUBLICATION, EVENT TRIGGER, COLLATION, CAST, OPERATOR and
OPERATOR CLASS/FAMILY, the TEXT SEARCH kinds, STATISTICS, and
similarly-shaped kinds) still fall through to mergeGroup’s blind
grp[len(grp)-1] last-object-wins default with no field-level merging
or conflict detection at all — unchanged from before this rule existed,
and a real, explicit residual rather than an oversight: extending
conflictTracker to these kinds is future work, not attempted in this
round.
D.4. Pipeline Registry Key Constants (amends Section 15)
The pipeline.Registry component system uses the following string
keys to register and resolve pipeline components:
| Key constant | Interface | Default implementation |
|---|---|---|
pipeline.KeyTokenizer | pipeline.Tokenizer | internal/scanner |
pipeline.KeyPGSQLParser | pipeline.PGSQLParser | internal/pgparser.LibPQParser |
pipeline.KeyBlockParser | pipeline.BlockParser | internal/blockparser |
pipeline.KeyIRBuilder | pipeline.IRBuilder | internal/ir.Builder |
pipeline.KeyMerger | pipeline.Merger | internal/merger |
pipeline.KeyDependencyResolver | pipeline.DependencyResolver | internal/graph |
pipeline.KeySnapshotStore | pipeline.SnapshotStore | internal/snapshot.FileStore |
pipeline.KeyDiffer | pipeline.Differ | internal/diff.Differ |
pipeline.KeyEmitter | pipeline.Emitter | internal/emit.Emitter |
pipeline.KeyApplyExecutor | pipeline.ApplyExecutor | internal/executor.PgxExecutor |
pipeline.KeyIntrospector | pipeline.Introspector | internal/introspect.CatalogIntrospector |
pipeline.KeyLinter | pipeline.Linter | internal/linter.BuiltinLinter |
pipeline.KeyPortabilityAnalyzer | pipeline.PortabilityAnalyzer | internal/portability.Analyzer |
pipeline.KeySecretResolver | pipeline.SecretResolver | internal/secrets.ChainResolver (with env: wired in) |
Each default implementation registers itself in its package’s init()
function. Alternative implementations MAY be registered using
pipeline.Default.Register(key, impl) before any command runs.
The pipeline.Default registry is a package-level singleton.
pipeline.MustResolve[T] panics if the component is not registered.
pipeline.Resolve[T] returns (T, bool) and does not panic.
D.5. SecretResolver Protocol Specification (amends Section 3.3)
The pipeline.SecretResolver interface resolves a URI string to a
plaintext secret value at connection time. The interface is:
type SecretResolver interface {
Resolve(uri string) (string, error)
}
ChainResolver (internal/secrets.ChainResolver) is the default
registered resolver. It is a scheme-keyed dispatch table, not a
fallback chain that tries multiple resolvers per URI in sequence: a
secret URI’s scheme unambiguously names the one resolver responsible
for it, so there is never a genuine “which one applies” question for a
chain to resolve by trial. Register(scheme, resolver) is the
extension point the backends below plug into (each self-registers from
its own subpackage’s init(); the subpackage must be imported,
typically via a blank import in cmd/dpg/main.go, for its scheme to be
available). No other scheme is recognised; link URIs with an
unrecognized scheme return an error at resolution time naming the
scheme and listing every scheme currently registered.
Authentication for every backend below is entirely ambient — each resolver defers to that provider’s own standard credential chain (env vars, config files, instance/managed identity, CLI-cached login, …) and never reimplements or wraps it. Constructing a resolver never makes a network call and never fails just because that backend isn’t configured; only actually resolving a URI for that scheme can fail.
env:<VAR_NAME> (internal/secrets.EnvResolver)
Resolves to os.Getenv(VAR_NAME). If the variable is not set
(empty string), the resolver returns an error. The variable name
MUST consist only of ASCII letters, digits, and underscores.
Example: link = "env:PRODUCTION_DB_URL" → resolves PRODUCTION_DB_URL
from the process environment (which may have been populated from the
.env file per Section 18’s global --env option).
vault:<mount>/<path>#<field> (internal/secrets/vault.Resolver)
Reads a HashiCorp Vault KV version 2 secret (VAULT_ADDR/VAULT_TOKEN
ambient auth, the same convention the vault CLI uses). mount is the
KV v2 engine’s mount path, path is the secret’s path within it. The
#field suffix is REQUIRED — a Vault secret is always a key-value map,
never a bare string, so there is no unambiguous “whole value” reading
to fall back to. Example: vault:secret/myapp/db#password.
aws-sm:<secret-id>[#<json-field>] (internal/secrets/awssm.Resolver)
Reads an AWS Secrets Manager secret via the AWS SDK’s default
credential chain. secret-id is the secret’s ARN or name (may contain
/). Without #json-field, resolves to the secret’s raw
SecretString. With it, SecretString is parsed as JSON and that key
extracted. Examples: aws-sm:myapp/prod/db-password,
aws-sm:myapp/prod/db#password.
gcp-sm:<project>/<secret-id>[/<version>][#<json-field>]
(internal/secrets/gcpsm.Resolver)
Reads a GCP Secret Manager secret via Application Default Credentials.
version defaults to "latest" if omitted. Without #json-field,
resolves to the raw payload as UTF-8 text; with it, the payload is
parsed as JSON and that key extracted. Example:
gcp-sm:my-project/db-password.
azure-kv:<vault-name>/<secret-name>[/<version>][#<json-field>]
(internal/secrets/azurekv.Resolver)
Reads an Azure Key Vault secret via DefaultAzureCredential.
vault-name resolves to https://<vault-name>.vault.azure.net/.
version omitted (or empty) means the latest version. Without
#json-field, resolves to the raw secret value; with it, the value is
parsed as JSON and that key extracted. Example:
azure-kv:my-vault/db-password.
Embedding a reference inside a literal: {{<secret-uri>}}
(pipeline.ResolveTemplate)
The five schemes above resolve a URI that occupies an entire field
(link in dpg.toml). Some fields are themselves a larger literal that
only a part of should come from a secret — a SUBSCRIPTION CONNECTION
conninfo string being the first case (Section 13.2): most of it (host, dbname,
user) is not sensitive, and forcing the whole value into a secret
(duplicating the non-sensitive parts into the backend, or requiring a
backend-side template) is worse than just resolving the one sensitive
part in place. pipeline.ResolveTemplate(s, resolver) scans s for
{{<secret-uri>}} placeholders — any of the six schemes above, or a
future one — and replaces each with resolver.Resolve(<secret-uri>),
leaving everything else untouched; s with no {{...}} at all never
touches the resolver, so a plain literal has zero behavioral change and
zero performance cost. This is a general mechanism, not
SUBSCRIPTION-specific — also used by Role PASSWORD (Section 11.1) and User
Mapping OPTIONS (Section 14.10).
{{...}} is deliberately the only trigger for resolution in such a
field: unlike link, a real literal in one of these fields may itself
contain a : (a conninfo string can be a postgresql://user:pass@host/db
URI), so treating any colon-prefixed substring as a candidate secret
reference would risk misreading a literal as broken. Curly braces never
appear in legitimate conninfo/option syntax, so {{...}} cannot collide.
D.6. Source Revision Detection (amends Section 16.2)
The source_revision field in the snapshot and migration header is
populated by reading .git/HEAD directly (no git binary required).
Algorithm:
Read
.git/HEADrelative to the current working directory. If not present,source_revisionis empty string.If the content starts with
"ref: ", strip the prefix and read the file at.git/<ref-path>. Example:"ref: refs/heads/main"→ read.git/refs/heads/main.Otherwise, the content itself is the commit hash (detached HEAD).
Trim whitespace. If the result is at least 7 characters, take the first 7 characters as the short hash. Otherwise, empty.
This method works without the git binary and without any network
access. It is intentionally simple; it does not handle packed refs
or shallow clones differently from full clones.
D.7. Additional CLI Error Codes (extends Appendix C)
DPG-E026–E029 and DPG-E032–E035, introduced by this addendum, are now listed directly in Appendix C’s main table alongside every other error code — that table is the single normative reference; this entry is kept only as a historical pointer to when and why each code was added (see Appendix E, entry E.7).
D.8. Root dpg.toml — [fmt]/[migrations] Integrated (amends Section 3.2)
[fmt] and [migrations] were originally documented only here,
omitted from Section 3.2’s own example. Both are now integrated directly
into Section 3.2, which is the single normative source for the root
dpg.toml schema; this entry is kept only as a historical pointer.
D.9. CLI Command Corrections (amends Section 18)
D.9.1. dpg validate, dpg portability, dpg init, dpg fmt — corrections integrated
Earlier drafts of Section 18.6, Section 18.7, Section 18.8, and Section 18.9 documented flag sets, defaults, and behavior that didn’t match the reference implementation. Those corrections (originally recorded here as D.9.1–D.9.4) have since been integrated directly into Section 18.6, Section 18.7, Section 18.8, and Section 18.9 themselves — those sections are now the single normative source for each command’s interface, and this entry is kept only as a historical pointer so a reader following an old cross-reference to “D.9.x” lands somewhere useful. See Appendix E, entries E.9 and the entry documenting this integration, for when each correction was made.
D.10. Name Maps
Name Maps provide a mechanism for attaching tool-specific naming conventions to DPG schema objects. They allow downstream consumers — ORM generators, type-safe query builders, API code generators — to transform DPG identifier names into the naming convention appropriate for their target language or framework, without requiring any changes to the DPG source files.
Name Maps operate at two orthogonal layers:
- Configuration layer (
dpg.toml): project-wide defaults, settable at root, cluster, and database scope. - Block layer (the
{ }DPG block): per-object or per-column overrides declared inline in the source file.
Resolution is most-specific-wins: block-level beats database config, database config beats cluster config, cluster config beats root config. Resolution is applied independently per tool key per object type.
D.10.1. Rule Keywords
DPG defines a closed set of naming-convention rule keywords. All keywords are case-insensitive in TOML config but stored and validated in their canonical upper-case form. A rule keyword MUST be written without quotation marks in the DPG block syntax.
| Rule Keyword | Example transformation of user_profile_id |
|---|---|
LOWER_SNAKE_CASE | user_profile_id |
UPPER_SNAKE_CASE | USER_PROFILE_ID |
LOWER_CAMEL_CASE | userProfileId |
UPPER_CAMEL_CASE | UserProfileId |
LOWER_KEBAB_CASE | user-profile-id |
UPPER_KEBAB_CASE | USER-PROFILE-ID |
TRAIN_CASE | User-Profile-Id |
LOWER_CASE | userprofileid |
UPPER_CASE | USERPROFILEID |
PASCAL_SNAKE_CASE | User_Profile_Id |
A literal target name (not a rule) is written as a double-quoted
identifier in the DPG block syntax (e.g., "MySpecialName").
Literal names are stored verbatim in the snapshot and are passed
through to consumers unchanged. Literal names are only supported
in the block layer, not in dpg.toml.
The reserved tool key default MUST NOT be used as an actual tool
name. It represents the catch-all rule applied by any tool for
which no explicit rule is configured.
D.10.2. Configuration Layer (dpg.toml)
All three configuration files (root, cluster, database) accept a
[namemaps] section. Within this section:
- Scalar string entries are global rules keyed by tool name.
- Subtable entries (
[namemaps.<type>]) are per-object-type rules keyed by tool name, where<type>matches the DPG object-type identifier (e.g.,column,table,view).
Deeper configuration scopes override shallower ones for the same (tool, type) pair.
# Root dpg.toml — project-wide defaults
[namemaps]
default = "LOWER_SNAKE_CASE" # catch-all for all tools
prisma = "LOWER_CAMEL_CASE" # Prisma-specific global rule
drizzle = "LOWER_CAMEL_CASE"
[namemaps.table]
prisma = "UPPER_CAMEL_CASE" # Prisma table names: PascalCase
drizzle = "LOWER_CAMEL_CASE" # Drizzle table names: camelCase
[namemaps.column]
prisma = "LOWER_CAMEL_CASE"
drizzle = "LOWER_CAMEL_CASE"
# Cluster or database dpg.toml — narrows/overrides root config
[namemaps]
sqlc = "LOWER_SNAKE_CASE"
[namemaps.function]
sqlc = "LOWER_SNAKE_CASE"
Rules MUST be one of the ten keywords listed in Appendix D.10.1. Unknown
keywords MUST cause a validation error (DPG-E030). Literal names
are NOT supported in dpg.toml; only rule keywords are accepted.
D.10.3. Block Layer — Inline Directives
Name map directives appear inside any DPG { } block (object-level
or column-level). Two forms are supported.
Singular form:
NAME MAP TO <rule> ;
NAME MAP <tool> TO <rule> ;
NAME MAP <tool> TO "LiteralName" ;
- When
<tool>is omitted, the directive applies to thedefaulttool key. <rule>is an unquoted rule keyword (case-insensitive)."LiteralName"is a double-quoted literal target identifier. Double quotes follow the PostgreSQL identifier quoting convention (identifiers, not string values).
Grouped form:
NAME MAPS {
<tool> TO <rule> ;
<tool> TO "LiteralName" ;
...
}
- The
NAME MAPS { }form requires explicit tool names; the implicitdefaultshorthand is NOT available inside the group. - Multiple singular
NAME MAPdirectives andNAME MAPSblocks MAY be mixed freely within the same{ }block. - If the same tool key appears more than once in a single
{ }block the last entry wins.
Examples:
TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY,
email TEXT NOT NULL,
username TEXT NOT NULL
) {
-- All tools: use LOWER_SNAKE_CASE (default catch-all)
NAME MAP TO LOWER_SNAKE_CASE;
-- Prisma and drizzle: override to camelCase
NAME MAPS {
prisma TO LOWER_CAMEL_CASE;
drizzle TO LOWER_CAMEL_CASE;
}
COLUMNS {
username {
-- Override: literal name for the sqlc tool only
NAME MAP sqlc TO "UserName";
}
}
}
ENUM user_status ('active', 'inactive', 'banned') {
NAME MAP prisma TO UPPER_CAMEL_CASE;
}
D.10.4. Snapshot Representation
Name maps are serialised to the snapshot name_maps array field
present on all typed snap objects (SnapTable, SnapColumn,
SnapView, SnapFunction, SnapType, SnapSchema, SnapExtension,
SnapSequence, SnapRole, SnapVirtualType, SnapOpaque).
Each entry is a SnapNameMapEntry:
{
"tool": "prisma",
"rule": "LOWER_CAMEL_CASE"
}
or, for a literal name:
{
"tool": "sqlc",
"name": "UserName"
}
Fields:
| Field | Type | Required | Description |
|---|---|---|---|
tool | string | YES | Tool key (e.g., "prisma", "default"). |
rule | string | one of rule/name | One of the ten rule keywords. Present when the value is a rule. |
name | string | one of rule/name | Literal target identifier. Present when the value is a literal. |
Exactly one of rule or name MUST be non-empty. Both being
populated in the same entry is invalid. The name_maps field is
omitted entirely from the JSON when the array is empty (omitempty).
The differ does not generate SQL for name map changes — name maps
are metadata annotations consumed by downstream tools, not by
PostgreSQL. A change to name_maps triggers a snapshot update
only (no SQL emitted, no migration step added).
D.10.5. Error Codes
| Error ID | Code String | Condition |
|---|---|---|
| DPG-E030 | invalid_namemap_rule | Unknown rule keyword in [namemaps] config or NAME MAP block directive. |
| DPG-E031 | duplicate_namemap_tool | Same tool key specified more than once at the same block level (warning only; last entry wins). |
D.11. SERIAL / BIGSERIAL / SMALLSERIAL Column Sugar (amends Section 7.2)
Section 7.2’s col-constraint ABNF and surrounding prose describe
GENERATED ALWAYS/BY DEFAULT AS IDENTITY in detail but say nothing
about PostgreSQL’s older SERIAL/BIGSERIAL/SMALLSERIAL
pseudo-types, which are syntactically just an ordinary type-ref
(Appendix A) and so were always accepted by the parser. Until this
revision, DPG gave them no distinct treatment anywhere past parsing:
a column declared SERIAL passed through the compiler as a literal
type named "serial" — not a real PostgreSQL catalog type — with no
record of the owned-sequence relationship, and diffing a live
SERIAL column against such a snapshot produced spurious
ALTER COLUMN TYPE drift. This section specifies the sugar’s actual
modeled behavior.
Normalization: the compiler MUST rewrite a column’s declared
type from a SERIAL-family spelling to the real underlying integer
type PostgreSQL itself stores, per the following table, and record
which spelling was used as a sibling marker rather than replacing
Column.Type:
| Declared spelling(s) | Underlying Column.Type | Marker (Column.Serial) |
|---|---|---|
SERIAL, SERIAL4 | integer | "SERIAL" |
SMALLSERIAL, SERIAL2 | smallint | "SMALLSERIAL" |
BIGSERIAL, SERIAL8 | bigint | "BIGSERIAL" |
Column.Serial is a sibling marker on the IR Column type,
analogous to Column.Identity: it augments Column.Type rather than
replacing it, so every other part of the pipeline that reasons about
a column’s real storage type (index/constraint compatibility checks,
cast validation, dump rendering of dependent objects) continues to
see the true underlying integer type. A schema-qualified type merely
named serial (e.g. myschema.serial) is a real user type reference
and MUST NOT be mistaken for the pseudo-type — only an unqualified,
case-insensitive match against the table above triggers this rule.
SERIAL implies NOT NULL: real PostgreSQL makes every
SERIAL-family column NOT NULL unconditionally, independent of
whether it is also PRIMARY KEY — the same shape as the existing
PRIMARY-KEY-implies-NOT-NULL rule in Section 7.2. The compiler MUST set
Column.NotNull = true for any SERIAL-family column regardless of
other constraints present in source.
Emitted DDL: CREATE TABLE and ADD COLUMN MUST render the
literal SERIAL/BIGSERIAL/SMALLSERIAL keyword for a column with
Column.Serial set, in place of the normalized underlying type, and
MUST suppress the NOT NULL and DEFAULT clauses that would
otherwise be rendered — PostgreSQL synthesizes both itself as part of
expanding the pseudo-type. This mirrors how GENERATED AS IDENTITY
columns already suppress those same clauses. ADD COLUMN for a
SERIAL-family column is classified SAFE (not CAUTION), the same
exemption already given to identity columns, since no data rewrite or
implicit cast is involved.
Introspection: a column is reconstructed as SERIAL-family when
its owning table has a pg_depend entry of deptype 'a'
(“auto”, i.e. internally-created and owned) linking it to a sequence
whose default expression is a nextval(...) call on that same
sequence, mirroring the existing GENERATED AS IDENTITY detection
shape. Column.Default is left nil in this case, exactly as it
already is for identity columns — the owned-sequence default is not
surfaced as an ordinary Default expression. A hand-rolled
owned-sequence-plus-nextval()-default on a non-integer column (legal
PostgreSQL, not SERIAL sugar) MUST fall through to ordinary
Default handling rather than being misdetected.
Dump reconstruction: dpg dump MUST render a reconstructed
SERIAL-family column using the literal keyword form above, not the
normalized underlying type with a hand-written
DEFAULT nextval('<table>_<column>_seq') — the latter is
non-reapplicable output, since dump does not separately declare the
owned sequence as its own SEQUENCE object (the same treatment
identity-backed sequences already receive).
Snapshot representation: SnapColumn gains an optional serial
string field (JSON key "serial", omitempty), populated with the
same three-value marker as Column.Serial. A snapshot written before
this revision has no serial field on any column and, for a
previously-declared SERIAL column, stores the literal type name
"serial"/"bigserial"/"smallserial" instead of the normalized
underlying type — the differ MUST recognize this legacy shape
specifically (declared column has Column.Serial set and the
snapshot’s stored type name is one of those three legacy spellings)
and treat it as already matching, rather than emitting a destructive
ALTER COLUMN TYPE. This is a one-time self-healing comparison: the
next snapshot write after any successful plan/apply stores the
normalized type and marker going forward, and the legacy-name check
never applies again for that column.
Linter interaction: the serial-sequence-declared rule
(Appendix D.3) triggers on Column.Serial in addition to
Column.Identity — a hand-declared SEQUENCE colliding with a
SERIAL column’s auto-managed sequence name is exactly as much a
mistake as the identity case, and both share the same
<table>_<column>_seq naming collision surface.
Appendix E. Revision History
This appendix records all substantive changes to this document after its initial publication.
| Revision | Date | Description |
|---|---|---|
| E.1 | 2026-05-13 | Initial publication. Formal IETF-style RFC superseding the informal design document rfc/v0.8.0.md. All sections written from scratch with normative RFC 2119 language, ABNF grammars, and exhaustive per-object specifications. |
| E.2 | 2026-05-13 | Appendix D added. Corrections to Section 16 (snapshot wire format: SnapObject discriminated union, SnapOpaque, corrected field names), Section 18 (--format text default, --watch flag, .env loading protocol, planJSON schema, target auto-selection), Section 19 (linter rule IDs use hyphens). Pipeline Registry key constants table and SecretResolver protocol specification added. Source revision detection algorithm formalised. |
| E.3 | 2026-05-13 | Appendix D.8–Appendix D.9 added. Root dpg.toml [fmt] and [migrations] sections documented. CLI corrections: dpg validate JSON schema, dpg portability flag set, dpg init default cluster name ("production"), dpg fmt TOML key names. ToC updated to include Appendix D subsections. |
| E.4 | 2026-05-17 | Appendix D.10 added. Name Maps feature specified: ten rule keywords, [namemaps] TOML config at all three levels (global + per-object-type rules), inline NAME MAP and NAME MAPS block directives, literal target name support via double-quoted identifiers, resolution order (block > database > cluster > root), snapshot name_maps array field on all object types, error codes DPG-E030 and DPG-E031. |
| E.5 | 2026-08-16 | Appendix D.11 added. SERIAL/BIGSERIAL/SMALLSERIAL column sugar specified as a first-class IR concept (Column.Serial, sibling marker to Column.Type): normalization table, SERIAL-implies-NOT NULL rule, literal-keyword emission with suppressed NOT NULL/DEFAULT, pg_depend-based introspection detection mirroring identity columns, non-reapplicable dump output fixed, SnapColumn.serial field, and a legacy-snapshot self-healing comparison for pre-existing snapshots that stored the literal "serial" type name. Appendix D.3’s serial-sequence-declared entry updated: now also triggers on Column.Serial, not Column.Identity only. |
| E.6 | 2026-08-17 | Section 23 and Section 25 updated with four scope decisions that had previously been made and recorded only in project working notes, not in this document: CREATE ACCESS METHOD, CREATE CONVERSION, CREATE [PROCEDURAL] LANGUAGE, and CREATE TRANSFORM are all formally out of scope (covered either by the extension-install path DPG already manages, or by having no realistic hand-declared use case). Brings this document in line with the two sibling decisions (CREATE DATABASE, REASSIGN OWNED BY/DROP OWNED BY) it already documented. |
| E.7 | 2026-08-17 | Section 3.3, Section 3.4, and Section 3.6 updated: cluster and database name were already documented as REQUIRED but the reference implementation never enforced it. Section 3.6’s Discovery Algorithm now normatively requires validating that both names are non-empty and, per Sections 3.3/3.4’s new constraint clauses, unique — cluster names project-wide, database names per-cluster only (the same database name legitimately recurring under a different cluster remains valid). Four new error codes added to Appendix D.7: DPG-E032/E033 (empty name), DPG-E034/E035 (duplicate name). Also fixed a related implementation bug found alongside this: dpg dump’s default (no -o) output path was reconstructed from declared names rather than the already-resolved real directory, silently writing into a disconnected sibling directory whenever a project’s directory name and declared name diverged — no RFC section previously specified this path should prefer the resolved directory, so no corresponding text amendment was needed beyond the behavior fix itself. |
| E.8 | 2026-08-19 | Section 11.5 added. ALTER DEFAULT PRIVILEGES FOR ROLE (Section 11.4) never actually applied to anything DPG created, because PostgreSQL attributes default-privilege eligibility to whichever role executed CREATE, and DPG always created objects as its connecting role, reassigning ownership only afterward via ALTER ... OWNER TO. The compiler now creates every object with a declared OWNER (owner-dir; see Section 7.11 for Table’s copy — every object kind defines its own) directly as that role via SET ROLE/RESET ROLE, matching real PostgreSQL creator semantics; a new pre-flight membership check (pg_has_role) runs before any DDL executes and aborts with error DPG-E036 (added to Appendix C) if the connecting role is not a member of a declared OWNER. |
| E.9 | 2026-08-19 | Section 22.1 updated. Edge source 3 (a view’s query referencing table/view B) was documented as real query analysis but the reference implementation actually used a blunt “every view depends on every table in the object set” approximation — corrected to describe the real static analysis now backing it. New edge source 9 added: a LANGUAGE sql/plpgsql function or procedure body’s static table/view references now create real dependency edges too (function/procedure bodies were previously opaque to the dependency graph entirely, for every language); dynamic SQL is documented as an accepted blind spot, matching real PostgreSQL’s own inability to validate it either. |
| E.10 | 2026-08-19 | Section 7.9 updated. REFERENCING OLD TABLE AS ... NEW TABLE AS ... was already specified in this section’s ABNF grammar and worked example, but the reference implementation had no handling for it at all — a hard parse error, not a silent no-op. Now implemented end-to-end (parser, IR, differ, snapshot, introspection, dump). New informative-only prose added documenting PostgreSQL’s real constraints on REFERENCING (AFTER-only, no CONSTRAINT triggers, no views/foreign tables/TRUNCATE), confirmed live against PostgreSQL 17 — consistent with DPG’s existing stance of performing zero trigger clause-combination validation of its own. |
| E.11 | 2026-08-23 | Section 22.1 edge source 9 corrected. E.9 extended edge source 9 to LANGUAGE plpgsql bodies as well as sql, but combined with the pre-existing edge source 6 (table→trigger-function) this could construct a 2-node table/function cycle with zero FK edges in it — a shape Section 22.2’s DEFERRABLE-only cycle-breaker has no mechanism to resolve — for an entirely ordinary pattern: a validation/audit trigger function whose body queries its own table. Edge source 9 is now LANGUAGE sql-only, matching the reference implementation’s pre-existing (and correct) function-calls-function edge, which already exempted plpgsql for the identical reason: PostgreSQL compiles plpgsql lazily and never resolves embedded SQL against the catalog at CREATE FUNCTION time, so the edge was never actually required for a successful apply. Confirmed live against PostgreSQL 17: the reference implementation reproduced the cycle before this fix and applies cleanly after it. |
| E.12 | 2026-08-23 | Appendix F added: the full Standard SQL / PostgreSQL-specific classification Tenet 3 (Section 1.4) promises but never previously published anywhere in normative text — closes the gap the reference dpg portability command’s own design has relied on informally since initial publication. Section 1.4 also gained an explicit PostgreSQL version target (floor 14, no ceiling, rolling) — previously only implied by front-matter metadata, never stated in body text. Section 5.4 (Domain Types) rewritten: DEFAULT/CONSTRAINT ... CHECK/NOT NULL moved from the { } block into native Part 1 CREATE DOMAIN syntax, correcting a Tenet-5 self-violation identified by the RFC completeness audit’s finding #1 (this is a breaking syntax change for any existing .dpg source declaring a Domain with these clauses — reference implementation update tracked separately, not part of this revision). |
| E.13 | 2026-08-23 | Sections 7/21/25 updated: new grammar for Tables/Views/Indexes/Sequences closing the RFC-completeness audit’s largest cluster — LIKE, typed tables (OF type_name), USING access method, index ONLY/opclass parameters/NULLS [NOT] DISTINCT/rename, ATTACH/DETACH PARTITION [CONCURRENTLY] via new ATTACHED FROM/DETACHED FROM directives, REPLICA IDENTITY, CLUSTER ON, trigger enable-state, constraint rename and deferrability-only ALTER CONSTRAINT, generated-column/identity-column ALTER paths, Sequence UNLOGGED/OWNED BY NONE/RESTART/REVOCATIONS. Introduced the generic cross-schema SET SCHEMA mechanism (renamed-from-dir: identifier → qual-name, additive) reused by every later revision below. Folded in Table LOGGED/UNLOGGED DESTRUCTIVE→safe-ALTER swap (Section 7.12). |
| E.14 | 2026-08-23 | Sections 5/9/21/25 updated: new grammar for Types/Domains/Functions/Procedures/Aggregates. ENUM positional ADD VALUE/RENAME VALUE; Composite attribute COLLATE and a { } block it previously entirely lacked; Range/Base type { } blocks (owner/rename) added likewise; Domain COLLATE/NOT VALID/VALIDATE CONSTRAINT/RENAME CONSTRAINT; Base type bare forward-declaration shell plus automatic shell-then-redefine cycle-breaking for self-referential support functions; Function/Procedure LEAKPROOF, TRANSFORM FOR TYPE, C/internal AS body forms, PG14+ BEGIN ATOMIC, [NO] DEPENDS ON EXTENSION, OWNER, REVOCATIONS; Aggregate agg-options formally defined (previously referenced but never enumerated) with RENAME TO/OWNER TO/SET SCHEMA as its only non-destructive ALTER operations, matching real PostgreSQL’s actual ALTER AGGREGATE surface. |
| E.15 | 2026-08-23 | Sections 7/11/14/21/25 updated: new Access Control grammar. Table MAINTAIN privilege (PG17) and GRANTED BY role; Trigger [NO] DEPENDS ON EXTENSION; Role WITH ADMIN/INHERIT/SET membership modifiers (PG16+), SET/RESET [IN DATABASE] session config, RENAMED FROM (closing a rename gap that could be genuinely impossible to work around via drop-and-recreate, since PostgreSQL refuses to drop a role that owns objects); Event Trigger gained a { } block for the first time (enable-state, OWNER, RENAME TO). New Section 11.6 Parameter Privileges: GRANT {SET|ALTER SYSTEM} ON PARAMETER, explicitly distinguished from the ALTER SYSTEM command itself remaining out of scope (Section 23). |
| E.16 | 2026-08-23 | Sections 6/7/12/13/14/21/25 updated. Full-Text Search: TS Configuration OWNER/ALTER MAPPING REPLACE; TS Dictionary/Parser/Template gained { } blocks for the first time. Namespace/Storage/FDW/Replication: Publication OWNER/RENAMED FROM; Foreign Server gained a { } block plus bare VERSION-only change; Tablespace RENAMED FROM and post-creation OWNER diffing; Collation gained a { } block, PG16+ RULES, and REFRESH VERSION (PG15+); Foreign Table’s previously entirely-missing diffing table added (verified directly against internal/diff/differ.go: SERVER change is DESTRUCTIVE, real PostgreSQL has no ALTER FOREIGN TABLE clause for it); ALTER EXTENSION ADD/DROP member_object added as an explicit non-goal. Also closed the last 4 of 5 Phase-5 DESTRUCTIVE→safe-ALTER swaps identified in the 2026-08-18 audit: Extension SET SCHEMA, Base type property changes (ALTER TYPE ... SET (...), diffing model changed from a single opaque hash to per-key comparison), TS Dictionary option changes, and RLS Policy TO/USING/WITH CHECK-only changes via ALTER POLICY (closing a real safety gap: drop-and-recreate previously opened a window with zero active policy for that command). |
| E.17 | 2026-08-23 | Sections 5-7/11/14/21/25 updated: newer-PostgreSQL-version grammar (PG15-18), closing the RFC-completeness audit’s last cluster. Generated-column VIRTUAL (PG18); NOT NULL ... NO INHERIT; ENFORCED/NOT ENFORCED on CHECK/FOREIGN KEY (PG18); WITHOUT OVERLAPS/PERIOD temporal keys (PG18, SQL:2011) with a new PERIOD FOR column-item production; table-level named NOT NULL constraint with its own NOT VALID/VALIDATE CONSTRAINT/[NO] INHERIT lifecycle (PG18); FK ON DELETE/ON UPDATE SET NULL/SET DEFAULT (col-list) (PG15); SET STATISTICS DEFAULT (PG17); Event Trigger login event (PG17, verified against PostgreSQL’s own documentation). Confirmed (not gaps, no grammar change) that EXCLUDE/identity columns on partitioned tables (PG17), foreign-table TRUNCATE triggers (PG16) and NOT NULL (PG18), and ALTER GROUP role-membership syntax were all already covered by existing generic grammar. DROP CONSTRAINT ... ONLY on partitioned tables (PG18) documented as a known, narrow open gap rather than force-fitting unusable grammar. Fixed a factual error in Section 5.1.1: ALTER TYPE ADD VALUE’s transaction-block restriction was lifted in PostgreSQL 12, not 16 as previously stated (verified against PostgreSQL 12.0’s release notes) — core/’s implementation has the identical error, not corrected as part of this (spec-only) revision. |
| E.18 | 2026-08-23 | Closed the RFC-completeness audit’s final mop-up items and a structural defect: Section 11.2 documents GRANT’s untyped, cross-object-kind-shared privilege list as an accepted offline validation limitation (every real privilege word is expressible; nothing is unexpressable, but DPG performs no offline “wrong privilege for this object kind” check); Section 14.6 confirms Extended Statistics on an expression (not just plain columns, PG14+) passes through cleanly as opaque-object-decl Part 1 text; Sections 8.1/25 note temporary views are excluded on the same terms as temporary tables (Section 7.12). Also: this document’s own physical section order was corrected to match its Table of Contents — Normative References/Informative References/Author’s Address, previously sandwiched between Appendix C and Appendix D, now correctly follow Appendix F as the final sections (standard IETF convention), matching how every other RFC-style document in this family is laid out. |
| E.19 | 2026-08-23 | Sections 7.8/7.9/7.13/12.1/25 updated, closing four gaps found during a full audit of RENAMED FROM coverage across every object kind DPG models (following that day’s fix of the generic cross-schema rename mechanism in core/). Policy gains RENAMED FROM (ALTER POLICY ... RENAME TO, SAFE, matching Constraint/Index’s sub-object precedent — not the CAUTION classification used for independently-referenceable top-level objects), with the real PostgreSQL restriction documented that RENAME TO cannot combine with a TO/USING/WITH CHECK change in one statement (two ALTER POLICY statements emitted when both differ). Trigger gains RENAMED FROM (ALTER TRIGGER ... RENAME TO, SAFE, same sub-object precedent), with the identical real-PostgreSQL restriction against [NO] DEPENDS ON EXTENSION documented. Partitioned Tables gain RENAMED FROM on a partition entry (ALTER TABLE ... RENAME TO, CAUTION — the same classification as a plain table rename, since a partition is an ordinary table under the hood); the previously example-only recursive sub-partitioning shape is also formalized in partition-decl’s own ABNF for the first time, so RENAMED FROM (and the grammar generally) is unambiguously available at any nesting depth. Text Search Configuration gains RENAMED FROM/SET SCHEMA (renamed-from-dir, CAUTION/SAFE matching Dictionary’s existing precedent) — closing a spec-only inconsistency where Configuration was the only Full-Text-Search kind without it, despite Dictionary/Parser/Template all already having it. Section 7.6’s cross-schema renamed-from-dir kind list corrected to include Section 12. All four additions verified against PostgreSQL’s own official documentation before drafting, not assumed. |
| E.20 | 2026-08-24 | min_pg_version promoted out of Section 23 (“Deferred Features”) now that the reference implementation has it working: new Section 3.8 (resolution order — database overrides cluster overrides project root, most specific wins; validation against this specification’s own floor of 14; enforcement via the new min-pg-version linter rule) and matching [compiler] config additions to Sections 3.2-3.4. Section 1.4’s Tenet 3 paragraph’s forward reference to the old Section 23 placeholder corrected to point at Section 3.8. Appendix D.3 gains the min-pg-version rule entry. Appendix F refreshed: new Min PG Version column added across the entire table (blank = available since the floor of PostgreSQL 14, this specification’s own supported minimum), and 14 rows added for constructs documented by E.13-E.19 that had never been given their own Tenet-3 classification at all — PARAMETER PRIVILEGES (Section 11.6), Collation REFRESH VERSION/RULES (Section 14.2), Event Trigger’s login event (Section 14.1), GRANTED BY role (Section 7.10), table-level named NOT NULL and temporal keys (Section 7.3), NOT NULL ... NO INHERIT/ENFORCED/NOT ENFORCED/FK column-scoped SET NULL/SET DEFAULT (Section 7.2), and Column STATISTICS DEFAULT (Section 7.4); the pre-existing generated-columns row split into STORED (floor-14) and VIRTUAL (PG18) since the two variants now carry different version gates. This documentation-only revision intentionally lands after the reference implementation (core/, commit 56ae62e) rather than before it — the user’s own explicit sequencing choice for this feature, so the RFC describes proven behavior rather than a plan. |
| E.21 | 2026-08-25 | Section 7.4’s generated-column diffing table implemented in the reference implementation (core/) for the first time — previously referenced from Section 7.2’s VIRTUAL text but not actually wired into the differ at all, so any change to a generated column’s expression, or a STORED/VIRTUAL switch, was silently undetected. Corrected two factual errors found live-testing that implementation against a real PostgreSQL 18 server: (1) Section 7.4’s “removed, column kept” row split into separate STORED (DROP EXPRESSION, SAFE) and VIRTUAL (drop-and-recreate, DESTRUCTIVE) variants — PostgreSQL 18 flatly rejects DROP EXPRESSION for a VIRTUAL column, which this document previously didn’t distinguish from STORED; Section 7.2’s VIRTUAL paragraph updated to match. (2) Section 7.1’s period-clause grammar production and Section 7.3’s PERIOD FOR name (start_col, end_col) generated-period-column construct removed entirely — confirmed live that no such syntax exists in real PostgreSQL 18 (a syntax error against a real server, and absent from PostgreSQL’s own official documentation); this document had described it in error. WITHOUT OVERLAPS/PERIOD temporal keys (Section 7.3) now correctly documented as referencing an already-existing range/multirange column directly, with no generated-range declaration form. Also corrected Section 7.3’s claim that a temporal key is “implemented as an exclusion constraint under the hood” — confirmed live (pg_constraint.contype) that it stays catalogued as an ordinary PRIMARY KEY/UNIQUE/FOREIGN KEY, not reclassified to EXCLUDE; only the internal enforcement mechanism (pg_constraint.conexclop) is exclusion-based. Appendix F’s temporal-keys row and Tenet-3 classification row updated to match. |
| E.22 | 2026-08-26 | Section 8.2’s TABLESPACE/WITH (...) clauses on a materialized view — grammar already documented, but never implemented: the reference implementation silently dropped both on parse, and never diffed them, so this specification’s own worked example (product_stats WITH (fillfactor = 90) TABLESPACE analytics_space) did not round-trip. Now implemented and live-verified against a real PostgreSQL 17 server; Section 8.2’s diffing-semantics paragraph gains the TABLESPACE/WITH (...) rows, both targeted ALTER MATERIALIZED VIEW forms, SAFE, matching a table’s identical TABLESPACE/WITH (...) treatment (Section 7.11). |
| E.23 | 2026-08-26 | Two doc-accuracy corrections found during a fresh audit, no reference-implementation changes needed (the code was already correct): (1) Appendix D.1.8’s SnapRole/SnapSequence wire-schema examples were stale and, for SnapRole, factually wrong — the accompanying note claimed role attributes are “NOT stored in the snapshot beyond the name and comment,” but PasswordHash and 14 other attribute fields genuinely are (confirmed against internal/snapshot/types.go), directly contradicting Section 24’s own correct description of password-hash storage; both examples rewritten to list every current field, and the note corrected. (2) Section 18.8 stated plainly “there is no --format flag” for dpg portability — false, cmd/dpg/portability.go has a real, working --format json flag (JSON per cluster/database analyzed); the false claim removed and the flag documented. |
| E.24 | 2026-08-26 | Closed the last open item from the 2026-08-26 fresh audit: DROP CONSTRAINT ... ONLY on partitioned tables (PostgreSQL 18+, Section 7.3), previously documented as a known, deliberately out-of-scope gap with no declarative representation at all. A partition’s PARTITIONS { } entry may now carry its own trailing { } body declaring one or more constraints independently of the parent (CONSTRAINT/CONSTRAINTS, the same Section 7.3 directive grammar a table’s own block already accepts) — the sole exception to a partition otherwise never declaring structure independently of its parent. When a constraint disappears from the parent’s own declaration while a direct partition declares it locally, the Differ emits ALTER TABLE ONLY parent DROP CONSTRAINT (SAFE) instead of the ordinary cascading drop; a constraint declared directly on a partition with no such parent-level history is simply an ordinary, version-independent ALTER TABLE partition ADD/DROP CONSTRAINT. Confirmed live against real PostgreSQL 17 and 18 servers: 17 rejects the ONLY statement outright (“cannot remove constraint from only the partitioned table when partitions exist”), 18 succeeds, flipping the constraint’s row on every existing partition from inherited to local — the identical catalog signature (conislocal = true, coninhcount = 0) a from-scratch partition-local constraint produces, which is why this document’s IR draws no distinction between the two histories either. Sections 7.13 and 21’s constraint-diffing tables, and Appendix F, updated to match. |
Appendix F. Standard SQL / PostgreSQL-Specific Classification
This appendix satisfies Tenet 3 (Section 1.4): for every construct this
specification documents, whether it is ISO/IEC 9075 (Standard SQL)
or PostgreSQL-specific — the classification dpg portability
reports to users. Three classifications are used:
- Standard — part of ISO/IEC 9075 in some edition (a
standard-conformant database may support a different dialect of
the same feature, e.g. a different
CREATE TABLEgrammar, but the concept is standardized). - PGSpecific — a PostgreSQL extension with no ISO/IEC 9075 equivalent, or a PostgreSQL-only spelling of a concept another vendor would express differently.
- N/A — not a real PostgreSQL DDL construct at all: DPG-native
syntax that generates no SQL of its own (RFC Section 23’s “No SQL”
classification in Section 25’s coverage matrix), or a DPG lifecycle/
tooling directive (
RENAMED FROM,PROTECTED,DEPRECATED,MIGRATE REMOVE, Name Maps, macros). Tenet 3’s Standard/ PGSpecific dichotomy doesn’t apply to these;dpg portabilitynever reports them.
A row classifies the construct as a whole; a construct with both a standard core and PostgreSQL-specific optional clauses is marked Mixed, with the PG-specific sub-clauses named in Notes.
Min PG Version: the version-gating input for min_pg_version
(Section 3.8) and its min-pg-version linter rule (Appendix D.3).
A blank cell means the construct has been available since this
specification’s own supported floor, PostgreSQL 14 (Section 1.4),
and is never gated regardless of a project’s configured
min_pg_version. A value means the construct requires at least
that PostgreSQL major version; a value with a parenthetical
qualifies which part of a Mixed row’s construct the gate applies to
when the row’s other part is floor-14.
| Section | Construct | Classification | Min PG Version | Notes |
|---|---|---|---|---|
| Sections 4.1-4.6 | Basic lexical rules (identifiers, comments, dollar-quoting, statement terminators) | N/A | — | DPG source-file syntax, not PostgreSQL DDL. |
| Section 5.1 | CREATE TYPE ... AS ENUM | PGSpecific | — | ISO SQL has no enumerated type; closest standard analogue is a CHECK constraint or DOMAIN. |
| Section 5.2 | CREATE TYPE ... AS (...) (composite) | PGSpecific | — | Structured/row types exist in SQL:1999+ but with different syntax and semantics (CREATE TYPE ... AS (...) the PostgreSQL way is not the standard’s CREATE TYPE for UDTs). |
| Section 5.3 | CREATE TYPE ... AS RANGE | PGSpecific | — | No standard range type. |
| Section 5.4 | CREATE DOMAIN core (name, base type) | Standard | — | SQL:1999+ CREATE DOMAIN. |
| Section 5.4 | Domain DEFAULT/CONSTRAINT ... CHECK/NOT NULL | Standard | — | Same standard CREATE DOMAIN clauses. |
| Section 5.4 | Domain COLLATE | PGSpecific | — | PostgreSQL-specific collation attachment syntax (the standard has collations, but not this clause shape). |
| Section 5.5 | CREATE TYPE (...) (base/shell type) | PGSpecific | — | C-level storage type definition; no standard equivalent. |
| Section 5.6 | VIRTUAL TYPE | N/A | — | DPG-native, generates no SQL. |
| Section 6 | CREATE EXTENSION | PGSpecific | — | PostgreSQL’s own extension-packaging mechanism. |
| Sections 7.1-7.2 | CREATE TABLE core (columns, types, PRIMARY KEY/UNIQUE/CHECK/FOREIGN KEY) | Standard | — | Core relational DDL. |
| Section 7.1 | WITH (storage_params) | PGSpecific | — | PostgreSQL storage parameters. |
| Section 7.1 | TABLESPACE | PGSpecific | — | No standard tablespace concept. |
| Section 7.1 | INHERITS | PGSpecific | — | PostgreSQL table inheritance; not in the standard. |
| Section 7.1 | UNLOGGED | PGSpecific | — | PostgreSQL crash-safety trade-off, no standard equivalent. |
| Section 7.2 | Generated columns (GENERATED ALWAYS AS ... STORED) | Standard | — | SQL:2003 generated columns. |
| Section 7.2 | Generated columns (GENERATED ALWAYS AS ... VIRTUAL) | Standard | 18 | SQL:2003 generated columns also standardize VIRTUAL; PostgreSQL only implemented the STORED variant until PostgreSQL 18 added this one. |
| Section 7.2 | Identity columns (GENERATED ... AS IDENTITY) | Standard | — | SQL:2003 identity columns. |
| Section 7.2 | SERIAL/BIGSERIAL/SMALLSERIAL | PGSpecific | — | Pre-standard PostgreSQL sequence sugar; IDENTITY is the standard-conformant replacement. |
| Section 7.2 | Column COMPRESSION/STORAGE | PGSpecific | — | PostgreSQL TOAST-related storage tuning. |
| Section 7.2 | EXCLUDE constraints | PGSpecific | — | No standard exclusion-constraint concept. |
| Section 7.2 | NOT VALID/VALIDATE CONSTRAINT | PGSpecific | — | PostgreSQL’s incremental constraint-validation lifecycle. |
| Section 7.2 | NOT NULL ... NO INHERIT | PGSpecific | 18 | Extends the pre-existing CHECK ... NO INHERIT modifier (floor-14 PGSpecific) to NOT NULL constraints for the first time. |
| Section 7.2 | ENFORCED/NOT ENFORCED (CHECK/FOREIGN KEY) | PGSpecific | 18 | PostgreSQL’s own catalog-recorded-but-unchecked constraint mode; no SQL-standard citation for this exact clause. |
| Section 7.2 | FK ON DELETE/ON UPDATE SET NULL/SET DEFAULT (col-list) | PGSpecific | 15 | Column-scoped refinement of the standard bare SET NULL/SET DEFAULT form (the bare form is covered by the CREATE TABLE core row above); the column-list itself has no SQL-standard equivalent. |
| Section 7.3 | Table-level named NOT NULL constraint | Standard | 18 | PostgreSQL 18 gave NOT NULL the same named, catalogued constraint treatment CHECK/PRIMARY KEY/UNIQUE/FOREIGN KEY already had; additive to the pre-existing inline column-level form (Section 7.2 row above), not a replacement. |
| Section 7.3 | Temporal keys (WITHOUT OVERLAPS/PERIOD, incl. temporal FOREIGN KEY) | Mixed | 18 | SQL:2011 standardizes temporal PRIMARY KEY/UNIQUE; PostgreSQL enforces it via an exclusion search under the hood while keeping the constraint catalogued as an ordinary PRIMARY KEY/UNIQUE/FOREIGN KEY — the concept is standard, the grammar and mechanism are PostgreSQL’s own dialect. |
| Section 7.4 | Column STATISTICS n / STATISTICS DEFAULT | PGSpecific | 17 (DEFAULT keyword only; numeric target is floor-14) | Planner statistics-target tuning; no standard equivalent. |
| Section 7.7 | CREATE INDEX core | Mixed | — | Indexes exist informally across all vendors but are not in ISO/IEC 9075 at all — classified PGSpecific as a whole (see next row), since “index” is not a standard DDL concept, only a near-universal vendor extension. |
| Section 7.7 | CREATE INDEX (all forms: access methods, partial, expression, covering, opclass) | PGSpecific | — | No SQL-standard CREATE INDEX statement exists; every vendor’s index DDL is proprietary. |
| Section 7.8 | ROW LEVEL SECURITY/CREATE POLICY | PGSpecific | — | No standard row-security mechanism. |
| Section 7.9 | CREATE TRIGGER core (BEFORE/AFTER, events, FOR EACH ROW) | Standard | — | SQL:1999+ triggers. |
| Section 7.9 | REFERENCING OLD/NEW TABLE (transition tables) | Standard | — | SQL:1999+ triggers include transition tables. |
| Section 7.9 | WHEN (condition), EXECUTE FUNCTION | PGSpecific | — | PostgreSQL trigger-function-calling model differs from the standard’s inline trigger action. |
| Section 7.10 | GRANTED BY role | Standard | — | Real PostgreSQL accepts this SQL-standard-compatibility clause but restricts the effective grantor to current_user in practice. |
| Section 7.11 | RENAMED FROM, PROTECTED, DEPRECATED, DROP CASCADE (directives) | N/A | — | DPG-native lifecycle metadata. |
| Section 7.11 | OWNER | PGSpecific | — | PostgreSQL’s object-ownership model (ALTER ... OWNER TO) has no standard equivalent (the standard ties privileges to the creating authorization identifier with no separate transferable “owner”). |
| Section 7.12 | UNLOGGED TABLE | PGSpecific | — | See Section 7.1 row. |
| Section 7.12 | CREATE FOREIGN TABLE | Standard | — | SQL/MED (ISO/IEC 9075-9) standardizes foreign tables; PostgreSQL’s FDW mechanism implements it. |
| Section 7.13 | PARTITION BY/PARTITION OF | Standard | — | SQL:2016 adds declarative partitioning as a standard concept, though exact grammar varies by vendor; PostgreSQL’s own syntax is a PG-specific dialect of a standardized concept — classified Mixed in spirit, PGSpecific in literal grammar. |
| Section 7.3 | DROP CONSTRAINT ... ONLY on a partitioned table (partition-local constraint declaration) | PGSpecific | 18 | PostgreSQL’s own declarative-partitioning constraint-inheritance mechanism; no standard concept of detaching a constraint from a partitioned parent while it stays local to existing partitions. |
| Section 8 | CREATE VIEW | Standard | — | Core SQL. |
| Section 8 | CREATE MATERIALIZED VIEW | PGSpecific | — | Materialized views are a common vendor extension, not in ISO/IEC 9075. |
| Section 8 | RECURSIVE VIEW/WITH RECURSIVE | Standard | — | SQL:1999+ recursive query support. |
| Sections 9.1-9.2 | CREATE FUNCTION core (name, args, RETURNS, LANGUAGE) | Standard | — | SQL/PSM (ISO/IEC 9075-4) standardizes stored routines; PostgreSQL’s concrete grammar and LANGUAGE mechanism are its own dialect of the standardized concept. |
| Section 9.1 | LANGUAGE sql/plpgsql dollar-quoted bodies | PGSpecific | — | Dollar-quoting itself, and plpgsql, are PostgreSQL-specific; SQL/PSM’s own procedural language differs. |
| Section 9.1 | PG14+ sql_body/BEGIN ATOMIC form | Standard | — | The ISO-standard-conformant alternative to dollar-quoting for LANGUAGE sql functions; available since this specification’s own floor, not a later gate. |
| Section 9.2 | VOLATILE/STABLE/IMMUTABLE, PARALLEL SAFE/etc., COST/ROWS, SUPPORT | PGSpecific | — | Query-planner hints with no standard equivalent. |
| Section 9.2 | SECURITY DEFINER/SECURITY INVOKER | Standard | — | SQL/PSM standardizes routine security characteristics (exact keyword differs by vendor, but the concept is standard). |
| Section 9.3 | CREATE PROCEDURE | Standard | — | SQL/PSM standardizes procedures distinct from functions. |
| Section 9.4 | CREATE AGGREGATE | PGSpecific | — | User-defined aggregates with this declaration shape (SFUNC/STYPE/etc.) are PostgreSQL-specific; the standard has no equivalent CREATE AGGREGATE. |
| Section 10 | CREATE SEQUENCE | Standard | — | SQL:2003+ standardizes sequences (AS/INCREMENT/MINVALUE/MAXVALUE/START/CACHE/CYCLE are all standard clauses). |
| Section 10 | OWNED BY | PGSpecific | — | PostgreSQL’s sequence-to-column ownership link has no standard equivalent. |
| Section 11.1 | CREATE ROLE/CREATE USER core (LOGIN, PASSWORD) | Standard | — | SQL standardizes authorization identifiers, though PostgreSQL’s unified role model (merging users and groups) is its own design. |
| Section 11.1 | SUPERUSER/CREATEDB/CREATEROLE/REPLICATION/BYPASSRLS/CONNECTION LIMIT | PGSpecific | — | PostgreSQL-specific role attributes. |
| Section 11.2 | GRANT/REVOKE core | Standard | — | Core SQL privilege model. |
| Section 11.2 | MAINTAIN privilege | PGSpecific | 17 | PostgreSQL-specific privilege covering VACUUM/ANALYZE/CLUSTER/REINDEX. |
| Section 11.3 | Role membership (GRANT role TO role, WITH ADMIN OPTION/WITH INHERIT/WITH SET) | Mixed | 16 (WITH INHERIT/WITH SET only) | Standard SQL has roles and role grants; WITH ADMIN OPTION is standard and floor-14, WITH INHERIT/WITH SET are PostgreSQL-specific refinements of role-attribute inheritance. |
| Section 11.4 | ALTER DEFAULT PRIVILEGES | PGSpecific | — | No standard mechanism for privilege templates applied to not-yet-created objects. |
| Section 11.5 | Owner impersonation (SET ROLE/RESET ROLE) | Standard | — | SQL standardizes SET ROLE; PostgreSQL’s specific creator-attribution semantics this section documents are PostgreSQL’s own catalog behavior. |
| Section 11.6 | PARAMETER PRIVILEGES (GRANT {SET|ALTER SYSTEM} ON PARAMETER) | PGSpecific | 15 | PostgreSQL’s grantable parameter-ACL object type; no standard equivalent. |
| Section 12.1 | CREATE TEXT SEARCH CONFIGURATION/MAPPING FOR ... WITH ... | PGSpecific | — | PostgreSQL full-text search is entirely proprietary. |
| Sections 12.2-12.4 | Text Search Dictionary/Parser/Template | PGSpecific | — | Same as above. |
| Section 13.1 | CREATE PUBLICATION | PGSpecific | — | PostgreSQL’s own logical-replication publish/subscribe model. |
| Section 13.2 | CREATE SUBSCRIPTION | PGSpecific | — | Same. |
| Section 14.1 | CREATE EVENT TRIGGER | PGSpecific | — | No standard DDL-event trigger mechanism. |
| Section 14.1 | Event Trigger login event | PGSpecific | 17 | One value of the ON <event> clause’s event-type enum; every other value (ddl_command_start/ddl_command_end/table_rewrite/sql_drop) is floor-14. |
| Section 14.2 | CREATE COLLATION | Standard | — | SQL:2003+ standardizes collations; PostgreSQL’s ICU/libc provider mechanism is its own extension of the standard concept. |
| Section 14.2 | Collation REFRESH VERSION | PGSpecific | 15 | Updates the catalog’s recorded collation-provider version; no standard equivalent. |
| Section 14.2 | Collation RULES (ICU tailoring) | PGSpecific | 16 | PostgreSQL’s own ICU collation-tailoring integration; no standard equivalent. |
| Section 14.3 | CREATE CAST | Standard | — | SQL standardizes user-defined casts (CREATE CAST), though PostgreSQL’s WITH FUNCTION/WITHOUT FUNCTION/WITH INOUT grammar has PostgreSQL-specific shorthand forms. |
| Section 14.4 | CREATE OPERATOR/OPERATOR CLASS/OPERATOR FAMILY | PGSpecific | — | User-defined operators and index access-method integration are PostgreSQL-specific; the standard has no equivalent. |
| Section 14.5 | (Cast — see Section 14.3) | — | — | Cross-reference; see Section 14.3 row. |
| Section 14.6 | CREATE STATISTICS (extended statistics objects) | PGSpecific | — | PostgreSQL planner-statistics extension; expression-based statistics (not just plain columns) are floor-14, passed through as opaque text. |
| Section 14.7 | CREATE TABLESPACE | PGSpecific | — | No standard physical-storage-location concept. |
| Section 14.8 | CREATE FOREIGN DATA WRAPPER | Standard | — | SQL/MED standardizes the FDW concept (CREATE FOREIGN DATA WRAPPER), though HANDLER/VALIDATOR functions are PostgreSQL’s own extension mechanism. |
| Section 14.9 | CREATE SERVER | Standard | — | SQL/MED standardizes foreign servers. |
| Section 14.10 | CREATE USER MAPPING | Standard | — | SQL/MED standardizes user mappings for foreign servers. |
| Section 14.11 | SECURITY LABEL | PGSpecific | — | PostgreSQL’s MAC/label-security integration point (SELinux/sepgsql etc.); no standard equivalent. |
| Sections 15-21 | Compilation pipeline, snapshot format, CLI, linter, safety classification, diffing semantics | N/A | — | DPG tooling, not PostgreSQL DDL. |
| Section 22 | Dependency graph / topological sort | N/A | — | DPG compiler internals. |
| Throughout | COMMENT ON ... | Standard | — | SQL:1999+ standardizes COMMENT ON, though PostgreSQL supports it on a broader set of object kinds than the standard requires. |
| Throughout | { } block itself, Name Maps, macros | N/A | — | DPG-native source syntax; generates no SQL directly. |
How this table is maintained: a construct newly documented by a
future revision of this specification MUST be classified here in
the same revision (Appendix E’s entry for that revision should note
the addition) — this is what keeps Tenet 3 checkable rather than
aspirational, the defect this appendix was added to close. A
construct gated by min_pg_version above its own floor of
PostgreSQL 14 MUST have its Min PG Version cell populated in that
same revision, for the identical reason applied to Section 3.8’s
min-pg-version rule.
Normative References
[RFC2119] Bradner, S., “Key words for use in RFCs to Indicate Requirement Levels”, BCP 14, RFC 2119, DOI 10.17487/RFC2119, March 1997, https://www.rfc-editor.org/rfc/rfc2119.
[RFC5234] Crocker, D. and P. Overell, “Augmented BNF for Syntax Specifications: ABNF”, STD 68, RFC 5234, DOI 10.17487/RFC5234, January 2008, https://www.rfc-editor.org/rfc/rfc5234.
[RFC8174] Leiba, B., “Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words”, BCP 14, RFC 8174, DOI 10.17487/RFC8174, May 2017, https://www.rfc-editor.org/rfc/rfc8174.
[RFC3629] Yergeau, F., “UTF-8, a transformation format of ISO 10646”, STD 63, RFC 3629, DOI 10.17487/RFC3629, November 2003, https://www.rfc-editor.org/rfc/rfc3629.
[PGDOC14] The PostgreSQL Global Development Group, “PostgreSQL 14 Documentation”, 2021, https://www.postgresql.org/docs/14/.
Informative References
[ATLAS] Ariga, “Atlas — Database Schema Management”, https://atlasgo.io/.
[PRISMA] Prisma Data, “Prisma ORM — Schema reference”, https://www.prisma.io/docs/concepts/components/prisma-schema.
[FLYWAY] Redgate, “Flyway — Database Migrations Made Easy”, https://flywaydb.org/.
[KAHN62] Kahn, A.B., “Topological sorting of large networks”, Communications of the ACM, 5(11), pp. 558–562, 1962.
[TARJAN72] Tarjan, R., “Depth-first search and linear graph algorithms”, SIAM Journal on Computing, 1(2), pp. 146–160, 1972.
[NDJSON] “Newline Delimited JSON”, http://ndjson.org/.
Author’s Address
Daniel Tsegaw Independent
Email: danieltsegaw.b@gmail.com
End of RFC 1 — Declarative PG (DPG) v0.8.1