RFC DPG-1: Declarative PG

RFCDPG-001StatusStandards Track
Version0.8.1TargetPostgreSQL 14+
Date2026-05-13AuthorsDaniel 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:

  1. The project root is the directory containing the root dpg.toml.

  2. A cluster directory is any immediate subdirectory of the project root that contains a dpg.toml with a [cluster] section.

  3. A database directory is any immediate subdirectory of a cluster directory that contains a dpg.toml with a [database] section, excluding the cluster objects directory.

  4. The cluster objects directory name is taken from the cluster.cluster_objects_dir field. It defaults to "cluster". No database within the cluster MAY share this name.

  5. All .dpg files descending from a database directory are automatically scoped to that database. No in-file database header keyword exists.

  6. .dpg files 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:

  1. Locate the project root by searching from the current working directory upward for a dpg.toml with a [compiler] or [linter] or [snapshots] section.

  2. For each cluster directory (immediate subdirectory of the project root whose dpg.toml contains [cluster]):

    a. Parse the cluster dpg.toml.

    b. For each database directory (immediate subdirectory of the cluster directory whose dpg.toml contains [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.

  3. 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 TABLE declared inside a SCHEMA { } block inherits the schema name; the developer does not repeat it.

  • An INDEX declared inside a TABLE’s { } block inherits the table and schema; the developer writes only the index name and column list.

  • A POLICY declared inside a TABLE’s POLICIES { } 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:

  1. On encountering the token AS followed by optional whitespace followed by a dollar-quoted delimiter $[tag]$, record the opening delimiter string D.

  2. 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.

  3. 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.

  4. The bytes immediately following the closing D MUST be ; (per Rule T2). The semicolon is the Part 1 terminator.

  5. 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 .dpg file 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 MACRO keyword 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 headerSingular keyword
INDICESINDEX
POLICIESPOLICY
TRIGGERSTRIGGER
GRANTSGRANT
REVOCATIONSREVOCATION
PARTITIONSPARTITION
COLUMNSCOLUMN
CONSTRAINTSCONSTRAINT

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:

  1. CREATE TYPE <schema>.<name>__dpg_new AS ENUM (<reduced-values>); — a new type with the value removed.

  2. Execute each DML statement in the MIGRATE REMOVE body verbatim.

  3. 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.

  4. 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;
    
  5. DROP TYPE <schema>.<name>;

  6. 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 same RENAMED FROM mechanism as table columns (Section 7.6) — emits ALTER TYPE <name> RENAME ATTRIBUTE old_name TO new_nameSAFE.
  • Owner changed: ALTER TYPE <name> OWNER TO roleSAFE.
  • Renamed (RENAMED FROM on the type itself, Section 5.1’s cross-reference): ALTER TYPE old RENAME TO newCAUTION; schema-qualified additionally emits ALTER TYPE old_schema.name SET SCHEMA new_schemaSAFE.

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. float8rangefloat8multirange) 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 VALIDCAUTION. 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 newSAFE — 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 DEFAULTSAFE.
  • Adding a constraint: ALTER DOMAIN <name> ADD CONSTRAINT <name> CHECK (...) [NOT VALID]CAUTION.
  • NOT VALID removed from an existing constraint: ALTER DOMAIN <name> VALIDATE CONSTRAINT <name>CAUTION.
  • Constraint renamed only (RENAMED FROM): ALTER DOMAIN <name> RENAME CONSTRAINT old TO newSAFE.
  • Dropping a constraint: ALTER DOMAIN <name> DROP CONSTRAINT <name>SAFE.
  • Changing the base type or COLLATE: requires DROP DOMAIN CASCADE + CREATE DOMAINDESTRUCTIVE.
  • Owner changed: ALTER DOMAIN <name> OWNER TO roleSAFE.
  • Renamed (RENAMED FROM on the domain itself): ALTER DOMAIN old RENAME TO newCAUTION; schema-qualified additionally emits ALTER DOMAIN old_schema.name SET SCHEMA new_schemaSAFE.

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.

ChangeDDL emittedSafety
Only RECEIVE/SEND/TYPMOD_IN/TYPMOD_OUT/ANALYZE/SUBSCRIPT/STORAGE differALTER TYPE name SET (key = value | NONE, ...)SAFE
Any other property differs (alone or alongside the above)DROP TYPE CASCADE + CREATE TYPEDESTRUCTIVE
Type removedDROP 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 form
    
  • Composite — 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 TYPE MAY be schema-qualified; if unqualified it defaults to default_schema.

  • No CREATE TYPE, ALTER TYPE, or DROP TYPE is EVER emitted for a virtual type.

  • Virtual types appear in the snapshot under "kind": "virtual_type" for round-trip consistency. dpg plan produces 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 accepts COMMENT and PREFERRED JSON FORMAT. Any other directive is a compiler error (DPG-E015).

  • PREFERRED JSON FORMAT json causes DPG to emit json / json[] instead of the default jsonb / 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:

ChangeDDL emittedSafety
New schemaCREATE SCHEMA nameSAFE
RENAMED FROMALTER SCHEMA old RENAME TO newCAUTION
OWNER changeALTER SCHEMA name OWNER TO roleSAFE
COMMENT changeCOMMENT ON SCHEMA name IS '...'SAFE
Schema removedDROP 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:

ChangeDDL emittedSafety
New extensionCREATE EXTENSION IF NOT EXISTS name [SCHEMA ...] [VERSION ...] [CASCADE]SAFE
VERSION changeALTER EXTENSION name UPDATE [TO version]CAUTION
SCHEMA changeALTER EXTENSION name SET SCHEMA new_schemaSAFE
Extension removedDROP 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 STOREDVIRTUAL (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 NULL on a PRIMARY KEY column in the source is accepted but is silently treated as redundant.
  • The compiler MUST NOT emit a redundant NOT NULL clause for PRIMARY KEY columns in generated CREATE TABLE DDL.
  • The compiler MUST NOT emit a spurious ALTER COLUMN SET NOT NULL when 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 classified CAUTION.
  • A type change requiring an explicit cast is classified DESTRUCTIVE unless a USING expression is present in the COLUMN name { USING expr; } block, in which case it is classified CAUTION.
  • The ALTER TABLE <t> ALTER COLUMN <c> TYPE <new> [USING <expr>] statement acquires an ACCESS EXCLUSIVE lock for the duration of the rewrite, which is why it is always at least CAUTION.

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. smallintinteger, realdouble precision) is CAUTION when PostgreSQL has a real implicit cast (pg_cast.castcontext = 'i') between them, DESTRUCTIVE otherwise — see internal/diff/ implicit_casts.go for 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 from pg_cast (which has no entry for a type to itself). Implemented in internal/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. Bare character (no length) is not treated as unbounded, unlike character varying — PostgreSQL’s own bare-word default for CHARACTER is character(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)”); unlike character, fixed bit has 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 stays DESTRUCTIVE.
    • 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 as numeric scale.
    • 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:

  1. First migration: ALTER TABLE <t> ADD CONSTRAINT <name> ... NOT VALID; — Safety CAUTION.

  2. When NOT VALID is removed in source, the compiler emits: ALTER TABLE <t> VALIDATE CONSTRAINT <name>; — Safety CAUTION.

  3. 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 newSAFE, 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] INHERITSAFE — 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:

DirectivePostgreSQL DDL emitted
COMMENT 'text'COMMENT ON COLUMN t.c IS '...'
STATISTICS nALTER TABLE t ALTER COLUMN c SET STATISTICS n
STATISTICS DEFAULT (PostgreSQL 17+)ALTER TABLE t ALTER COLUMN c SET STATISTICS DEFAULT
COMPRESSION methodALTER TABLE t ALTER COLUMN c SET COMPRESSION m
STORAGE typeALTER TABLE t ALTER COLUMN c SET STORAGE s
DEPRECATED 'msg'COMMENT ON COLUMN t.c IS '[DEPRECATED] msg'
USING exprALTER TABLE t ALTER COLUMN c TYPE ... USING expr
RENAMED FROM oldALTER 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).

  • COLUMN blocks do NOT declare new columns. New columns are declared only in the ( ) list.

  • After a rename, the COLUMN block MUST use the new name; RENAMED FROM carries 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:

ValueMeaning
-1Reset to table default (normally 100 at cluster level)
DEFAULT (PostgreSQL 17+)Same effect as -1SET STATISTICS DEFAULT is real PostgreSQL’s more explicit spelling of the same reset, added alongside the pre-existing -1 form rather than replacing it
0Disable statistics collection for this column
1–10000Explicit 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:

