pgdesign v0.26.0 /Diff Guide
On this page

Guide to pgdesign's diff command: comparing your TOML schema against a live database, another TOML file, or a git ref, with risk-annotated output.

#Diff Guide

The pgdesign diff command compares your current TOML schema against a target and reports every difference: tables, columns, enums, views, functions, constraints, indexes, policies, sequences, domains, composite types, state machines, and partitioning configuration. Each column change carries a risk classification (Safe, Caution, or Dangerous) so you know what will happen before you run a migration.

Source: cmd/pgdesign/handlers_diff.go, internal/diff/

#internal/diff

Package diff compares two resolved schemas or a schema against a live database and produces a structured diff with risk annotations on each change.

#SchemaDiff

Go go
type SchemaDiff struct

SchemaDiff describes the differences between a desired and actual schema.

#RenamePair

Go go
type RenamePair struct

RenamePair is a resolved from->to rename (table or column).

#RenameSpec

Go go
type RenameSpec struct

RenameSpec is the set of declared renames consumed at diff time (parsed from the project config [renames] section). It is a pure, committed, CI-safe migration directive — never part of the schema's canonical identity.

#ColumnRenameSpec

Go go
type ColumnRenameSpec struct

ColumnRenameSpec declares a single column rename within a table.

#SMTransitionDiff

Go go
type SMTransitionDiff struct

SMTransitionDiff describes changes to a state machine type's transitions. Enum value changes (states added/removed) are tracked separately in EnumsChanged.

#SMTransitionRef

Go go
type SMTransitionRef struct

SMTransitionRef identifies a single directed transition edge (from -> to).

#TableDiff

Go go
type TableDiff struct

TableDiff describes the differences within a single table.

#ColumnChange

Go go
type ColumnChange struct

ColumnChange describes a change to a single column, with risk classification.

#EnumDiff

Go go
type EnumDiff struct

EnumDiff describes changes to an enum type.

#EnumValueInsert

Go go
type EnumValueInsert struct

EnumValueInsert describes an enum value inserted in the middle of an existing enum, requiring BEFORE/AFTER syntax in ALTER TYPE.

#ViewDiff

Go go
type ViewDiff struct

ViewDiff describes changes to a view.

#MaterializedViewDiff

Go go
type MaterializedViewDiff struct

MaterializedViewDiff describes changes to a materialized view.

#SequenceDiff

Go go
type SequenceDiff struct

SequenceDiff describes changes to a sequence.

#CompositeTypeDiff

Go go
type CompositeTypeDiff struct

CompositeTypeDiff describes changes to a composite type. Composite type changes are destructive (DROP + CREATE CASCADE).

#CompositeFieldChange

Go go
type CompositeFieldChange struct

CompositeFieldChange describes a change to a composite type field.

#FunctionDiff

Go go
type FunctionDiff struct

FunctionDiff describes changes to a function/procedure.

#DomainDiff

Go go
type DomainDiff struct

DomainDiff describes changes to a domain type.

#FKChange

Go go
type FKChange struct

FKChange describes a changed foreign key constraint.

#IndexChange

Go go
type IndexChange struct

IndexChange describes a changed index.

#TriggerChange

Go go
type TriggerChange struct

TriggerChange describes a changed trigger.

#PolicyDiff

Go go
type PolicyDiff struct

PolicyDiff describes changes to a single RLS policy.

#PartitionDiff

Go go
type PartitionDiff struct

PartitionDiff describes changes to a table's partitioning configuration.

#MaintenanceDiff

Go go
type MaintenanceDiff struct

MaintenanceDiff describes changes to a table's partman maintenance configuration.

#LiveNormalizer

Go go
type LiveNormalizer interface

LiveNormalizer resolves the ≈_pg RESIDUE that pure N cannot reach: catalog-dependent cast materialization (e.g. status = 'active' vs the PG-stored status = 'active'::text). It round-trips a desired-side expression through the target database — PG computes its own canonical form — so the desired side matches the introspected (already PG-canonical) side.

