Skip to content

Supabase Pipelines (public alpha): what a PAT reaches and how the engine replicates

Supabase Pipelines streams changes from a project’s Postgres to an external destination and has been in public alpha since 2026-07-21.1 This page covers two things: what a personal access token (PAT) can reach of the managed service, which is nothing, and how the open-source engine behind it copies, lags, applies DDL, handles RLS and behaves when stopped. It sits next to pgmig, which uses the same Postgres logical replication for a one-off move, and Supabase durability tiers, which covers what a replication slot does to a restore.

The managed service could not be driven with a PAT, so no figure below is a managed-service figure.

  • The public Management API description (GET /api/v1-json, 115 paths) has no pipelines route (PL01a).
  • GET /platform/replication/{ref}/{sources,destinations,pipelines} with the PAT answered 401 {"message":"Unsupported access token"} on all three, while GET /v1/projects with the same PAT answered 200 (PL01b).
  • Creating a destination, including a DuckLake one, is done in the Dashboard. The request bodies are known only from the open-source Studio data layer, which was read and not run.2

Every behaviour below comes from the open-source engine, which the docs say Supabase runs for the managed service,3 run as its public container image4 on 2026-10-10:

PartWhat was used
Enginecommit 3fc88dd52a55..., the main branch head on 2026-10-09; which commit the managed service runs was not observed
DestinationDuckLake with a local Postgres 16 catalog and a local S3-compatible store (VersityGW), not Supabase projects
Sourceone Pro-organisation project, eu-central-1, Micro compute, IPv4 add-on (direct 5432 is IPv6-only otherwise and the replicator container has no IPv6), PostgreSQL 17.11
Vantagea laptop in Singapore; select 1 round trip to the source over 15 calls: p50 171 ms, max 176 ms (PL02a)
Source settingswal_level logical, max_replication_slots 10, max_wal_senders 10, max_slot_wal_keep_size 512MB, wal_keep_size 0, checkpoint_timeout 5min (PL02a)

The docs say managed pipelines run in AWS eu-central-1, independently of the source and destination regions.3 The source here is in that region and the engine was not, so copy time and lag include a 171 ms round trip the managed topology does not have. Read them as pessimistic bounds on the engine. Two earlier development runs on the same day are not quoted except where named; they used a SeaweedFS object store that stalled the first large write for about 19 minutes, and it was replaced by VersityGW before the runs below.

Run 1 was PL01-PL08 in one invocation (4 pass, 1 fail, the rest informational), run 2 repeated PL07 on the same project, and PL99 deleted the project (DELETE 200, 0 projects left under the lab’s prefix). Local containers and volumes were removed. Figures are pasted from the pvlab --facts output of the run artifacts, which are not published. Module ids are in Modules.

Whether DuckLake is open to the Pro organisation used here was not observed. The 2026-07-21 changelog lists ClickHouse, Snowflake and DuckLake as available on request through an early access form,1 and the DuckLake guide read on 2026-10-10 lists it as public alpha.5

TL;DR:

  • A PAT reaches no part of the managed pipelines control plane. The Management API description has no route for it and the platform replication routes refuse the token (PL01).
  • Initial copy of 1,000,000 rows (101.1 MB heap) took 53 s from start to sync_done, 13.3 s of it copy (PL02).
  • Lag follows batch.max_fill_ms: p50 5577 ms at the 10000 ms default, 1131 ms at 1000 ms (PL03).
  • RLS applies to neither the copy nor the change stream for a role with BYPASSRLS. A role without it copied only the 100 policy-visible rows of 1000, with no error (PL04).
  • Add, rename and drop column, drop NOT NULL and constant defaults reached the destination in about 5 s. Tightening NOT NULL and volatile defaults were skipped with a warning. An int to bigint change left the destination INTEGER, and a later out-of-range value made the replicator process exit while the table state still read ready (PL05).
  • 0 duplicate rows in 11 kill and stop trials, with and without a primary key. That is “none observed”, not a bound (PL06).
  • A stopped pipeline retains WAL at about 410-620 bytes per 1 KB-class row written. Past max_slot_wal_keep_size the slot went unreserved, then lost at what is consistent with the next automatic checkpoint (PL07).
  • The etl_pipeline add-on is listed at 0.053 USD per hour and a PAT PATCH to attach it was refused with 400 (PL08).