ChangeDDL emittedSafety
GENERATED ... AS (expr) added where none existedALTER 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 itDESTRUCTIVE (the column’s prior values are discarded, not merely rewritten in place)
STOREDVIRTUAL changed, expression unchangedSame drop-and-recreate as above — confirmed live that SET EXPRESSION never changes a column’s STORED/VIRTUAL kind, only its expression textDESTRUCTIVE
Existing generated expression text changed, STORED/VIRTUAL kind unchangedALTER 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 EXISTSSAFE (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 aboveDESTRUCTIVE (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:

ChangeDDL emittedSafety
Identity clause added where none existedALTER TABLE t ALTER COLUMN c ADD GENERATED {ALWAYS|BY DEFAULT} AS IDENTITY [(seq-options)]CAUTION
ALWAYSBY DEFAULT changed, options unchangedALTER TABLE t ALTER COLUMN c SET GENERATED {ALWAYS|BY DEFAULT}SAFE
identity-opts (increment/min/max/cache/cycle) changedALTER 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 keptALTER 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:

  1. The compiler sees email_address in the ( ) list and COLUMN email_address { RENAMED FROM email; } in the { } block.

  2. It looks up email in the snapshot.

    • If email is in the snapshot and email_address is NOT: this is State A (fresh rename). Emit ALTER TABLE users RENAME COLUMN email TO email_address; (Safety: CAUTION).
    • If email_address is already in the snapshot: the rename has already been applied (State B). Treat as a normal column update; the RENAMED FROM directive is a no-op.
    • If neither email nor email_address is in the snapshot: this is a new column with a stale RENAMED FROM. Emit error DPG-E021 (stale RENAMED FROM directive).
  3. After emitting the rename, all constraint and index declarations MUST use email_address. The compiler validates and emits DPG-E019 on any reference to email.

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 TO omitted since the bare name is the same).
  • VIEW archive.order_summary { RENAMED FROM public.orders_summary; }ALTER VIEW public.orders_summary SET SCHEMA archive; then ALTER 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 CONCURRENTLY on an individual index emits CREATE INDEX CONCURRENTLY. This is a MANUAL operation (non-transactional; emitted after COMMIT) — except for a brand-new table’s own indexes (see below), where it is silently ignored.

  • Omitting CONCURRENTLY emits plain CREATE INDEX (transactional; Safety CAUTION for 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 rejects CREATE INDEX CONCURRENTLY inside a transaction block. The compiler therefore forces these non-concurrent unconditionally — an explicit CONCURRENTLY on 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:

ChangeDDL emittedSafety
New index (existing table)CREATE [UNIQUE] INDEX [CONCURRENTLY] [IF NOT EXISTS] name ON ...MANUAL if CONCURRENTLY written, else CAUTION (default)
Index removedDROP INDEX [CONCURRENTLY] nameCAUTION
Index renamed only (RENAMED FROM, nothing else differs)ALTER INDEX old_name RENAME TO new_nameSAFE
Any other structural changeDrop + recreateCAUTION 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:

ChangeDDL emittedSafety
ENABLE ROW LEVEL SECURITY addedALTER TABLE t ENABLE ROW LEVEL SECURITYSAFE
FORCE ROW LEVEL SECURITY addedALTER TABLE t FORCE ROW LEVEL SECURITYSAFE
FORCE ROW LEVEL SECURITY removed, ENABLE still presentALTER TABLE t NO FORCE ROW LEVEL SECURITYSAFE
Both removedALTER TABLE t DISABLE ROW LEVEL SECURITYSAFE
New policyCREATE 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_nameSAFE
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 differsDROP POLICY name ON t; CREATE POLICY ...SAFE
Policy removedDROP POLICY name ON tSAFE

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_nameSAFE, 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:

ChangeDDL emittedSafety
New triggerCREATE [CONSTRAINT] TRIGGER name ... ON t ...SAFE
Trigger changedDROP TRIGGER name ON t; CREATE TRIGGER ...SAFE
Renamed only (RENAMED FROM, nothing else differs)ALTER TRIGGER old_name ON t RENAME TO new_nameSAFE
Enable state changed only (trigger-enable-state)ALTER TABLE t ENABLE/DISABLE TRIGGER name or ALTER TABLE t ENABLE REPLICA/ALWAYS TRIGGER nameSAFE
[NO] DEPENDS ON EXTENSION changed (depends-on-extension-dir, Section 9.1)ALTER TRIGGER name ON t [NO] DEPENDS ON EXTENSION extSAFE
Trigger removedDROP TRIGGER name ON tSAFE

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_nameSAFE, 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 AFTER trigger may specify transition tables — BEFORE and INSTEAD OF are rejected (“transition table name can only be specified for an AFTER trigger”).
  • A CONSTRAINT trigger cannot use REFERENCING at all — real PostgreSQL’s grammar rejects the combination outright (a syntax error, not a semantic one).
  • Valid for both FOR EACH ROW and FOR 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 TRUNCATE trigger 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 CLUSTERSAFE. 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 methodCAUTION (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:

ChangeDDL emittedSafety
OPTIONS changedALTER FOREIGN TABLE t OPTIONS (ADD/SET/DROP key 'value', ...)SAFE
SERVER changedDROP 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 as ATTACHED FROM existing_table FOR VALUES ... — where existing_table is a standalone table already present in the snapshot — emits ALTER TABLE parent ATTACH PARTITION existing_table FOR VALUES ... instead of CREATE TABLE ... PARTITION OF .... Real PostgreSQL validates the existing table’s structure and any CHECK constraints against the target partition bound at attach time; DPG performs no additional validation of its own (passthrough principle, as elsewhere in this document). ATTACH PARTITION has no CONCURRENTLY variant in real PostgreSQL — none is offered here either.
  • A standalone TABLE declaration carrying DETACHED FROM parent_table [CONCURRENTLY] in its { } block — where the table is currently a partition of parent_table per the snapshot — emits ALTER TABLE parent_table DETACH PARTITION name [CONCURRENTLY] instead of the drop this document would otherwise prescribe for “declared table disappeared from its parent’s PARTITIONS { } block.” CONCURRENTLY runs the detach in two non-blocking steps (matching real PostgreSQL); omitting it holds a brief ACCESS EXCLUSIVE on the parent.

Diffing semantics:

ChangeDDL emittedSafety
New partitionCREATE TABLE <name> PARTITION OF <parent> FOR VALUES ...SAFE
Partition renamed only (RENAMED FROM, bound/strategy unchanged)ALTER TABLE old_name RENAME TO new_nameCAUTION
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 changeRequires --approve-partition-rebuildMANUAL
Parent constraint dropped, retained locally by a direct partition (PG18+, Section 7.3)ALTER TABLE ONLY parent DROP CONSTRAINT nameSAFE
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 nameDESTRUCTIVE

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):

  1. Create new partitioned table with target strategy.
  2. Create all declared partitions.
  3. INSERT INTO new_table SELECT * FROM old_table.
  4. Verify row counts match.
  5. DROP TABLE old_table.
  6. ALTER TABLE new_table RENAME TO old_table.
  7. 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:

ChangeDDL emittedSafety
New viewCREATE VIEW ...SAFE
Query changed, same output column listCREATE OR REPLACE VIEW ...SAFE
Output column list changed (any way)DROP VIEW CASCADE; CREATE VIEWDESTRUCTIVE
Owner changedALTER VIEW name OWNER TO roleSAFE
Comment changedCOMMENT ON VIEW name IS '...'SAFE
View renamed (RENAMED FROM)ALTER VIEW old RENAME TO newCAUTION
View moved to another schema (RENAMED FROM schema-qualified, Section 7.6)ALTER VIEW old_schema.name SET SCHEMA new_schemaSAFE
View removedDROP 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

AttributeMeaning
VOLATILEDefault. May modify DB; result may differ per call.
STABLEConstant within a single transaction for given inputs. Cannot modify DB.
IMMUTABLEConstant for all time for given inputs. Index-eligible.
STRICTAlias for RETURNS NULL ON NULL INPUT. Returns NULL if any argument is NULL.
SECURITY DEFINERExecutes with the privileges of the function owner.
SECURITY INVOKERDefault. Executes with the privileges of the calling role.
LEAKPROOFNever leaks argument data via errors/side channels — governs whether the planner may push a qual through an RLS-restricted view.
NOT LEAKPROOFDefault. May leak argument data.
PARALLEL SAFESafe for parallel execution in any worker.
PARALLEL RESTRICTEDParallel-safe but must run in the leader process.
PARALLEL UNSAFEDefault. Cannot run in parallel.
COST nEstimated execution cost in cpu_operator_cost units.
ROWS nEstimated number of rows returned (set-returning functions only).
SUPPORT funcPlanner support function (PostgreSQL 12+).
SET param = valueSets the named GUC to value for the duration of the call.
SET param FROM CURRENTSets the named GUC from its current session value.
WINDOWDeclares 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_nameCAUTION; schema-qualified additionally emits ALTER AGGREGATE ... SET SCHEMA new_schema (SAFE), the same renamed-from-dir extension as every other kind (Section 7.6).
  • Owner changed (owner-dir, func-block): ALTER AGGREGATE name (input_types) OWNER TO roleSAFE.
  • 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. A BEGIN ATOMIC ... END body (Section 9.1) is canonicalised identically — each statement between BEGIN ATOMIC and END is ordinary SQL, parsed/re-deparsed/hashed the same way a dollar-quoted LANGUAGE SQL body 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 (via libpg_query’s PL/pgSQL parse-to-JSON entry point, fed a full, argument-accurate CREATE FUNCTION/PROCEDURE statement — 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, a RETURN expression, a RAISE parameter, an embedded SQL statement) is also independently re-parsed and re-deparsed before hashing, using the exact raw parser mode libpg_query itself records for that fragment (RAW_PARSE_PLPGSQL_EXPR for a bare expression, RAW_PARSE_PLPGSQL_ASSIGN1/2/3 for 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 to libpg_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||b reformatted to a || b) is now absorbed too, for every fragment shape the current libpg_query version 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 than plpgsql): plain text normalisation only —
    1. Stripping leading and trailing whitespace from the body text.
    2. 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 FUNCTION can update (SECURITY DEFINER, STRICT, VOLATILE/STABLE/ IMMUTABLE, LEAKPROOF, PARALLEL, COST, ROWS, SET options): emit CREATE OR REPLACE FUNCTION — Safety SAFE. (Setting LEAKPROOF requires 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 REPLACE for these. The compiler emits DROP FUNCTION CASCADE followed by CREATE FUNCTION — classified as DESTRUCTIVE.


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:

ChangeDDL emittedSafety
New sequenceCREATE SEQUENCE ...SAFE
Increment/min/max/cache/cycle changedALTER SEQUENCE name ...SAFE
OWNED BY changed (incl. to/from NONE)ALTER SEQUENCE name OWNED BY {table.col|NONE}SAFE
RESTART [WITH n] present in sourceALTER SEQUENCE name RESTART [WITH n] (re-emitted every plan/apply while present, see above)MANUAL
UNLOGGED toggledALTER SEQUENCE name SET LOGGED/UNLOGGEDCAUTION
AS type changedDROP SEQUENCE; CREATE SEQUENCEDESTRUCTIVE
Sequence removedDROP SEQUENCE nameDESTRUCTIVE

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:

ChangeDDL emittedSafety
New roleCREATE ROLE name <options>SAFE
LOGIN/SUPERUSER/CREATEDB/CREATEROLE/INHERIT/REPLICATION/BYPASSRLS/CONNECTION LIMIT/VALID UNTIL changedALTER ROLE name <changed-options>SAFE
PASSWORD changedALTER ROLE name PASSWORD '<resolved>'CAUTION (invalidates existing sessions/connections relying on the old password)
IN ROLE/ROLE/ADMIN membership addedGRANT role TO member [WITH ADMIN OPTION] [WITH INHERIT bool] [WITH SET bool]SAFE
Membership WITH option changed, membership itself unchangedGRANT role TO member WITH <changed-option> (PostgreSQL treats a repeated GRANT as updating the option)SAFE
IN ROLE/ROLE/ADMIN membership removedREVOKE role FROM memberCAUTION (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 ALLSAFE
Renamed (RENAMED FROM)ALTER ROLE old RENAME TO newCAUTION
Role removedDROP ROLE nameDESTRUCTIVE

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:

ChangeDDL emittedSafety
New configCREATE TEXT SEARCH CONFIGURATION ...SAFE
Mapping added/changedALTER TEXT SEARCH CONFIGURATION ... ALTER MAPPING FOR ...SAFE
Mapping removedALTER TEXT SEARCH CONFIGURATION ... DROP MAPPING FOR ...SAFE
REPLACE form declaredALTER TEXT SEARCH CONFIGURATION ... ALTER MAPPING [FOR token-types] REPLACE old WITH newSAFE
Owner changedALTER TEXT SEARCH CONFIGURATION name OWNER TO roleSAFE
Renamed (RENAMED FROM)ALTER TEXT SEARCH CONFIGURATION old RENAME TO newCAUTION
Moved to another schema (RENAMED FROM schema-qualified, Section 7.6)ALTER TEXT SEARCH CONFIGURATION old_schema.name SET SCHEMA new_schemaSAFE
Config removedDROP TEXT SEARCH CONFIGURATION nameDESTRUCTIVE

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:

ChangeDDL emittedSafety
Option (non-TEMPLATE) added/changed/removedALTER TEXT SEARCH DICTIONARY name (key [= value], ...)SAFE
TEMPLATE changedDROP TEXT SEARCH DICTIONARY + recreateDESTRUCTIVE
Owner changedALTER TEXT SEARCH DICTIONARY name OWNER TO roleSAFE
Renamed (RENAMED FROM)ALTER TEXT SEARCH DICTIONARY old RENAME TO newCAUTION
Dictionary removedDROP TEXT SEARCH DICTIONARY nameDESTRUCTIVE (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:

ChangeDDL emittedSafety
New publicationCREATE PUBLICATION ...SAFE
Table list changedALTER PUBLICATION name SET TABLE ...SAFE
Options changedALTER PUBLICATION name SET (...)SAFE
Owner changedALTER PUBLICATION name OWNER TO roleSAFE
Renamed (RENAMED FROM)ALTER PUBLICATION old RENAME TO newCAUTION
Publication removedDROP PUBLICATION nameDESTRUCTIVE

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.

ChangeDDL emittedSafety
New subscriptionCREATE 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 changeCOMMENT ON SUBSCRIPTION ... IS ... (or IS NULL)SAFE
Subscription removedDROP SUBSCRIPTION nameDESTRUCTIVE (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:

ChangeDDL emittedSafety
New event triggerCREATE EVENT TRIGGER ...SAFE
Event/WHEN TAG IN/function changedDROP EVENT TRIGGER + CREATE EVENT TRIGGERSAFE (no data involved)
Enable state changed only (trigger-enable-state)ALTER EVENT TRIGGER name ENABLE/DISABLE (or ENABLE REPLICA/ENABLE ALWAYS)SAFE
Owner changedALTER EVENT TRIGGER name OWNER TO roleSAFE
Renamed (RENAMED FROM)ALTER EVENT TRIGGER old RENAME TO newCAUTION
Event trigger droppedDROP EVENT TRIGGER nameSAFE

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:

ChangeDDL emittedSafety
Owner changedALTER COLLATION name OWNER TO roleSAFE
Renamed (RENAMED FROM)ALTER COLLATION old RENAME TO newCAUTION; schema-qualified additionally emits ALTER COLLATION old_schema.name SET SCHEMA new_schema (SAFE)
REFRESH VERSION present in sourceALTER 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 COLLATIONDESTRUCTIVE (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:

ChangeDDL emittedSafety
New operatorCREATE OPERATOR ...SAFE
PROCEDURE/FUNCTION changedDROP OPERATOR; CREATE OPERATORDESTRUCTIVE
Optimizer hint changes (RESTRICT, JOIN, COMMUTATOR, NEGATOR, HASHES, MERGES)ALTER OPERATOR ... (...)SAFE
Operator removedDROP 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:

ChangeDDL emittedSafety
Member addedALTER 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 removedALTER 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 CREATEinherits 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:

ChangeDDL emittedSafety
New statistics objectCREATE STATISTICS ...SAFE
statistics_target changedALTER STATISTICS name SET STATISTICS nSAFE
Column list or kinds changedDROP STATISTICS; CREATE STATISTICSDESTRUCTIVE
Object removedDROP STATISTICS nameSAFE

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:

ChangeDDL emittedSafety
Owner changedALTER TABLESPACE name OWNER TO roleSAFE
Renamed (RENAMED FROM)ALTER TABLESPACE old RENAME TO newCAUTION
WITH (...) option changedALTER TABLESPACE name SET (...)/RESET (...)CAUTION
LOCATION changedDROP TABLESPACE + CREATE TABLESPACEDESTRUCTIVE
Tablespace removedDROP TABLESPACE nameDESTRUCTIVE

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:

ChangeDDL emittedSafety
New serverCREATE SERVER ...SAFE
OPTIONS changedALTER SERVER name OPTIONS (SET key 'value', ...)SAFE
VERSION changed only, OPTIONS/FDW unchangedALTER SERVER name VERSION 'new_version'SAFE
Owner changedALTER SERVER name OWNER TO roleSAFE
Renamed (RENAMED FROM)ALTER SERVER old RENAME TO newCAUTION
FDW changedDrop + recreateDESTRUCTIVE
Server removedDROP 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).

ChangeDDL emittedSafety
New mappingCREATE USER MAPPING ...SAFE
OPTIONS changedDROP USER MAPPING ... + CREATE USER MAPPING ...DESTRUCTIVE then SAFE
Mapping removedDROP USER MAPPING FOR user SERVER serverDESTRUCTIVE

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 SAFESECURITY LABEL never touches data, only catalog metadata, the same classification COMMENT ON already gets throughout this document.

ChangeDDL emittedSafety
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 removedSECURITY LABEL [FOR provider] ON ... IS NULLSAFE


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 — the pipeline.ObjectKind identified from the leading keyword(s).

  • Part1 — the raw Part 1 text, with the leading DPG keyword(s) stripped. The PGSQLParser prepends the correct CREATE verb.

  • Part2 — the raw Part 2 { } block text (the braces are stripped), or the empty string if absent.

  • Schema — the name of the enclosing SCHEMA block if this declaration is nested inside one; otherwise empty.

  • Pos — the SourcePos of the first token.

The tokenizer MUST handle:

  • Comments (-- and /* ... */): stripped before keyword detection. Line numbers MUST be preserved for SourcePos accuracy.

  • 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 with Schema set 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 TYPE and MACRO are DPG-native and have no PostgreSQL CREATE equivalent. The PGSQLParser returns a PGParseResult with Kind set to the appropriate object kind and Raw set to the raw Part1 text string (not a parse tree).

  • The SchemaContext field of the returned PGParseResult is populated from RawObject.Schema for 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 SchemaContext or the database’s default_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 SourcePos to 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 REFERENCES constraint 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:

  1. If every FK in the cycle is DEFERRABLE, the resolver emits the tables in any order (all tables first, then circular FKs as ALTER TABLE ADD CONSTRAINT ... DEFERRABLE INITIALLY DEFERRED statements).

  2. 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 in BEGIN; ... COMMIT; by the executor.

  • Non-transactional: op.Transactional() == false — emitted after COMMIT and 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": { ... }
}
FieldTypeDescription
dpg_versionstringThe DPG version that wrote this snapshot.
clusterstringThe cluster name from the cluster dpg.toml.
databasestringThe database name from the database dpg.toml. Absent for cluster-level snapshots.
applied_atRFC 3339 stringUTC timestamp of the last successful dpg apply.
source_revisionstringThe git commit hash at apply time, if available. Empty if git is unavailable.
objectsobjectMap 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 valueSub-object fieldObject types covered
"table"tableTABLE, UNLOGGED TABLE, FOREIGN TABLE
"view"viewVIEW, MATERIALIZED VIEW, RECURSIVE VIEW
"function"functionFUNCTION
"type"typeENUM, COMPOSITE, RANGE, DOMAIN, BASE
"schema"schemaSCHEMA
"extension"extensionEXTENSION
"sequence"sequenceSEQUENCE
"role"roleROLE
"virtual_type"virtual_typeVIRTUAL 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"opaqueAll 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:

FieldTypeDescription
kindstringObject kind (same as outer kind).
schemastringSchema name; empty for cluster-level objects.
namestringObject name.
argsstringType-only argument key for overloaded objects (procedures, aggregates). Empty for non-overloaded.
body_hashstring"sha256:<hex>" of the normalised Part 1 body text. Empty string means body was empty.
commentstring|nullComment text if any.
grantsarrayGrant 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:

  1. A header block with metadata comments.
  2. A transactional section wrapped in BEGIN; / COMMIT;.
  3. 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.

ClassCriteriaDefault behaviour
SAFENo data loss possible; no excessive locking.Applied automatically.
CAUTIONAcquires ACCESS EXCLUSIVE lock for non-trivial duration; or reorders data; or may affect query plans.Applied with a warning logged to stderr.
DESTRUCTIVEData loss is possible (DROP TABLE, DROP COLUMN, type change without USING, etc.).Blocked unless --allow-destructive is passed to dpg apply.
MANUALCannot 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:

OperationSafety
CREATE TABLESAFE
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 ... USINGCAUTION
ALTER TABLE ALTER COLUMN TYPE (no implicit cast, no USING)DESTRUCTIVE
ALTER TABLE DROP COLUMNDESTRUCTIVE
ALTER TABLE ADD CONSTRAINT ... NOT VALIDCAUTION
ALTER TABLE VALIDATE CONSTRAINTCAUTION
ALTER TABLE RENAME COLUMNCAUTION
ALTER TABLE RENAME TOCAUTION
ALTER TABLE ENABLE ROW LEVEL SECURITYSAFE
ALTER TABLE DISABLE ROW LEVEL SECURITYSAFE
DROP TABLEDESTRUCTIVE
CREATE INDEX (new table)SAFE
CREATE INDEX (existing table, not concurrent)CAUTION
CREATE INDEX CONCURRENTLYMANUAL
DROP INDEXCAUTION
CREATE VIEWSAFE
CREATE OR REPLACE VIEWSAFE
DROP VIEW CASCADEDESTRUCTIVE
CREATE MATERIALIZED VIEWSAFE
DROP MATERIALIZED VIEW CASCADEDESTRUCTIVE
CREATE FUNCTION / CREATE OR REPLACE FUNCTIONSAFE
DROP FUNCTION CASCADEDESTRUCTIVE
ALTER TYPE ... ADD VALUESAFE (Section 1.4’s version floor of 14 is past PostgreSQL 12’s transaction-block restriction)
DROP TYPE CASCADEDESTRUCTIVE
CREATE POLICYSAFE
DROP POLICYSAFE
CREATE TRIGGERSAFE
DROP TRIGGERSAFE
GRANT ...SAFE
REVOKE ...SAFE
CREATE SEQUENCESAFE
DROP SEQUENCEDESTRUCTIVE
ALTER SCHEMA RENAME TOCAUTION
DROP SCHEMA CASCADEDESTRUCTIVE
DROP EXTENSION CASCADEDESTRUCTIVE
DROP ROLEDESTRUCTIVE

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 MANUAL and 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 apply on 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 EXISTS in concurrent index creation.
  • CREATE EXTENSION IF NOT EXISTS for 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:

  1. Loading is only performed when at least one cluster uses a link connection string; clusters using an inline url = never trigger it.
  2. Path resolution order: the path given by --env <path>, if provided; otherwise <project-root>/.env, if it exists.
  3. Existing process environment variables are NEVER overwritten — the file only sets variables not already present in the process environment (“process env wins”).
  4. File format: blank lines and lines starting with # are ignored; a leading export is stripped; entries are KEY=VALUE or KEY = VALUE; single/double-quoted values have the quotes stripped.
  5. A missing .env file 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
}
FieldTypeDescription
clusterstringCluster name.
databasestringDatabase name; empty for cluster-level plans.
generated_atRFC 3339 stringUTC timestamp of plan generation.
source_revisionstringGit short SHA, or empty if unavailable.
opsarrayOrdered list of DiffOp objects.
ops[].sqlstringThe SQL statement text.
ops[].safetystringOne of "SAFE", "CAUTION", "DESTRUCTIVE", "MANUAL".
ops[].filestringSource file path relative to project root, or omitted if unknown.
ops[].lineinteger1-based source line number, or omitted if unknown.
emptybooleantrue 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:

  1. Run the linter. If any error-level diagnostics exist (or any warnings with --strict), abort and print diagnostics.
  2. Compute the migration (dpg plan).
  3. If --dry-run, print and exit.
  4. If the migration contains DESTRUCTIVE ops and --allow-destructive is absent, abort with error DPG-E025.
  5. Print the migration SQL.
  6. Unless --yes, prompt: Apply this migration? [y/N]. Abort if the operator does not confirm.
  7. Execute transactional ops in a single BEGIN/COMMIT per database.
  8. Execute non-transactional ops sequentially.
  9. Update the snapshot atomically (write to a temp file, rename).
  10. 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:

FieldTypeDescription
clusterstringCluster name.
databasestringDatabase name; "(cluster)" for cluster-level objects.
objectsintegerNumber of IR objects successfully compiled.
errorsarrayDiagnostics with IsError = true. Empty array if none.
warningsarrayDiagnostics 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 IDDescriptionDefault Level
hardcoded_passwordROLE PASSWORD 'literal' detected. Use env:VAR_NAME.Error
hardcoded_fdw_passwordUSER MAPPING OPTIONS has a literal value under a password-like key (password/passwd/pwd/secret/passphrase, matched as a substring).Error
deprecated_referenceA non-deprecated object references a deprecated object or column.Warning
missing_column_commentA column has no COMMENT and require_column_comments = true.Warning (configurable to Error)
column_count_exceededA table has more columns than max_columns_per_table.Warning
scalar_merge_conflictTwo files provide conflicting scalar values for the same object property.Warning
security_definer_search_pathA SECURITY DEFINER function has no explicit SET search_path.Warning
serial_sequence_declaredA sequence is declared with a name matching an auto-managed SERIAL/IDENTITY sequence.Warning
unnecessary_revocationA revocation targets a role that was never granted the privilege by DPG.Warning
stale_renamed_fromRENAMED FROM directive references a name not in the snapshot (DPG-E021).Error
unguarded_enum_removalAn ENUM value is removed without a MIGRATE REMOVE block.Error
protected_drop_attemptThe 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 objectUsed for
pg_classTables, views, materialized views, sequences, indexes
pg_attributeColumn definitions (incl. attstattarget, attcompression, attstorage)
pg_constraintTable constraints
pg_indexIndex definitions
pg_procFunctions, procedures, aggregates
pg_triggerTrigger definitions
pg_policyRow security policies
pg_typeTypes (ENUMs, composites, domains, ranges, base)
pg_enumENUM values
pg_namespaceSchemas
pg_extensionInstalled extensions
pg_publicationPublications
pg_subscriptionSubscriptions (all attributes except subconninfo, Section 13.2)
pg_foreign_tableForeign tables
pg_foreign_serverForeign servers
pg_user_mappingUser mappings
pg_foreign_data_wrapperForeign data wrappers
pg_statistic_extExtended statistics objects
pg_event_triggerEvent triggers
pg_collationCollations
pg_operatorOperators
pg_opclassOperator classes
pg_opfamilyOperator families
pg_castCasts
pg_partitioned_tablePartitioning metadata
pg_inheritsTable inheritance
pg_sequenceSequence parameters
pg_ts_configText search configurations
pg_ts_dictText search dictionaries
pg_ts_parserText search parsers
pg_ts_templateText search templates
information_schema.column_privilegesColumn-level grants
pg_roles (or pg_authid)Roles and role memberships
pg_tablespaceTablespaces

20.2. Drift Detection

The dpg verify command compares the snapshot with the live catalog:

  1. Introspect the live catalog → produce a []IRObject of live state.
  2. Load the committed snapshot → produce a []IRObject of snapshot state.
  3. 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.
  4. 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:

FieldChange detectionDDLSafety
Columns (added)New col name not in snapshotALTER TABLE t ADD COLUMN c TYPE [DEFAULT ...]SAFE
Columns (dropped)Col name in snapshot, absent in desiredALTER TABLE t DROP COLUMN cDESTRUCTIVE
Column type changedTypeRef.String() differsALTER TABLE t ALTER COLUMN c TYPE newtype [USING expr]CAUTION or DESTRUCTIVE
Column NOT NULL addednullable false→true (was true in snap)ALTER TABLE t ALTER COLUMN c SET NOT NULLCAUTION
Column NOT NULL removednullable true→falseALTER TABLE t ALTER COLUMN c DROP NOT NULLSAFE
Column DEFAULT addeddefault was nilALTER TABLE t ALTER COLUMN c SET DEFAULT exprSAFE
Column DEFAULT changeddefault text differsALTER TABLE t ALTER COLUMN c SET DEFAULT exprSAFE
Column DEFAULT removeddefault is nilALTER TABLE t ALTER COLUMN c DROP DEFAULTSAFE
Column statisticsstatistics_target differsALTER TABLE t ALTER COLUMN c SET STATISTICS n (or DEFAULT, PG17+)SAFE
Column compressioncompression differsALTER TABLE t ALTER COLUMN c SET COMPRESSION mSAFE
Column storagestorage differsALTER TABLE t ALTER COLUMN c SET STORAGE sSAFE
Column commentcomment differsCOMMENT ON COLUMN t.c IS '...'SAFE
Constraint addedName absent in snapshotALTER TABLE t ADD CONSTRAINT name ...CAUTION
Constraint droppedName absent in desiredALTER TABLE t DROP CONSTRAINT nameDESTRUCTIVE
Constraint renamed only (RENAMED FROM, Section 7.3)renamed_from set, body unchangedALTER TABLE t RENAME CONSTRAINT old TO newSAFE
Constraint deferrability-only change (FK, Section 7.3)Only DEFERRABLE/INITIALLY ... differsALTER TABLE t ALTER CONSTRAINT name ...SAFE
NOT NULL constraint inheritability-only change (PG18, Section 7.3)Only NO INHERIT differsALTER TABLE t ALTER CONSTRAINT name [NO] INHERITSAFE
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 { } bodyALTER TABLE ONLY t DROP CONSTRAINT nameSAFE
Constraint changedBody text differsDrop + re-addDESTRUCTIVE
NOT VALID removednot_valid false in desiredALTER TABLE t VALIDATE CONSTRAINT nameCAUTION
Generated-column expression added/changed/dropped (Section 7.4)GENERATED ... AS (expr) differsADD GENERATED/SET EXPRESSION/DROP EXPRESSION (Section 7.4 table)SAFE/CAUTION
Identity clause added/changed/dropped (Section 7.4)identity-opts differADD GENERATED AS IDENTITY/SET GENERATED/SET <option>/DROP IDENTITY (Section 7.4 table)SAFE/CAUTION
Index added (existing table)Name absent in snapshotCREATE [UNIQUE] INDEX [CONCURRENTLY] ...MANUAL or CAUTION
Index droppedName absent in desiredDROP INDEX [CONCURRENTLY] nameCAUTION
Index renamed only (RENAMED FROM, Section 7.7)Name changed, nothing else differsALTER INDEX old RENAME TO newSAFE
Index changedAny other field differsDrop + recreateCAUTION/MANUAL
RLS enabledrls_enabled changedALTER TABLE t ENABLE ROW LEVEL SECURITYSAFE
RLS disabledrls_enabled changedALTER TABLE t DISABLE ROW LEVEL SECURITYSAFE
RLS force removed (enable still set, Section 7.8)rls_forced false in desired, rls_enabled still trueALTER TABLE t NO FORCE ROW LEVEL SECURITYSAFE
Policy addedName absent in snapshotCREATE POLICY name ON t ...SAFE
Policy TO/USING/WITH CHECK changed only (Section 7.8)FOR/AS unchangedALTER POLICY name ON t ...SAFE
Policy FOR/AS changedEither field differsDrop + recreateSAFE
Policy droppedName absent in desiredDROP POLICY name ON tSAFE
Trigger addedName absent in snapshotCREATE TRIGGER name ...SAFE
Trigger changedAny field differsDrop + recreateSAFE
Trigger enable state changed only (Section 7.9)trigger-enable-state differs, rest unchangedALTER TABLE t ENABLE/DISABLE TRIGGER name (or ENABLE REPLICA/ALWAYS TRIGGER)SAFE
Trigger droppedName absent in desiredDROP TRIGGER name ON tSAFE
Grant addedNot in snapshot grant listGRANT privs ON TABLE t TO roleSAFE
Owner changedowner differsALTER TABLE t OWNER TO roleSAFE
Comment changedcomment differsCOMMENT ON TABLE t IS '...'SAFE
Table renamedrenamed_from setALTER TABLE old RENAME TO newCAUTION
Table moved to another schema (RENAMED FROM schema-qualified, Section 7.6)Schema component of renamed_from differsALTER TABLE old_schema.name SET SCHEMA new_schemaSAFE
Tablespace changedtablespace (Section 7.1’s TABLESPACE clause) differsALTER TABLE t SET TABLESPACE tsCAUTION
Access method changedUSING method (Section 7.1) differsALTER TABLE t SET ACCESS METHOD methodCAUTION
Storage parameters changedWITH (...) (Section 7.1) params differALTER TABLE t SET (...) / ALTER TABLE t RESET (...)SAFE
Parent added/removedINHERITS (...) (Section 7.1) list differsALTER TABLE child INHERIT parent / ALTER TABLE child NO INHERIT parentSAFE
Typed-table association added/switched/removedOF type_name (Section 7.1) differsALTER TABLE t OF type_name / ALTER TABLE t NOT OFCAUTION
REPLICA IDENTITY changedreplica-identity-dir (Section 7.11) differsALTER TABLE t REPLICA IDENTITY ...SAFE
CLUSTER ON changed/removedcluster-dir (Section 7.11) differsALTER TABLE t CLUSTER ON index / ALTER TABLE t SET WITHOUT CLUSTERSAFE
LOGGED/UNLOGGED toggled (Section 7.12)UNLOGGED prefix differsALTER TABLE t SET LOGGED / ALTER TABLE t SET UNLOGGEDCAUTION
Partition attached/detached (Section 7.13)ATTACHED FROM/DETACHED FROM presentALTER TABLE parent ATTACH/DETACH PARTITION ...CAUTION/MANUAL
Table droppedAbsent in desired, not PROTECTEDDROP TABLE t [CASCADE]DESTRUCTIVE

FUNCTION / PROCEDURE:

FieldChangeDDLSafety
New functionAbsent in snapshotCREATE OR REPLACE FUNCTION ...SAFE
Body hash changedbody_hash differsCREATE OR REPLACE FUNCTION ...SAFE
Attribute changed (volatility, strict, security, leakproof, parallel, cost, rows, set options)Field differsCREATE OR REPLACE FUNCTION ...SAFE
Argument list or return type changedType key differsDROP FUNCTION CASCADE; CREATE FUNCTIONDESTRUCTIVE
[NO] DEPENDS ON EXTENSION changed (Function/Procedure, Sections 9.1/9.2)Extension-dependency set differsALTER FUNCTION ... [NO] DEPENDS ON EXTENSION extSAFE
Grant addedNot in snapshotGRANT EXECUTE ON FUNCTION ...SAFE
Revocation addedNot in snapshotREVOKE EXECUTE ON FUNCTION ... FROM roleSAFE
Owner changedowner differsALTER FUNCTION ... OWNER TO roleSAFE
Comment changedcomment differsCOMMENT ON FUNCTION ...SAFE
Renamed (RENAMED FROM)renamed_from setALTER FUNCTION ... RENAME TO newCAUTION
Moved to another schema (RENAMED FROM schema-qualified)Schema component differsALTER FUNCTION ... SET SCHEMA new_schemaSAFE
Function droppedAbsent in desiredDROP 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:

ChangeDDLSafety
New viewCREATE VIEW ...SAFE
Query changed, same column listCREATE OR REPLACE VIEW ...SAFE
Column list changedDROP VIEW CASCADE; CREATE VIEWDESTRUCTIVE
View/Matview renamed (RENAMED FROM, Sections 8.1/8.2)ALTER [MATERIALIZED] VIEW old RENAME TO newCAUTION
View/Matview moved to another schema (RENAMED FROM schema-qualified)ALTER [MATERIALIZED] VIEW old_schema.name SET SCHEMA new_schemaSAFE
View droppedDROP VIEW CASCADEDESTRUCTIVE

ENUM:

ChangeDDLSafety
New valueALTER 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 changedCOMMENT ON TYPE name IS '...'SAFE
Owner changedALTER TYPE name OWNER TO roleSAFE
Renamed (RENAMED FROM on the type)ALTER TYPE old RENAME TO newCAUTION
Moved to another schema (RENAMED FROM schema-qualified)ALTER TYPE old_schema.name SET SCHEMA new_schemaSAFE

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:

ChangeDDLSafety
New sequenceCREATE SEQUENCE ...SAFE
Numeric parameters changedALTER 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/UNLOGGEDCAUTION
AS type changedDrop + recreateDESTRUCTIVE
Sequence droppedDROP SEQUENCE nameDESTRUCTIVE

ROLE:

ChangeDDLSafety
New roleCREATE ROLE name WITH ...SAFE
Any option changedALTER ROLE name WITH [options]SAFE
Membership added/changed (WITH ADMIN/INHERIT/SET, Section 11.1)GRANT role TO member [WITH ...]SAFE
Membership removedREVOKE role FROM memberCAUTION
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 newCAUTION
Role droppedDROP ROLE nameDESTRUCTIVE

EVENT TRIGGER:

ChangeDDLSafety
New event triggerCREATE EVENT TRIGGER ...SAFE
Event/tags/function changedDROP EVENT TRIGGER + CREATE EVENT TRIGGERSAFE
Enable state changed only (Section 14.1)ALTER EVENT TRIGGER name ENABLE/DISABLE [REPLICA/ALWAYS]SAFE
Owner changedALTER EVENT TRIGGER name OWNER TO roleSAFE
Renamed (RENAMED FROM)ALTER EVENT TRIGGER old RENAME TO newCAUTION
Event trigger droppedDROP EVENT TRIGGER nameSAFE

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:

  1. A table column whose type is a user-defined type or domain creates an edge from the table to the type/domain.

  2. A REFERENCES FK constraint creates an edge from the source table to the target table.

  3. 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.

  4. A function’s search_path or SECURITY DEFINER context creates an edge from the function to the schema.

  5. An index that uses a custom operator class creates an edge from the index (and transitively its table) to the operator class.

  6. A trigger function reference creates an edge from the table to the trigger function.

  7. A domain whose base type is a user-defined type creates an edge from the domain to the type.

  8. A partition creates an edge from the partition to its parent partitioned table.

  9. A LANGUAGE sql function/procedure body that statically references table or view B (a FROM/JOIN clause, an INSERT/UPDATE/DELETE target, a CTE, or a subquery) creates an edge from the function/procedure to B. Dynamic SQL (an EXECUTE whose 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. A LANGUAGE plpgsql function/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 plpgsql is exempt: PostgreSQL compiles a plpgsql body lazily — the embedded SQL statements are not resolved against the catalog until the function is first called, so a plpgsql function can be created referencing a table (or another function) that does not exist yet. Adding a function→table edge for plpgsql bodies is therefore never required for a successful apply, 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 no FOREIGN KEY anywhere in it, which Section 22.2’s cycle-breaker cannot resolve (step 2a’s DEFERRABLE check has nothing to examine, since the cycle contains no FK edge at all). Exempting plpgsql here mirrors the reasoning already used for function-calls-function edges: a LANGUAGE sql body calling another function creates an edge (sql is validated eagerly), a plpgsql body 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:

  1. Identify all strongly connected components (SCCs) using Tarjan’s algorithm.

  2. 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 ... DEFERRABLE after all tables in the SCC are created).

    c. Re-run the topological sort on the cycle-free graph.

  3. After the topological sort, append ALTER TABLE ADD CONSTRAINT ops 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: if link = "env:VAR" is used, the resolved value is never written to disk (via pipeline.SecretResolver/ChainResolver, Appendix D.5). If url = 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 CONNECTION strings (Section 13.2): MAY hold one or more {{secret-uri}} placeholders embedded in an otherwise-literal conninfo (or the whole value), resolved via pipeline.ResolveTemplate (Appendix D.5) immediately before CREATE SUBSCRIPTION executes — never during plan/diff, never written to the snapshot, an archived migration file, or any error message. A CONNECTION value with no {{...}} at all is opaque literal text, same as before this existed — the same operator responsibility as url = above.
  • Role PASSWORD (Section 11.1): same {{secret-uri}} mechanism as Subscription CONNECTION above. The snapshot stores a hash of the declared text (never the resolved value), enabling rotation detection — not just a boolean has_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 — OPTIONS keys are foreign-data-wrapper-specific, not fixed by DPG, so there’s no single clause to target the way CONNECTION/PASSWORD are. 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.
FeatureStatusNotes
Tables (regular)Declared, DiffedFull 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, DiffedUNLOGGED prefix; toggling post-creation uses SET LOGGED/SET UNLOGGED, not drop+recreate
Tables (temporary)Out of scopeSession-scoped
Columns — all built-in typesDeclared, DiffedIn () list
Columns — generated (ALWAYS AS ... STORED/VIRTUAL)Declared, DiffedIn () list; VIRTUAL is PG18+ (Section 7.2)
Columns — identity (AS IDENTITY)Declared, DiffedIn () list
Column COMPRESSIONDeclared, DiffedCOLUMN c { COMPRESSION m; }
Column STORAGEDeclared, DiffedCOLUMN c { STORAGE s; }
Column statistics targetsDeclared, DiffedCOLUMN c { STATISTICS n | DEFAULT; }DEFAULT is PG17+
Column commentsDeclared, DiffedCOLUMN c { COMMENT '...'; }
Column DEPRECATEDDeclared, DiffedCOLUMN c { DEPRECATED '...'; }
Column USING (type change)Declared, DiffedCOLUMN c { USING expr; }
Column renamesDeclared, DiffedCOLUMN new { RENAMED FROM old; }
Column-level grantsDeclared, DiffedCOLUMN c { GRANTS { ... } }
Column-level revocationsDeclared, DiffedCOLUMN c { REVOCATIONS { ... } }
Inline constraints (PK, UNIQUE, CHECK, FK, NOT NULL)Declared, DiffedSingle-column emitted inline; table-level named NOT NULL is PG18+ (Section 7.3)
Named constraints in () listDeclared, DiffedEmitted inline for single-column
Constraints in {} blockDeclared, DiffedNOT VALID required here
EXCLUSION constraintsDeclared, DiffedIn () or {} block; on partitioned tables since PG17 (Section 7.3, passthrough)
NOT VALID / VALIDATE CONSTRAINTDeclared, DiffedMulti-migration lifecycle; extended to NOT NULL constraints in PG18 (Section 7.3)
ENFORCED/NOT ENFORCED (CHECK/FOREIGN KEY)Declared, DiffedPG18+ (Sections 7.2/7.3)
Temporal keys (WITHOUT OVERLAPS/PERIOD)Declared, DiffedPG18+; 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, DiffedPG15+, column-scoped variant of the existing bare form (Section 7.2)
Indexes — all access methodsDeclared, Diffedbtree, hash, gin, gist, brin, spgist, bloom
Indexes — partialDeclared, DiffedWHERE predicate as text
Indexes — expressionDeclared, DiffedExpression as text
Indexes — covering (INCLUDE)Declared, DiffedDrop + recreate on change
Indexes — concurrent creationDeclared, ManualCREATE INDEX CONCURRENTLY
Indexes — ON ONLY, opclass parameters, NULLS [NOT] DISTINCT, renameDeclared, DiffedONLY suppresses partition recursion; rename is metadata-only (Section 7.7)
ENUM typesDeclared, DiffedMIGRATE REMOVE for value removal; BEFORE/AFTER positional ADD VALUE, RENAME VALUE, OWNER, RENAMED FROM/SET SCHEMA
Composite typesDeclared, DiffedAttribute COLLATE; OWNER, RENAMED FROM/SET SCHEMA
Range typesDeclared, DiffedAny option change = DESTRUCTIVE; OWNER, RENAMED FROM/SET SCHEMA; multirange companion auto-created, never separately declared
Domain typesDeclared, DiffedCOLLATE; NOT VALID/VALIDATE CONSTRAINT/RENAME CONSTRAINT lifecycle; OWNER, RENAMED FROM/SET SCHEMA
Base (shell) typesDeclared, PassthroughBare 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 typesDeclared, No SQLDPG-native; snapshot only
ViewsDeclared, DiffedColumn list change = DESTRUCTIVE; RENAMED FROM and cross-schema SET SCHEMA supported (Section 7.6, Section 8.1)
Materialized viewsDeclared, DiffedQuery change = DESTRUCTIVE; RENAMED FROM/SET SCHEMA supported (Section 8.2)
Recursive viewsDeclared, Diffed
Functions — all languagesDeclared, Passthrough bodyBody hash-diffed; LEAKPROOF, TRANSFORM FOR TYPE, C/internal AS forms, PG14+ BEGIN ATOMIC, [NO] DEPENDS ON EXTENSION, OWNER, REVOCATIONS, RENAMED FROM/SET SCHEMA
ProceduresDeclared, Passthrough bodySame additions as Functions above ([NO] DEPENDS ON EXTENSION, OWNER, REVOCATIONS, RENAMED FROM/SET SCHEMA)
AggregatesDeclared, DiffedFull 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 functionsDeclared, Passthrough body
Row Level SecurityDeclared, DiffedTO/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)
TriggersDeclared, DiffedRENAMED FROM supported, metadata-only via ALTER TRIGGER (Section 7.9)
Event triggersDeclared, PassthroughReconstructed 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)
SequencesDeclared, DiffedUNLOGGED, OWNED BY NONE, REVOCATIONS, RESTART [WITH n] (Section 10)
SchemasDeclared, Diffed
ExtensionsDeclared, DiffedSCHEMA change uses non-destructive ALTER EXTENSION ... SET SCHEMA for relocatable extensions (Section 6.2)
RolesDeclared, DiffedCluster-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 grantsDeclared, DiffedAdditive model; MAINTAIN privilege (PG17) and GRANTED BY role (Section 7.10) also supported
Parameter Privileges (Section 11.6)Declared, DiffedCluster-level; GRANT {SET|ALTER SYSTEM} ON PARAMETER, additive model like Table-level grants
Column-level grantsDeclared, DiffedAdditive model
Explicit revocationsDeclared, Diffed
Default PrivilegesDeclared, Diffed
Security Labels (Section 14.11)Declared, DiffedKeyed by provider; every kind PostgreSQL’s own SECURITY LABEL grammar supports and DPG models
TablespacesDeclared, PassthroughCluster-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 WrappersDeclared, PassthroughCluster-level; reconstructed from catalog, hash-diffed
Foreign ServersDeclared, PassthroughReconstructed from catalog; hash-diffed. OWNER, RENAMED FROM, bare VERSION-only change also supported (Section 14.9)
User MappingsDeclared, PassthroughReconstructed 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 TablesDeclared, DiffedSERVER/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 TablesDeclared, DiffedPartition RENAMED FROM supported, same mechanism as a plain table rename (Section 7.13)
Sub-partitioningDeclared, Diffed
PublicationsDeclared, PassthroughReconstructed from catalog; hash-diffed. OWNER, RENAMED FROM also supported (Section 13.1)
SubscriptionsDeclared, PassthroughReconstructed 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
CollationsDeclared, PassthroughReconstructed 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)
OperatorsDeclared, PassthroughReconstructed from catalog, hash-diffed; any change = DESTRUCTIVE
Operator ClassesDeclared, PassthroughReconstructed from catalog, hash-diffed; any AS member-list change = DESTRUCTIVE (PostgreSQL has no incremental ALTER OPERATOR CLASS)
Operator FamiliesDeclared, Passthrough + DiffedHeader (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
CastsDeclared, PassthroughReconstructed from catalog, hash-diffed; any change = DESTRUCTIVE
Extended Statistics ObjectsDeclared, PassthroughReconstructed from catalog; hash-diffed
Text Search ConfigurationsDeclared, PassthroughReconstructed from catalog; hash-diffed. OWNER, RENAMED FROM/SET SCHEMA, ALTER MAPPING REPLACE (bulk and per-token-type) also supported (Section 12.1)
Text Search DictionariesDeclared, PassthroughReconstructed from catalog; hash-diffed. OWNER/COMMENT/RENAMED FROM block now supported (Section 12.2)
Text Search ParsersDeclared, PassthroughReconstructed from catalog; hash-diffed. COMMENT/RENAMED FROM block now supported, no OWNER (real PostgreSQL has none for this kind, Section 12.3)
Text Search TemplatesDeclared, PassthroughReconstructed from catalog; hash-diffed. COMMENT/RENAMED FROM block now supported, no OWNER (Section 12.4)
Macro preprocessorDeclared, No SQLCompile-time text expansion
Cross-file macro sharingDeclared, No SQLMacros defined in any file in the compilation scope are available to all others
Rules (REWRITE)Out of scopeLegacy
IMPORT FOREIGN SCHEMAOut of scopeRuntime discovery
REFRESH MATERIALIZED VIEWOut of scopeRuntime DML
Temporary tablesOut of scopeSession-scoped
Temporary viewsOut of scopeSession-scoped, same reasoning as temporary tables (Section 8.1)
Inline data seedingOut of scopeDPG is a schema tool; data management is outside its scope
Database management (CREATE/ALTER/DROP DATABASE)Out of scopeCluster-provisioning, not schema management; see Section 23 — permanent, not deferred
REASSIGN OWNED BY / DROP OWNED BYOut of scopeOne-shot maintenance command, not a declarable object; see Section 23
ALTER SYSTEMOut of scopeCluster-wide GUC configuration, not schema state; see Section 23
CREATE ACCESS METHODOut of scopeCovered by extension install (CREATE EXTENSION); see Section 23
CREATE CONVERSIONOut of scopeNo realistic use case under UTF-8 dominance; see Section 23
CREATE [PROCEDURAL] LANGUAGEOut of scopeCovered by extension install (CREATE EXTENSION); see Section 23
CREATE TRANSFORMOut of scopeAlways bundled with a specific extension pairing; see Section 23
ALTER EXTENSION ADD/DROP member_objectOut of scopeExtension-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

CodeNameDescription
DPG-E000unlabeled_compile_errorGeneric 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-E001unknown_config_keyUnknown key in dpg.toml.
DPG-E002ambiguous_connectionBoth url and link set in cluster config.
DPG-E003no_connection_configuredCommand requires a connection but neither url nor link is set.
DPG-E004reserved_name_conflictA database directory name matches the cluster objects directory name.
DPG-E005conflicting_set_memberSame-named set-valued property has conflicting definitions across files.
DPG-E006forbidden_verbCREATE, ALTER, or DROP at declaration level in a .dpg file.
DPG-E007macro_inside_blockMACRO declaration found inside a block.
DPG-E008paren_macro_in_blockParen-body macro spread inside a { } block.
DPG-E009brace_macro_in_parenBrace-body macro spread inside a ( ) list.
DPG-E010undefined_macroSpread of undefined macro name.
DPG-E011duplicate_macroMacro name redeclared in the same file.
DPG-E012circular_macroCircular macro reference detected.
DPG-E013enum_migration_data_remainsRows still hold a removed ENUM value after the MIGRATE REMOVE DML ran.
DPG-E014unguarded_enum_removalENUM value removed without a MIGRATE REMOVE block.
DPG-E015invalid_virtual_type_directiveVIRTUAL TYPE { } block contains a directive other than COMMENT.
DPG-E016not_valid_in_paren_listNOT VALID used in a column or constraint inside the ( ) list.
DPG-E017unresolvable_cycleCircular FK dependency with no DEFERRABLE FK.
DPG-E018unknown_column_referenceCOLUMN name { } block references a column not in the ( ) list.
DPG-E019stale_column_name_in_indexIndex or constraint references old column name after a rename.
DPG-E020statistics_target_out_of_rangeSTATISTICS n value is outside [-1, 10000].
DPG-E021stale_renamed_fromRENAMED FROM references a name absent from both source and snapshot.
DPG-E022protected_drop_attemptDiff would drop a PROTECTED object.
DPG-E023temporary_table_declaredTEMPORARY TABLE keyword found in a .dpg file.
DPG-E024unknown_block_directiveUnknown directive for this object kind in a { } block.
DPG-E025destructive_ops_blockedMigration contains DESTRUCTIVE ops but --allow-destructive not passed.
DPG-E026multiple_clusters_no_flagMultiple clusters found; --cluster required.
DPG-E027cluster_not_found--cluster value does not match any cluster.
DPG-E028multiple_databases_no_flagMultiple databases found; --database required.
DPG-E029database_not_found--database value does not match any database.
DPG-E030invalid_namemap_ruleUnknown rule keyword in [namemaps] config or NAME MAP block directive.
DPG-E031duplicate_namemap_toolSame tool key specified more than once at the same block level (warning only; last entry wins).
DPG-E032cluster_name_requiredCluster dpg.toml is missing a [cluster] name (Section 3.3).
DPG-E033database_name_requiredDatabase dpg.toml is missing a [database] name (Section 3.4).
DPG-E034duplicate_cluster_nameTwo cluster directories declare the same name (Section 3.6).
DPG-E035duplicate_database_nameTwo database directories within the same cluster declare the same name (Section 3.6).
DPG-E036owner_role_not_a_memberConnecting 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:

  1. If there is exactly one cluster in the project, it is selected automatically. No --cluster flag is required.
  2. If there are multiple clusters and --cluster is not set, the compiler MUST emit error DPG-E026 listing all available cluster names.
  3. If --cluster is 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):

  1. If there is exactly one database in the cluster, it is selected automatically.
  2. If there are multiple databases and --database is not set, the compiler MUST emit error DPG-E028 listing the available database names.
  3. If --database is 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)DescriptionDefault Level