It is used ONLY on the live diff path (diff --live). Identity NEVER consumes its output: the pure/encoding path has no database. Implementations are best-effort total — an expression a round-trip cannot reach (referencing an absent table/column) falls to the minimal forward-simulation rule set, which is N itself.

#Diff

Go go
func Diff(desired, actual *model.Schema) *SchemaDiff

Diff compares two registry-present models (desired vs actual) and returns a structured diff. Items in desired but not in actual are "added"; items in actual but not in desired are "removed". Both sides are the SAME model class (L7): semantic type names are compared. Use DiffLive when actual is an introspected (registry-absent) schema.

#DiffLive

Go go
func DiffLive(desired, actual *model.Schema, ln LiveNormalizer) *SchemaDiff

DiffLive compares a registry-present desired model against an INTROSPECTED (registry-absent) actual schema, with an optional LiveNormalizer. Because the introspected side carries no semantic type information (L7 — a different model class), class-aware fields such as Column.SemanticTypeName are NOT compared, so they never false-drift. When ln is non-nil (the diff --live path), the DESIRED side's table-scoped expressions are round-tripped through the target DB before comparison, resolving the catalog-dependent cast residue. The caller's desired schema is never mutated — a normalized copy is built.

#IsWidening

Go go
func IsWidening(oldType, newType string) bool

IsWidening returns true if oldType -> newType is a safe widening conversion. Arguments are SQL type strings (e.g., from typeinfo.Reconstruct output).

#ChangedObjectKeys

Go go
func ChangedObjectKeys(desired, actual *model.Schema) ([]enc.Key, error)

ChangedObjectKeys is the per-object-id DIFF FAST PATH (roadmap kernel 1.4, Part I). It builds both models' revision manifests (kind-qualified key -> object-id) and returns the keys whose canonical bytes differ: objects present on one side only, or present on both with a different content id. A caller can then DEEP-diff only these objects and skip every object whose id is unchanged, turning a whole-schema comparison into O(changed objects).

When the result is empty the two models are byte-identical object-for-object, hence ≈_syn-equal, hence diff-empty (the forward conformance direction).

WIRING CHOICE (reported): this is the "kernel utility diff CONSUMES" option, not a short-circuit injected into Diff. Diff remains the AUTHORITATIVE full comparison for two reasons: (1) building two manifests on every Diff call would add an encode pass to the common (unequal) path; (2) more importantly, a manifest-equality short-circuit inside Diff would MASK Diff's own object-by-object logic from the forward-conformance test (which compares a model against its round-trip) — the test would then exercise the short-circuit rather than Diff. Keeping the fast path as a consumed utility preserves both Diff's semantics and the test's teeth. The on-disk chain (roadmap 5.2), where both manifests are already materialized, is the natural caller that prunes its O(objects) work through this function.

#FormatTerminal

Go go
func FormatTerminal(d *SchemaDiff) string

FormatTerminal renders the diff as human-readable colored terminal output.

#FormatJSON

Go go
func FormatJSON(d *SchemaDiff) string

FormatJSON renders the diff as a JSON string.

#CheckTruncationCollisions

Go go
func CheckTruncationCollisions(s *model.Schema) error

CheckTruncationCollisions is the NAMEDATALEN collision guard for the diff path. Content-derived constraint/index names can exceed 63 bytes; when two DISTINCT such names in one table collection truncate to the same 63 bytes, PostgreSQL stores them identically and matchObjectsTrunc's truncation-aware fallback can no longer tell them apart — it would silently pair both desired names against the one truncated actual. This guard runs on the DESIRED schema before any diff and returns a HARD ERROR naming the truncation and every colliding name, per-collection and per-table, so the ambiguity is surfaced loudly instead of producing a wrong migration. It is a pure function of the desired schema (the collision is a property of desired names alone).

#ResolveRenames

Go go
func ResolveRenames(d *SchemaDiff, desired, actual *model.Schema, spec RenameSpec, actualIntrospected bool) error