Source (eu-central-1)Laptop, Singapore (171 ms RTT)Postgres 17.11publication + slotsetl schemareplicator(engine image)initial copy+ WAL streamDuckLake catalogPostgres 16metadataS3-compatible store(VersityGW)data files
  1. The source Postgres holds the publication, the slots and the etl schema.
  2. The replicator, the engine’s container image, connects to the source, copies each table, then applies the WAL stream.
  3. The DuckLake destination is a Postgres catalog plus an object store, both local in this run.

On first start the engine installed an etl schema in the source (state, schema and progress tables plus helper functions) and the event trigger supabase_etl_ddl_message_trigger, as the FAQ documents (PL02).6 It used at most 2 slots during the copy, supabase_etl_apply_<id> and supabase_etl_table_sync_<id> (PL02).

QuestionAnswerBasis
Can I create or stop a managed pipeline with a PAT?No route in the API description; the platform routes answer 401PL01, measured 2026-10-10
How long is the initial copy?53 s for 1M rows through the engine from Singapore, 13.3 s of it copyPL02, one run, not a managed figure
How stale is the destination?About the batch wait: p50 5577 ms at 10000 ms, 1131 ms at 1000 msPL03, 65 and 63 rows
Does RLS filter what is copied?Not for a BYPASSRLS role; for a role without it, the copy holds only visible rowsPL04, one run each
Does DDL follow?Most column changes in about 5 s; type changes and volatile defaults do notPL05, one statement each
Can a restart duplicate rows?None seen in 11 trials; the docs say at-least-oncePL06; FAQ6
What does a stopped pipeline cost the source?Retained WAL, then a lost slot past max_slot_wal_keep_sizePL07, two runs plus one development run

PL01 sent one probe per route.

ProbeResult
GET /api/v1-json200, 115 paths, 0 matching any of pipeline, replication, etl, destination, ducklake, warehouse (read-replica routes excluded)
GET /platform/replication/{ref}/sources401 Unsupported access token
GET /platform/replication/{ref}/destinations401 Unsupported access token
GET /platform/replication/{ref}/pipelines401 Unsupported access token
GET /v1/projects, same PAT200

The 401s came from an unused ref, because no lab project existed when PL01 ran in run 1. An earlier development run against a live lab project gave the same 401 on all three routes. Not measured: whether a Dashboard session token reaches those routes.

What the Studio source shows is a ducklake destination definition with catalog.type: supabase_project and storage.type: supabase_storage, posted to /platform/replication/{ref}/destinations-pipelines.2 That path is the Dashboard’s, not part of the documented API.

PL02 copied one table of 1,000,000 rows (heap 101.1 MB, 123.7 MB with indexes), seeded server-side in 5.4 s.

IntervalTime
compose up to sync_done53 s
data_sync to finished_copy13.3 s (7.61 MB/s of heap)
finished_copy to sync_done1.2 s
one-row update to ready13.5 s

The rest of the 53 s is engine start and slot creation over the 171 ms link. The state timeline from the source’s etl.replication_state was init, data_sync, finished_copy, sync_done. ready did not follow on its own: a table sits at sync_done until the apply loop sees later WAL, which here was the one-row update. Destination count, sum(id), sum(qty) and sum(length(note)) equalled the source.

Not measured: managed copy time, copy of tables above 1M rows, the effect of the managed “Initial sync connections per table” and “Table sync workers” settings (engine defaults were used), and the documented initial sync price of 0.60 USD per GB.7

PL03 ran a writer that inserted a row every 0.8 to 1.6 s for 90 s per setting. A DuckDB session on the destination polled max(id) (poll duration p50 10-11 ms, p95 14 ms). Lag is the laptop clock at the first poll that saw the row minus the laptop clock at commit acknowledgement.

batch.max_fill_msrowsminp50p95max
10000 ms (engine default; docs list “Batch wait time” 10000 ms3)65235 ms5577 ms10177 ms10940 ms
1000 ms6390 ms1131 ms1880 ms1928 ms