hardcoded-passwordA 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-passwordA 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-passwordA 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
deprecatedObject 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-commentColumn 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-exceededTable 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-pathSECURITY DEFINER function body does not reference search_path.Warning
serial-sequence-declaredA 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-revocationA 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-referenceA 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-conflictTwo 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-versionA 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 constantInterfaceDefault implementation
pipeline.KeyTokenizerpipeline.Tokenizerinternal/scanner
pipeline.KeyPGSQLParserpipeline.PGSQLParserinternal/pgparser.LibPQParser
pipeline.KeyBlockParserpipeline.BlockParserinternal/blockparser
pipeline.KeyIRBuilderpipeline.IRBuilderinternal/ir.Builder
pipeline.KeyMergerpipeline.Mergerinternal/merger
pipeline.KeyDependencyResolverpipeline.DependencyResolverinternal/graph
pipeline.KeySnapshotStorepipeline.SnapshotStoreinternal/snapshot.FileStore
pipeline.KeyDifferpipeline.Differinternal/diff.Differ
pipeline.KeyEmitterpipeline.Emitterinternal/emit.Emitter
pipeline.KeyApplyExecutorpipeline.ApplyExecutorinternal/executor.PgxExecutor
pipeline.KeyIntrospectorpipeline.Introspectorinternal/introspect.CatalogIntrospector
pipeline.KeyLinterpipeline.Linterinternal/linter.BuiltinLinter
pipeline.KeyPortabilityAnalyzerpipeline.PortabilityAnalyzerinternal/portability.Analyzer
pipeline.KeySecretResolverpipeline.SecretResolverinternal/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:

  1. Read .git/HEAD relative to the current working directory. If not present, source_revision is empty string.

  2. 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.

  3. Otherwise, the content itself is the commit hash (detached HEAD).

  4. 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 KeywordExample transformation of user_profile_id
