Schema migrations5 min de lecture

Detect PostgreSQL schema drift without noisy diffs

G

Guepard Team

14 novembre 2026

PostgreSQL 18 scope. The SQL in this article was run on PostgreSQL 18.6 against synthetic fixtures.

A schema diff should answer whether the deployed database matches the intended contract. In practice, many diffs mostly report object ordering, ownership, ACLs, extension versions, and dump-tool differences. Teams then learn to ignore them, including the important line hidden among the noise.

The answer is not to delete every inconvenient field. Define separate comparison layers: structural schema, security configuration, and environment metadata. A difference can be expected in one layer and dangerous in another. The normalization policy should make that distinction explicit.

Build the reference from migrations

For PostgreSQL 18, create an empty reference database using the same server major version and run the same migration sequence that defines the release. Do not use production itself as the only reference; that can bless a manual change merely because it already exists.

Use the same pg_dump executable for both sides. The command below is a shell example, not SQL. Connection service names are placeholders for locally configured, read-only inspection access.

pg_dump --dbname=service=reference --schema-only \
  --no-owner --no-privileges --file=reference.sql
pg_dump --dbname=service=deployed --schema-only \
  --no-owner --no-privileges --file=deployed.sql
diff -u reference.sql deployed.sql

This deliberately excludes ownership and grants from the structural comparison. It does not declare them irrelevant. Schedule a separate security comparison with the appropriate role visibility. Otherwise, an accidental public grant can disappear behind an apparently clean structural diff.

PostgreSQL 18 dump output can contain a generated restriction key for psql's restricted mode. Such run-specific scaffolding can produce differences unrelated to the schema. If normalizing it, recognize only the exact generated control lines; do not broadly remove backslash commands or arbitrary text. Keep raw dumps as evidence alongside the normalized comparison.

Inventory the environment before comparing text

SELECT current_setting('server_version') AS server_version,
       current_database() AS database_name;

SELECT extname, extversion
FROM pg_extension
ORDER BY extname;

SELECT n.nspname, c.relname, c.relkind,
       pg_get_userbyid(c.relowner) AS owner
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
  AND n.nspname !~ '^pg_toast'
ORDER BY n.nspname, c.relname, c.relkind;

An extension upgrade can change exposed objects without a migration editing those objects directly. Collation differences can change ordering behavior even when the SQL definitions look identical. Record extension versions, database encoding, locale provider, and relevant collation versions as environmental facts. A clean schema-only diff is not proof of identical query semantics.

Compare complete schemas before narrowing to selected objects. A dump filtered to one schema can omit dependencies living elsewhere. If a team's contract covers only an application namespace, document the required external schemas and extensions rather than treating their absence from the diff as proof they match.

Layered schema drift comparison

Reference drift pipeline. Structural normalization does not replace separate ownership, grant, extension, and collation checks.

Use catalog queries for precise invariants

Textual diffs are excellent review artifacts, but some invariants deserve direct queries. For example, a foreign key that exists but has not been validated is different from a fully validated one.

SELECT conrelid::regclass AS relation,
       conname, contype, convalidated,
       pg_get_constraintdef(oid, true) AS definition
FROM pg_constraint
WHERE connamespace = 'public'::regnamespace
ORDER BY conrelid::regclass::text, conname;

SELECT indexrelid::regclass AS index_name,
       indisvalid, indisready,
       pg_get_indexdef(indexrelid) AS definition
FROM pg_index
WHERE indrelid IN (
  SELECT oid FROM pg_class WHERE relnamespace = 'public'::regnamespace
)
ORDER BY indexrelid::regclass::text;

The second query can reveal an invalid index left by an interrupted concurrent build. Merely comparing expected index names would miss that state. Likewise, checking only constraint names misses changed predicates or validation status.

For role and grant checks, compare the permissions required by the application identity, not only the object owner. An owner can read a table successfully while the service role cannot. Conversely, an unintended grant may allow an extra identity to read it. Security assertions need both positive and negative cases, reviewed under the organization's access policy.

Make exceptions specific and expiring

A useful allowlist entry names the object, the differing property, the environment, the reason, and the owner who will review it. “Ignore all indexes” is not a drift policy. “This staging-only diagnostic index is permitted until the performance investigation closes” is reviewable.

Do not strip comments, function bodies, or expressions wholesale to reduce noise. Function source is executable behavior. A changed default can alter every future insert. Even comments may carry operational metadata that tooling depends on. If formatting differences are normalized, retain an exact raw copy so reviewers can inspect what the normalizer changed.

Run drift detection against a stable deployment state. Comparing halfway through an authorized migration can report a real but temporary difference. Record the migration version and deployment timestamp with each result, and distinguish an in-flight rollout from an unexplained manual change.

Decide what to do with a difference

The detector should produce evidence, not automatically “repair” the deployed database. An unexpected column may contain valuable data. A missing index may reflect an intentionally paused build. An extra grant can require an incident review rather than a casual normalization update.

Route each finding to one of three outcomes: fix the deployment with a reviewed migration, update the intended contract because the change was legitimate, or document a narrow temporary exception. The goal is a diff that people trust enough to read, with no hidden trade where less noise also means less protection.

Sources and review scope

G

Guepard Team

Guepard Engineering

Book a demo →