All rows were seen. Lag tracks the batch wait, and the engine’s own processing plus the laptop link add under about 1 s on top. Not measured: lag under sustained high write rates, and lag in the managed topology.

PL04 used one run per setup on a 1000-row table.

StepEngine connects asResult
a, controlauthenticated, reading the source0 of 1000 rows visible
bpostgres (rolbypassrls true, PL02a)destination 1000 of 1000 after the copy; after 200 inserts and 20 deletes, 1180 of 1180; 50 of 50 updates present
cpublication (id, owner) where (owner = 'keep')destination 500 of 1000 rows, columns id owner, owner values keep
da role with REPLICATION, NOBYPASSRLS and SELECT, whose only policy exposes 100 of 1000 rowscopy reached sync_done with no error; destination held 100 of 1000 rows

So RLS filtered neither the copy nor the change stream for a BYPASSRLS role (step b). Step c is the publication’s own row filter, which is a different mechanism. Step d is the case to decide on deliberately: the copy completes without error and delivers the policy-visible subset only. The change stream was not tested with that role. This is the same by-role rule as in RLS enforcement by connection identity.

The docs read on 2026-10-10 do not mention RLS, so “RLS does not apply” is measured here and not documented. Which database role the managed service connects as was not observed.

PL05 ran each statement once, on a ready table with the batch wait at 1000 ms, then inserted one row shaped for the new schema. “Visible” is the time until that row appeared at the destination. A plain row at the same batch wait showed a p50 of 1131 ms (PL03), so the schema change itself added about 4 s in these single probes. The check loop sleeps 700 ms between polls (5.7 s is 5 s plus one poll), and the near-constant 5 s may be a fixed engine or poll interval rather than a per-statement cost. One probe per statement cannot tell.

Source statementVisibleDestination result
add nullable column5 scolumn present, nullable
add column default 425 scolumn nullable, default '42'; old row 1 reads 42
add column default gen_random_uuid()5 scolumn present, no default; WARN “skipping unsupported source column default”
add NOT NULL column default 75.7 scolumn nullable, default '7'; WARN “adding a source not null column as nullable”
rename column5 srenamed
drop column5 scolumn absent
drop NOT NULL5 snullable
set NOT NULL11.4 sdestination stays nullable; WARN “does not tighten an existing nullable column”
set default 95 sdefault '9'; a row without the column reads 9
drop default5 sdefault removed
type change int to bigint, then row b=15.7 sdestination stays INTEGER; WARN “column type changes are currently unsupported … may fail or behave unpredictably”; the row replicated
row b=5000000000 after the type changenot seen in 60 s62 WARN/ERROR lines, the first error append row ... Call to EndRow before all columns have been appended to!; destination INTEGER
a later row b=1not seen in 120 sthe replicator process had exited: [DestinationAtomicBatchRetryable] DuckLake atomic table batch sequence failed after retries; etl.replication_state still said ready

The final source column list was id a b c2 e f g h with e, h and b NOT NULL. The destination had the same names, all nullable except id and b.

The attribution of the failure to the out-of-range value is an inference from the pair: row b=1 passed and row b=5000000000 failed after the same type change. The separating probe, a bigint destination, cannot be run because the destination cannot take the type change. The docs line “Data type changes are skipped with a warning in every destination”3 holds for the schema change. It does not cover the rows that follow.

PL06 wrote to two tables at about 100 rows/s each (20-row inserts every 150 ms), one with a primary key and one without (insert-only). There were 11 trials: four SIGKILLs at 25-34 s and one SIGTERM at 30 s with the default batch wait, and six SIGKILLs at 8-19 s with a 1000 ms batch wait. After each trial the destination was waited on until it held every source id and had been stable for 20 s.

  • 0 duplicate rows in either table in all 11 trials: distinct ids equalled row count and every source id was present.
  • SIGKILL returned in 106-364 ms (10 trials); restart to a converged destination took 43-56 s.
  • The destination was behind the source at the kill (2086 of 2146 rows in default trial 1), so the kills landed with data in flight.