LOWER_SNAKE_CASEuser_profile_id
UPPER_SNAKE_CASEUSER_PROFILE_ID
LOWER_CAMEL_CASEuserProfileId
UPPER_CAMEL_CASEUserProfileId
LOWER_KEBAB_CASEuser-profile-id
UPPER_KEBAB_CASEUSER-PROFILE-ID
TRAIN_CASEUser-Profile-Id
LOWER_CASEuserprofileid
UPPER_CASEUSERPROFILEID
PASCAL_SNAKE_CASEUser_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 the default tool 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 implicit default shorthand is NOT available inside the group.
  • Multiple singular NAME MAP directives and NAME MAPS blocks 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:

FieldTypeRequiredDescription
toolstringYESTool key (e.g., "prisma", "default").
rulestringone of rule/nameOne of the ten rule keywords. Present when the value is a rule.
namestringone of rule/nameLiteral 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 IDCode StringCondition
DPG-E030invalid_namemap_ruleUnknown rule keyword in [namemaps] config or NAME MAP block directive.
DPG-E031duplicate_namemap_toolSame 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.TypeMarker (Column.Serial)
SERIAL, SERIAL4integer"SERIAL"
SMALLSERIAL, SERIAL2smallint"SMALLSERIAL"
BIGSERIAL, SERIAL8bigint"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.

