Schema Changes
How Supabase ETL handles DDL and evolving table schemas.
Schema-change support is in public beta and is being expanded incrementally. The current implementation is intentionally conservative: the source-side event trigger captures a rich PostgreSQL-shaped snapshot, while ETL currently models well-understood column changes: adds, drops, renames, and column default and nullability changes, plus publication column-list changes for tables the running pipeline already tracks. A few known edge cases remain.
Built-in destination support varies by destination DDL capabilities. BigQuery, ClickHouse, DuckLake, and Snowflake apply supported schema changes automatically; Iceberg is deprecated for new deployments and does not support schema-change DDL.
Short Version
For published permanent tables, ETL currently models these ALTER TABLE
changes:
| Source change | ETL interpretation |
|---|---|
| Add a replicated column | Add column |
| Drop a replicated column | Drop column |
| Rename a replicated column | Rename column |
| Change a replicated column default | Column default modification |
| Drop a replicated column default | Column default removal |
Drop NOT NULL from a replicated column | BigQuery relaxes an existing REQUIRED column to NULLABLE; other built-in destinations currently leave nullability unchanged |
Set NOT NULL on a replicated column | Detected in the schema snapshot, but not applied to built-in destinations |
| Several of the above in one statement | One schema snapshot, diffed into column additions, removals, and grouped column modifications |
When several attributes of the same logical column change at once, ETL groups
them into one column change with multiple modifications. For example, renaming a
column and changing its default in one ALTER TABLE statement is treated as a
single logical column change.
How It Works
ETL installs a PostgreSQL ddl_command_end event trigger named
supabase_etl_ddl_message_trigger. When an ALTER TABLE statement or supported
ALTER PUBLICATION change affects a published permanent table, the trigger
emits a transactional logical message with prefix supabase_etl_ddl.
PostgreSQL does not pass user-defined arguments to an event-trigger function.
The function reads TG_TAG and the object addresses returned by
pg_event_trigger_ddl_commands(), then resolves the post-DDL catalogs:
- For
ALTER TABLE, the object address identifies the table. The message has no publication name because a physical table change applies to every publication containing that table. - For per-table
ALTER PUBLICATIONchanges, the object address identifies a survivingpg_publication_relrow, which links the explicitly named table to its publication. This path does not expand a named partition root into its effective leaves. - For publication-level
ALTER PUBLICATIONchanges, the object address identifies apg_publicationrow. The trigger does not parse which parameter changed; it expands the publication's complete post-command effective table set throughpg_publication_tables.
The publication name is therefore optional in the payload for table DDL but
required for publication DDL. Logical decoding exposes custom messages to a
slot independently of pgoutput's table filtering, so ETL accepts an
ALTER PUBLICATION message only when its resolved name exactly matches the
pipeline's configured publication. A missing name fails closed.
That message is internal plumbing. Destinations do not receive it directly. Instead, ETL:
- Parses the schema-change message.
- Stores a new versioned table schema using a composite snapshot ID ordered by commit LSN and then message LSN.
- Invalidates the in-memory relation state for that table.
- Waits for PostgreSQL pgoutput to emit a fresh
RELATIONmessage before the next row event for that table. - Sends destinations a public
Event::Relationwith the newReplicatedTableSchema.
Snapshot IDs display both LSNs as decimal unsigned 64-bit values in
commit_lsn:message_lsn form. Their ordering is the numeric tuple ordering of
those two components, not the lexical ordering of the displayed string.
ALTER PUBLICATION ... DROP TABLE intentionally emits no schema snapshot for
the removed table because it is no longer part of the publication. PostgreSQL
14 may still send an empty BEGIN/COMMIT pair for that transaction;
PostgreSQL 15 and later suppress empty logical-replication transactions. The
empty pair contains no schema or row event and does not affect snapshot
ordering. See the PostgreSQL 15 release
notes and the upstream
change.
For a table already known to the pipeline, a supported publication change
creates a new snapshot ID even when the full physical table schema is unchanged.
The following RELATION message supplies the current publication and identity
masks. This gives successive mask changes distinct snapshot IDs and preserves
their ordering. Multiple schema messages sharing one commit LSN are ordered by
their message LSN. A newly published table that the running pipeline does not
know is ignored until startup publication reconciliation creates its table
state and initial sync.
ALTER PUBLICATION support boundary
The trigger's ability to observe an ALTER PUBLICATION command is not the same
as runtime support for that publication change. The trigger emits schema
snapshots; it does not add or remove ETL table state, start an initial sync, or
change which relation OIDs the running apply worker owns.
| Publication change | Trigger behavior | Runtime behavior |
|---|---|---|
| Change the column list of an already tracked table | Emits a snapshot scoped to that table and publication | Supported. The next RELATION message installs the new replication mask before following row events. |
ADD TABLE or a membership-changing SET TABLE | Emits snapshots for surviving, explicitly identified pg_publication_rel rows. A named partition root is not expanded into leaves on this path. | Newly effective relation OIDs are ignored until startup reconciliation initializes and copies them. |
DROP TABLE | Emits no snapshot because the pg_publication_rel row is gone at ddl_command_end. | Removed table state is purged during startup reconciliation. Destination data is not automatically removed. |
Add, set, or drop TABLES IN SCHEMA | The current trigger does not resolve pg_publication_namespace events into table snapshots. | Effective membership is loaded during startup reconciliation. |
| Change a row filter | May emit a per-table snapshot, but row-filter mutation is not part of the supported live-update contract. | Do not rely on changing row filters while the pipeline is running. |
Change a publication-level setting such as publish | A pg_publication event expands to one snapshot for every post-command effective table, even if physical schemas did not change. | Publication settings are not a supported live schema-update interface. |
Change publish_via_partition_root | Emits snapshots for the complete post-change effective set: the root when enabled, or the leaves when disabled. | Unsupported while running. The new relation OIDs are not dynamically initialized, and the previous destination relation layout is not migrated. |
| Rename the publication or change its owner | These are publication-level events and may expand across the effective table set. A rename scopes messages to the new name. | Not supported as live schema changes. A configured pipeline does not automatically adopt a new publication name. |
At startup, ETL calls pg_get_publication_tables() to load the same effective
relation identities PostgreSQL uses for pgoutput. New identities enter initial
sync and identities no longer returned by PostgreSQL have their ETL state
purged. This startup process does not rename, merge, or delete destination
tables created for an earlier root or leaf identity.
Warning
Only publication column-list changes for already tracked tables are supported
while a pipeline is running. Other ALTER PUBLICATION changes can produce
skipped events, stale destination tables, or missing or duplicated data. Plan
a controlled change with source writes paused and the pipeline stopped,
followed by an explicit restart, table resynchronization, or new pipeline
instead of relying on live behavior.
The important public boundary is:
... -> internal DDL message -> Relation(new schema) -> Insert/Update/Delete ...Destinations should treat Event::Relation as the point where the active schema changes for following row events.
Relation is an ordered event, not a batch boundary. ETL batches calls to
write_events() based on size and time, so a single destination batch may
contain zero, one, or many schema changes, including multiple relation events
for the same table.
Destination-Specific DDL Behavior
ETL has one shared schema-change signal, but DDL behavior is implemented per destination. A destination may choose to apply DDL automatically, reject a schema change, or require operator handling.
For every built-in destination, a relation whose snapshot ID and replication
mask exactly match the applied destination metadata is idempotent. BigQuery,
ClickHouse, DuckLake, and Snowflake currently reject an older snapshot, or the
same snapshot with a different replication mask, instead of attempting to infer
schema ordering from later row events. A relation has no DML sequence key of its
own, so destination row-replay deduplication does not prove that reverse or
ambiguously ordered DDL is safe. Recovering from either rejection currently
requires resynchronizing the table. This comparison applies only to metadata
already marked Applied; it neither defines nor initiates recovery from an
interrupted Applying state.
| Destination | Current DDL behavior |
|---|---|
| BigQuery | Supports add, drop, rename, REQUIRED to NULLABLE relaxation, and supported literal default metadata. BigQuery requires added columns to be nullable and does not backfill existing rows for ADD COLUMN ... DEFAULT. PostgreSQL remains responsible for enforcing later SET NOT NULL changes because BigQuery cannot tighten an existing column in place. |
| ClickHouse | Supports add, drop, rename, and supported literal defaults. ReplacingMergeTree rejects primary-key drops or renames because the ordering expression cannot be rewritten safely. ClickHouse default expressions are metadata-only unless explicitly materialized; ETL does not issue MATERIALIZE COLUMN. |
| DuckLake | Supports add, drop, rename, and supported literal defaults. DuckLake records supported add-time defaults as metadata without rewriting existing data files. |
| Snowflake | Supports add, drop, rename, create-table literal defaults, and literal add-column defaults. Literal defaults are included in ADD COLUMN so Snowflake can expose add-time default values for existing rows; non-literal defaults and later default changes are skipped with a warning. |
| Iceberg | Deprecated. An identical relation is idempotent, but schema-change DDL is not supported and any newer schema is rejected. Older or ambiguously masked relations are rejected before row writes. |
| Custom destinations | Destination authors decide which Event::Relation changes to apply, reject, or handle manually. |
See Destinations for the canonical maturity status and broader limitations of every built-in implementation.
Default Backfills
When a source table adds a replicated column with a default, PostgreSQL can make
pre-existing source rows read as though they already contain that default. ETL
deliberately avoids physical destination backfills for those existing rows.
Built-in destinations avoid operations such as UPDATE, MERGE, CTAS/swap
rewrites, or ClickHouse MATERIALIZE COLUMN because those operations can
rewrite large tables, block replication progress, and create
destination-specific cost spikes.
Instead, destinations apply the schema change in the cheapest safe form they support:
- BigQuery adds the column as nullable, then sets supported literal default metadata for future writes.
- ClickHouse may expose default values for pre-existing rows through default
metadata, but ETL does not issue
MATERIALIZE COLUMN. - DuckLake uses add-time initial default metadata for supported defaults; its schema evolution does not rewrite data files.
- Snowflake uses
ADD COLUMN ... DEFAULTfor supported literal defaults. Snowflake exposes default values for existing rows and does not document this as a physical row rewrite, but defaults created this way cannot later be dropped.
As a result, pre-existing destination rows might not match PostgreSQL's
historical ADD COLUMN ... DEFAULT view unless the destination has a
metadata-only initial-default mechanism and the source default is supported.
This does not mean future replicated tuples lose their default values:
PostgreSQL sends evaluated column values in row data after the relation change,
and ETL writes those values normally. The limitation is only that unsupported
defaults are not installed as destination schema default metadata, and existing
destination rows are not rewritten by ETL.
Supported Column Defaults
Column defaults are best-effort metadata translations, not a PostgreSQL
expression evaluator. ETL reads the source default from PostgreSQL's
pg_get_expr output, parses only deterministic literal defaults, and asks each
destination whether that parsed default can be rendered safely in that
destination's SQL dialect.
If a default is not in the supported subset, ETL skips the destination default metadata with a warning. Replication does not fail, and future tuples still carry evaluated values from PostgreSQL. This is intentional: runtime-generated defaults can evaluate at different times or under different session settings in different systems, so installing a similar-looking destination default can create silent mismatches. ETL can add support for specific additional defaults later when their semantics can be preserved for the destination.
The shared parser currently recognizes only these source default shapes:
| Source default shape | Examples |
|---|---|
| String literals | 'pending'::text, ('don''t'::text) |
| Numeric literals | 42, -1, '42.10'::numeric(10,2) |
| Boolean literals | true, false, 'true'::boolean |
| Date/time/timestamp literals | '2026-01-01'::date, '12:30:00'::time, '2026-01-01 12:30:00'::timestamp |
| JSON literals | '{}'::jsonb, '{"enabled": true}'::json |
| UUID literals | 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'::uuid |
The parser is intentionally conservative. These PostgreSQL defaults are examples of unsupported expressions:
default nextval('users_id_seq')
default 'a' || 'b'
default lower('USER' || '_ID')
default concat('a', 'b')
default md5('x')
default random()
default clock_timestamp()
default now()
default current_timestamp
default current_user
default gen_random_uuid()
default uuid_generate_v4()
default timezone('UTC', now())
default array['a', 'b']
default (select 'x')
default current_setting('app.tenant_id')
default 1e6
default interval '1 day 2 hours'Unsupported defaults are skipped because translating arbitrary PostgreSQL expressions would require both a PostgreSQL parser and a destination-specific expression translator. Most destinations do not accept arbitrary PostgreSQL expressions as column defaults, and even similar-looking SQL can have different volatility, time zone, type coercion, or evaluation semantics. Skipping the destination schema default does not drop actual row values emitted by PostgreSQL.
Destination support may be narrower than parser support:
| Destination | Supported default behavior |
|---|---|
| BigQuery | Supports compatible string, numeric, boolean, date, time, timestamp, JSON, and UUID literals. Added columns are created nullable and supported defaults are set afterward for future writes. |
| ClickHouse | Supports compatible string, numeric, boolean, date, time, timestamp, JSON, and UUID literals. Defaults are metadata only unless separately materialized. |
| DuckLake | Supports compatible string, numeric, date, time, timestamp, JSON, and UUID literals. Boolean defaults are currently skipped by the DuckLake destination. |
| Snowflake | CREATE TABLE supports compatible string, numeric, boolean, date, time, timestamp, JSON, and UUID literals. ADD COLUMN only receives the literal subset Snowflake allows for add-column defaults: string, numeric, and boolean literals. Later default changes on existing columns are skipped. |
When changing a default from one supported expression to an unsupported
expression, destinations that can safely remove defaults drop the old supported
default to avoid leaving stale destination behavior behind. Snowflake is the
exception for defaults introduced by ADD COLUMN ... DEFAULT, because Snowflake
does not allow those defaults to be dropped safely.
Diff Semantics
ETL stores schemas in PostgreSQL column ordinal order (pg_attribute.attnum)
and computes destination schema diffs over replicated columns only.
The current diff rules are:
- Same ordinal position, different name: column rename.
- Same ordinal position, different default expression: column default change.
- Same ordinal position, different nullability: detected schema metadata change.
- Old ordinal position missing from the new schema: column drop.
- New ordinal position missing from the old schema: column add.
This matches PostgreSQL's normal behavior for simple column operations:
renames keep the same attnum, dropped columns disappear from the visible
schema, and newly added columns receive new ordinal positions.
Destination Handling
Custom destinations should handle schema changes in write_events() by
watching for Event::Relation.
A practical flow is:
- Iterate through the batch in order, treating each relation event as a possible schema transition for that table.
- Flush any buffered rows/events for the old schema before processing the relation event.
- Compare the old destination schema with the relation event's new
ReplicatedTableSchema. - Mark destination metadata as
Applyingif the destination needs recovery bookkeeping for the DDL transition. - Apply supported destination DDL for adds, drops, renames, and default changes.
- Mark destination metadata as
Appliedonly after the destination schema is actually ready for following row events. - Process following row events with the new schema.
The built-in BigQuery, ClickHouse, DuckLake, and Snowflake destinations follow
this shape: they mark destination schema metadata as Applying, apply the
supported DDL operations, then mark the schema as Applied. Because destination
DDL is not always transactional, recovery is destination-specific. ClickHouse
and DuckLake can retry an interrupted Applying operation. When an arriving
relation drives that retry, it must exactly match the snapshot ID and
replication mask recorded as the target; DuckLake startup recovery can instead
reconstruct that exact target from durable schema state. BigQuery does not
automatically repair Applying schema-change metadata, and Snowflake only
automatically retries interrupted initial setup, not an interrupted schema
change. Other interrupted states require resynchronization.
Other destination modules may support a narrower schema-change surface. Treat
Event::Relation as the stable ETL contract, then check the destination's
status and implementation before relying on automatic destination DDL.
Supported Scope
The source event trigger intentionally observes a broad schema snapshot, but ETL currently supports the simplest safe cases:
ALTER TABLE ... ADD COLUMNfor replicated columns.ALTER TABLE ... DROP COLUMNfor replicated columns.ALTER TABLE ... RENAME COLUMNfor replicated columns.ALTER TABLE ... ALTER COLUMN ... SET DEFAULTwhere the destination supports setting compatible default metadata.ALTER TABLE ... ALTER COLUMN ... DROP DEFAULTwhere the destination supports removing default metadata safely.- Multi-subcommand
ALTER TABLEstatements composed of those simple changes. - Changes to published permanent tables only.
The trigger ignores temporary tables, unpublished tables, generated columns, dropped-column catalog tombstones, extension-owned DDL, and non-logical-WAL databases.
Known Beta Limitations
These behaviors are not full destination DDL semantics yet:
- Only
ALTER TABLEand supportedALTER PUBLICATIONchanges are captured by the ETL DDL trigger today. - Type changes, constraint changes, identity changes, and replica-identity changes may be visible in the emitted snapshot, but they are not yet interpreted as destination DDL operations.
- Table create/drop/rename operations are outside the current schema-change contract. Publication membership and table cleanup remain separate pipeline lifecycle concerns.
- Live publication membership, row-filter, operation-setting, publication-name,
and
publish_via_partition_rootchanges are unsupported. Although the source trigger may emit schema snapshots for some of these commands, it does not dynamically reconcile table ownership or migrate destination table identity. - If a table-sync worker decodes a DDL or publication-column change during
catch-up but receives no following relation before its handover boundary, it
cannot construct the complete decoder required by
SyncDone. The DDL message supplies the physical schema, but only the relation supplies the exact publication and replica-identity masks for that WAL position. ETL cannot safely reuse older masks or read newer catalog state, so this correctness edge case fails the table sync closed. Retry or resynchronize the table after schema activity has settled. - A drop and re-add is not treated as a rename. It becomes a drop plus an add because PostgreSQL assigns a new ordinal position to the new column.
- Destination defaults are best-effort metadata translations. Unsupported
defaults are skipped with a warning instead of failing replication. This does
not remove values PostgreSQL emits in future row events; it only means the
destination schema default metadata is not set. If a previously supported
default becomes unsupported, ETL removes the old destination default where the
destination supports that operation so stale behavior is not left behind.
Snowflake default changes on existing columns are skipped with a warning
because
ALTER COLUMN SET DEFAULTis documented only for existing sequence defaults, and defaults introduced byALTER TABLE ADD COLUMN ... DEFAULTcannot be dropped safely. - Runtime-generated defaults are intentionally unsupported as destination schema
defaults. Examples include
now(),clock_timestamp(),gen_random_uuid(),random(), sequence defaults, and session-dependent expressions such ascurrent_user. Those expressions can evaluate at different times or under different session settings in each destination. PostgreSQL still sends evaluated values for future row events, but existing destination rows are not physically backfilled by ETL. Avoid runtime-generated defaults for replicated schema changes when exact historical values matter. ADD COLUMN ... DEFAULTsemantics differ by destination. ETL intentionally avoids destination DDL that rewrites all existing rows. BigQuery leaves pre-existing destination rows null for newly added defaulted columns, while ClickHouse, DuckLake, and Snowflake can expose supported add-time defaults without ETL issuing a materialization rewrite. Snowflake only receives add-column defaults for source defaults that can be rendered as Snowflake literals.- BigQuery applies ongoing-replication
DROP NOT NULLchanges so future source NULL values remain writable. BigQuery cannot change an existingNULLABLEcolumn toREQUIRED, so PostgreSQL enforces laterSET NOT NULLchanges while the destination column remains nullable. Other built-in destinations currently leave ongoing-replication nullability changes unchanged. Newly added columns remain nullable where the destination requires that for historical rows. - The trigger payload includes
current_queryfor debugging only. It can contain literals and multiple statements, so it must not be treated as replayable DDL. - BigQuery, ClickHouse, DuckLake, and Snowflake reject stale or ambiguously ordered relation schemas instead of rewinding. An older snapshot could drive reverse DDL that drops newer columns and their data. An equal snapshot with a different replication mask is also rejected: supported publication column-list changes always receive a new composite snapshot ID, so the conflicting masks have no ordering with which to choose a safe winner. The pipeline fails with a schema-rewind error and the affected table must be resynchronized.
- Sessions can set
supabase_etl.skip_ddl_log = 'true'as an emergency opt-out while recovering a system. DDL executed with that setting enabled is not logged for ETL.
Event Ordering
Schema changes are transactional logical messages, so they appear in WAL order
relative to row changes. ETL updates its stored schema when it decodes the DDL
message, then waits for the next RELATION message to rebuild the runtime
replication and identity masks for row decoding.
This matters for custom destinations: row events after a relation event should be decoded and written using that relation event's schema. Row events before it belong to the previous schema.