
A SaaS product that gives every customer its own PostgreSQL database keeps tenants apart in production and makes analytics harder, because the data for one report now lives in as many databases as there are customers. Multi-tenant CDC brings those databases together. Change data capture (CDC) identifies the changes made in a source database and delivers them downstream; in a database-per-tenant design, it has to do that for every tenant and land the results in one set of shared tables.
Run one log-based capture per tenant database, tag every record with its tenant as it's captured, key the shared destination tables on tenant plus primary key, and add tables to the pipeline deliberately rather than letting discovery add them.
We tested the design with three tenant PostgreSQL databases feeding shared analytics tables in PostgreSQL and Snowflake, then added tenants to test onboarding and configuration failures.
Key Takeaways
A PostgreSQL replication slot streams changes from a single database, so database-per-tenant CDC needs one slot per tenant database, and Estuary's PostgreSQL capture takes one database each.
Tenants reuse primary key values. Key the shared destination tables on a tenant identifier plus the primary key, and set that key at the destination.
Scale by adding captures and grouping them into cohorts. Add new tables to the pipeline explicitly.
Each tenant's capture fails and recovers independently. Failures in what tenants share, such as the materialization or a shared PostgreSQL server, can reach every tenant.
Roll schema changes out additively. In our test, a new column from one tenant briefly paused delivery for every tenant, and so did the first delete on a new or reset collection.
What is CDC for database-per-tenant SaaS?
Database-per-tenant means each customer has its own PostgreSQL database with the same schema, usually at slightly different versions while migrations roll out. For CDC, each of those databases is a separate replication source.
Three tenancy models are common. In database-per-tenant, each customer has a database, and each database needs its own capture. In schema-per-tenant, one database holds a schema per customer; one capture can read every schema, and its limits are the number of tables it captures and the discovery load. Shared tables with a tenant column are already one source, and ordinary CDC applies.
Per-tenant batch jobs for database-per-tenant analytics get harder as tenants are added: one schedule per tenant and repeated scans of every table. A timestamp query also can't see hard deletes, at any scale. Log-based CDC reads changes from PostgreSQL's write-ahead log (WAL) instead, so after the initial backfill (the first copy of existing rows), it picks up changes without re-querying the tables. Multi-tenant data replication then means running the same capture definition once per tenant, with a tenant identifier on every row.
Reference architecture: tenant databases to shared analytics
PostgreSQL's logical decoding docs state the constraint: "Each slot streams a sequence of changes from a single database." Any log-based tool therefore needs one replication slot, and one reader, per tenant database. Estuary's PostgreSQL capture takes one database in its configuration.
The CDC fan-in architecture, many sources feeding shared tables, has four layers:
- Each tenant database has its own slot and publication. A replication slot holds back the WAL its reader still needs.
- One capture per tenant reads that tenant's tables and stamps every document with the tenant's identifier.
- Estuary stores captured data in collections, which sit between capture and destination and are backed by cloud storage. Every tenant capture writes its orders table into the same orders collection: one collection per source table, shared by all tenants. Estuary supports several captures writing into one collection, and the source tag exists to tell them apart.
- One materialization, the task that writes collections to a destination, reads each shared collection into a shared destination table, keyed on tenant plus primary key.
Captures can also be grouped into cohorts for operations: by region, by data plane (where Estuary runs the task), by throughput, or by schema version during a migration. A cohort is a set of captures managed together.
The same architecture is how you consolidate multiple PostgreSQL databases for analytics without merging them at the source: the tenant databases stay separate, and the shared tables exist only downstream.
Preserving tenant identity and avoiding key collisions
Tenants reuse primary key values. tenant_alpha and tenant_beta can both have an order with id = 1001, for different customers. If the shared table is keyed on id alone, the second tenant's row overwrites the first, and a delete in one tenant removes the other tenant's row.
In any CDC design, the fix is to carry a tenant identifier on every row and key the destination on tenant plus primary key.
| Tenant (source tag) | Source key id | Collection key /id | Destination key | Row |
|---|---|---|---|---|
| tenant_alpha | 1001 | 1001 | (tenant_alpha, 1001) | Alpha's order |
| tenant_beta | 1001 | 1001 | (tenant_beta, 1001) | Beta's order |
In Estuary, tenant identity is applied in two places, one setting at capture and one at the destination:
- Source Tag on each capture, in the capture's Advanced Options. Estuary's PostgreSQL capture adds the value as tag in each document's source metadata, at /_meta/source/tag. Set it to a stable, unique identifier per tenant database, such as the tenant's slug.
- Group-by keys on the materialization binding, set with the Group By button in its field selection. A materialization binding maps one collection to one destination table. Group-by keys let you choose the destination key independently of the collection key. With the tag and id as the group-by key, the destination table is keyed on (tag, id).
Discovery marks the tag as optional, because a capture without a tag doesn't write one. In our test, the PostgreSQL materialization rejected the tag as a key for that reason. Requiring the tag in the shared collection's read schema, the schema that materializations validate documents against, fixed it. Every capture that writes to the collection then has to set a tag.
Keep the tag out of the capture's collection key. Estuary recommends this based on customer deployments. The PostgreSQL capture uses its collection key to page through the source table during backfill, and the tag isn't a column in that table.
That leaves the shared collection keyed on id while the destination is keyed on tag plus id. Estuary reduces documents that share a key into one, keeping the most recent by default, and the key depends on where the reduction happens. A capture reduces on the collection key, but it writes only its own tenant's documents, so its reductions stay within that tenant. The shared collection then holds every tenant's documents side by side, and a materialization binding with group-by keys reduces on tag plus id.
The group-by key changes the destination table, not the shared collection. Anything that reads the collection by its own key, such as a materialization binding without the group-by key or a derivation (an Estuary transformation that reads collections and writes a new one) keyed on id, sees two tenants' 1001 as the same document. Key every downstream consumer on tenant plus id.
Scaling: cohorts, bindings, and discovery
In database-per-tenant designs, you scale by adding captures. The limits in this section come from Estuary's documentation and PostgreSQL's settings; our test ran a handful of tenants and doesn't measure scale. Each tenant capture holds only that tenant's tables.
Estuary recommends that "any one task be limited to 500 bindings" for performance. A capture has one binding per source table it captures, and a materialization one per destination table. The recommendation is a guideline per task, not a tenant limit. It matters most for schema-per-tenant captures, where bindings are schemas times tables, and for a materialization that keeps a separate binding per tenant instead of shared collections.
Discovery also adds load as tenants grow: a capture discovers by querying the source's catalog, and Estuary's PostgreSQL capture repeats that on a rediscovery interval (15 minutes by default) to notice schema changes. To keep onboarding predictable:
- Prepare each tenant database. The capture's database user needs the REPLICATION attribute, read access to the captured tables, and write access to the watermarks table (a small table the capture uses to coordinate backfills), which the capture's read-only mode doesn't use. The capture creates its replication slot, and creates the publication and watermarks table itself when its user has the permissions. Make the database reachable from Estuary through a firewall rule for Estuary's IP addresses, an SSH tunnel, or private networking on private and BYOC deployments.
- Turn off "Automatically add new collections" in a fan-in design and add each new table and its mapping deliberately. Estuary recommends this because an automatically added binding writes to a new per-tenant collection rather than the shared one.
- Use the PostgreSQL capture's Discovery Filters to limit discovery to listed schemas or table patterns.
- Run onboarding from CI. Each tenant capture is a specification. A script can create the tenant's database user, generate its capture with its own tag and slot name, and publish it with flowctl catalog publish, authenticated with the FLOW_AUTH_TOKEN environment variable (flowctl in CI/CD). A script like that published a new tenant into our test pipeline in about 20 seconds; its rows arrived about 15 seconds later.
- Plan a backfill per tenant. A new tenant's capture backfills that tenant's existing rows while it processes ongoing changes, reading every captured table on the tenant's database. Backfilling PostgreSQL CDC at scale covers sizing and pacing it on a live database.
For schema-per-tenant monoliths with thousands of bindings, Estuary recommends keeping broad discovery off. Discovery across that many tables loads the source, and bindings that discover separately can overwrite one another's version of a shared collection's schema. One customer runs a separately scoped capture with discovery on as the schema authority, at the cost of an extra slot.
When tenant databases share a PostgreSQL server, the server has limits of its own:
- Each tenant database adds a replication slot and, while its capture is connected, a WAL sender process. Both count against max_replication_slots and max_wal_senders, which default to 10 and change only with a restart. Managed services can hold slots of their own: the Neon server we tested on kept two.
- Slot names are unique across the whole server, not per database. Estuary's capture defaults its slot name to flow_slot. Give each tenant capture its own. In our test, a second tenant capture left at the default refused to start with replication slot "flow_slot" exists in database "tenant_delta", but the configured database is "tenant_epsilon". The first tenant's slot was untouched.
- WAL is shared across the server, and a slot only advances when its own database has changes to send. A quiet tenant's slot can therefore hold back WAL that busy tenants write. In its default mode Estuary's capture writes to the watermarks table in each tenant database, which keeps each slot advancing. Read-only mode skips those writes; a quiet tenant's read-only capture then needs a heartbeat table that's updated every few minutes and included in the capture. What log-based CDC costs the database covers the per-slot cost.
- A stalled tenant capture's slot holds back WAL for the whole server. A pipeline problem in one tenant can therefore fill the disk that every tenant's database writes to. max_slot_wal_keep_size (PostgreSQL 13 and later, unlimited by default) caps the WAL a slot can retain at checkpoint time. A slot past the cap can be invalidated at a checkpoint, and that tenant's capture then needs a backfill.
Tenants on separate servers avoid these shared limits; tags, group-by keys, and onboarding work the same either way.
For 100 tenants, a server at the default settings streams at most 10 tenant databases, fewer if the service holds slots of its own, so 100 tenants means spreading them across servers or raising max_replication_slots and max_wal_senders on each. Each capture stays the size of one tenant, and with shared collections the materialization holds one binding per table, however many tenants feed it. Cohorts by server or data plane keep alerting, upgrades, and slot limits manageable one group at a time.
Failure isolation, lag, and recovery
Each tenant has its own capture, slot, backfill, and recovery. When we closed one tenant database to its capture, only that capture failed, the other tenants' rows kept landing, and its slot held its position; when access came back, it resumed from the slot within 15 seconds, without a backfill.
Tenants share the collections and the materialization, and the PostgreSQL server and its disk when they're on one. In our test, errors in the shared materialization, whether the destination raised them or a document failed validation, stopped delivery for every tenant in that destination. When we cut one destination off, every tenant stopped there, while the captures kept running and a second destination reading the same collection carried on.
A slot advances with its capture, independently of the destination: the slots kept advancing through our destination outage, and a stalled capture holds WAL on the source. Watch each tenant capture's status and each slot's retained WAL as well as the destination's freshness. An aggregate freshness check can stay green while one tenant's capture is stalled.
If a tenant's slot is lost, its capture fails and needs manual recovery. When we dropped one, the capture stopped with replication slot "flow_slot_tenant_gamma" does not exist, and an incremental backfill recovered it, but a row deleted during the outage stayed in the destination. A backfill of the materialization binding didn't remove it either: the rebuild reloaded the table from the collection, which still held the row's last document. The shared table was empty for every tenant for a few seconds during that rebuild.
In a separate test, a dataflow reset removed a row deleted during a slot outage by re-backfilling every tenant's capture, emptying the PostgreSQL destination table for about a minute and rebuilding Snowflake's, where it also erased the soft-delete history. One tenant's broken capture can block the reset for everyone, because the reset validates every capture that writes to the collection.
No other recovery picks up the deletes missed while the slot was gone, so removing those rows takes a reset. If they don't matter for your analytics, the incremental backfill alone recovers the tenant without touching the others. Backfilling PostgreSQL CDC at scale covers the backfill modes.
Tags don't restrict access. At the source, give each tenant database its own capture user. PostgreSQL grants CONNECT on every database to all roles by default; revoke it from PUBLIC on each tenant database, then grant it to that tenant's capture user and to every application and operational role that connects there, or those roles lose access at their next connection. A tenant's user could still connect to any tenant database where the revoke was missing.
Isolation at the destination is row-level security, per-tenant views, or per-tenant schemas, decided by whoever owns the destination.
Regional separation also needs storage mappings and a destination in the region, not only a regional data plane; captures in several regions feeding one shared collection don't keep the data apart.
| Decision | Option | Choose when | What it costs |
|---|---|---|---|
| Tenancy model | Database-per-tenant | Each customer has its own database | A capture and a slot per tenant |
| Schema-per-tenant | One database holds every customer | Binding count and discovery load in one capture | |
| Failure to plan for | Tenant outage | A tenant database goes down or loses its slot | That tenant's recovery, a backfill if the slot is lost, and a reset for every tenant to clear missed deletes |
| Schema drift | Tenants migrate at different times | A shared schema that accepts every tenant's version | |
| Onboarding | Discovery adds bindings | Few tenants, each with its own collections | Added bindings write to new per-tenant collections |
| Explicit bindings | Fan-in, many tenants | A provisioning step for each new table | |
| Cohorts | By server | Tenants share PostgreSQL servers | Slot, sender, and WAL limits managed per server |
| By data plane or region | Tenants have data-residency requirements | A data plane, storage mapping, collection, and destination for each region | |
| By schema version | A migration rolls out in waves | Each wave's captures updated and checked together |
Schema drift across tenants
A rollout that runs tenant by tenant leaves the databases disagreeing for hours or days, and the shared collection receives every version.
Each tenant's PostgreSQL table has its own definition, and Estuary validates documents against JSON schemas on the shared collection. The write schema checks the documents each capture writes, and the read schema checks documents when a materialization or derivation reads them. In our test, the read schema included an inferred schema, which Estuary learns from the documents the collection has received. Estuary handles schema change with two mechanisms, AutoDiscover and evolutions. AutoDiscover re-reads the source tables' definitions, updates the collections' write schemas, and can add bindings; evolutions handle incompatible changes.
Roll changes out additively: add a nullable column everywhere before anything writes to it or depends on it. When a column reaches one tenant first, it still arrives: in our test a column added to one tenant only, with automatic discovery off, reached the destination without intervention. The capture sends every column it reads from the WAL, and the write schema doesn't list the new column but doesn't forbid it. The shared materialization paused for about 45 seconds on the first document carrying the new field, until the collection's inferred schema updated. It then added the column, NULL for the other tenants, whose captures kept running.
On a newly created collection, and again after the collection was reset, the first delete caused a similar pause, because a delete document, under the default replica identity, carries only the key and metadata while the inferred schema still expected every column.
The pauses come from inference, which learns only from documents the collection has received. A read schema you write yourself, without the inferred schema, avoids them, at the cost of maintaining it as tenants change; Estuary recommends the inferred schema where it works.
Discovery from several tenant captures can disagree about the shared schema. In a local flowctl test, discovery for alpha, then beta, then alpha rewrote the write schema to each tenant's version in turn: alpha's added the new column and beta's removed it. We didn't publish those specifications or test automatic discovery. One capture acting as the primary for schema updates, with discovery off on the others, keeps the captures from overwriting each other's version. That arrangement breaks down when the primary has an outage or when the other tenants drift from the schema it expects. The shared schema still has to accept every tenant's version.
With automatic discovery off and the shared schema unchanged, a breaking change in one tenant stopped only that tenant's capture: when beta's status column changed from text to integer, beta's capture failed validation and the other tenants kept flowing.
Estuary's schema inference only widens: when a column's type changes in one tenant, the inferred schema grows to accept both types. The write schema doesn't widen this way: with discovery off, beta's write schema kept the old type and rejected the new one. Changes that can't be widened, such as a new collection key or new logical partitions, follow the materialization's onIncompatibleSchemaChange setting. By default Estuary resets the collection: every capture writing to it backfills from its source database, and every materialization reading it rebuilds its tables. The setting can instead disable the binding, disable the materialization, or stop and leave the change to you. On a shared collection, a reset covers every tenant.
A materialization backfill, which rebuilds the destination table alone, reads the collection's retained history, and rows whose documents have aged out are missing from the rebuilt table; Estuary's default storage bucket keeps 20 days. A collection reset re-reads the source databases instead. Keep longer history in your own storage mapping, and schedule breaking changes across all tenants at once.
Practical example: three tenant databases
We ran the design on three tenant databases, tenant_alpha, tenant_beta, and tenant_gamma, on one PostgreSQL 18 server (a Neon project): the shared-server case. Each had the same orders table with three sample rows, including an order with id = 1001 for a different customer in each. The test covers behavior on sample data; it doesn't establish production volume, retention, or schema management. Three Estuary captures, one per database, each with its own source tag and slot name, wrote into one shared orders collection. A Snowflake materialization keyed on tag plus id wrote it to a shared analytics table with the Snowflake materialization's default soft deletes, and a PostgreSQL materialization with hard deletes read the same collection for comparison. During testing, we added three more tenants: tenant_delta and tenant_epsilon to test slot names, and tenant_zeta to test scripted onboarding.
| Test | Result |
|---|---|
| Initial backfill | PostgreSQL: three rows per tenant, every tenant's id = 1001 as its own row; Snowflake, added later, backfilled the same separation |
| Update tenant_alpha's 1001 | Only alpha's row changed |
| Delete one tenant's row | Snowflake kept it marked as deleted; PostgreSQL removed it; other tenants' rows unchanged |
| Close tenant_gamma's database to its capture | Only gamma's capture failed; the other tenants kept flowing |
| Restore access | gamma resumed from its slot and delivered the row written during the outage, no backfill |
| Drop gamma's slot, then run an incremental backfill | Capture failed until the backfill recreated the slot; a row deleted during the outage stayed |
| Then backfill the PostgreSQL materialization binding | The deleted row returned from the collection; the table was empty for every tenant for a few seconds |
| Dataflow reset of the shared collection | Every tenant re-backfilled; PostgreSQL emptied for about a minute, Snowflake rebuilt |
| Cut one destination off | Every tenant stopped there; captures and the other destination kept running |
| Add a column to tenant_alpha only | Delivered after a brief pause for all tenants; NULL for the other tenants |
| Change a column type in tenant_beta only | Only beta's capture stopped |
| Run discovery locally for alpha, then beta, then alpha | Each pass rewrote the shared write schema in the local specification |
| Onboard another tenant with a script | Published in about 20 seconds; rows in the destination about 15 seconds later |
| Add two more tenant captures left on the default slot name | The second refused to start; the first was unaffected |
Both destination tables were keyed on (tag, id). Snowflake declares that key without enforcing it. Estuary's merge on the key keeps tenants apart.
When Estuary fits: trade-offs and cost
A self-managed log-based design needs the same structure: a slot and a reader per tenant database, plus the code that tags records, maps every tenant into shared tables, keys them correctly, and tracks each reader's position and failures.
Estuary provides the captures, group-by keys at the destination, per-capture status, and a data-plane choice per connector. Onboarding a tenant means preparing its database and publishing one more capture specification.
Estuary's pricing is $0.50 per GB plus $100 a month per connector instance ($50 after your sixth), prorated by the hour (list rates, checked 2026-11-02). One capture per tenant is one connector instance per tenant. As an illustration at those list rates, 100 tenant captures, and one materialization cost 6 × $100 + 95 × $50 = $5,350 a month before any data is counted. Annual plans come with volume discounts, and Estuary prices a deployment with this many connector instances individually. For many small tenants, instance hours can outweigh data volume. Data is counted when it's captured and again when it's delivered. How much real-time data ingestion costs walks through modeling a workload like this.
Another approach fits better in some cases:
- With a handful of tenants and an existing Kafka platform with people to run it, self-managed connectors are a reasonable choice.
- Tenants already in shared tables with a tenant column are one source, and ordinary CDC applies.
- For analytics that tolerate a daily load, multi-tenant ETL on a schedule is simpler, as long as hard deletes don't matter.
Planning CDC Across Tenant Databases?
Talk to Estuary about your tenant count, your PostgreSQL topology, and your destination, and we'll help you plan the captures, keys, and onboarding for multi-tenant CDC. Book a demo, or start with the PostgreSQL capture docs.
FAQs
How do you avoid primary key collisions when consolidating tenant databases?
Should auto-discovery be on when you have hundreds of tenants?
Does one tenant's outage affect the others?

About the author
Brian Sheehan is a Principal Technical Writer with more than 14 years of experience documenting software platforms, most recently leading documentation for Jira Software integrations at Atlassian. Brian specializes in REST APIs and integration workflows, and writes Estuary's guides to change data capture, turning what engineers know about production pipelines into steps teams can follow.
