RevisionDateDescription
E.12026-05-13Initial 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.22026-05-13Appendix 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.32026-05-13Appendix 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.42026-05-17Appendix 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.52026-08-16Appendix 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.62026-08-17Section 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.72026-08-17Section 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.82026-08-19Section 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.92026-08-19Section 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.102026-08-19Section 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.112026-08-23Section 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.122026-08-23Appendix 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.132026-08-23Sections 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: identifierqual-name, additive) reused by every later revision below. Folded in Table LOGGED/UNLOGGED DESTRUCTIVE→safe-ALTER swap (Section 7.12).
E.142026-08-23Sections 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.152026-08-23Sections 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.162026-08-23Sections 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.172026-08-23Sections 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.182026-08-23Closed 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.192026-08-23Sections 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.202026-08-24min_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.212026-08-25Section 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.222026-08-26Section 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.232026-08-26Two 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.242026-08-26Closed 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 TABLE grammar, 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 portability never 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.

SectionConstructClassificationMin PG VersionNotes
Sections 4.1-4.6Basic lexical rules (identifiers, comments, dollar-quoting, statement terminators)N/ADPG source-file syntax, not PostgreSQL DDL.
Section 5.1CREATE TYPE ... AS ENUMPGSpecificISO SQL has no enumerated type; closest standard analogue is a CHECK constraint or DOMAIN.
Section 5.2CREATE TYPE ... AS (...) (composite)PGSpecificStructured/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.3CREATE TYPE ... AS RANGEPGSpecificNo standard range type.
Section 5.4CREATE DOMAIN core (name, base type)StandardSQL:1999+ CREATE DOMAIN.
Section 5.4Domain DEFAULT/CONSTRAINT ... CHECK/NOT NULLStandardSame standard CREATE DOMAIN clauses.
Section 5.4Domain COLLATEPGSpecificPostgreSQL-specific collation attachment syntax (the standard has collations, but not this clause shape).
Section 5.5CREATE TYPE (...) (base/shell type)PGSpecificC-level storage type definition; no standard equivalent.
Section 5.6VIRTUAL TYPEN/ADPG-native, generates no SQL.
Section 6CREATE EXTENSIONPGSpecificPostgreSQL’s own extension-packaging mechanism.
Sections 7.1-7.2CREATE TABLE core (columns, types, PRIMARY KEY/UNIQUE/CHECK/FOREIGN KEY)StandardCore relational DDL.
Section 7.1WITH (storage_params)PGSpecificPostgreSQL storage parameters.
Section 7.1TABLESPACEPGSpecificNo standard tablespace concept.
Section 7.1INHERITSPGSpecificPostgreSQL table inheritance; not in the standard.
Section 7.1UNLOGGEDPGSpecificPostgreSQL crash-safety trade-off, no standard equivalent.
Section 7.2Generated columns (GENERATED ALWAYS AS ... STORED)StandardSQL:2003 generated columns.
Section 7.2Generated columns (GENERATED ALWAYS AS ... VIRTUAL)Standard18SQL:2003 generated columns also standardize VIRTUAL; PostgreSQL only implemented the STORED variant until PostgreSQL 18 added this one.
Section 7.2Identity columns (GENERATED ... AS IDENTITY)StandardSQL:2003 identity columns.
Section 7.2SERIAL/BIGSERIAL/SMALLSERIALPGSpecificPre-standard PostgreSQL sequence sugar; IDENTITY is the standard-conformant replacement.
Section 7.2Column COMPRESSION/STORAGEPGSpecificPostgreSQL TOAST-related storage tuning.
Section 7.2EXCLUDE constraintsPGSpecificNo standard exclusion-constraint concept.
Section 7.2NOT VALID/VALIDATE CONSTRAINTPGSpecificPostgreSQL’s incremental constraint-validation lifecycle.
Section 7.2NOT NULL ... NO INHERITPGSpecific18Extends the pre-existing CHECK ... NO INHERIT modifier (floor-14 PGSpecific) to NOT NULL constraints for the first time.
Section 7.2ENFORCED/NOT ENFORCED (CHECK/FOREIGN KEY)PGSpecific18PostgreSQL’s own catalog-recorded-but-unchecked constraint mode; no SQL-standard citation for this exact clause.
Section 7.2FK ON DELETE/ON UPDATE SET NULL/SET DEFAULT (col-list)PGSpecific15Column-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.3Table-level named NOT NULL constraintStandard18PostgreSQL 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.3Temporal keys (WITHOUT OVERLAPS/PERIOD, incl. temporal FOREIGN KEY)Mixed18SQL: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.4Column STATISTICS n / STATISTICS DEFAULTPGSpecific17 (DEFAULT keyword only; numeric target is floor-14)Planner statistics-target tuning; no standard equivalent.
Section 7.7CREATE INDEX coreMixedIndexes 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.7CREATE INDEX (all forms: access methods, partial, expression, covering, opclass)PGSpecificNo SQL-standard CREATE INDEX statement exists; every vendor’s index DDL is proprietary.
Section 7.8ROW LEVEL SECURITY/CREATE POLICYPGSpecificNo standard row-security mechanism.
Section 7.9CREATE TRIGGER core (BEFORE/AFTER, events, FOR EACH ROW)StandardSQL:1999+ triggers.
Section 7.9REFERENCING OLD/NEW TABLE (transition tables)StandardSQL:1999+ triggers include transition tables.
Section 7.9WHEN (condition), EXECUTE FUNCTIONPGSpecificPostgreSQL trigger-function-calling model differs from the standard’s inline trigger action.
Section 7.10GRANTED BY roleStandardReal PostgreSQL accepts this SQL-standard-compatibility clause but restricts the effective grantor to current_user in practice.
Section 7.11RENAMED FROM, PROTECTED, DEPRECATED, DROP CASCADE (directives)N/ADPG-native lifecycle metadata.
Section 7.11OWNERPGSpecificPostgreSQL’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.12UNLOGGED TABLEPGSpecificSee Section 7.1 row.
Section 7.12CREATE FOREIGN TABLEStandardSQL/MED (ISO/IEC 9075-9) standardizes foreign tables; PostgreSQL’s FDW mechanism implements it.
Section 7.13PARTITION BY/PARTITION OFStandardSQL: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.3DROP CONSTRAINT ... ONLY on a partitioned table (partition-local constraint declaration)PGSpecific18PostgreSQL’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 8CREATE VIEWStandardCore SQL.
Section 8CREATE MATERIALIZED VIEWPGSpecificMaterialized views are a common vendor extension, not in ISO/IEC 9075.
Section 8RECURSIVE VIEW/WITH RECURSIVEStandardSQL:1999+ recursive query support.
Sections 9.1-9.2CREATE FUNCTION core (name, args, RETURNS, LANGUAGE)StandardSQL/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.1LANGUAGE sql/plpgsql dollar-quoted bodiesPGSpecificDollar-quoting itself, and plpgsql, are PostgreSQL-specific; SQL/PSM’s own procedural language differs.
Section 9.1PG14+ sql_body/BEGIN ATOMIC formStandardThe ISO-standard-conformant alternative to dollar-quoting for LANGUAGE sql functions; available since this specification’s own floor, not a later gate.
Section 9.2VOLATILE/STABLE/IMMUTABLE, PARALLEL SAFE/etc., COST/ROWS, SUPPORTPGSpecificQuery-planner hints with no standard equivalent.
Section 9.2SECURITY DEFINER/SECURITY INVOKERStandardSQL/PSM standardizes routine security characteristics (exact keyword differs by vendor, but the concept is standard).
Section 9.3CREATE PROCEDUREStandardSQL/PSM standardizes procedures distinct from functions.
Section 9.4CREATE AGGREGATEPGSpecificUser-defined aggregates with this declaration shape (SFUNC/STYPE/etc.) are PostgreSQL-specific; the standard has no equivalent CREATE AGGREGATE.
Section 10CREATE SEQUENCEStandardSQL:2003+ standardizes sequences (AS/INCREMENT/MINVALUE/MAXVALUE/START/CACHE/CYCLE are all standard clauses).
Section 10OWNED BYPGSpecificPostgreSQL’s sequence-to-column ownership link has no standard equivalent.
Section 11.1CREATE ROLE/CREATE USER core (LOGIN, PASSWORD)StandardSQL standardizes authorization identifiers, though PostgreSQL’s unified role model (merging users and groups) is its own design.
Section 11.1SUPERUSER/CREATEDB/CREATEROLE/REPLICATION/BYPASSRLS/CONNECTION LIMITPGSpecificPostgreSQL-specific role attributes.
Section 11.2GRANT/REVOKE coreStandardCore SQL privilege model.
Section 11.2MAINTAIN privilegePGSpecific17PostgreSQL-specific privilege covering VACUUM/ANALYZE/CLUSTER/REINDEX.
Section 11.3Role membership (GRANT role TO role, WITH ADMIN OPTION/WITH INHERIT/WITH SET)Mixed16 (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.4ALTER DEFAULT PRIVILEGESPGSpecificNo standard mechanism for privilege templates applied to not-yet-created objects.
Section 11.5Owner impersonation (SET ROLE/RESET ROLE)StandardSQL standardizes SET ROLE; PostgreSQL’s specific creator-attribution semantics this section documents are PostgreSQL’s own catalog behavior.
Section 11.6PARAMETER PRIVILEGES (GRANT {SET|ALTER SYSTEM} ON PARAMETER)PGSpecific15PostgreSQL’s grantable parameter-ACL object type; no standard equivalent.
Section 12.1CREATE TEXT SEARCH CONFIGURATION/MAPPING FOR ... WITH ...PGSpecificPostgreSQL full-text search is entirely proprietary.
Sections 12.2-12.4Text Search Dictionary/Parser/TemplatePGSpecificSame as above.
Section 13.1CREATE PUBLICATIONPGSpecificPostgreSQL’s own logical-replication publish/subscribe model.
Section 13.2CREATE SUBSCRIPTIONPGSpecificSame.
Section 14.1CREATE EVENT TRIGGERPGSpecificNo standard DDL-event trigger mechanism.
Section 14.1Event Trigger login eventPGSpecific17One 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.2CREATE COLLATIONStandardSQL:2003+ standardizes collations; PostgreSQL’s ICU/libc provider mechanism is its own extension of the standard concept.
Section 14.2Collation REFRESH VERSIONPGSpecific15Updates the catalog’s recorded collation-provider version; no standard equivalent.
Section 14.2Collation RULES (ICU tailoring)PGSpecific16PostgreSQL’s own ICU collation-tailoring integration; no standard equivalent.
Section 14.3CREATE CASTStandardSQL standardizes user-defined casts (CREATE CAST), though PostgreSQL’s WITH FUNCTION/WITHOUT FUNCTION/WITH INOUT grammar has PostgreSQL-specific shorthand forms.
Section 14.4CREATE OPERATOR/OPERATOR CLASS/OPERATOR FAMILYPGSpecificUser-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.6CREATE STATISTICS (extended statistics objects)PGSpecificPostgreSQL planner-statistics extension; expression-based statistics (not just plain columns) are floor-14, passed through as opaque text.
Section 14.7CREATE TABLESPACEPGSpecificNo standard physical-storage-location concept.
Section 14.8CREATE FOREIGN DATA WRAPPERStandardSQL/MED standardizes the FDW concept (CREATE FOREIGN DATA WRAPPER), though HANDLER/VALIDATOR functions are PostgreSQL’s own extension mechanism.
Section 14.9CREATE SERVERStandardSQL/MED standardizes foreign servers.
Section 14.10CREATE USER MAPPINGStandardSQL/MED standardizes user mappings for foreign servers.
Section 14.11SECURITY LABELPGSpecificPostgreSQL’s MAC/label-security integration point (SELinux/sepgsql etc.); no standard equivalent.
Sections 15-21Compilation pipeline, snapshot format, CLI, linter, safety classification, diffing semanticsN/ADPG tooling, not PostgreSQL DDL.
Section 22Dependency graph / topological sortN/ADPG compiler internals.
ThroughoutCOMMENT ON ...StandardSQL:1999+ standardizes COMMENT ON, though PostgreSQL supports it on a broader set of object kinds than the standard requires.
Throughout{ } block itself, Name Maps, macrosN/ADPG-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