This does not show that replay cannot duplicate. The docs say processing is at-least-once and that recovery can replay acknowledged data.6 The engine also ships a __etl_replay_epochs table in the DuckLake catalog, seen in its start-up log notices and not studied, which may suppress replay on this destination. Eleven kills sample a narrow window, between the destination commit and the slot acknowledgement, a few times. Zero events in 11 is “none observed”.

A logical slot that nothing consumes pins WAL, the same mechanism as an orphan slot after a dropped subscription in the durability tiers page and the wal_status='lost' abort in pgmig’s watch. Postgres marks a slot unreserved once max_slot_wal_keep_size is exceeded and lost after the WAL is removed.89 The source’s limit was 512MB, which is 536.9 MB (10^6-byte MB throughout). The invalidation part used a table of 100,000 rows of about 1 KB. PL07 ran twice, plus one development run for the restart timing.

StepRun 1Run 2
running slot, wal_status reserved (a)retained 1 MB, safe_wal_size 552.5 MBretained 0 MB, safe_wal_size 547.7 MB
stop with SIGTERM (b)0.6 s, slot active false0.8 s, slot active false
retained WAL after 75,000 rows written (5000 per 10 s for 150 s)47.2 MB (616 bytes per row), stepped growth30.7 MB (409 bytes per row), linear growth
safe_wal_size after that, slot still reserved506.3 MB517 MB
restart to destination equal to source and under 1 MB unconfirmed (c)did not drain in 180 s: 100,000 of 175,000 rows, 116.3 MB unconfirmed43.6 s (development run: 41.4 s)
retained WAL after three full-table UPDATE passes, replicator stopped (d)773.6 MB, safe_wal_size -220.1 MB688.5 MB, safe_wal_size -140.8 MB
wal_status after those passesunreserved before any checkpointunreserved before any checkpoint
time until lost (invalidation_reason wal_removed)368 s215 s
restart with invalidated_slot_behavior: recreate, 50 rows deleted while lost (f)174,950 rows after 47.5 s174,950 rows after 43.3 s
  • With no writes for 30 s the retained WAL did not grow (0 KB). Growth was linear in run 2 (2 4 6 8 ... 30.7 MB) and stepped in run 1 (3 5 7 18.6 20.6 ... 33.3 ...). The 616 against 409 bytes per row spread is unexplained.
  • pg_ls_waldir() is readable by the postgres role: 18 files, 285.2 MB before and after the writes in run 1. The WAL directory peaked at 822.1 MB (50 files) during invalidation.
  • CHECKPOINT is refused for the postgres role (permission denied to execute CHECKPOINT command). No checkpoint was observed. The attribution of lost to an automatic checkpoint is inferred from checkpoint_timeout 5min, which both delays are consistent with. Postgres documents unreserved as WAL “to be removed at the next checkpoint”.9
  • extended was not seen: the first sample after each pass was already unreserved.
  • The run-1 stall in step c was not diagnosed. Restart to drain is n = 3 with one failure.
  • Restart with the default invalidated_slot_behavior (e): the container exited within 60 s with [ReplicationSlotInvalidated] Replication slot has been invalidated, and etl.replication_state still said ready.
  • With recreate, the deletes could not have come from the lost WAL, so the table was rebuilt and not caught up: states init, data_sync, finished_copy, sync_done, and a new slot reserved and active.

Not tested: restarting while the slot is unreserved. Postgres documents that state as able to return to reserved or extended.9 See Supabase compute and disk sizing for the disk this WAL lands on.

PL08 made two calls on the source project.

  • GET /v1/projects/{ref}/billing/addons lists etl_pipeline, variant etl_pipeline_default, listed as 39 USD per month, price.amount 0.053, interval hourly, type usage. 0.053 x 730 is 38.69 USD, which matches the docs’ per-hour figure and the “ETL Pipeline Hours 730 Hours” example line on the usage page.7
  • PATCH /v1/projects/{ref}/billing/addons with {addon_type: "etl_pipeline", addon_variant: "etl_pipeline_default"} answered 400 {"message":"addon_variant: Invalid input"}. The same call shape worked for ipv4 / ipv4_default on the same project. Nothing was attached, so no detach ran.