ResolveRenames applies the rename gate to a computed diff. actual is the base model (the reconstructed head, or the introspected live schema); it may be nil at genesis, where no removals and therefore no renames are possible. actualIntrospected suppresses class-aware column comparison against an introspected base (matching diffColumn's own contract).

#SchemaDiff.IsEmpty

Go go
func (d *SchemaDiff) IsEmpty() bool

IsEmpty returns true if the diff contains no changes.

#SchemaDiff.Summary

Go go
func (d *SchemaDiff) Summary() string

Summary returns a human-readable summary of the diff.

#When to use diff

Use diff whenever you want to preview what a migration would change without actually generating or applying one. It answers the question: "what is different between what I have declared and what exists somewhere else?"

Common scenarios:

  • You edited the TOML schema and want to see what changed before running migrate generate
  • You want to verify that a running database matches your schema (drift detection)
  • You want to compare your working copy against a branch or tag to see the schema delta of a PR
  • You want to compare two independent TOML schema files

#The three modes

The diff command has exactly three modes, specified by mutually exclusive flags: --live compares against a running database, --against compares two TOML files, and --base compares against a git ref. You must pass exactly one flag per invocation.

#--live -- compare against a running database

pgdesign diff schema.toml --live postgres://user:pass@localhost/mydb

Connects to the PostgreSQL database, introspects its catalog (tables, columns, types, constraints, indexes, policies, triggers, functions, sequences, views, materialized views), and compares the result against your compiled TOML schema. The connection URL can also come from the PGDESIGN_DB environment variable.

This mode uses introspect.Introspect to read the live database, then runs DiffLive -- a class-aware comparison that suppresses semantic type name comparisons (the database has no concept of pgdesign's semantic types, so comparing would produce false positives).

When the connection succeeds, pgdesign also performs live round-trip normalization: it round-trips boolean predicates (CHECK expressions, partial-index WHERE clauses, exclusion WHERE clauses, policy USING/WITH CHECK expressions) from the desired side through the target database. PostgreSQL computes its own canonical form, resolving catalog-dependent cast differences (e.g., status = 'active' vs status = 'active'::text) that no pure normalizer can reach. This is best-effort -- if the round-trip connection fails, the diff still runs without it.

The live server's PostgreSQL version is resolved onto the desired model before diffing, so a pinned-but-stale [meta].version does not surface as a spurious pg_version changed line.

Use this mode to:

  • Detect schema drift in a deployed database
  • Verify that a migration was applied correctly
  • Audit a database against the declared schema

#--against -- compare against another TOML file

pgdesign diff schema.toml --against other/schema.toml

Parses and builds both TOML schemas independently, then runs a full Diff comparison between them. Both sides are registry-present models, so all fields are compared -- including semantic type names, which affect codegen output even when the underlying PostgreSQL type is identical.

Use this mode to:

  • Compare two versions of a schema stored in different files
  • Diff a feature branch schema against the main branch schema (when both are checked out)
  • Compare schemas from different projects to find structural differences

#--base -- compare against a git ref

pgdesign diff schema.toml --base main
pgdesign diff schema.toml --base HEAD~3
pgdesign diff schema.toml --base v0.5.0

Extracts the schema files from the specified git ref (branch, tag, or commit) using git show, parses and builds them, then diffs against your current working-copy schema. The ref can be anything git show accepts: a branch name, a tag, a commit SHA, or a relative ref like HEAD~1.

The command resolves which files to extract by reading pgdesign.toml from the git ref (if it exists at that ref) to find the project.schemas list. If the config file does not exist at the ref, it falls back to the same file paths as the current invocation.

Like --against, both sides are registry-present models, so semantic type names are compared.

Use this mode to:

  • Review schema changes in a PR (diff against main)
  • See what changed since a specific release (diff against a version tag)
  • Inspect the schema delta between any two points in history

#Reading the diff output

#Terminal output (default)

The default output is a colored terminal format showing additions, removals, and modifications across all schema object types. The first line is a summary count of changes (e.g., "3 additions, 2 removals, 1 change"), followed by per-object details with risk classification.

Symbols:

Terminal output (default)
SymbolColorMeaning
+greenAdded (new object)
-redRemoved (object deleted)
~yellowChanged (object modified)

Object types reported: extensions, enums, tables, views, materialized views, composite types, domains, sequences, state machine transitions, functions, state machine types.

Column changes include a risk badge:

Terminal output (default)
BadgeMeaning
[SAFE]No data risk. Default changes, comment changes, safe widenings (e.g., integer to bigint).
[CAUTION]Potential data impact. Requires careful review.
[DANGEROUS]Data loss or table rewrite risk. Type narrowing, dropping NOT NULL on a populated column, collation changes.

Each changed column lists the specific fields that differ (type, nullable, default, comment, generated, stored, identity, array, collation, json_schema, statistics, semantic_type), showing the old and new values.

Enum changes distinguish between safe appends (values added at the end) and middle insertions (which require BEFORE/AFTER syntax in ALTER TYPE). Reordering is flagged as dangerous.

Table changes detail every sub-object: columns, foreign keys, indexes, unique constraints, check constraints, exclusion constraints, triggers, policies, RLS settings, partitioning, maintenance (partman) config, and append-only status.

Example output:

2 table(s) changed (3 column(s) modified), 1 enum(s) changed

~ enum order_status
  + cancelled (safe, appended)

~ table orders
  + column tracking_number text [SAFE]
  ~ column amount [CAUTION]
    type: integer -> bigint
  - column legacy_code

~ table users
  ~ column email [SAFE]
    default: "" -> "[email protected]"

#JSON output (--json)

Pass --json for machine-readable output. The JSON structure mirrors the SchemaDiff type exactly, with fields for every object category (tables_added, tables_removed, tables_changed, enums_added, etc.). Empty arrays are included; empty optional fields are omitted.

pgdesign diff schema.toml --live $PGDESIGN_DB --json

The JSON output is useful for CI pipelines, automated drift detection, or feeding into other tools.

#Empty diff

An empty diff means the schema and target are semantically identical after normalization -- every object type (tables, columns, constraints, indexes, views, functions, sequences, policies, triggers) matches between the two sides. When the schema matches the target exactly, the output format determines the representation:

  • Terminal: prints Schema is up to date.
  • JSON: all arrays are empty, all optional fields are null

#Normalization

The diff engine applies 4 categories of normalization before comparing to avoid false positives from cosmetic differences that do not represent real schema changes. Without normalization, differences in whitespace, keyword casing, default precision values, and type aliases would surface as spurious schema drift:

  • SQL expressions (CHECK constraints, index WHERE clauses, policy expressions, generated column expressions, trigger WHEN conditions) are compared via the sqlparse.ExprEqual normalizer, which handles whitespace, case folding of keywords, and cast alias normalization
  • Default values are normalized: expression defaults go through the SQL normalizer; literal defaults are compared exactly (case-sensitive, since 'Active' and 'active' are distinct values)
  • Type precision uses default-aware comparison: timestamp (no precision) equals timestamp(6) because 6 is PostgreSQL's default microsecond precision
  • Index methods default to btree when unspecified, matching PostgreSQL's behavior
  • Policy types default to PERMISSIVE when unspecified
  • Interval strings in partman config normalize unit words (month/mon/mons all compare equal)

#The rename gate

When the diff detects a table or column that was removed and another was added with an identical definition (differing only in name), it treats this as a plausible rename and raises a hard error instead of silently generating a destructive drop-and-recreate.

To resolve the error, either:

  1. Declare the rename in [renames] in pgdesign.toml -- this tells the migration system to emit ALTER TABLE ... RENAME (data-preserving) instead of DROP + CREATE
  2. Make the definitions genuinely different (e.g., change a comment) if the drop-and-recreate is intentional

The rename gate runs after the diff and before operation lowering. It applies to both table-level and column-level renames, and detects ambiguous cases (one removed object matching multiple added objects) as a separate hard error.

Search