Destinations
Official built-in destinations, release status, requirements, and limitations.
Supabase ETL ships official destination implementations in the
etl-destinations crate. Enable only the destination feature you need when
embedding ETL or building the standalone replicator. ClickHouse is the
easiest destination to start with locally: cargo x init starts it, and
cargo x setup replicator configures it by default. BigQuery is the most
mature cloud destination.
| Feature | Destination | Status | Guidance |
|---|---|---|---|
clickhouse | ClickHouse | Public alpha | Recommended local getting-started path |
bigquery | Google BigQuery | Public alpha | Most mature cloud destination |
ducklake | DuckLake | Public alpha | Evaluate for your workload |
snowflake | Snowflake | Public alpha | Evaluate for your workload |
iceberg | Apache Iceberg | Deprecated | Do not use for new deployments |
Status definitions
- Public alpha: The managed service is in public alpha. Features and behavior may change. Local and self-hosted use of the open-source destinations is unrestricted. Review limitations and test recovery and schema changes against your workload before production use.
- Deprecated: Retained for compatibility but no longer recommended for new deployments. Plan to move away from it.
Row value handling
ETL validates structural prerequisites such as primary keys, replica identity, column mappings, and schema transitions. It does not pre-validate each row value against destination-specific domains. When ETL can represent a value in the destination request or wire format, it sends that value and relies on the destination's native coercion, rounding, canonicalization, and rejection behavior. These semantics apply consistently to initial copy, inserts, and updates.
For row values, ETL returns a local encoding error when it cannot construct a faithful wire representation. Known destination differences do not add per-cell runtime validation. Test destination-specific value semantics that matter to your workload.
Publication column additions
An existing column newly exposed by a publication is added as nullable so old
destination rows remain NULL. Defaults and array handling differ by
destination. See Publication column
changes for the
complete policy.
Destination ownership
ETL assumes exclusive ownership of every data-bearing table it creates.
Destination metadata is the authority for whether initial setup or a schema
change completed; process-local caches do not replace that state. Once metadata
is Applied, ETL does not recreate a missing table or repair external schema
changes during ordinary writes or restart. Such changes are unsupported and
typically cause the destination operation to fail. Use the table
resynchronization lifecycle when a destination table must be rebuilt.
ETL may rebuild ephemeral client state and derived non-data-bearing objects, such as Snowflake streaming channels or BigQuery views. Direct destination truncation is also outside ETL's lifecycle: later behavior depends on the destination's offset and write semantics, and previously replicated rows are not restored automatically.
BigQuery
Public alphaThis status applies to the managed service, not local or self-hosted use.
BigQuery uses the BigQuery Storage Write API and BigQuery change data capture to maintain destination tables. Use it when you already have a Google Cloud project. For a local first pipeline, prefer ClickHouse.
Physical table versions and upgrades
Query the stable logical view for each replicated table. Initial copy, recopy,
and source TRUNCATE operations create physical tables with fresh random
version IDs. ETL does not deliberately reuse a deleted physical name:
streaming writes after same-name recreation can be acknowledged without
reaching the replacement table. The active logical view and its physical target
must remain available; older physical tables are no longer needed.
Cleanup is optimistic: after truncate switches the view and persists the new target, ETL attempts background deletion of previously discovered versions. Explicit drop/reset also attempts physical cleanup after removing the view. Listing or physical deletion failures are logged and can leave unused tables behind without blocking replication. A later truncate or reset can try again; there is no cleanup queue or periodic collector. Operators can use the table IDs in warnings to remove unused tables manually, after checking that they are not the current view target or the target recorded in destination metadata. Interrupted creation leaves its abandoned table for these same cleanup paths. Creation, view changes, and metadata persistence still propagate failures.
The suffix is an underscore followed by 25 lowercase letters and digits, encoding a full UUID without relying on the clock. It adds 26 ASCII bytes to the encoded source name. BigQuery permits table names up to 1,024 UTF-8 bytes.
Existing numeric versions remain readable and active after an upgrade. The next truncate or recopy allocates a random version while preserving the logical view name. No metadata database migration or immediate recopy is required. Once random IDs have been persisted, downgrading requires a release that understands this format; older releases that only parse numeric suffixes cannot resume those tables. Do not edit stored IDs to enable a downgrade. If rollback to an older deployment must remain possible, deploy support for reading both formats in that release before deploying random-ID allocation.
Query completion and recovery
ETL waits for BigQuery DDL to finish before advancing destination metadata. Query submission, completion polling, result pages, and retries share a ten-minute deadline. A pending response keeps the original job ID and location. Each poll requests a ten-second server wait; another incomplete response is followed by a one-second pause. The server wait is a hint: HTTP latency can exceed it, and it does not limit the remote job's lifetime.
Transient HTTP failures allow up to 20 retries of the same request. Backoff starts at one second, doubles to a 32-second cap, and adds up to one second of jitter within that cap, following BigQuery's reliability guidance. Only BigQuery's explicit retryable failed-job responses permit resubmission with a new request ID, with at most four replacement jobs. The shared deadline can stop either retry loop sooner. Permanent SQL, permission, and configuration errors require correction.
A local timeout does not cancel the remote job. ETL reports an unconfirmed outcome and requires manual recovery instead of automatically replaying DDL. Use the reported request ID, job ID, and location to investigate the outcome before retrying or resetting the affected table. View changes and metadata writes are separate operations; after an interrupted transition, inspect both before deleting any physical table. Storage Write API propagation retries are separate from these query retries.
Table partitioning and clustering
Use table_options to select the physical layout for individual source
tables. table_options, along with max_staleness_mins, is applied only
when a table is created or recreated, never to a table that already exists.
Restarting a pipeline does not modify an existing table, and changing either
setting has no effect until the table is created again. A table is created,
and so picks up the current settings, when it is replicated for the first
time, when its replication state is reset, or when a source truncate makes
ETL create a new physical table.
destination:
big_query:
# Project, dataset, and authentication fields omitted.
table_options:
tables:
- table_id: 16384
partition_by:
kind: time_column
column: created_at
granularity: day
cluster_by:
- tenant_id
- event_typetable_id is the source PostgreSQL table OID, matching the identifiers used by
table_sync_copy. Find it in your database using the qualified table name:
SELECT 'public.events'::regclass::oid AS table_id;Replace public.events with the table you want to configure. Because the
configuration follows the source table ID rather than
its name, renaming the source table does not detach its physical-layout
settings, and destination metadata can continue pointing it at a different
physical table. If a table is dropped and recreated, PostgreSQL assigns it a
new OID, so its configuration must use the new table_id.
partition_by and cluster_by are independent. Set either one or both in a
table entry: omit cluster_by for a partitioned-only table, or omit
partition_by for a clustered-only table. To use BigQuery's default
unpartitioned and unclustered layout, omit that table from tables. An entry
with neither option is rejected because it has no effect.
Time-column partitioning supports replicated PostgreSQL date, timestamp,
and timestamptz columns with hour, day, month, or year granularity;
date columns do not support hour. Use kind: integer_range with column,
start, end, and interval for an integer column, or
kind: ingestion_time with granularity for ingestion-time partitioning.
Clustering accepts one to four ordered, distinct replicated column names.
BigQuery validates whether clustering column types are supported.
partition_by.column and each cluster_by entry use the source PostgreSQL
column name; ETL applies the same flexible column
names mapping to them as it does to the column
itself, so a partitioning or clustering column does not need to be quoted or
otherwise adjusted for the destination.
See the BigQuery documentation for partition expressions and clustering column requirements.
Flexible column names
BigQuery's flexible column names accept spaces, most punctuation, and non-ASCII Unicode characters. ETL passes any replicated PostgreSQL column name through to BigQuery for table creation, schema changes, and row writes; a column name is no longer required to be an ASCII identifier.
BigQuery documents column names as case-insensitive but does not specify the
Unicode case-folding algorithm it uses for identifier resolution. ETL folds
only ASCII letters when mapping a source column name to its destination name,
matching the destination-name
collision behavior used
elsewhere for BigQuery. Name and name therefore collide locally and are
treated as one destination column, while Ä and ä remain distinct. If
BigQuery's own case-insensitivity considers such a non-ASCII pair equivalent,
the table DDL is rejected instead of ETL silently merging the two columns.
Limitations
- Every replicated source table needs a primary key, and all primary-key columns must be included in the publication.
- PostgreSQL arrays containing
NULLelements are not supported. - BigQuery stores a top-level PostgreSQL
NULLarray as an empty array, so the two source values are indistinguishable in the destination. - Numeric and JSON values follow BigQuery's native domain, rounding, and canonicalization behavior. BigQuery may transform or reject values that do not map exactly to its types.
- Supported schema changes are applied automatically, but PostgreSQL default expressions and backfill behavior are only supported where they map safely to BigQuery. See Schema Changes.
- BigQuery column renames are subject to BigQuery's row-access-policy and metadata limitations. Review BigQuery schema change behavior before renaming columns with tags, aspects, or policies.
ClickHouse
Public alphaThis status applies to the managed service, not local or self-hosted use.
ClickHouse is the local default destination. cargo x init starts ClickHouse
on http://localhost:8123 (etl / etl), and cargo x setup replicator
writes config for that service. It needs no cloud account.
See the Standalone Replicator guide for setup and the ClickHouse destination guide for queries, example flags, and upgrades.
ClickHouse supports a current-state layout with ReplacingMergeTree and an
append-only event log with MergeTree.
engine applies only when ETL creates or recreates a table. ClickHouse cannot
change a table's engine, so if the configured engine differs from an existing
table, ETL fails the write rather than silently keeping the old layout. Drop
and resynchronize the table, or revert engine.
When ETL has no destination metadata for a source table, it creates the
ClickHouse table and, for ReplacingMergeTree, its __current view. If either
name already exists, ETL fails the table instead of reusing an object it cannot
prove it owns. This happens when a table is re-added to the publication, when
ETL state is reset against the same database, when two pipelines share a
database, or when a resync after a rename would copy into another table's
destination. Drop the conflicting object or use another database.
Limitations
- The default
ReplacingMergeTreelayout requires a source primary key and ClickHouse 23.5 or newer. - In either engine, publish every primary-key column if the source has a key.
- In either engine, the source primary key must not be
DEFERRABLE. A deferrable key lets one statement move a row into a key before the key's previous row moves out, which per-key current state cannot represent. ETL fails the table during its initial copy. A schema change that makes an existing key deferrable fails instead, and replication of every table stops until you resynchronize this one. - For updates and deletes on tables with a primary key, replica identity must
be
DEFAULT,FULL, orUSING INDEXwith the same identity columns as the primary key. ETL rejects other index identities. WithNOTHING, Postgres rejects published updates and deletes before they reach ETL. A primary-key-changing update writes a tombstone for the old key followed by the new row. MergeTreekeeps an append-only event log; current state needs alimit 1 byquery overcdc_lsnandcdc_tx_ordinal.- A replicated
TRUNCATEclears the destination table in either engine, includingMergeTreeevent history. - A source type change that changes the mapped ClickHouse type, such as
integertobigint, fails the schema change before ETL alters anything, as doestimestamptotimestamptz, which shares a ClickHouse type but converts existing values through the session time zone. Replication stops until you resynchronize the table. Changes that keep the ClickHouse type, such as a longervarchar, continue without DDL. Every insert names its columns and types, so ClickHouse also rejects writes to a column whose type changed outside ETL. - PostgreSQL
timestampandtimestamptzcolumns, and their arrays, map toDateTime64(6, 'UTC'). Atimestampvalue reads back as its source wall-clock value in any server or session time zone. Tables created by earlier ETL versions useDateTime64(6)fortimestampand fail with a column type mismatch; follow the upgrade steps in the ClickHouse destination guide. - ClickHouse
Date32andDateTime64columns support 1900-01-01 through 2299-12-31. ETL writes dates and timestamps outside that range exactly, and ClickHouse stores them, but reads them back clamped to the nearest limit, without an error. For example,1800-06-15reads back as1900-01-01. Filters match neither the source value nor the clamped one. Keep replicated dates and timestamps inside this range. - ClickHouse
Arraycolumns are non-nullable. As with BigQuery, ETL stores a top-level PostgreSQLNULLarray as an empty array, so the two source values are indistinguishable in the destination.NULLarray elements are preserved. - ETL sends any value RowBinary can encode; ClickHouse applies its own conversion and rejection rules.
- You may add
MATERIALIZED,ALIAS, orEPHEMERALcolumns to a table ETL created; ETL ignores them when writing. A resynchronization recreates the table without them. - Dotted nested subcolumns can be renamed only within the same parent, matching
ClickHouse's documented
RENAME COLUMNlimitation. - Tombstone cleanup and
OPTIMIZE ... FINAL CLEANUPare operator-managed. Follow ClickHouse's cleanup requirements to avoid resurrecting deleted rows.
DuckLake
Public alphaThis status applies to the managed service, not local or self-hosted use.
DuckLake writes through DuckDB to a file or PostgreSQL catalog and local or object storage.
Limitations
- Data storage URLs currently support
file,s3, andgsschemes. - Deployments must account for the required DuckDB extensions and the catalog, storage, and maintenance services they configure.
- Primary-key sorting requires source tables to have a primary key; other sorting modes do not add that requirement.
- DuckLake adds streaming columns as nullable. Supported literal defaults are
stored as metadata; ETL does not emit
DEFAULT(NULL)when no source default exists, although a catalog client may display an omitted default asNULL.
Snowflake
Public alphaThis status applies to the managed service, not local or self-hosted use.
Snowflake uses direct Snowpipe Streaming and key-pair authentication.
Resynchronizing after a source table rename
A source rename preserves the recorded destination table name during streaming. Resynchronization drops that recorded table, then creates a fresh destination from the current source name. If that name is already occupied, ETL fails the copy instead of writing into another table's destination.
For example, suppose public.orders is renamed to public.ordersold, and
public.ordersnew is renamed to public.orders. The first table still owns
PUBLIC_ORDERS, so resynchronizing the second table fails when it tries to
create PUBLIC_ORDERS.
To recover, resynchronize public.ordersold first and wait for it to finish.
Provided PUBLIC_ORDERSOLD is available, this rebuilds the first table there
and frees PUBLIC_ORDERS. Then retry resynchronizing public.orders.
Resynchronization deletes previous destination data and CDC history. Existing
source rows are copied again only when initial copying is enabled. Update any
queries that depend on the first table's previous destination name.
If the existing destination must remain unchanged, use a new pipeline targeting an unused destination schema. Do not manually delete or rename a destination that another replicated table still uses.
Limitations
- Setup requires a Snowflake user and role with the documented warehouse, database, and schema privileges.
- The effective
QUOTED_IDENTIFIERS_IGNORE_CASEparameter must beFALSE. Connectivity validation rejects other settings because ETL preserves quoted source column names exactly. See Snowflake identifier resolution. - Schema evolution support is still in progress. Only defaults that ETL can translate safely at table creation or column addition are applied. Later existing-column default changes are skipped because Snowflake default provenance cannot be inferred safely.
jsonandjsonbuse nativeVARIANTstorage and preserve SQLNULLseparately from JSONnull. ETL omits destination defaults for these columns and removes existing defaults before opening or refreshing their streaming channels. PostgreSQL still applies source defaults before replication. If a destination default cannot be removed, channel setup fails before writing. Existing destination columns mapped fromjsonorjsonbmust have physical typeVARIANT; incompatible types are rejected during channel setup.- PostgreSQL
json[]andjsonb[]arrays containing SQLNULLelements are rejected during encoding because the ingestion format cannot distinguish them from JSONnullelements. JSONnullelements, empty arrays, and a whole SQLNULLarray are supported. - Earlier versions could merge SQL
NULLand JSONnull. The encoding fix does not repair existing rows; restoring that distinction requires the source data or a fresh copy. - Validate channel recovery, committed offsets, and account-specific resource limits before production use.
Iceberg
DeprecatedThe Apache Iceberg implementation is retained for compatibility and is not recommended for new deployments.
Limitations
- Schema-change DDL is not supported. A newer relation schema is rejected even when only a default, nullability, mapped-equivalent type, or publication mask changed. Existing tables must match the exact generated Iceberg schema.
- Apache Iceberg supports native schema evolution, but this deprecated ETL adapter does not implement it and does not translate PostgreSQL defaults.
- The destination is deprecated and is not receiving the same product investment as the active destination implementations.
Custom destinations and upstream support
Adding an official destination is a long-term maintenance commitment. It requires durable and idempotent writes, schema-evolution behavior, restart and recovery coverage, integration infrastructure, credential handling, ongoing dependency updates, and operational support. For that reason, maintainers are careful about accepting new destination implementations upstream. Open an issue or discussion before investing in an upstream implementation; acceptance is not guaranteed.
You do not need to contribute a destination upstream to use ETL. Implement the
Destination trait in your own Rust
project, wire it into Pipeline::new, and maintain it alongside your
application. See Custom Implementations for
a complete example.