The usage page says pipeline hours are measured while a pipeline remains configured, including while stopped, and that deleting the pipeline ends the charge.7 That is documented and not tested: it needs a managed pipeline and an invoice or usage export.


  • Copy time and lag carry a 171 ms round trip and a laptop-hosted destination, so they bound the engine from above and give no exact figure for the managed service. The batch-wait relationship in PL03 (lag about equal to the wait) holds at any round trip; the absolute millisecond figures do not.
  • PL05 is one statement per row. The 5 s visibility is a ceiling on these probes; the per-statement cost is unknown.
  • PL06 and PL04d are single setups. PL06 bounds nothing. PL04d shows that a silent partial copy exists; how a managed pipeline’s role is set up was not observed.
  • PL07 retention per row (409 and 616 bytes) depends on row width and write shape. It is two runs on 1 KB-class rows, so it suits sizing a monitoring threshold and is too thin for a budget.
  • All engine behaviour is the commit pinned above. A managed rollout on a different commit can differ.
PracticeEvidenceModule
Do not plan to script pipeline creation with a PAT.API description has 0 matching routes in 115; /platform/replication/{ref}/... answers 401 Unsupported access token while /v1 answers 200. A Dashboard session was not tried.PL01
Monitor wal_status and retained WAL of the pipeline slots on the source.Stopped: 409-616 bytes per 1 KB-class row, unreserved past 536.9 MB, lost 215-368 s later. pg_ls_waldir() is readable by postgres.PL07
Delete a pipeline you will not restart soon instead of leaving it stopped.Retained WAL grew linearly while stopped. Docs: billing continues while stopped and deleting ends it (not tested). Whether delete drops the slot was not observed.PL07, PL08
Grant a non-BYPASSRLS role only after deciding what RLS means for the copy.A role with NOBYPASSRLS and a 100-of-1000 policy copied 100 rows to sync_done with no error. Change stream not tested for that role.PL04
Avoid volatile column defaults on replicated tables.gen_random_uuid() default: destination column present with no default, WARN “skipping unsupported source column default”.PL05
Do not tighten NOT NULL on a replicated table and expect the destination to follow.Set NOT NULL: 11.4 s, destination stays nullable, WARN “does not tighten an existing nullable column”.PL05
Do not change a replicated column’s type in place.int to bigint: destination stays INTEGER; b=5000000000 was not seen in 60 s and the replicator process exited. Attribution is an inference from one pair of rows.PL05
Alert on replicator process exit, not on table state ready.etl.replication_state read ready after the DuckLake write failure and after ReplicationSlotInvalidated.PL05, PL07
Set the batch wait from the lag you can accept.Lag p50 5577 ms at 10000 ms, 1131 ms at 1000 ms. The managed “Batch wait time” setting was not exercised.PL03
Let append-only consumers tolerate repeated rows.Docs say at-least-once. 0 duplicates in 11 trials on the engine, which is “none observed”.PL06
Use invalidated_slot_behavior: recreate only where a table rebuild is acceptable.After lost, recreate rebuilt the table (174,950 rows in 43.3-47.5 s); the default exits ReplicationSlotInvalidated.PL07
Pipeline stopped ornot deliveringpg_replication_slotswal_statusrestart: drain took41.4-43.6 s in 2 of 3,1 stalled at 180 sreservedlost 215-368 s later in PL07;restart at this statenot triedunreserveddefault restart exitsReplicationSlotInvalidated;recreate rebuilds the tablelost
  1. reserved: restart. Drain took 41.4 s (development run) and 43.6 s (run 2); run 1 did not drain in 180 s and was not diagnosed.
  2. unreserved: the slot was lost 215 s and 368 s later in the two runs. Restarting at this state was not tried.
  3. lost: a default restart exits with ReplicationSlotInvalidated; invalidated_slot_behavior: recreate rebuilds the table.
