Datastream — Serverless CDC & Replication
Google Cloud's serverless CDC (Change Data Capture) service. Reads a source database's transaction log and replicates changes into BigQuery or Cloud Storage, near real-time, with no infrastructure to run.
Mental model (from AWS)
Datastream has the same three-part shape as AWS DMS:
| Datastream | AWS DMS | Role |
|---|---|---|
| Connection profile | DMS endpoint | Where to connect + how to authenticate (one per endpoint) |
| Stream | DMS replication task | What to move + how — binds two profiles, runs |
| (serverless, hidden) | DMS replication instance | The compute — Google manages it |
Sources: PostgreSQL, MySQL, Oracle, SQL Server. Destinations: BigQuery (managed merge) or Cloud Storage (Avro/JSON files you process yourself).
Object model
- Connection profile — one endpoint, decoupled from any stream. Holds
address + credentials + connectivity method. Reusable across streams.
Creating one validates connectivity immediately (it dials the DB).
- Source (
postgresql_profile): hostname, port, user, password, database. - Destination (
bigquery_profile {}): empty — authenticates as the Datastream service agent, which needsroles/bigquery.dataEditor.
- Source (
- Stream — the running pipeline. References two profiles, selects tables
(
include_objects), sets backfill strategy + destination config. Stateful and long-lived (Running / Paused / Not started / Failed / Draining). - Private connectivity config — optional VPN/PSC path (skip if using IP allowlisting).
Scope & hierarchy
PostgreSQL hierarchy: server → databases → schemas → tables.
| Question | Answer |
|---|---|
| Multiple schemas per stream? | ✅ Yes — list several postgresql_schemas in include_objects. |
| Multiple databases per stream? | ❌ No (Postgres) — one stream = one database. Logical replication (slot + publication) is database-scoped. Use one stream per database. |
MySQL treats schemas as databases, so one MySQL stream can span several. The one-database limit is a PostgreSQL property, not a Datastream one.
Backfill vs CDC
CDC only captures changes from slot-creation forward — pre-existing rows never appear in the log. Backfill fills that gap.
| Backfill | CDC | |
|---|---|---|
| What | One-time bulk copy of rows that already existed | Every change after the stream starts |
| How | Chunked SELECT-style reads (not the log) | The transaction log / replication slot |
| When | Once per table, at stream start | Continuous, ongoing |
| Load profile | Heavy (scans, cache churn) | Light (log decode) |
- Backfill + CDC run concurrently; CDC starts from the pinned LSN, backfill loads the snapshot, and once a table's backfill completes it is CDC-only.
backfill_all {}— auto-backfill every table.backfill_none {}— CDC-only (used to trigger backfills manually later).- "No backfills in progress" = initial load finished → pure CDC steady state.
Is backfill complete / assured?
Yes for all supported data — and it's a real guarantee, not best-effort:
- The replication slot is created first, pinning a starting LSN; from that instant every change is retained in WAL.
- Backfill snapshots existing rows (may take hours).
- Rows that change during backfill are also captured by CDC.
- BigQuery
MERGEis keyed on the primary key, ordered by source timestamp/LSN → the newest version wins. The table converges (eventual consistency), even if backfill read a stale row.
Exceptions (surface as Unsupported events, not silent loss): tables without a primary key, oversized rows / LOBs (truncated), unsupported column types.
How long does backfill take?
No fixed SLA — throughput-bound. For hundreds of GB cross-cloud, plan for hours to ~a day, usually limited by the source→GCP link, not Datastream.
| Factor | Effect |
|---|---|
| Cross-cloud bandwidth | Usually the limiter |
| Source DB load headroom | Throttle to spare prod → slower |
max_concurrent_backfill_tasks | Parallelism across tables |
| Table shape (wide rows / LOBs / no PK) | Slower |
| Processed-byte inflation (2–5×) | More bytes move than raw size |
Run a small POC (one table), measure GB/hour, extrapolate — that number also sets your required WAL-retention window (see Operational risk).
Scheduling / staggering backfill
No native "run at 2 AM" cron, but equivalent control:
backfill_none+ manual per-object backfill triggered in off-hours (the clean "backfill at night" pattern).- Throttle with
max_concurrent_backfill_tasks. - Backfill from a read replica for isolation (Postgres caveats apply).
- Stagger by table via Terraform: apply a stream with wave-1 tables in
include_objects; once stable, add wave-2 tables andterraform apply(in-place update — backfills only the new objects). Staggering also lowers peak WAL retention, because CDC drains the slot between waves.
Postgres → BigQuery mapping
Flat, 1:1 table mapping. Relationships are NOT preserved. Each source table → one BigQuery table; foreign keys become ordinary columns. Datastream does not join, nest, or denormalize — that's the silver layer's (dbt) job.
Example — a normalized FSM schema with FKs:
regions(id PK, name)
customers(id PK, name, region_id→regions, created_at timestamptz, metadata jsonb)
work_orders(id PK, customer_id→customers, status, amount numeric(10,2), tags text[])
work_order_lines(id PK, work_order_id→work_orders, product_id→products, qty int)
products(id PK, sku, name, price numeric)
→ 5 independent flat BigQuery tables (typically <schema>_<table>, e.g.
public_work_orders — verify with bq ls). FK columns are plain INT64; you
rebuild relationships with JOINs downstream.
Type mapping (common):
| PostgreSQL | BigQuery |
|---|---|
smallint/int/bigint/serial | INT64 |
real/double precision | FLOAT64 |
numeric(p,s)/decimal | NUMERIC (→ BIGNUMERIC if huge) |
boolean | BOOL |
char/varchar/text | STRING |
timestamptz | TIMESTAMP |
timestamp (no tz) | DATETIME |
date/time | DATE/TIME |
json/jsonb | JSON |
uuid | STRING |
bytea | BYTES |
arrays (text[]) | JSON (flattened, not native ARRAY) — verify |
enum/interval/ranges/custom | STRING or unsupported — check per type |
Plus a datastream_metadata column (STRUCT: source_timestamp, uuid).
In merge mode the BQ table holds current state (deletes remove rows),
keyed/clustered on the source PK.
PostgreSQL source prerequisites
The source DBA must enable, before a stream can run:
wal_level = logical- A publication:
CREATE PUBLICATION <name> FOR ALL TABLES; - A logical replication slot (
pgoutputplugin) - A role with
REPLICATION+SELECTon replicated tables - Network reachability (IP allowlist / SSH tunnel / private connectivity)
Operational risk — WAL retention & replication slots
WAL is not a permanent history. Segments are recycled after checkpoints
once all consumers have passed them. A replication slot pins WAL at its
restart_lsn — Postgres will not delete WAL the slot hasn't confirmed. That is
what makes CDC lossless and dangerous.
The backfill trap: while a long backfill runs, the slot's restart_lsn can't
advance, so all WAL generated during backfill accumulates on the source disk.
required free WAL space ≥ peak_WAL_rate (GB/hr) × backfill_hours × safety_margin
Example: 15 GB/hr × 18 h ≈ 270 GB → provision ~350 GB free on the WAL volume before starting.
No max_slot_wal_keep_size | With max_slot_wal_keep_size set |
|---|---|
| Slot never dropped, but slow consumer → WAL fills disk → DB write outage | DB protected, but runaway slot invalidated → full re-backfill |
Right config: size WAL disk for worst-case backfill and set
max_slot_wal_keep_size as a backstop. Monitor slot lag:
pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn).
How it processes the log — two stages
Source WAL ──(A) stream continuously──► Datastream ──(B) merge on cadence──► BigQuery
logical replication slot Storage Write API + MERGE
(event-driven, at commit) (≤ data_freshness)
| Stage | What happens | Frequency |
|---|---|---|
| A — read source | Connects as a logical replication client; DB pushes changes as transactions commit | Continuous / near-real-time (not polled) |
| B — write to BigQuery | Buffers, streams via Storage Write API, periodic MERGE | Bounded by data_freshness (the only tunable) |
- Postgres logical decoding emits changes at COMMIT, in commit order.
data_freshnessis a ceiling, not an interval — actual lag can be seconds even with a900ssetting.- Lower
data_freshness= more frequent merges = more BigQuery cost.
BigQuery destination
- Storage Write API + periodic
MERGE→ BQ table is a current-state mirror (upserts + deletes applied, not append-only). - Lands the bronze layer; dbt builds silver/gold. Datastream's job ends at bronze — it replicates, it does not transform.
single_target_dataset(all tables → one dataset) orsource_hierarchy_datasets(dataset per source schema).
Pricing — the free-tier trap
- Billed on GB processed — an internal representation typically 2–5× raw size. Backfill priced separately (first 500 GB/month free).
- Perpetual free tier (100 GiB CDC/month) applies ONLY to first-party sources: AlloyDB and Spanner — NOT Cloud SQL PostgreSQL, NOT self-managed Postgres.
| Source | Free tier? |
|---|---|
| AlloyDB for PostgreSQL / Spanner | ✅ Yes (100 GiB CDC/mo) |
| Cloud SQL for PostgreSQL | ❌ Paid |
| Self-managed PostgreSQL | ❌ Paid |
Terminology
| Term | Meaning |
|---|---|
| Stream | The pipeline (source ↔ destination). |
| Connection profile | Config for one endpoint. |
| Object | One replicated source table. |
| Backfill | One-time bulk load of pre-existing rows. |
| CDC | Ongoing change capture from the log. |
| Event | One change record (insert/update/delete) or one backfilled row. |
| Events processed | Cumulative — backfill + CDC. |
| Unsupported events | Changes it couldn't replicate (bad type, LOB, no PK, DDL). 0 = healthy. |
| Data freshness / staleness | How far behind the destination is, in seconds. |
data_freshness | Config ceiling on staleness (e.g. 900s). |
| Merge | BigQuery op applying changes → current-state table. |
Monitoring
gcloud datastream streams describe STREAM --location=REGION --format="value(state)"
gcloud datastream objects list --stream=STREAM --location=REGION # per-table backfill
Console → stream → Monitoring: Throughput, Data freshness, Events
processed, Unsupported events. Metrics:
datastream.googleapis.com/stream/{total_latency,throughput,unsupported_event_count}.
Reading the panel: Events processed = backfill + CDC (e.g. 3 seed rows + 46 changes = 49). Unsupported = 0 is healthy. Freshness spiking then returning to 0 = a merge cycle completed; BQ is caught up.
Terraform skeleton
resource "google_datastream_connection_profile" "source" {
connection_profile_id = "src-postgres"
location = var.region
postgresql_profile {
hostname = "..." # validated on create — source must be reachable NOW
port = 5432
username = "datastream"
password = "..."
database = "app"
}
}
resource "google_datastream_connection_profile" "dest" {
connection_profile_id = "dest-bq"
location = var.region
bigquery_profile {} # empty — uses the Datastream service agent + IAM
}
resource "google_datastream_stream" "s" {
stream_id = "pg-to-bq"
location = var.region
desired_state = "RUNNING"
source_config {
source_connection_profile = google_datastream_connection_profile.source.id
postgresql_source_config {
publication = "datastream_pub"
replication_slot = "datastream_slot"
include_objects { postgresql_schemas { schema = "public" } }
}
}
destination_config {
destination_connection_profile = google_datastream_connection_profile.dest.id
bigquery_destination_config {
data_freshness = "900s"
single_target_dataset { dataset_id = google_bigquery_dataset.bronze.id }
}
}
backfill_all {}
}
Gotcha: the connection profile validates connectivity on create (not the stream), so the source must be reachable before the profile applies.
Q&A
Does backfill run once, then CDC forever?
Yes. CDC actually starts first (from the slot's pinned LSN) and runs continuously; backfill runs concurrently as a one-time catch-up of pre-existing rows. Once a table's backfill completes, it's CDC-only. You can manually re-trigger a backfill later, but normally it happens once per table.
Does backfill hurt source database performance? Can I run it off-peak?
Yes — backfill runs large chunked SELECTs: CPU, I/O, and shared_buffers
churn (evicts hot OLTP pages, slowing other queries). No blocking locks
(ACCESS SHARE only), but real resource contention.
No native scheduler, but you can: create the stream with backfill_none and
manually trigger per-object backfill in off-hours; lower
max_concurrent_backfill_tasks; or backfill from a read replica.
Does Postgres WAL keep every transaction ever executed?
No. WAL is transient — segments are recycled after checkpoints once all
consumers pass them. A replication slot pins WAL at its restart_lsn (won't
delete un-consumed WAL) — the mechanism that makes CDC lossless, and the reason
a slow/stopped consumer can grow WAL until the disk fills. Permanent history
lives in the tables (and any WAL archive), not live WAL.
What does "size the WAL disk for worst-case backfill duration" mean?
During a long backfill the slot's restart_lsn can't advance, so all WAL
generated during backfill accumulates on the source. Size for it:
free WAL space ≥ peak_WAL_rate (GB/hr) × backfill_hours × margin
e.g. 15 GB/hr × 18 h ≈ 270 GB → provision ~350 GB. If the WAL disk fills,
Postgres stops accepting writes (outage). Backstop with
max_slot_wal_keep_size — but hitting it invalidates the slot → full
re-backfill. So: size the disk and set the backstop.
How do I stagger the load by table?
Apply a stream with wave-1 tables in include_objects; once they backfill and
reach steady CDC, add wave-2 tables and terraform apply (in-place stream
update — only new objects backfill). Bonus: staggering lowers peak WAL
retention, since CDC drains the slot between waves.
Can one stream cover multiple databases or schemas?
Schemas: yes — list several postgresql_schemas. Databases: no (for
Postgres) — one stream = one database, because the slot + publication are
database-scoped. Use one stream per database.
How do foreign keys map to BigQuery?
They don't — relationships aren't preserved. Each source table becomes one
flat BQ table; FK columns are plain INT64. Rebuild the relationships with
JOINs in the silver layer (dbt). Datastream mirrors normalized bronze; you
denormalize downstream.