drift
retrofit drift reads a schema dump, typically pg_dump --schema-only,
though any DDL that re-creates the schema’s current shape will do. It
infers what changed since the oplog’s head and appends the ops that
bring the oplog to that shape, asking a question where a change has
more than one reading. Under --dry-run it reports those ops and
writes nothing.
Read concepts.md first for the oplog / cut / element
identity vocabulary.
What drift sees
Two inputs, both authoritative for their side of the diff:
- The cut of your unsealed tail. drift replays your oplog up to the last appended op and asks the genealogy model for the set of live elements (tables, columns, constraints, indexes, views) with their current names, types, and adjacencies. This is the before state: not what the database currently shows, but what your oplog would produce if applied.
- The target SQL. Parsed as DDL. drift treats this as a
state (“this is what the schema looks like now”) regardless
of how it got that shape. Typically a
pg_dump --schema-onlycapture, or any DDL that re-creates the same shape by hand. Migration scripts (Flyway, Liquibase, hand-written.sql) belong toimportinstead: they are authored ops, andimporttranslates each statement literally. Seeimport.mdfor migration-script intake. Custom DML statements take different paths in the two verbs:importpreservesINSERT,UPDATE,DELETE, andMERGEby default, while drift captures theINSERTrows of the tables declared as reference data. See § Reference data below.
drift is per-schema. Schema selection follows the six-step
resolver chain: pass --schema <name> explicitly, or rely on [directory_scope] / implicit
oplog-dir / default_schema. Unlike import, drift does not
lazy-create: the schema must already be registered.
retrofit drift dump.sql # resolver picks schema
retrofit drift --schema sales dump.sql # explicit
retrofit drift --schema sales dump.sql --dry-run # diff only; never write
retrofit drift --schema sales dump.sql -i # force editor open
retrofit drift --schema sales dump.sql --seal # apply, then seal the new tail
retrofit drift --schema sales dump.sql --ignore-drops # author no Drop ops
retrofit drift --schema sales dump.sql --ignore-drops=table,column # skip DropTable/DropColumn; author other drops
retrofit drift --schema sales dump.sql --show=additions # report-side filter (writes unchanged)-i and --dry-run together refuse kind-mismatch.
Flags
Write side
| Flag | Effect |
|---|---|
| (default) | Converge: emit Add / Create / Alter for additions, Drop for removals |
--ignore-drops | Suppress every Drop op the diff would have emitted |
--ignore-drops=<kinds> | Suppress only named Drop kinds. CSV of table, column, index, view, materialized-view, constraint, foreign-key, primary-key, not-null, identity, schema (kebab-case, matching the Drop* operator-class suffixes) |
Suppressed drops are still reported in the dry-run plan and event
stream as deferred-drop events, so the user (or CI) sees what was
deferred. The plan header reflects them too: with --ignore-drops,
the removals count is rendered as M removals (K deferred) when
K > 0.
The suppression holds for interactive answers too: an answer that
would author a suppressed drop refuses drift-drop-deferred-conflict
(see § Failure modes). A join’s surplus key column is the exception:
--ignore-drops=column keeps it and the join lands. Answer another
way, or narrow the CSV.
On a survivor-extract question the recovery is skip or a narrower
CSV. To reshape without either, author the split directly with the
invocation the advisory prints; that path preserves each moved
column’s identity and authors no Drop at all.
Use cases: untrusted drops (author additions now and review drops
separately); CI drift detection (drift --ignore-drops --dry-run against prod, with additions auto-PR’d and drops alerted on).
Matches Terraform’s prevent_destroy / Pulumi’s ignoreChanges.
Report side
Independent of write behavior; filters what the dry-run and live event stream surface:
| Flag | Effect |
|---|---|
--show=both (default) | List additions and removals |
--show=additions | Show only additions |
--show=removals | Show only removals |
--show is presentation-only: writes proceed for both directions
regardless of value. The drift plan: header above the stream and
the post-apply summary below it are NOT filtered; they always show
the full diff shape and what was written. Use --ignore-drops to
gate writes.
Question policy
--on-question=<error|editor|skip> controls how the run handles
ambiguity that drift can’t auto-resolve:
error: refuse withneeds-interactive-resolution(overrides the default auto-elevation when$EDITOR/$VISUALis set).editor: open the editor; equivalent to-ionce a question fires.skip: emit each question as askippedevent with kindunresolved-question:<class>and proceed with the ops drift could mint. Useful for unattended regression harnesses that batch many drift steps and want to surface ambiguity in the skip manifest rather than stop on the first one.
Unset, when a question fires drift opens the editor if $EDITOR or
$VISUAL is set, and otherwise refuses needs-interactive-resolution.
--dry-run does neither.
Reference data
Reference rows are schema, so drift converges them as it converges DDL:
the tables declared as reference data at drift’s new head are captured
in the same run, with no flag. Declare a table with retrofit schema ref-tables add <table>, or pass --ref <table> for a table this run
creates. A project with no declared table and no reference chain is
untouched: its data statements stay in the skip manifest and no data
file is written.
For each declared table, drift considers its INSERT statements (and a
TRUNCATE or unqualified DELETE as an empty observation); it does not
capture UPDATE, DELETE ... WHERE, or MERGE statements. The
comparison runs at the new head, after drift’s DDL plan: a table whose
rows differ from its chain is captured as a full observed table state
(disposition=replace, one file per changed table), and an unchanged
one reads already-captured. Rows of undeclared tables are never
captured by drift and stay in the skip manifest; each is listed
not captured with its cause. Seed rows are not schema: capture them
with retrofit data capture --label <label> <source>. The retired
--capture-data flag is unknown, and --label refuses
flag-retired pointing at data capture --label.
A declared table drift cannot compare is reported with its cause, never read as drift:
| Condition | Report | Recourse |
|---|---|---|
the table’s band is a COPY ... FROM stdin block | not captured: <t> (...) — not comparable: COPY band | re-dump with pg_dump --inserts |
the table’s chain cannot be folded offline (an append whose body is UPDATE, DELETE, or MERGE) | the verdict carries not comparable: append at <op> ... | capture a fresh replace at HEAD |
the source states no rows for the table (a schema-only dump, pg_dump -t) | reference rows not observed: N tables; JSON capture.not_observed | none; absence is never drift |
a reference chain whose table is no longer declared (ref-tables remove without data retire) | not compared: <t> (declaration removed at <op>); JSON capture.not_compared | retrofit data retire <schema.table>, or declare it again |
A captured table that lost a key since its chain is still captured
(the observation is what the database holds), with a
reference-key-lost warning naming the lost keys: reference data
should be monotonic, so restore the row or mark it inactive through its
status column instead. data capture raises the same warning.
A declared table the source observes empty (an unfired TRUNCATE or
WHERE-less DELETE its only statement) is captured as replace body=none
with a reference-table-emptied warning, the extreme removal: an empty
lookup table usually calls for DropTable, Archive or schema ref-tables remove instead. data capture raises the same warning.
The exit code is unchanged: it means the run succeeded, with or without
row drift. On a real run, drift --as=json carries the per-table verdicts,
row deltas, and causes in the drift-run-summary record’s capture member;
that stream is the seam CI asserts over.
Under --dry-run the per-table classification rides the stdout stream
as one rehearsal record per table:
{"type":"rehearsal","table":"public.currencies","purpose":"ref",
"labels":[],"records":1,"rows":12,"disposition":"captured"}records is a record count and rows is a row count, so the record
carries both; disposition, supersedes_anchor and
overwrote_anchor describe what the capture would do to the chain.
Under --dry-run the drift-run-summary record carries no capture
member; these records are the per-table report.
Captured records join the same shared corpus that import produces,
under the schema’s declared data_root. They are full observed table
states (disposition=replace) anchored at the reconciled oplog head,
so snapshot and migrate discover them through their normal corpus
scan. See import.md § Custom DML statements
for the data-file format and consumer behavior.
A divergent capture at an unchanged oplog head replaces the existing,
unwitnessed version as a set: the matched files rewritten where they
sit, surplus pages deleted and named in the report, and each replaced
file reported overwrote: with its digest (git retains the old
bytes). A witnessed key refuses (data-witnessed-key).
Capture accepts INSERT-style dumps such as pg_dump --inserts or
pg_dump --column-inserts. A COPY ... FROM stdin data block whose
rows do not parse as SQL stops the source parse: with a declared table
(or --ref) drift refuses the run copy-form-source, pointing to the
INSERT-style dump forms; without one it refuses parse-error.
Either way it writes neither the oplog delta nor a data file.
| Flag | Effect |
|---|---|
--ref <table> | Declare a table as reference data (one this run creates, or one already live); repeatable |
--table <pattern> | Restrict captured source tables by bare relname or glob; repeatable |
--skip-manifest <path> | Write the capture skip manifest as JSONL |
Seal
--seal appends a Seal op covering drift’s new tail after a
successful apply. Bare --seal derives the reason from the source
("drifted to <basename>"); --seal=<text> overrides with a custom
reason. The Seal passes the gate op seal runs over the range it is
about to seal, before anything is written: a reference record the range
would cover with no witness inside it (a prior data capture never
witnessed) refuses data-witness-missing, and a reference replace
anchored in the range with no upsert form at its anchor refuses
replace-no-upsert-form (docs/migrate.md). Drift’s own capture
anchors on the Seal line, outside that range, so it is never what
refuses.
Objects the project does not own: [[exclude]]
A target dump often carries objects your application does not author: a
migration runner’s history table, extensions the hosting provider installs,
schemas it creates. A project declares them once, as an [[exclude]] array in
retrofit.toml (kind = table, extension or schema; name, a table’s
schema, and a required reason), and drift removes every statement an
entry owns from the target before anything reads it. The plan is computed over
the target less those statements, so an exclusion alone never changes the
plan or the exit status.
Each entry that owned at least one removed statement is reported on every run
as an exclusion-applied advisory on stderr
(~ (excluded) table public.audit — 2 statement(s) — <reason>) and, under
--as=json, as an excluded event on stdout beside skipped;
drift-run-summary tallies the reported entries as excluded, apart from
skipped_by_class. An entry that owned nothing in a run is listed in one
courtesy line (-q removes it): a misspelt entry leaves its object as
divergence. A kept statement that references an excluded table or extension
refuses exclusion-dependent (naming the statement by src_file:line), and an
entry naming an object the oplog already holds refuses exclusion-in-log
(naming the op); both refuse before anything is written and exit 2. A kept
reference into an excluded schema keeps the disposition a reference into an
unmanaged schema has and is reported as excluded-schema-referenced.
No flag adds to or overrides the set, and drift never edits the oplog to
honour it. See getting-started.md for adoption.
What drift produces
drift authors ops in both directions by default. Elements in the
target but not the cut materialize as Add* / Create* ops.
Elements in the cut but not the target materialize as Drop* ops.
The supplied source is authoritative for the schema’s current
shape; refusing to mint drops would leave your oplog asserting a
fictional model.
A Drop* op loses no rows by itself; the oplog is a scratchpad. Rows
are lost when a script carrying the DROP is applied: a migrate
script over the op, which emits it with no flag, or a snapshot --include-drop=scoped|schema prologue.
Use --ignore-drops to suppress drops you don’t trust; see
§ Flags.
Three kinds of event in the stream:
op-applied: drift found an unambiguous transition and authored the op directly. No question to the user. Examples: a table appeared in the target that the cut doesn’t have (CreateTable); a column type changed and only one column with that name exists (CastColumn); aSetDefault/DropDefault/SetReplicaIdentity/ comment change. These land in the oplog without confirmation.question: drift found a transition whose shape is real but whose authoring intent is ambiguous. The user (or$EDITOR, or an LLM driving$EDITOR) picks from a candidate list. Question classes: the rename/cast set (table-rename,column-rename,column-cast,combined-rename-cast,index-rename,view-rename) and the structural set (copy-table,extract-table,join-table,merge-table,partition-table,departition-table; see § Data-motion SMOs).skipped: drift encountered a statement it authors no op for (e.g. grants and default privileges, a standalone sequence, an object outside--schema). Skipped statements carry through to the report with their kind, name and class; they do not silently disappear. The class decides the exit; see § Exit status.
A terminal record ({"type":"ok"} or {"type":"failed",...})
closes every run. See cli-output-contract.md
for the full JSON shape.
Exit status
drift’s exit status depends on its skips, as import’s does. Every skipped item carries a class from the classifier
import uses:
decision: set aside by a named rationale. The class covers an object outside--schema(cross-schema), materialized views, grants and default privileges, a bareDROP,COPYdata, a cross-schema foreign key, thepg_dumppreamble rows drift tolerates (setval, schema and extension comments,DROP … IF EXISTS, standalone sequences),unresolved-question:<class>under--on-question=skip, a function or trigger entangled with a skipped table rename, a VALID → NOT VALID change PostgreSQL cannot express, and a dependent whose every unresolved reference is such a root: a view, materialized view or policy over across-schemaor materialized-view object, or a view, policy or foreign key over a table an unanswered question claims.preserved: DML set aside, as on import.gap: content retrofit has no operator for, or cannot bind. Every other skip kind, including one the classifier does not list (which also raises adrift-skip-unclassifiedwarning).internal-defect: a parser version-boundary tripwire; adrift-skip-internal-defectwarning, and the run still exits 0.
The run exits 0 over itemized content: ops applied (or previewed
under --dry-run) and decision, preserved or internal-defect
skips. Exit 0 does not mean “no divergence”; expressible drift still
applies ops at exit 0.
When any gap skip stands, the run exits 2 with
drift-unreconciled-skips, on --dry-run and real runs, in text and
--as=json alike. The drift-run-summary record (with its
skipped_by_class tally) streams first; a real run’s appended ops,
and a --seal seal, persist before it fails. The hint names each gap
kind with its count. A view, policy or foreign key that read a table
under a question this run answered is named apart: rerun drift, and
the next run plans it against the answer.
--allow-capability-gaps accepts the gaps: exit 0, with the same
summary record. A stock pg_dump --clean dump needs it, because its
preamble’s ALTER TABLE … DROP CONSTRAINT / ALTER COLUMN … DROP DEFAULT statements skip as gaps.
Outside the skip contract:
- Every refusal, parse error and
needs-interactive-resolution(exit 3) fires first and wins. questionitems and--ignore-drops’deferred-dropitems never change the exit.- Content drift drops with no item at all never reaches the exit: an
EXCLUDEconstraint (inline orALTER TABLE … ADD CONSTRAINT), and an index orENABLE ROW LEVEL SECURITYon a table drift pairs as a rename.
What drift appends vs. what it asks
drift’s contract: every candidate it advertises is one the resolver can execute, and an ambiguous step is a question.
- Appended without a question: unambiguous structural diff. One
table dropped, one table added with a different name and the
same shape →
RenameTableop-applied. The auto-rename heuristic fires only when there is exactly one viable pairing; any tie auto-elevates to a question. A policy or trigger that keeps its parent and payload under a new name pairs the same way (RenamePolicy/RenameTrigger); a tie there keeps the drop and create. - Asked (question event): more than one defensible pairing. Multiple drops and adds with overlapping shapes; a column whose type changed and whose name might have changed; a drop-add pair that could be either a rename or a real drop + a real add. drift shows you the candidates; you pick.
drift needs no --force for ordinary work: every ambiguous step
becomes a question. With neither $EDITOR nor $VISUAL set, or
under --on-question=error, drift refuses
needs-interactive-resolution when questions remain; -i with no
editor refuses the same way, and --dry-run never does.
What drift cannot produce, and why
The boundary is information-shape, not capability. drift sees one cut and one target snapshot. Some operators carry meaning that cannot be derived from end-state alone.
Schema-context ops
CreateSchema is N/A:single-schema-context. drift runs against
one schema; the schema set is a CLI precondition, not a drift
output. Use retrofit schema create instead.
Data-motion SMOs: CopyTable, ExtractTable, JoinTable, SplitTable, MergeTable
First-class via op add + snapshot. drift recognizes four of
the five from an end-state diff and surfaces each as a
question (candidate, never a silent guess):
CopyTable: source persists plus a same-shape twin.ExtractTable: either arm. The consume arm drops the source and adds ≥2 tables whose disjoint columns cover it, and offers anaccept. The survivor arm drops nothing and may add exactly one table: a surviving table loses columns while an added table appears carrying them. That arm is advisory only: it offers noacceptand stamps no confidence; its only candidate isdrop-add. See below.JoinTable: ≥2 disjoint-column drops + 1 union-shape add, FK-gated; the editor fills in the joinpredicateandkind.MergeTable: dropped same-shape sources beside a liveinto.intois a member of the sources’ naming family and sets thehightier but is never a source; an addedinto(versioned or snapshot sources collapsing into a new table) ismedium. The accept fills onepredicate.<source>per source and mintsMergeTablewithintokeeping its ElementID (an addedintomintsCreateTablefirst); a predicate left empty for any source declines the whole question to aDropTableper source. A family whose sources orintodiffer in a column raisesdrift-merge-shape-mismatchnaming it and takes no accept.
The survivor-arm extract raises
drift-survivor-extract-unrecognized at warning level rather than
passing silently as a flat DropColumn + CreateTable pair: it
pairs the moved columns by name alone, without the shared-key signal
a recognized ExtractTable is graded on. Which arm applies is
decided by whether the source survives, never by column names.
drop-add reshapes the target now, but each moved column’s ElementID
dies and re-mints. The identity-preserving path is to author the
split yourself, following the invocation the advisory prints (the
spellings below). The
advisory names the re-projected shared column when the added table
carries one, and withholds the invocation entirely when any
advertised column carries a live identity facet, because that split
would either refuse outright or land somewhere other than the state
you declared.
drift also surfaces the two identity-preserving partition converts
(PartitionTable / DepartitionTable) as questions when a plain
heap appears partitioned in the target, or the inverse.
An existing table that joins or leaves a partitioned parent the
target keeps mints the membership op itself: a cut table the target
attaches (ALTER TABLE … ATTACH PARTITION, pg_dump’s spelling) mints
AttachPartition after the table’s own deltas, and a partition the
target declares free-standing mints DetachPartition. A re-home to
another parent, a re-bound, a PARTITION OF spelling over an existing
table, an attach whose columns are not the parent’s, and a detached
born partition that declares state the detach does not carry (its own
key, CHECK or index) keep the pg-declarative-partition-convert-unsupported
skip, with a hint naming why.
SplitTable is excluded (N/A:no-end-state-signal): its
per-sibling row predicate lives in the migration script, not in
residual schema, so a split leaves no recoverable signal in a cut →
target diff. The split keeps its source, so a kept source beside
same-shape adds is CopyTable’s signature: drift mints no
SplitTable, the copy-table question names
op add SplitTable … --conditions= as the alternative, and a
same-shape add no copy marker names raises the warning
drift-split-unrecognized naming the same.
To author any of these directly:
retrofit op add CopyTable <schema> <source-table> <new-name>
retrofit op add ExtractTable <schema> <source-table> --into "hot=<col>"
retrofit op add ExtractTable <schema> <source-table> --into "hot=<col>" --rest <new-source-name>
retrofit op add JoinTable <schema> "<a>,<b>" <combined> "<join-predicate>" inner
retrofit op add MergeTable <schema> "<a>,<b>" <into> --conditions="predicate=<predicate-a>" --conditions="predicate=<predicate-b>"
retrofit op add SplitTable <schema> <source-table> "hot,cold" --conditions="predicate=<predicate-1>" --conditions="predicate=<predicate-2>"The two
ExtractTablespellings above are both the survivor arm: any live column left unassigned by--intostays on the source, which survives in place.--rest <name>renames that surviving source; it never names an extra child. Assign every live column to an--intochild to get the consume arm instead.
op add ExtractTable <schema> <source-table> --into "<child>=<cols>"is how you keep the moved columns’ ElementIDs on a survivor-extract question; the alternative, answeringdrop-add, re-mints them. The<schema>positional is required: the command does not parse without it. Name only the columns that MOVED: a shared key the child re-projects is omitted from--intodeliberately, because this path synthesizes the child’s rejoin key (NOT NULL+ PRIMARY KEY + FOREIGN KEY back to the source) itself.
SplitTable(PRISM row-axis refactoring; keeps its source) is unrelated to PG’s declarativePARTITION OFclause (physical storage attribute; parent persists). Declarative partitioning is owned byAttachPartition/DetachPartition.
PG18-only syntax
drift parses with the PostgreSQL 17 grammar (libpg_query v6), so a
target using PG18-only syntax (NOT ENFORCED, ALTER CONSTRAINT … [NO] INHERIT, VIRTUAL generated columns) does not parse.
PostgreSQL versions gives every verb’s row at
each major.
Interactive resolution
When drift has questions, it opens $EDITOR (or $VISUAL) on a
buffer of resolution lines, one per question, with candidates
already enumerated. You edit the buffer, save, exit; drift reads
back your choices and authors the ops.
-i opens the editor even when there are no questions, which is
useful for inspection.
--dry-run parses, diffs, and emits the report. It never opens
the editor and never writes the oplog.
On a plan with no questions (every plan under --on-question=skip)
the dry run mints and validates the ops exactly as the real run
does, and a refusal comes back with the real run’s code and exit
after the plan streams. Exit 0 there means the ops would apply.
A plan with questions is rehearsed against the default answers, so
only the refusals no answer can change (the extract-* family) fail
the dry run; the rest wait for the real run’s answers.
See op revise -i for the buffer grammar; drift’s editor uses
the same helper. (--seal semantics are covered in §Flags above.)
Failure modes
source-empty: a<source>that is a stream (-,<(…), a FIFO or another descriptor) delivered zero bytes, which on a pipe is the signature of a producer that failed upstream. Exit 2, Paramsource, refused before any parse; a zero-byte.sqlfile is read as it always was. Check the producer, or save its output to a.sqlfile and pass the path.data-band-over-cap: a table’s INSERT band in the source is larger than the schema’sdata_band_max_bytes(or the built-in default when the key is absent). The bound is checked before the source is parsed and before any plan output; every over-cap table is named in one envelope. Raisedata_band_max_bytesfor the named schema, or remove the key for the built-in default.drift-unreconciled-skips: the run left agapskip (exit 2; see § Exit status). Model the gap, re-scope--schema, or accept it with--allow-capability-gaps.needs-interactive-resolution: drift has questions (or-iwas passed) and no editor opens, because neither$EDITORnor$VISUALis set or because--on-question=erroris in force. Set$EDITOR, drop--on-question=error, or run with--dry-run.bootstrap-needed: drift was run against an empty or bootstrap-only oplog (the cut contains onlyCreateSchema, no user-facing ops). drift converges an existing oplog to a moved state; it does not mint the initial shape. Useretrofit import <source> --schema <name>to bootstrap from a.sqlfile. Seeimport.mdfor bootstrap intake.schema-not-registered:--schema <X>named a schema not in[[schema]]. drift does not lazy-create; runschema create <X>(orimport --schema <X> <source>to bootstrap from DDL).default-schema-unset: the resolver chain produced no result; pass--schema,--chdir, or runschema set-default.unsupported-on-engine-version: the target SQL contains DDL the pinned engine version cannot produce. Seeretrofit schema enginefor the current pin.kind-mismatch: a flag-combination or argument shape drift rejects up front. These are-iwith--dry-run,-iwith--on-question=skip|error,--dry-runwith--on-question=editor, the wrong positional arg count, an--on-question=<v>outsideerror|editor|skip, or a<source>with an unrecognized file extension / URL scheme, or an extension-less regular file (a stream with no extension is read as.sql).drift-pre-flight-refused: the proposeditems[]failed to replay against the cut in a pre-write dry run (e.g. a primary-key change that would dangle an FK). The inner precondition cause rides along; drift writes nothing.rule-no-match: an emitted op failed its precondition validator against the current cut (the same op-validation envelopeimportsurfaces).drift-accept-not-offered: an interactive buffer edit answeredacceptfor a candidate drift never offered for that target.drift-drop-deferred-conflict: an interactive answer cannot be applied without authoring a Drop op the run already deferred via--ignore-drops. The answers it refuses aredrop-addon a structural question: theDropTableof each consumed source, and on a survivor-extract theDropColumnof each moved column plus any live constraint or index that column carries; andaccepton a join or an extract, the drop of each source constraint and index it authors: on a join, those the product lacks (including each kept index it rebuilds around the surplus key column); on an extract that consumes its source, each one it drops ahead of theExtractTable(a primary key that reparents onto a child is kept). Refused all-or-nothing; the message names each deferred object by kind and name. Nothing is written. On a survivor-extract question the hint offersskipand narrowing the CSV.drift-convert-no-partitions: a partition-tableacceptwas taken but the target has noPARTITION OFchild to convert.drift-tied-answer-unresolved: the answers to a tied table-rename pick a pairing under which a target policy or trigger on a tie member does not resolve against the cut, though it does under the default pairing (a body alias that collides with the renamed member’s name, for example). The message names the statement. Nothing is written; answer another pairing or adjust the target statement.drift-convert-recreate-incomplete: a partition-table or departition-tabledrop-addwas taken, but the recreated table cannot carry target state the surviving table already holds (a key whose unmodeled facet, such as an adopted index’sINCLUDElist, the cut models; a policy whose target body does not resolve), or a target trigger whose callback function the run neither finds nor creates. The message names each such object. Nothing is written. The hint offersaccept, which converts the table in place and keeps that state, only where the question offers it; a nested convert offersdrop-addalone, so its hint isskipand an authored conversion.buffer-parse-failed: an edited resolution buffer (fromop revise -i-style interactive drift) is malformed (a rename line missing an element id, an unknown action, etc.). Detail names the line.seal-nothing-to-seal:--sealwas passed but drift produced no new tail to cover.data-witness-missing:--sealwas passed and the range the Seal would cover holds a reference record with no witness inside it; the same refusalop sealmakes. Nothing is written. Runretrofit data witness, then seal.replace-no-upsert-form:--sealwas passed and a referencereplaceanchored in the range has noON CONFLICTform at its anchor; the same refusalop sealmakes (exit 2). Nothing is written. Follow the hint (docs/migrate.md), then seal.flag-invalid-value: a closed-set flag was given a value outside its vocabulary (--show=<v>outsideboth|additions|removals, or--ignore-drops=<csv>naming a kind outside the closed set). The error envelope names the flag (Param) and the rejected value.io-error: the source file is unreadable, the oplog cannot be written, the editor exited non-zero. Detail line carries the syscall reason.parse-error: the source SQL or the oplog failed to parse. Line number in the error envelope.drift-mint-unresolved: an op the plan authored names an object that is neither on the cut nor created in this batch, so it cannot be minted. This is a user-environment state, not an internal fault: the target SQL references a dependency drift never saw. Add the missing dependency to the target SQL, or author the op directly withop add.add-fk-unresolved-ref(askippedentry; under an openquestion-claimedreason it classesdecision, otherwisegap, and the run exits 2 per § Exit status): a foreign key’s referenced table could not be bound at plan time, so the constraint is not minted. The skipped payload carries areasonnaming which of the four states applies:question-claimed: the referenced table is the subject of an unanswered table-rename question (or was consumed by a merge), so its identity is undecided. Answer the question, then re-run.drop-claimed: the referenced table is dropped in this same batch. Keep the table, or drop the referencing constraint too.ref-column-miss: the named referenced column is not live on the referenced table (commonly a co-renamed column). A partition owns no columns of its own, so a foreign key to a partition’s column takes this skip too, whether the partition is on the cut or created in this run (PARTITION OForATTACH PARTITION); reference the partitioned parent instead. When the referencing table is new in this run the skip’s kind isadd-fk-unresolved-ref-column, naming the missingtable.column.unmodelled-facet: the constraint carries a column-restrictedON DELETE SET NULL (cols)/SET DEFAULT (cols)action retrofit cannot reproduce; minting it would silently widen the action to every column.
With no
reason, the reference names an object retrofit has never seen. In every case the remediation is the same: settle the referenced table’s disposition and re-run, or author the constraint directly withop add AddForeignKey. Use--as=jsonto read thereasonand the unbound reference off theskippedpayload.add-unique-unmodeled-facet/add-pk-unmodeled-facet(askippedentry classedgap; the run exits 2 per § Exit status): a target UNIQUE or PRIMARY KEY carries a facet its op cannot reproduce (NULLS NOT DISTINCT, INCLUDE,WITH (…)on the constraint; DEFERRABLE on a primary key), so it is not minted.ADD CONSTRAINT … USING INDEX idxis not such a facet: drift mints the constraint withbacking_indexwhen idx is on the cut or created earlier in the same plan, and an equal live key pairs with it as the same key. Nor is anINCLUDE (…)list equal to the one on the index an equal live key adopted throughbacking_index: that is the same key assnapshotand pg_dump spell it, so drift reports nothing. The same holds for aUSING INDEX idxwhose idx covers that list: it is the index the live key already adopted. When an index is the cause, the payload carries areasonand ahint:index-unresolved: the target does not declare idx on that table, or the plan neither creates nor keeps it.backing-index-ineligible: idx is not a unique btree index free of a predicate, expression keys, INCLUDE columns, non-default ordering, NULLS NOT DISTINCT,WITH (…)options, an operator class and COLLATE, or a constraint already adopts it.adopted-index-include: the index an equal live key adopted covers columns the target key does not list the same way (a differentINCLUDE (…)list, or none). No op changes an adopted index’s covering columns in place.
For the first two, fix the index and re-run, or author the constraint directly with
op add AddUnique/AddPrimaryKey/ChangePrimaryKeyand itsbacking_index. For the third, declare the live list on the key in the target, or rebuild the key:op add DropUnique/DropPrimaryKey, thenop add AddUnique/AddPrimaryKey, adopting an index with the target’s list throughbacking_indexwhen the target declares one.add-check-unmodeled-facet(askippedentry classedgap; the run exits 2 per § Exit status): a target CHECK is declaredNO INHERIT. Table inheritance is not modeled andAddCheckhas no slot for the flag, so the constraint is not minted, and a live CHECK it pairs with is not reported as equal.importskips the same constraint ascheck-constraint.
Scope contract
What drift handles, by category:
- Produced: every
Add*/Create*/Set*/Drop*the diff resolves unambiguously, plus the rename/cast questions and the data-motion Copy / Extract / Join / Merge and partition converts surfaced as questions (see § Data-motion SMOs). The full per-operator inventory is insmo-catalog.md. - Out by information-shape:
CreateSchema(single-schema-context: the schema set is a CLI precondition, not a drift output),SplitTable(no-end-state-signal: the per-row predicate leaves no schema residue), and ops with no DDL primitive at all (no-pg-primitive). - Not parsed: PG18-only syntax. drift parses with the PostgreSQL 17 grammar (libpg_query v6); see § PG18-only syntax.
Related
concepts.md: oplog, cut, element identity.smo-catalog.md: every operator with status.cli-output-contract.md: per-verb stdout / stderr / exit-code shape.principles.md: never-silent, determinism, identity-preservation.