ClaimStatusHow it was checked
A PAT reaches no managed pipelines routemeasured 2026-10-10PL01, one probe per route
Initial copy 53 s, copy phase 13.3 smeasured on the engine, not managedPL02, n = 1
Lag p50 5577 ms at 10000 ms, 1131 ms at 1000 msmeasured on the enginePL03, 65 and 63 rows
RLS applies to neither copy nor stream for BYPASSRLS; partial copy without itmeasured on the engine; not in the docsPL04, one run each
Column DDL propagation; skipped casesmeasured on the engine; skip-with-warning for types documented3PL05, one statement each
0 duplicates in 11 trialsmeasured on the engine; at-least-once documented6PL06, 11 kills and stops
Stopped pipeline retains WAL; slot lost past the keep sizemeasured on the enginePL07, two runs
etl_pipeline listed at 0.053 USD per hour; PAT PATCH refused 400listing and refusal measuredPL08, n = 1
Pipeline billed while stoppeddocumented, not tested7usage page; needs an invoice or usage export
Managed pipelines run in eu-central-1documented, not tested3docs page
Plan: Pro, Team or Enterprisedocumented, not tested6FAQ; DuckLake access of the Pro organisation used here is unknown

The docs-versus-runtime differences here are two. The changelog lists DuckLake as available on request while the DuckLake guide lists it as public alpha, and the docs say nothing on RLS, which PL04 measured. The RUNLOG records no upstream filing for either.

Not measured, with the missing prerequisite:

  • Everything on the managed service: a Dashboard session to create, stop and restart a pipeline, plus an invoice or usage export for billing while stopped.
  • The Supabase-backed DuckLake catalog and Storage: needs the Dashboard flow. The engine accepts a catalog URL and S3 credentials, which this lab pointed at local containers.
  • BigQuery, ClickHouse and Snowflake destinations: accounts the lab does not have.
  • Engine runs from the managed topology, a host in eu-central-1.

The harness is experiments/pipelines/ in supabase-lab: make -C experiments/pipelines run. PL02 creates a Pro-organisation project and PL99 deletes it. The engine image runs as local containers, with a local Postgres 16 catalog and a local S3-compatible store for the DuckLake destination. The step-by-step record is the RUNLOG.

ModuleExperimentTestArtifact
PL01pipelinespl01-api-surface.tsnone published
PL02pipelinespl02-initial-copy.tsnone published
PL03pipelinespl03-steady-lag.tsnone published
PL04pipelinespl04-rls.tsnone published
PL05pipelinespl05-ddl.tsnone published
PL06pipelinespl06-duplicates.tsnone published
PL07pipelinespl07-paused-wal.tsnone published
PL08pipelinespl08-billing-addon.tsnone published
PL99pipelinespl99-teardown.tsnone published
  1. Supabase, “[Public Alpha] Supabase Pipelines,” Supabase Changelog, 2026-07-21. https://supabase.com/changelog/pipelines ↩ ↩2

  2. Supabase, “apps/studio/data/replication,” supabase/supabase, GitHub. https://github.com/supabase/supabase/tree/master/apps/studio/data/replication ↩ ↩2

  3. Supabase, “Pipelines,” Supabase Docs. https://supabase.com/docs/guides/database/replication/pipelines ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  4. Supabase, “supabase/etl,” GitHub. https://github.com/supabase/etl ↩

  5. Supabase, “Pipelines: DuckLake,” Supabase Docs. https://supabase.com/docs/guides/database/replication/pipelines/ducklake ↩

  6. Supabase, “Pipelines FAQ,” Supabase Docs. https://supabase.com/docs/guides/database/replication/pipelines-faq ↩ ↩2 ↩3 ↩4 ↩5

  7. Supabase, “Manage Pipelines usage,” Supabase Docs. https://supabase.com/docs/guides/platform/manage-your-usage/pipelines ↩ ↩2 ↩3 ↩4

  8. PostgreSQL Global Development Group, “Replication - max_slot_wal_keep_size,” PostgreSQL 17 Documentation. https://www.postgresql.org/docs/current/runtime-config-replication.html ↩

  9. PostgreSQL Global Development Group, “pg_replication_slots,” PostgreSQL 17 Documentation. https://www.postgresql.org/docs/current/view-pg-replication-slots.html ↩ ↩2 ↩3