supaschema diff, a lot happens between reading your SQL files and producing a finished migration. supaschema works through a structured pipeline of six stages — Extract, Plan, Render, Check, Verify, and Apply — before the finished SQL reaches your deployment runner. Understanding each stage helps you interpret error messages, tune safety settings, and trust the output. This page walks through every stage in detail.
supaschema never connects to or modifies your production database. The pipeline is a pure SQL generation process. Applying the migration is always a separate, explicit step owned by your runner (such as the Supabase CLI or a custom script).
Pipeline Overview
supaschema resolves both the
--from and --to sources into a SchemaModel each. A SchemaModel is a structured, in-memory catalogue of every database object found in that source, with each object carrying a normalised definition and a deterministic content hash.dir:CREATE statements become model entriesgit:database:pg_class, information_schema, etc.) are queried read-onlydump:dir: with one filecatalog:After extraction you have two models: the current state (from) and the desired state (to). Every subsequent stage works from these two models — no source is read again.
# You can inspect what supaschema extracted from any source
supaschema inspect --from dir:supabase/schemas
supaschema inspect --from database:$DATABASE_URL
// Abbreviated inspect output
{
"fingerprint": "e7b4a1d3f2c9...",
"objectCount": 38,
"objects": [
{ "kind": "table", "schema": "public", "name": "orders", "hash": "a3f9c2..." },
{ "kind": "index", "schema": "public", "name": "orders_user_id_idx", "hash": "b2e811..." },
{ "kind": "function", "schema": "public", "name": "place_order", "hash": "d4c73f..." }
]
}
Once both SchemaModels exist, supaschema calls
planSchemaDiff() to compute a MigrationPlan — an ordered list of operations describing every change needed to evolve the “from” state into the “to” state.interface MigrationOperation {
kind: "create" | "alter" | "replace" | "drop" | "rename";
ref: ObjectRef; // { kind: ObjectKind, schema, name, ... }
key: string; // fully-qualified stable identity string
destructive: boolean; // would this lose data?
blocked: boolean; // requires an explicit hint to proceed
diagnostics: Diagnostic[];
}
create — the object exists in “to” but not in “from”. Always safe.alter — the object exists in both but the definition changed. Safe for most column and constraint changes.replace — the object must be dropped and recreated (e.g. changing a function’s return type or an enum’s label). Potentially destructive.drop — the object exists in “from” but not in “to”. Always destructive.rename — supaschema detected a rename from an explicit hint. Produces ALTER TABLE … RENAME rather than a drop-and-create pair.Operations with
destructive: true are blocked by default. supaschema will refuse to render SQL for them unless you provide an explicit hint (via the hints.destructive list in your config or an inline -- @supaschema allow-drop comment in your schema file). This prevents accidental data loss during automated deployments.The planner passes the MigrationPlan to the renderer, which turns operations into SQL text. The renderer respects dependency order — if creating table B requires table A to exist first, table A’s
CREATE TABLE appears earlier in the output, regardless of which file defined it.-- Rendered output (idempotent)
ALTER TABLE public.orders
ADD COLUMN IF NOT EXISTS fulfilled_at timestamptz;
CREATE INDEX IF NOT EXISTS orders_user_id_idx
ON public.orders (user_id);
If any operation in the plan is
blocked: true (i.e. destructive and not explicitly allowed), the render stage emits an error listing every blocked operation and exits before producing any SQL. This is intentional — a partially rendered migration with some operations omitted would leave the schema in an inconsistent state.To allow a specific destructive operation, add the object key to
hints.destructive in your supaschema.config.json. This makes the intent explicit and keeps the approval tracked in version control alongside the schema change.Before the migration SQL leaves supaschema, it passes through a static analysis phase: the
check stage. This stage runs more than 20 replay-safety rules against the rendered SQL without executing it.Replay-safe means: the migration can be applied to a database that is already at the target state without raising an error or changing any data. In other words, applying it twice produces the same result as applying it once.
CREATE TABLE must use IF NOT EXISTS; ADD COLUMN must use IF NOT EXISTSDROP without an explicit allow hint is a check failureNOT NULL constraint to an existing column with no default is flaggedCREATE INDEX without CONCURRENTLY on a large table triggers a warningowned by a column that no longer exists is flaggedThe
check command exits with code 0 when all rules pass, 2 when any rule fails, and 1 when the pipeline itself encounters a runtime error. This makes it suitable as a standalone CI gate.The
verify stage is the final and strongest safety layer. It provisions a throwaway database (an ephemeral PostgreSQL instance that supaschema spins up and destroys), applies the migration twice, and confirms that:If the second application changes the schema state — for example, because a
CREATE TABLE was rendered without IF NOT EXISTS — the verify stage fails and reports exactly which objects differed between application one and application two.Stage 6 — Apply (Your Runner)
supaschema stops at the end of the Verify stage. It outputs the finished migration SQL to your migrations directory (by defaultsupabase/migrations/) and then steps back.
Applying that SQL to a real database is the responsibility of your deployment runner:
Exit Codes
supaschema uses consistent exit codes across all commands so you can rely on them in CI scripts:| Code | Meaning |
|---|---|
0 | Success — pipeline completed, all checks passed |
1 | Runtime error — an unexpected problem (parse failure, missing file, etc.) |
2 | Diagnostic errors — at least one check rule or validation error was found |
3 | Drift found — --fail-on-diff detected operations in the plan |