GCP Architecture

GCP Data Lakehouse Architecture: Cloud Storage, Dataflow, BigQuery and the Streaming Path

Every data platform review I run starts with the same confession: “we have a data lake and a data warehouse, and they disagree.” The lake — files in Cloud Storage — holds everything cheaply but nobody trusts it. The warehouse — BigQuery — is trusted but holds a copy that is hours or days behind, loaded by a pipeline someone wrote in 2022 and nobody dares touch. Finance numbers come from the warehouse, data-science features come from the lake, and the two drift apart quietly until a board deck and an ML model disagree about last quarter’s revenue. The lakehouse pattern exists to kill that split: one copy of the data, in open formats on cheap object storage where it must be open, in managed warehouse storage where speed and mutation matter — with one security model, one catalog, and one set of tables that every engine (SQL, Spark, BI, ML) reads.

Google Cloud is unusually well shaped for this pattern because BigQuery was born storage-compute separated: its native storage already behaves like a managed lake tier, and BigLake extends BigQuery’s security and table semantics outward over Parquet and Apache Iceberg files sitting in your GCS buckets. Add Pub/Sub as the event backbone, Dataflow as the batch-and-streaming processor, the BigQuery Storage Write API as the high-throughput ingestion door, Dataplex as the governance overlay, and Dataform for in-warehouse ELT, and you have every layer of a lakehouse as a serverless, per-use service — no clusters to keep warm, no HDFS to babysit.

This article is the full architecture at production depth: GCS zone layout and file strategy, the batch path versus the streaming path (and what “exactly-once” actually means at each hop), the BigQuery internals that decide your bill — slots, partitioning, clustering, editions — BigLake and Iceberg so Spark and BigQuery read the same tables, Dataplex governance, Looker and Analytics Hub consumption, and Composer-versus-Workflows orchestration. Every decision comes as a table you can argue from; every build step comes with real gcloud/bq/SQL and Terraform. It is the reference I wish existed the first time I was told to “just make the lake and the warehouse agree.”

What problem this solves

The two-stack pattern — a Hadoop-descended lake plus a separate warehouse — fails in slow motion. Each failure looks local, but they compound into the platform nobody trusts:

Symptom in production Root cause in the two-stack design What it costs you
Finance dashboard and ML feature store disagree on revenue Two copies of the same facts, loaded by different pipelines on different schedules Weeks of reconciliation; execs stop trusting data
“Can you add this column?” takes three weeks Change must land in lake schema, ETL job, warehouse DDL and BI model separately Analytics velocity collapses; teams build shadow extracts
Security review fails on the lake Object-storage ACLs are bucket-grained; row/column policies exist only in the warehouse Lake gets locked down; data scientists copy data out to work — worse exposure
Storage bill doubles yearly Every consumer keeps a private copy (lake → warehouse → team marts → CSV exports) 4–6× storage multiplication is typical; so is the ETL compute to maintain it
Streaming events reach dashboards next day Streaming lands in the lake; the warehouse loads nightly batches “Real-time” product decisions made on yesterday’s data
One bad file silently corrupts a month of reports No contract between landing files and consumption tables; no data-quality gate Backfills, apology emails, and a “data trust” project every year

The lakehouse answer is architectural, not tooling cosmetics: store once in open formats, govern once through one catalog and one ACL model, and let every engine come to the data. On GCP: GCS holds the raw and open-format zones, BigQuery holds hot serving tables, BigLake makes lake files first-class BigQuery tables with row- and column-level security, and Dataplex applies quality gates and lineage across all of it. “The lake and warehouse disagree” becomes a category error — the warehouse is a governed view over the lake.

Who hits this: any team past ~1 TB and two consumers, and hardest when streaming (clickstream, IoT, CDC) enters, because bolting streaming onto a batch two-stack multiplies pipelines again. The streaming path is where most designs go wrong, which is why it gets the deepest section here.

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should be comfortable with: SQL at the window-function level, one IaC tool (examples here are Terraform), IAM fundamentals (principals, roles, bindings — see GCP IAM and Service Accounts: Roles, Bindings and Least Privilege), and BigQuery basics. If BigQuery’s serverless model or the $/TiB scan economics are new, read BigQuery for Beginners: How Serverless Analytics Works first, then BigQuery for Data Analytics: Warehousing, Querying and Visualization — this article builds directly on both and does not re-teach the query lifecycle. Familiarity with Pub/Sub and event-driven architecture and Cloud Storage classes and lifecycle helps; both are recapped only as far as the lakehouse needs.

Where it fits: this is the anchor architecture for the GCP data track — the piece that connects ingestion, storage, processing, governance and consumption into one system. It maps heavily to the Professional Data Engineer certification (storage design, pipeline design, exactly-once semantics, cost optimization are all blueprint items) and partially to Professional Cloud Architect.

Ownership in a real org splits like this — worth agreeing on before you build, because every later argument is really this table:

Layer GCP services Typical owner The decision that lives here
Sources & CDC Datastream, application producers, SaaS exports Source app teams Contract: schema, keys, delivery guarantees
Event backbone Pub/Sub (topics, schemas, DLQs) Platform/data engineering Retention, ordering, schema enforcement
Processing Dataflow (Beam), occasionally Dataproc/Spark Data engineering Exactly-once vs at-least-once; enrichment
Lake storage Cloud Storage buckets (zones) Data platform Zone layout, formats, lifecycle, region
Lake/warehouse bridge BigLake, BigLake metastore, connections Data platform Which tables are open-format vs native
Warehouse BigQuery datasets, reservations Data platform + analytics eng Partitioning, editions, workload isolation
ELT & modeling Dataform, scheduled queries Analytics engineering Dependency DAG, tests, incremental logic
Governance Dataplex (catalog, DQ, lineage, policy tags) Data governance Quality gates, PII policy, discoverability
Consumption Looker, Looker Studio, Connected Sheets, notebooks, Vertex AI BI + DS teams Semantic layer, acceleration, access
Sharing Analytics Hub Data platform What leaves the team/org, zero-copy

Core concepts

Five ideas carry the whole architecture. Get these right and every section below is a detail.

1. The lakehouse is one copy plus two access paths — not two stores. A data lake is open files (Parquet, Avro, ORC, Iceberg) on object storage: cheap, engine-agnostic, schema-on-read, weak governance. A data warehouse is managed tables: ACID, fast SQL, fine-grained security, schema-on-write, but historically closed. The lakehouse keeps one physical copy and gives it both personalities: open files that behave like governed tables. On GCP the trick is that BigLake projects BigQuery’s table semantics (IAM at table level, row/column security, masking, metadata caching) onto GCS files, while BigQuery native storage remains available for the hot, heavily-mutated serving tables where a managed format wins. You choose per table where it lives; consumers never notice.

2. Storage and compute never share a bill. GCS and BigQuery storage charge per GiB-month; BigQuery compute charges per TiB scanned (on-demand) or per slot-hour (editions); Dataflow per worker-second; Pub/Sub per TiB moved. Nothing idles except what you explicitly reserve — which is why lakehouse-on-GCP has no “cluster sizing” step. The design questions are layout questions (partitioning, file sizes, formats), because layout turns into scan bytes and scan bytes are the bill.

3. The streaming path and the batch path converge on the same tables. Batch lands files in GCS and loads (or federates) into BigQuery. Streaming flows Pub/Sub → Dataflow → Storage Write API straight into the same partitioned tables. One table, two doors. The design constraint is that both doors must be idempotent and both must respect the table’s partitioning, or your “one copy” quietly becomes “one table, duplicated rows.”

4. Governance is an overlay, not a gate you build later. Dataplex organizes buckets and datasets into logical lakes and zones, auto-discovers files into catalog entries, runs data-quality scans that gate promotion from raw to curated, and records lineage across BigQuery and pipeline jobs. Column policy is policy tags + dynamic masking — defined once, enforced identically on native and BigLake tables. Bolt governance on later and you will redo the zone layout; design it in.

5. Everything downstream consumes tables, never files. Looker, Looker Studio, Connected Sheets, notebooks, Analytics Hub subscribers — all read tables through BigQuery’s API surface. Files are the platform team’s implementation detail; the moment a consumer touches gs:// directly, you’ve forked governance.

The vocabulary, pinned:

Term One-line definition Layer
Zone (lake) A lifecycle stage of data: raw → staging → curated GCS / Dataplex
BigLake table GCS files exposed as a governed BigQuery table via a connection Bridge
Connection BigQuery resource wrapping a service account that reads GCS for BigLake/external tables Bridge
External table Plain federation over GCS files — caller needs bucket access, no fine-grained security Bridge (legacy)
Iceberg Open table format adding ACID, snapshots, schema evolution to Parquet files Lake format
Slot BigQuery’s unit of query compute (virtual CPU + RAM); queries run on many Warehouse
Edition Slot pricing tier: Standard / Enterprise / Enterprise Plus with autoscaling Warehouse
Storage Write API gRPC ingestion API: streams rows into BigQuery with exactly-once option Ingestion
Default stream The always-existing Storage Write API stream: at-least-once, highest simplicity Ingestion
Watermark Dataflow’s estimate of event-time completeness; drives window firing Processing
AutoDQ scan Dataplex data-quality scan: declarative rules, scheduled, results to BigQuery Governance
Policy tag Taxonomy node attached to columns; IAM on the tag = column-level security Governance
Linked dataset Analytics Hub subscriber’s zero-copy, read-only view of a shared dataset Sharing
Slot-hour Billing unit for editions: one slot for one hour (billed per second, 1 min minimum) Cost

And the three storage architectures side by side — the decision you’ll make per table for the rest of your tenure:

Dimension Data lake (GCS files only) Warehouse (BigQuery native) Lakehouse (BigLake / Iceberg over GCS + native hot tables)
Storage cost (us-central1, indicative) $0.020/GB Standard, to $0.0012 Archive $0.02/GiB active logical (or $0.04/GiB physical, compressed) Lake price for open zones; BQ price for hot tables
Open engine access (Spark, Trino) Full, native Via Storage Read API only Full on open zones; Read API on native
Row/column security, masking None (bucket ACLs) Full Full — BigLake enforces it on files
ACID / mutation None (object overwrite) Full DML, multi-statement transactions Iceberg: snapshots + merge; native: full DML
Streaming ingest Files every N minutes Storage Write API, seconds Both doors
Time travel Object versioning (manual) 2–7 days built in + 7-day fail-safe Iceberg snapshots / BQ time travel
Schema enforcement Convention only Enforced on write Enforced via table definitions + DQ scans
Typical role in this architecture Raw + archive zones Serving marts, high-mutation tables The curated core everyone reads

The lake: Cloud Storage zone design

The lake is three (sometimes four) zones, each a contract about trust and mutability — not a folder aesthetic. Everything else in the platform keys off this:

Zone Contents Format Mutability contract Who reads it Storage class strategy
raw (bronze) Exactly what the source sent — CDC records, JSON events, CSV drops, API dumps Source-native (JSON/Avro/CSV) Append-only, immutable, never edited Pipelines + incident forensics only Standard 30d → Nearline 90d → Coldline/Archive
staging (silver) Parsed, typed, deduplicated, conformed keys; still per-source Parquet Rebuildable from raw at any time Data engineering Standard; lifecycle-delete after N days if rebuildable
curated (gold) Business entities and facts; partitioned; DQ-gated Parquet or Iceberg Merge via pipeline only; consumers read-only Everyone (via BigLake/BigQuery) Standard (hot)
export (optional) Outbound extracts for partners Per contract Regenerated per run External transfer jobs Standard, lifecycle-delete 30d

Three rules I hold in every review: raw is legally sacred (it’s your replay and audit source — losing it means losing the ability to rebuild anything downstream), staging is disposable by design (if it can’t be rebuilt from raw, it’s not staging), and only curated is a consumption surface — and even then only through BigLake/BigQuery tables, never gs:// paths.

Bucket topology, region and settings

One bucket per zone per environment is the sweet spot — coarse enough to manage, fine enough that IAM, lifecycle and retention differ where they must:

Setting Value for the lake Why / trade-off Gotcha
Bucket granularity <org>-<env>-lake-{raw,staging,curated} IAM + lifecycle boundaries align with trust boundaries One mega-bucket makes zone-level lifecycle/IAM impossible
Location Same region as BigQuery dataset (e.g. asia-south1) Loads and BigLake require co-location; egress-free US multi-region BQ can load from any US bucket; EU stricter — plan region first
Uniform bucket-level access On, always Kills object ACL sprawl; IAM-only Legacy ACL-dependent tools break — good, find them now
Public access prevention Enforced A lake leak is a résumé event Org policy storage.publicAccessPrevention makes it non-negotiable
Versioning On for raw; off elsewhere Protects the sacred zone from overwrite bugs Pair with lifecycle numNewerVersions or versions accumulate cost
Soft delete Default 7-day window Recovers from bucket-level fat-fingers Retained bytes are billed; tune the window deliberately
Retention policy / lock Consider on raw for compliance WORM guarantee for auditors Locked retention is irreversible — model cost before locking
CMEK Per data classification Compliance; key revocation = kill switch Key and bucket must be co-located; grant the GCS service agent
Autoclass Good default on raw Auto-transitions objects by access; no retrieval fees or min durations Management fee per 1,000 objects; explicit lifecycle is cheaper at known access patterns

Class economics in one line (us-central1, indicative): Standard $0.020/GB-month with no retrieval fee, Nearline $0.010 (+$0.01/GB retrieval, 30-day minimum), Coldline $0.004 (+$0.02/GB, 90-day), Archive $0.0012 (+$0.05/GB, 365-day). Retrieval fees are why you never point a Spark backfill at Archive casually — re-reading 50 TB of Archive costs $2,500 in retrieval before a single vCPU spins. Full class mechanics live in Cloud Storage Classes and Lifecycle.

Layout, file sizing and the small-files disease

Inside curated, layout is performance. Use Hive-style partition paths so BigLake/BigQuery can prune by directory:

gs://acme-prod-lake-curated/events/
  event_date=2026-07-06/hr=22/part-00000-....parquet   (~256 MB–1 GB each)
  event_date=2026-07-07/hr=00/...
Concern Target Why How to enforce
File size (Parquet) 256 MB–1 GB Fewer files = fewer opens/lists; row-group parallelism inside Dataflow FileIO.withNumShards sized to window volume; periodic compaction job
Files per table Thousands, not millions BQ load caps at 10M files/job; listing dominates query startup Hourly compaction of streaming spill; Iceberg rewrite_data_files
Partition path key Low-cardinality time (event_date=) Directory pruning; matches BQ partitioning downstream hive_partition_uri_prefix on the BigLake table
Key naming Avoid monotonic prefixes for high write QPS GCS ranges shard by key; sequential names hotspot one shard Time goes in the directory, randomness in the filename
Request rate Bucket auto-scales from ~1,000 writes/s, 5,000 reads/s; doubles as load ramps ~every 20 min Sudden 10× bursts before ramp see 429s Gradual ramp-up; exponential backoff (client libraries do this)
Object size cap 5 TiB hard limit per object Irrelevant if you’re sizing files correctly
Compression Snappy (Parquet default) or ZSTD Splittable via row groups; gzip’d CSV is not splittable Set in the writer; never gzip CSV for anything you’ll query

Everything above as code — CLI first, then Terraform:

# Region variable — decide ONCE, everything co-locates with it
export REGION=asia-south1 PROJECT=acme-data-prod

gcloud storage buckets create gs://acme-prod-lake-raw \
  --project=$PROJECT --location=$REGION \
  --uniform-bucket-level-access --public-access-prevention \
  --soft-delete-duration=7d

# Lifecycle: raw cools as it ages
cat > /tmp/lc-raw.json <<'EOF'
{"rule":[
  {"action":{"type":"SetStorageClass","storageClass":"NEARLINE"},"condition":{"age":30}},
  {"action":{"type":"SetStorageClass","storageClass":"COLDLINE"},"condition":{"age":90}},
  {"action":{"type":"SetStorageClass","storageClass":"ARCHIVE"},"condition":{"age":365}},
  {"action":{"type":"Delete"},"condition":{"age":30,"numNewerVersions":3}}
]}
EOF
gcloud storage buckets update gs://acme-prod-lake-raw --lifecycle-file=/tmp/lc-raw.json
resource "google_storage_bucket" "curated" {
  name                        = "acme-prod-lake-curated"
  project                     = "acme-data-prod"
  location                    = "asia-south1"
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"

  soft_delete_policy { retention_duration_seconds = 604800 } # 7 days

  lifecycle_rule {
    action { type = "Delete" }
    condition { age = 30, matches_prefix = ["tmp/", "export/"] }
  }
}

The batch path: files into BigQuery

Batch is the boring path and it should stay boring. The routes, exhaustively:

Route Mechanism Compute cost Transform capability Best for Hard limits to know
Load job (bq load, API) Shared free slot pool copies files → native storage Free (no SLA on the shared pool) None (schema mapping only) The default; 90% of batch 1,500 loads/table/day; 100k/project/day; 15 TB total bytes/job; 10k URIs; 10M files
LOAD DATA SQL Same engine, SQL statement Free, or your reservation via PIPELINE assignment Column list, partition target SQL-first teams; Dataform pre-ops Same as load jobs
BigQuery Data Transfer Service Managed scheduler (GCS, SaaS: Ads, GA4, S3, Teradata…) Free for GCS source (loads underneath) None Recurring drops, SaaS ingestion Min 15-min schedule granularity for GCS
Dataflow batch Beam job reads GCS, transforms, writes BQ Worker-seconds (vCPU ~$0.056/hr batch) Full code Heavy transform/enrich/PII-scrub before land Your pipeline’s own correctness
Datastream CDC Serverless CDC MySQL/Postgres/Oracle/SQL Server → BQ or GCS Per-GB CDC processed Soft-delete columns, staleness knob Operational DB replicas without pipeline code Backfill throughput; source log retention
Storage Write API (pending mode) Programmatic batch: append to stream, commit atomically $0.025/GiB after free 2 TiB/mo Your code Exactly-once programmatic batch from apps Stream TTL; commit is the atomic unit
CTAS over BigLake CREATE TABLE AS SELECT from lake table Query cost (scan or slots) Full SQL Materializing curated → native marts Scans the source — partition-prune it

Two non-obvious facts drive most batch designs. Load jobs are free but SLA-less — they run on a shared pool and can queue at busy hours; if a load feeds a 06:00 SLA dashboard, assign a reservation with job_type=PIPELINE so it draws your paid slots. And loads are atomic per job — full commit or full failure — which makes retries trivially safe.

Format rules in brief: Parquet is the default (splittable via row groups, additive schema evolution on load); Avro suits CDC/interop because the schema travels with the data; CSV and newline JSON belong in raw only — compressed CSV/JSON files cap at 4 GB each, aren’t splittable when gzipped, and CSV’s positional schema is a standing invitation to silent column drift.

And the idempotent load pattern — partition-scoped truncate-load, the single most reliable batch trick on BigQuery:

# Reload one day atomically: retry-safe, no dedupe needed
bq load --source_format=PARQUET --replace \
  'acme_analytics.events$20260707' \
  'gs://acme-prod-lake-curated/events/event_date=2026-07-07/*.parquet'
-- Same thing in SQL (runs free, or on PIPELINE reservation slots)
LOAD DATA OVERWRITE acme_analytics.events
PARTITIONS (event_date = '2026-07-07')
FROM FILES (
  format = 'PARQUET',
  uris = ['gs://acme-prod-lake-curated/events/event_date=2026-07-07/*.parquet']
);

That table$YYYYMMDD partition decorator targets exactly one partition; --replace/OVERWRITE scopes the truncation to it. Rerun it five times, get one copy.

The streaming path: Pub/Sub → Dataflow → Storage Write API

This is the section that separates working lakehouses from expensive ones. The streaming path has three hops, and each hop has its own delivery semantics; “exactly-once” is only true end-to-end if every hop holds its part of the contract.

Hop 1 — Pub/Sub, the buffer that saves you

Pub/Sub is a global, serverless message bus: publishers write to a topic, each subscription gets every message, subscribers ack within a deadline or the message redelivers. Its lakehouse role is shock absorber and replay log — when Dataflow is draining, deploying or broken, Pub/Sub holds the backlog. The settings that matter:

Setting Values Default Lakehouse recommendation Trade-off / gotcha
Topic message retention up to 31 days none (subscription-level applies) 7 days on critical topics Topic-level retention lets new subscriptions seek backwards; billed $0.27/GiB-mo
Subscription retention 10 min – 7 days 7 days 7 days Includes acked messages only if retain_acked_messages
Ack deadline 10–600 s 10 s 60 s+ for Dataflow (it manages extensions itself) Too low → redelivery storms → duplicate pressure
Delivery type pull, push, BigQuery, Cloud Storage pull pull for Dataflow; BigQuery type for no-transform tables Push caps throughput; export types have no code hook (SMT aside)
Exactly-once delivery on/off (pull, regional) off Off when Dataflow dedupes anyway Adds latency; only intra-region; Dataflow doesn’t need it
Ordering keys per-key FIFO off Only if consumers require order 1 MB/s throughput cap per key; a hot key throttles
Schema (Avro/Protobuf) attach to topic none Attach Avro schema + revisions on curated topics Rejects bad producers at publish time — your first DQ gate
Dead-letter topic after 5–100 attempts off On, 5 attempts, alert on DLQ depth Without it a poison message redelivers forever, stalling backlog
Filter attribute expression none Filter per subscription to cut egress Filtered-out messages still count as delivered for billing? No — filtered messages are acked free of your code, but you pay throughput
Message size ≤10 MB Keep ≤1 MB; batch small events at producer 10 MB is a hard limit; oversized publishes fail
gcloud pubsub topics create events-clickstream \
  --message-retention-duration=7d \
  --schema=clickstream-v1 --message-encoding=binary

gcloud pubsub topics create events-clickstream-dlq

gcloud pubsub subscriptions create events-clickstream-dataflow \
  --topic=events-clickstream --ack-deadline=60 \
  --dead-letter-topic=events-clickstream-dlq --max-delivery-attempts=5

The operational metric that matters is oldest_unacked_message_age — that’s your streaming SLO leading indicator, not Dataflow CPU.

Hop 2 — Dataflow, where correctness is manufactured

Dataflow runs Apache Beam pipelines serverlessly: autoscaled workers, checkpointed state, and — critically — effectively-once processing inside the pipeline: Pub/Sub redeliveries are deduplicated on message ID, and each bundle’s outputs commit atomically with its state. The pipeline does parse → validate → enrich → window → write, quarantining poison messages to a DLQ instead of dying on them:

import apache_beam as beam
from apache_beam.options.pipeline_options import PipelineOptions
from apache_beam.io.gcp.bigquery import WriteToBigQuery, BigQueryDisposition

class ParseEvent(beam.DoFn):
    def process(self, msg):
        try:
            row = parse_and_validate(msg)          # types, required keys, PII scrub
            yield beam.pvalue.TaggedOutput("ok", row)
        except Exception:
            yield beam.pvalue.TaggedOutput("dead", msg)  # → DLQ, never crash the bundle

with beam.Pipeline(options=PipelineOptions(
    streaming=True, project="acme-data-prod", region="asia-south1",
    enable_streaming_engine=True, max_num_workers=10)) as p:

    parsed = (p
        | beam.io.ReadFromPubSub(subscription="projects/acme-data-prod/subscriptions/events-clickstream-dataflow")
        | beam.ParDo(ParseEvent()).with_outputs("ok", "dead"))

    (parsed.ok | WriteToBigQuery(
        "acme-data-prod:acme_analytics.events",
        method=WriteToBigQuery.Method.STORAGE_WRITE_API,   # exactly-once sink
        write_disposition=BigQueryDisposition.WRITE_APPEND,
        with_auto_sharding=True))

    (parsed.dead | beam.io.WriteToPubSub("projects/acme-data-prod/topics/events-clickstream-dlq"))

The pipeline options that decide cost and correctness:

Option Values Default Set it to Why
Streaming Engine on/off on for newer SDKs on Moves shuffle/state off workers → smaller, cheaper, faster-scaling workers
max_num_workers 1–1000+ 100 Explicit cap (e.g. 10) Autoscaling without a cap is a blank cheque against a backlog surge
Machine type any n1-standard-4-ish n2d/e2 small for parse-heavy Right-size; streaming billed per worker-second (vCPU ~$0.069/hr streaming)
At-least-once mode vs exactly-once exactly-once At-least-once if sink dedupes or duplicates are tolerable Skips dedup/state overhead → lower latency & cost
with_auto_sharding (BQ sink) on/off off on Lets the service pick write parallelism to avoid throttling
Update / drain Drain before replacing; update for compatible changes Cancel drops in-flight windows → gaps or (with replay) duplicates
Autoscaling algorithm THROUGHPUT_BASED / NONE THROUGHPUT_BASED default NONE only for fixed-cost experiments
Dataflow Prime on/off off Usually off Per-DCU billing model; evaluate against plain Streaming Engine

No-code alternative: Google’s provided template does Pub/Sub → BigQuery with a UDF hook and DLQ table, one command:

gcloud dataflow flex-template run ps-to-bq-events \
  --template-file-gcs-location=gs://dataflow-templates-asia-south1/latest/flex/PubSub_Subscription_to_BigQuery_Flex \
  --region=asia-south1 \
  --parameters inputSubscription=projects/acme-data-prod/subscriptions/events-clickstream-dataflow,outputTableSpec=acme-data-prod:acme_analytics.events,outputDeadletterTable=acme-data-prod:acme_analytics.events_dlq

Hop 3 — the Storage Write API, BigQuery’s ingestion door

The BigQuery Storage Write API is a gRPC, protobuf-based append protocol — the modern replacement for legacy insertAll streaming. It’s cheaper ($0.025/GiB vs $0.05/GB, first 2 TiB/month free), faster, and it’s the only door with real exactly-once semantics, via streams:

Stream type Visibility Semantics Use when
Default stream Immediate per append At-least-once (retries can duplicate) Simple producers; Beam at-least-once mode; Pub/Sub BQ subscriptions use this door
Committed Immediate per append Exactly-once when you supply offsets (out-of-order/duplicate offsets rejected with ALREADY_EXISTS/OUT_OF_RANGE) Streaming with strict no-duplicates (Dataflow’s exactly-once sink does this for you)
Pending Invisible until stream commit Atomic batch (all-or-nothing) Programmatic batch replacing load jobs
Buffered Per-row flush control Row-level checkpointing Rare; connector internals

Quotas you’ll actually hit (defaults; raisable): concurrent connections 1,000 per project per region (10,000 in US/EU multi-regions); throughput ~3 GiB/s per project in multi-regions; CreateWriteStream is rate-limited — reuse streams (or use the default stream) rather than creating one per batch. Fresh rows sit in write-optimized storage until background compaction moves them into columnar Capacitor blocks, queryable within seconds either way. The legacy insertAll streaming buffer restriction (DML failing with would affect rows in the streaming buffer) is one more reason to leave insertAll behind.

Choosing your streaming route

All four viable routes compared — this is the table to argue from in design review:

Pub/Sub BigQuery subscription Dataflow at-least-once + default stream Dataflow exactly-once + committed streams Legacy insertAll
Transforms Schema mapping only (+ lightweight single-message transforms) Full Beam Full Beam n/a (client-side)
Delivery to table At-least-once At-least-once Exactly-once At-least-once (best-effort insertId dedupe)
Latency to queryable Seconds Seconds Seconds (slightly higher) Seconds
Cost model (indicative) ~$50/TiB subscription (Write API included) Workers + $0.025/GiB Workers (+~10–20% CPU for dedupe/state) + $0.025/GiB $0.05/GB — 2× Write API
Failure handling DLQ topic on schema mismatch Your DLQ pattern Your DLQ pattern Client retries
Ops burden None Pipeline to run Pipeline to run Client library only
Pick when Events already table-shaped Duplicates tolerable / dedupe downstream via MERGE Money, inventory, anything counted Never for new builds

The honest architectural note: at-least-once + idempotent MERGE downstream is often the best engineering trade. Exactly-once inside Dataflow costs state and CPU; if your curated layer already merges on a business key (as CDC forces you to anyway), the cheaper mode plus a dedupe step gives the same downstream truth:

-- Hourly dedupe/merge from streamed appends into the curated fact
MERGE acme_analytics.orders_curated T
USING (
  SELECT * EXCEPT(rn) FROM (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY ingest_ts DESC) rn
    FROM acme_analytics.orders_stream
    WHERE event_date = CURRENT_DATE('Asia/Kolkata')
  ) WHERE rn = 1
) S ON T.order_id = S.order_id
WHEN MATCHED THEN UPDATE SET status = S.status, amount = S.amount, updated_at = S.ingest_ts
WHEN NOT MATCHED THEN INSERT ROW;

And the zero-code route in Terraform — Pub/Sub writing straight to BigQuery:

resource "google_pubsub_subscription" "events_to_bq" {
  name  = "events-clickstream-bq"
  topic = google_pubsub_topic.events.id

  bigquery_config {
    table               = "acme-data-prod.acme_analytics.events_raw"
    use_topic_schema    = true
    write_metadata      = true    # publish_time, message_id columns
    drop_unknown_fields = true
  }
  dead_letter_policy {
    dead_letter_topic     = google_pubsub_topic.events_dlq.id
    max_delivery_attempts = 5
  }
}

Gotcha that catches everyone once: the Pub/Sub service agent (service-<project-number>@gcp-sa-pubsub.iam.gserviceaccount.com) needs roles/bigquery.dataEditor on the target dataset or the subscription sits in an error state writing nothing.

Streaming limits & failure table

Limit / behavior Value (default) What hitting it looks like Response
Pub/Sub message size 10 MB hard Publish INVALID_ARGUMENT Batch at producer; claim-check big payloads to GCS
Ordering key throughput 1 MB/s per key Publish throttling on hot keys Shard keys; question whether order is truly required
Write API throughput ~3 GiB/s/project (multi-region) RESOURCE_EXHAUSTED on AppendRows Quota increase; multiplex connections; check row batching
Write API connections 1,000/region (10k US/EU) Connection refusals under fan-out Connection pooling / multiplexing; fewer, fatter writers
Committed offset replay ALREADY_EXISTS on append Not an error — that’s exactly-once working; skip and continue
Offset gap OUT_OF_RANGE Client bug: rewind to last committed offset and resend
Dataflow watermark stuck Data freshness climbing, no errors Hot key or stuck DoFn: check side-input stalls, key skew; Reshuffle
Pub/Sub backlog age oldest_unacked_message_age growing Scale max_num_workers; check DLQ for poison loop
BQ subscription error state Subscription stops, state != ACTIVE Table deleted / schema mismatch / IAM: fix and it resumes

BigQuery internals recap: slots, layout, editions

Full treatment lives in BigQuery for Data Analytics; here is the layer the lakehouse depends on, with the current pricing model.

Slots and the two ways to buy compute

A slot is BigQuery’s unit of compute. Queries decompose into stages; stages fan out across however many slots the scheduler grants; shuffle moves intermediate data between stages. You buy compute one of two ways:

Standard Enterprise Enterprise Plus
$/slot-hour (PAYG, us-central1) $0.04 $0.06 $0.10
Commitments (1y/3y) No Yes Yes
Max reservation size 1,600 slots Higher (quota) Higher (quota)
Fine-grained security (row/column/masking) No Yes Yes
BigQuery ML, BI Engine No Yes Yes
CMEK No Yes Yes
Managed DR / Assured Workloads No No Yes
Intended for Dev, ad-hoc Production default Regulated/compliance

The crossover rule of thumb: one slot running 24×7 on Enterprise PAYG is 730 × $0.06 ≈ $43.8/month — the price of 7 TiB of on-demand scanning. Move to editions when steady scan volume outruns an equivalent autoscaled reservation; keep spiky, low-volume projects on-demand. Mixed estates are normal: reservations assign per project/folder with job_type granularity (QUERY, PIPELINE, ML_EXTERNAL), so BI gets a baseline while ad-hoc stays on-demand.

# Admin project holds reservations; assign to workload projects
bq mk --location=asia-south1 --reservation --edition=ENTERPRISE \
  --slots=100 --autoscale_max_slots=400 prod-bi
bq mk --reservation_assignment --reservation_id=acme-admin:asia-south1.prod-bi \
  --assignee_type=PROJECT --assignee_id=acme-data-prod --job_type=QUERY

Partitioning and clustering — the layout that is the bill

Partitioning type Key Granularity Max partitions Use
Time-unit column DATE/TIMESTAMP/DATETIME column hourly / daily / monthly / yearly 10,000 The default for facts (daily)
Ingestion-time _PARTITIONTIME pseudo-column same 10,000 Only when no event-time column exists
Integer-range INT64 column fixed buckets 10,000 Tenant/shard IDs
(none + clustering) Small/dimension tables

Clustering sorts data within (or without) partitions by up to 4 columns, in order; queries filtering on prefix columns read only matching blocks, and BigQuery re-clusters in the background for free. The decision matrix:

If the column is… Partition or cluster? Why
Event date, ~daily query windows Partition (DAY) Hard pruning + partition-scoped ops ($20260707 decorators, per-partition expiry)
Hourly windows on huge tables Partition (HOUR) — watch the 10k cap (416 days) Cap forces archive strategy
High-cardinality filter (customer_id, sku) Cluster Partitioning would explode partition counts
Both date + IDs Partition by date, cluster by IDs The canonical lakehouse fact layout
Low-cardinality enum (status) Cluster (late position) Not worth a partition dimension
Multi-tenant isolation Integer-range partition on tenant bucket Enables per-tenant deletes/expiry
CREATE TABLE acme_analytics.events
(
  event_id STRING NOT NULL,
  event_date DATE NOT NULL,
  event_ts TIMESTAMP NOT NULL,
  customer_id STRING,
  event_name STRING,
  payload JSON
)
PARTITION BY event_date
CLUSTER BY customer_id, event_name
OPTIONS (
  require_partition_filter = TRUE,   -- refuse full scans outright
  partition_expiration_days = 730
);

require_partition_filter = TRUE is the single highest-ROI line in this article: any query without a WHERE event_date … predicate fails instead of scanning everything. Note the sharp edge: the filter must hit the column bareWHERE DATE(event_ts) = '2026-07-07' on a table partitioned by event_date prunes nothing.

Cost-control settings, enumerated

Control Scope What it does Set it
maximum_bytes_billed Per query/job Hard-fails a query that would scan more Default it in BI tooling: e.g. 100 GB
Dry run Per query Returns scan estimate, costs nothing bq query --dry_run; CI check on Dataform PRs
Cached results Project Free identical-query hits for 24 h On by default; deterministic SQL keeps it warm
require_partition_filter Table Rejects unpruned queries Every large fact table
Materialized views Dataset Auto-refreshed pre-aggregation; queries rewrite to them transparently Top 5 dashboard aggregates
BI Engine Project/location In-memory acceleration (per-GiB-hour reservation) Looker Studio-heavy estates
Custom quotas Project/user Daily bytes-scanned caps per user/project 1–5 TiB/user/day stops runaways
Time travel window Dataset 2–7 days of point-in-time reads ALTER SCHEMA … SET OPTIONS (max_time_travel_hours=48) on churny staging
Storage billing model Dataset Logical ($0.02 active/GiB) vs physical ($0.04 active/GiB on compressed bytes, incl. time-travel/fail-safe) Physical wins when compression >2×: check INFORMATION_SCHEMA.TABLE_STORAGE
Long-term storage Automatic Untouched 90 days → ~half-price storage Free win; don’t “touch” cold partitions needlessly
-- Is physical (compressed) billing cheaper for this dataset?
SELECT table_name,
  ROUND(SUM(active_logical_bytes)/POW(1024,3),1)  AS logical_gib,
  ROUND(SUM(active_physical_bytes)/POW(1024,3),1) AS physical_gib,
  ROUND(SAFE_DIVIDE(SUM(active_logical_bytes), SUM(active_physical_bytes)),1) AS compression_x
FROM `acme-data-prod.asia-south1`.INFORMATION_SCHEMA.TABLE_STORAGE
WHERE table_schema = 'acme_analytics'
GROUP BY table_name ORDER BY logical_gib DESC;

BigLake, external tables and Iceberg: unifying lake and warehouse

This is the hinge of the whole lakehouse. Four table flavours can sit over (or in) your data, and choosing per-table is the architecture:

External table BigLake table BigQuery managed Iceberg table Native table
Data lives in Your GCS bucket Your GCS bucket Your GCS bucket (Parquet + Iceberg metadata) BigQuery storage (Capacitor)
Who reads GCS The querying user (needs bucket IAM) The connection’s service account — users need only table grants The connection n/a
Row/column security, masking
Metadata caching / perf ✗ (lists objects per query) ✓ (30 min–7 day staleness window) ✓ (managed) ✓ (native)
Writable from BigQuery ✗ (read-only) ✓ DML + Storage Write API + background compaction ✓ everything
Writable from Spark ✓ (it’s just files) ✓ via BigLake metastore (Iceberg) Read via metadata snapshots; writes via BigQuery Storage Read API only
Schema evolution Manual redefine Manual / detected Iceberg-native Native
Use for Quick federation, one-offs Curated lake zones Curated zones needing BQ writes + open reads Hot marts, high-mutation serving

The mechanics: a connection is a BigQuery resource wrapping a Google-managed service account. You grant that account roles/storage.objectViewer on lake buckets; users get bigquery.dataViewer on tables. Nobody but the platform holds bucket access — delegated access — which is what makes fine-grained security on files possible at all.

bq mk --connection --location=asia-south1 --connection_type=CLOUD_RESOURCE lake-conn
bq show --connection asia-south1.lake-conn   # → serviceAccountId

gcloud storage buckets add-iam-policy-binding gs://acme-prod-lake-curated \
  --member="serviceAccount:bqcx-…@gcp-sa-bigquery-condel.iam.gserviceaccount.com" \
  --role=roles/storage.objectViewer
CREATE EXTERNAL TABLE acme_lake.events_curated
WITH PARTITION COLUMNS (event_date DATE)
WITH CONNECTION `asia-south1.lake-conn`
OPTIONS (
  format = 'PARQUET',
  uris = ['gs://acme-prod-lake-curated/events/*'],
  hive_partition_uri_prefix = 'gs://acme-prod-lake-curated/events',
  require_hive_partition_filter = TRUE,
  max_staleness = INTERVAL 4 HOUR,          -- ← this makes it BigLake-cached
  metadata_cache_mode = 'AUTOMATIC'
);

The metadata cache settings deserve their own row-by-row, because they’re the most misunderstood knob in BigLake:

Setting Values Effect Gotcha
metadata_cache_mode AUTOMATIC / MANUAL Caches object listing + file stats so queries skip GCS listing MANUAL means you must refresh or results go stale-but-valid
max_staleness INTERVAL 30 min – 7 days Queries may serve from cache this old New files can be invisible up to this long — size it to your landing cadence
Manual refresh CALL BQ.REFRESH_EXTERNAL_METADATA_CACHE('acme_lake.events_curated') Immediate visibility Call it as the last step of batch landing jobs
Cache off (plain external) omit both Every query lists GCS Fine under ~1k files; painful at 100k

Iceberg is where the lake stops being “files” and becomes a database-grade table: manifest-tracked snapshots give ACID commits, time travel, schema evolution and compaction. You’ll meet it two ways on GCP. BigLake Iceberg external tables: Spark owns writes via the BigLake metastore (the serverless Iceberg catalog); BigQuery reads, always consistent to a snapshot. BigQuery managed Iceberg tables: BigQuery owns the table — full DML, Storage Write API streaming, automatic background compaction — while storing Parquet + Iceberg metadata in your bucket and exporting metadata snapshots so Spark/Trino read without touching BigQuery. Managed Iceberg is the strongest “one copy” story on the platform: warehouse ergonomics, open files.

CREATE TABLE acme_lake.orders_iceberg
(order_id STRING, order_date DATE, amount NUMERIC, status STRING)
CLUSTER BY order_date
WITH CONNECTION `asia-south1.lake-conn`
OPTIONS (
  file_format = 'PARQUET',
  table_format = 'ICEBERG',
  storage_uri = 'gs://acme-prod-lake-curated/iceberg/orders'
);

Rounding out the family: object tables expose unstructured GCS objects (images, PDFs, audio) as a queryable metadata table over the same connection — the on-ramp for ML over the lake (ML.GENERATE_EMBEDDING, remote Vertex AI models) without moving a byte. And BigQuery Omni runs the same BigLake model over S3/ADLS when acquisitions leave data on other clouds.

Governance: Dataplex over everything

Ungoverned lakehouses re-create the swamp with better formats. Dataplex is the governance overlay: it doesn’t move data; it organizes, describes, tests and traces it.

Logical organization. A Dataplex lake contains zones (typed raw or curated); zones contain assets — attached GCS buckets and BigQuery datasets. Discovery jobs crawl attached buckets and register files as tables (into BigQuery datasets / the metastore) so “what’s in the lake?” has a queryable answer:

Dataplex object Maps to Enforces
Lake A data domain (e.g. commerce) Ownership boundary; per-domain admins
Raw zone …-lake-raw bucket Heterogeneous formats allowed
Curated zone …-lake-curated bucket + BQ datasets Format/schema conformance required (Parquet/ORC/Avro, consistent schema)
Asset One bucket or dataset Discovery settings, per-asset IAM
Entry (catalog) A discovered/registered table Searchable metadata + aspects

This maps one-to-one onto a domain-oriented org — each domain gets a lake, and the data mesh operating model falls out naturally.

Data quality as a gate, not a dashboard. Dataplex data profiling scans learn column distributions; AutoDQ scans run declarative rules on schedule (or on-demand from a pipeline) and write results to BigQuery. The rule types, enumerated:

Rule type Checks Example
nonNullExpectation Column has no NULLs order_id
uniquenessExpectation No duplicate values order_id per day
rangeExpectation Numeric/date bounds amount BETWEEN 0 AND 500000
setExpectation Value ∈ allowed set status IN ('placed','shipped',…)
regexExpectation Pattern match GSTIN/phone formats
rowConditionExpectation Arbitrary SQL predicate per row refund_amount <= amount
tableConditionExpectation Aggregate SQL predicate COUNT(*) > 0.9 * yesterday (freshness/volume)
sqlAssertion Custom SQL returning violating rows Cross-table referential checks
gcloud dataplex datascans create data-quality orders-dq \
  --location=asia-south1 \
  --data-source-resource="//bigquery.googleapis.com/projects/acme-data-prod/datasets/acme_analytics/tables/orders_curated" \
  --data-quality-spec-file=orders-dq.yaml \
  --schedule="TZ=Asia/Kolkata 30 5 * * *"

The production pattern: the orchestrator runs the scan between staging and curated promotion and fails the DAG on rule failure — quality is a gate in the DAG, and bad data never reaches the zone consumers trust.

Lineage and column policy. The Data Lineage API records table-level lineage automatically for BigQuery jobs (plus Composer and Dataflow integrations) — “what feeds this dashboard?” becomes a graph query. Column protection is policy tags: a taxonomy (PII > phone, PII > pan_number) attached to columns; only principals holding Fine-Grained Reader on the tag read them, and data policies attach dynamic masking (SHA-256, nullify, default value) for everyone else. Enforced identically on native and BigLake tables — the payoff of routing all access through BigQuery:

Control Granularity Mechanism Works on BigLake?
Dataset/table IAM Table Standard IAM
Authorized views/datasets Row/column via SQL View with grant, no base access
Row-level access policy Row CREATE ROW ACCESS POLICY … GRANT TO (…) FILTER USING (region = "IN")
Policy tags (column-level) Column Taxonomy + Fine-Grained Reader
Dynamic data masking Column Data policy on the tag
VPC Service Controls API perimeter Blocks exfil to outside projects ✓ (see below)

The ELT layer: Dataform and scheduled queries

Raw and staged data becomes curated marts inside BigQuery — ELT, not ETL, because the warehouse is the best transform engine you own. Dataform is GCP’s native answer (free; you pay only the BigQuery jobs it runs): git-backed repositories of SQLX files, where ref() builds the dependency DAG, assertions are tests, and incremental tables handle the streaming tail:

-- definitions/orders_daily.sqlx
config {
  type: "incremental",
  schema: "acme_marts",
  bigquery: { partitionBy: "order_date", clusterBy: ["customer_id"] },
  assertions: { uniqueKey: ["order_date", "customer_id"], nonNull: ["revenue"] }
}
SELECT order_date, customer_id,
       SUM(amount) AS revenue, COUNT(*) AS orders
FROM ${ref("orders_curated")}
${when(incremental(), `WHERE order_date >= DATE_SUB(CURRENT_DATE('Asia/Kolkata'), INTERVAL 3 DAY)`)}
GROUP BY 1, 2

Compilation happens through release configurations (pin an environment to a git branch/tag) and execution through workflow configurations (cron inside Dataform) or an external orchestrator. Where each ELT tool fits:

Scheduled queries Dataform dbt (core/Cloud) Dataflow SQL/Beam
Dependency DAG ✗ (independent statements) ref() graph Code-level
Tests / assertions ✓ built-in Custom
Git + code review ✗ (UI-owned SQL) ✓ native
Incremental models Manual MERGE ✓ declarative Streaming-native
Cost Free (query cost only) Free (query cost only) Infra/SaaS + query cost Workers
Fit 1–5 simple refreshes GCP-native ELT default Multi-warehouse teams, dbt talent Transform before landing

Scheduled queries (BigQuery Data Transfer Service under the hood) still earn their keep for single-statement refreshes:

bq query --use_legacy_sql=false \
  --display_name="mv_repair_orders_daily" \
  --schedule="every day 01:30" \
  --replace --destination_table=acme_marts.orders_snapshot \
  'SELECT * FROM acme_analytics.orders_curated WHERE order_date = CURRENT_DATE("Asia/Kolkata")'

But the moment statement B depends on statement A, scheduled queries become a race condition with a UI — move to Dataform.

Consumption: Looker, Looker Studio, BI Engine and the ML door

The consumption layer reads governed tables, never files. The realistic GCP options:

Looker (core) Looker Studio Looker Studio Pro Connected Sheets Notebooks / BigQuery DataFrames
Semantic layer LookML — governed metrics, one definition of “revenue” None (per-report logic) None None Code
Governance Central model, per-user attributes → row filters Per-report sharing Team workspaces, IAM, audit Sheet sharing Project IAM
Cost (indicative) Quote-based: platform + per-user Free ~$9/user/project/month Workspace license Compute only
Query pattern Generated SQL + aggregate awareness + PDTs Direct queries per widget Same + scheduled delivery BigQuery data menu, extracts Storage Read API (Arrow)
Failure mode to design against PDT rebuild storms at 09:00 Unpruned SELECT * per viewer refresh Same as Studio Analysts extracting full tables Full-table reads into pandas
Fit The governed BI standard Ad-hoc, external sharing Teams standardizing on Studio Finance power users DS/ML

Two defensive moves pay for themselves immediately. Point BI at views/materialized views with partition filters baked in, never raw facts — a widget refreshed by 200 viewers must not be able to full-scan. And reserve BI Engine GiB with preferred_tables: Looker Studio latencies drop to sub-second on hot aggregates without changing a query.

The ML door: BigQuery ML trains where the data lives, remote models call Vertex AI endpoints (including Gemini for ML.GENERATE_TEXT over text columns), and BigQuery DataFrames (bigframes.pandas) gives notebooks a pandas API that compiles to SQL — killing the 200 GB to_dataframe() download anti-pattern.

Sharing: Analytics Hub

Cross-team and cross-org sharing is where lakehouses historically leak — extracts, SFTP, “temporary” buckets. Analytics Hub replaces all of it with zero-copy sharing: a publisher lists a dataset in an exchange; a subscriber gets a linked dataset — a read-only pointer, not a copy. Publisher pays storage once; each subscriber’s queries bill to the subscriber’s own compute; revoke the listing and every linked dataset dies with it.

Object / role What it is Governance note
Data exchange Container of listings (private by default) Per-exchange IAM; VPC-SC compatible
Listing One shared dataset (or Pub/Sub topic) + metadata Can restrict egress (block copy-out of results)
Linked dataset Subscriber’s zero-copy read view Always same region as source — cross-region needs dataset replication first
Analytics Hub Admin / Publisher / Subscriber / Viewer The four IAM personas Map to platform / producing team / consuming team / browsers
Usage metrics Per-listing subscriber/query stats Your “is anyone using this?” answer before deprecation
resource "google_bigquery_analytics_hub_data_exchange" "commerce" {
  project = "acme-data-prod"
  location = "asia-south1"
  data_exchange_id = "commerce_gold"
  display_name = "Commerce curated marts"
}

resource "google_bigquery_analytics_hub_listing" "orders" {
  project = "acme-data-prod"
  location = "asia-south1"
  data_exchange_id = google_bigquery_analytics_hub_data_exchange.commerce.data_exchange_id
  listing_id = "orders_daily"
  display_name = "Orders daily mart"
  bigquery_dataset { dataset = "projects/acme-data-prod/datasets/acme_marts" }
}

Inside one company, Analytics Hub is how domain teams consume each other’s gold layer in a mesh; outside, it’s the productization path (the same mechanism powers Google’s public datasets program).

Orchestration: Composer vs Workflows

Something must sequence load → transform → DQ scan → publish and own retries, backfills and alerting. Two credible options, honestly compared:

Dimension Cloud Composer (managed Airflow) Workflows (+ Cloud Scheduler)
Model Python DAGs, operators, sensors Serverless YAML steps calling HTTP/connectors
Cost floor Environment always-on: roughly $350–450+/month small env ~zero: $0.01/1k internal steps (5k/month free) + Scheduler jobs
Backfills / catchup Native (catchup, airflow dags backfill) Build it yourself
Complex dependencies Rich (branching, pools, task groups, cross-DAG) Parallel steps + conditions; painful past ~30 steps
GCP task coverage Operators for BQ, Dataflow, Dataplex, GCS… Connectors for the same APIs
Long waits Sensors/deferrable operators Callbacks; executions can run up to a year
Failure UX Airflow UI: per-task logs, retries, SLA misses Execution log per run; thinner
Team skill bet Python + Airflow ecosystem YAML + API shapes
Pick when >10 interdependent pipelines, backfills matter, dedicated DE team Event-driven glue, small estates, cost-sensitive

The honest sizing note: Composer’s floor means it must replace at least a person-day of monthly toil to justify itself. Small lakehouses run beautifully on Scheduler + Workflows + Dataform’s own scheduler; graduate to Composer when backfills and cross-pipeline dependencies become weekly pain. A Workflows chain for the nightly promotion:

main:
  steps:
    - runDQScan:
        call: http.post
        args:
          url: https://dataplex.googleapis.com/v1/projects/acme-data-prod/locations/asia-south1/dataScans/orders-dq:run
          auth: { type: OAuth2 }
        result: scan
    - runDataform:
        call: http.post
        args:
          url: https://dataform.googleapis.com/v1beta1/projects/acme-data-prod/locations/asia-south1/repositories/acme-elt/workflowInvocations
          auth: { type: OAuth2 }
          body:
            compilationResult: ${scan.body.name}   # gate: only after DQ passes

Architecture at a glance

Read the diagram left to right — it is the whole article in one picture. Sources (operational databases via Datastream CDC, and apps/devices emitting events) feed the ingest tier: Pub/Sub buffers and schema-validates the stream, Dataflow parses, deduplicates and enriches. From Dataflow the data forks — the batch/lake path lands hourly Parquet into the GCS zone buckets (raw → curated) where BigLake exposes the curated zone as governed, Iceberg-backed tables; the streaming path writes straight into partitioned BigQuery tables through the Storage Write API with exactly-once commits. Dataplex overlays the warehouse and lake with data-quality gates, lineage and policy tags. Consumption never touches files: Looker reads governed SQL, Analytics Hub shares curated datasets zero-copy, and Vertex AI/BigQuery ML train on the same tables. The numbered badges mark the six design decisions the legend narrates — where exactly-once is manufactured, where partition pruning saves the bill, and where governance actually bites.

GCP data lakehouse architecture: sources (OLTP databases via Datastream CDC and event-producing apps) flow into an ingest tier of Pub/Sub (schema-validated topics with dead-letter queues and 7-day retention) and Dataflow (Beam pipelines with autoscaling and exactly-once processing). Dataflow lands hourly Parquet files into Cloud Storage lake zones (raw to curated) where BigLake exposes Iceberg-backed governed tables, and simultaneously streams into BigQuery partitioned-and-clustered tables via the Storage Write API. Dataplex provides data-quality scans, lineage and policy tags over the warehouse tier. Consumers — Looker with LookML and BI Engine, Analytics Hub zero-copy linked datasets, and Vertex AI/BigQuery ML — read only governed tables. Numbered badges mark the exactly-once streaming commit, lake file layout, fine-grained BigLake security, partition pruning, governance gates and replay/backpressure design points.

Six structural properties to internalize: (1) every consumer reads through BigQuery’s API surface — one IAM/masking model covers everything; (2) wherever BigLake/Iceberg is used, lake and warehouse are the same tables — nothing to reconcile; (3) both ingestion doors converge on identically partitioned tables; (4) Pub/Sub retention makes the stream replayable — streaming DR is a seek, not a heroic backfill; (5) governance attaches to tables, so it survives storage migrations; (6) each tier scales and bills independently — no cluster couples ingestion to query performance.

Real-world scenario: Trellico’s duplicate-revenue incident

Trellico, a fictional but realistic Bengaluru quick-commerce company, ran this exact architecture at moderate scale: ~5,000 events/s clickstream plus order CDC via Datastream, ~12 TiB/month of new data, BigQuery in asia-south1, on-demand pricing, Looker Studio dashboards for city managers.

The incident: finance flagged Monday’s GMV dashboard reading ₹4.1 crore against ₹3.6 crore in the payment gateway’s settlement report — 14% high. Lineage led the platform team to the cause in ninety minutes: the orders pipeline ran in Dataflow at-least-once mode writing via the default stream, chosen months earlier “temporarily” for latency. A worker preemption Sunday night caused bundle retries and ~610k order events landed twice. Dashboards summed raw appends — the hourly dedupe MERGE existed, but a Dataform release misconfiguration had silently stopped scheduling it eleven days earlier. Two defects, one visible failure.

The fix came in three layers, and the order matters. Immediately (day 0): re-ran the dedupe MERGE for the affected partitions — corrected dashboards by noon; used time travel (FOR SYSTEM_TIME AS OF) to snapshot the corrupted state for the postmortem. Structurally (week 1): moved the orders pipeline (money) to Dataflow exactly-once with the Storage Write API committed-stream sink, accepting ~15% higher worker cost on that one pipeline; left the clickstream pipeline (tolerant) on at-least-once. Institutionally (week 2): added a Dataplex tableConditionExpectation scan comparing SUM(amount) in curated against a Datastream-replicated settlements table with 0.5% tolerance, wired as a hard gate in the promotion Workflow — the class of bug now pages before finance sees it; and added an assertion-freshness alert on Dataform so a non-running model is itself an incident.

Post-stabilization numbers, worth quoting because they generalize: the exactly-once orders pipeline ran 3× n2d-standard-2 workers (~$510/month with Streaming Engine); Pub/Sub ~25 TiB/month (~$1,000); Storage Write API ~$270/month. The retro’s headline was not technical: at-least-once had been fine for months because duplicates were rare — the architecture was betting correctness on preemption luck plus an unmonitored cleanup job. Delivery semantics are a per-table financial decision, not a pipeline default: clickstream tolerates duplicates, revenue does not, and a table feeding finance either gets exactly-once writes or gets its dedupe monitored like production code — because it is.

Advantages and disadvantages

Advantages Disadvantages
One copy of data; lake/warehouse reconciliation eliminated by construction BigLake/Iceberg feature set moves fast — designs need an owner who tracks releases
Fully serverless tiers: no clusters to size, patch or keep warm Serverless ≠ cheap by default: unpruned scans and unbounded autoscaling bill instantly
Open formats (Parquet/Iceberg) keep Spark/Trino access and an exit path Native-table features (full DML breadth, some perf) still exceed open-format tables
Fine-grained security (rows, columns, masking) reaches files, not just warehouse tables All fine-grained enforcement assumes access via BigQuery — direct GCS readers bypass it
Streaming and batch converge on the same tables with seconds-level freshness Exactly-once streaming costs real CPU/state; semantics need per-table decisions
Governance (DQ, lineage, catalog) is declarative and enforceable as pipeline gates Dataplex adds its own IAM/object model to learn; scans are a billable line item
Cost scales with use and is attributable per query/job/team On-demand pricing is a footgun without require_partition_filter + quotas + monitoring
Zero-copy sharing (Analytics Hub) replaces extract sprawl Same-region constraint pushes you into replication planning for global sharing

When do the disadvantages dominate? Sub-terabyte estates with one consumer (a single Postgres replica + Metabase beats all of this), teams with zero streaming needs (skip Pub/Sub/Dataflow entirely — GCS + load jobs + Dataform is a fine “small lakehouse”), and orgs that cannot route consumption through BigQuery (heavy direct-Spark shops should weigh Dataproc + Iceberg + BigQuery-as-one-engine instead).

Hands-on lab: a miniature lakehouse in one sitting

Free-tier-friendly: stays inside 1 TiB/month query + 10 GiB storage + Pub/Sub 10 GiB free allowances; the only pennies are GCS storage and the BigQuery subscription’s per-TiB fee on a few MB. Total < ₹10. Requires a project with billing linked and bq, gcloud ≥ 460.

1. Set the stage.

export PROJECT=$(gcloud config get-value project) REGION=asia-south1
gcloud services enable bigquery.googleapis.com pubsub.googleapis.com \
  bigqueryconnection.googleapis.com dataplex.googleapis.com
bq mk --location=$REGION --dataset lakehouse_lab
gcloud storage buckets create gs://$PROJECT-lab-curated --location=$REGION \
  --uniform-bucket-level-access --public-access-prevention

2. Land batch files (the lake).

cat > /tmp/orders.csv <<'EOF'
order_id,order_date,customer_id,amount,status
O-1001,2026-07-06,C-9,2499.00,delivered
O-1002,2026-07-06,C-4,899.50,delivered
O-1003,2026-07-07,C-9,4999.00,placed
EOF
gcloud storage cp /tmp/orders.csv "gs://$PROJECT-lab-curated/orders/order_date=2026-07-06/part-0.csv"

3. Bridge it with a BigLake connection + external table.

bq mk --connection --location=$REGION --connection_type=CLOUD_RESOURCE lab-conn
SA=$(bq show --connection --format=json $REGION.lab-conn | python3 -c 'import json,sys;print(json.load(sys.stdin)["cloudResource"]["serviceAccountId"])')
gcloud storage buckets add-iam-policy-binding gs://$PROJECT-lab-curated \
  --member="serviceAccount:$SA" --role=roles/storage.objectViewer
CREATE EXTERNAL TABLE lakehouse_lab.orders_lake
WITH CONNECTION `asia-south1.lab-conn`
OPTIONS (format='CSV', uris=['gs://YOUR_PROJECT-lab-curated/orders/*'],
         skip_leading_rows=1, max_staleness=INTERVAL 30 MINUTE,
         metadata_cache_mode='AUTOMATIC');
SELECT * FROM lakehouse_lab.orders_lake;   -- 3 rows, straight off GCS

4. Create the warehouse fact — partitioned + clustered.

CREATE TABLE lakehouse_lab.events
(event_id STRING, event_date DATE, event_ts TIMESTAMP, customer_id STRING, event_name STRING)
PARTITION BY event_date CLUSTER BY customer_id
OPTIONS (require_partition_filter = TRUE);

5. Stream into it — zero code, Pub/Sub BigQuery subscription.

gcloud pubsub topics create lab-events
# Pub/Sub's service agent must be able to write:
PN=$(gcloud projects describe $PROJECT --format='value(projectNumber)')
bq add-iam-policy-binding --member="serviceAccount:service-$PN@gcp-sa-pubsub.iam.gserviceaccount.com" \
  --role=roles/bigquery.dataEditor lakehouse_lab
gcloud pubsub subscriptions create lab-events-bq --topic=lab-events \
  --bigquery-table=$PROJECT:lakehouse_lab.events

gcloud pubsub topics publish lab-events --message='{"event_id":"E-1","event_date":"2026-07-07","event_ts":"2026-07-07T10:15:00Z","customer_id":"C-9","event_name":"add_to_cart"}'
SELECT * FROM lakehouse_lab.events WHERE event_date = '2026-07-07';
-- your event, queryable within seconds of publish

6. Prove the two guardrails. Run SELECT COUNT(*) FROM lakehouse_lab.events; — it fails with Cannot query over table … without a filter over column(s) event_date (that’s require_partition_filter earning its keep). Then dry-run the pruned version and note totalBytesProcessed:

bq query --use_legacy_sql=false --dry_run \
  'SELECT COUNT(*) FROM lakehouse_lab.events WHERE event_date="2026-07-07"'

7. Optional governance taste: in the console, create a Dataplex data-profile scan on lakehouse_lab.orders_lake and view the published profile on the table’s Dataplex tab.

8. Teardown.

gcloud pubsub subscriptions delete lab-events-bq && gcloud pubsub topics delete lab-events
bq rm -r -f -d $PROJECT:lakehouse_lab
bq rm --connection $REGION.lab-conn
gcloud storage rm -r gs://$PROJECT-lab-curated

Common mistakes & troubleshooting

The playbook — symptom to fix, in the order the platform usually teaches them:

# Symptom Root cause Confirm (exact path) Fix
1 External/BigLake query: Access Denied … does not have storage.objects.get Connection SA (or user, for plain external) lacks bucket read bq show --connection, check bucket IAM for that SA Grant roles/storage.objectViewer on the bucket to the connection’s SA
2 Quota exceeded: … load jobs per table Micro-batching >1,500 loads/table/day INFORMATION_SCHEMA.JOBS_BY_PROJECT WHERE job_type='LOAD' count by table/day Consolidate to hourly loads, or move the feed to Storage Write API
3 Duplicate rows in a streamed fact At-least-once path (default stream / Pub/Sub BQ sub / retries) with no dedupe SELECT event_id, COUNT(*) … HAVING COUNT(*)>1 on a recent partition Exactly-once sink for money tables; else scheduled MERGE dedupe — monitored
4 Pub/Sub backlog grows; Dataflow “healthy” Poison message loop, or autoscaling capped Metrics: oldest_unacked_message_age + Dataflow data freshness; DLQ empty? Wire DLQ (--max-delivery-attempts=5); raise max_num_workers; fix the parser
5 BQ subscription writes nothing, no errors in your code Subscription in error state: missing bigquery.dataEditor for Pub/Sub SA, or schema mismatch gcloud pubsub subscriptions describe lab-events-bq --format='value(state)' Grant the service agent on the dataset; align topic schema / drop_unknown_fields
6 Query scans TBs despite partitioning Predicate wraps the partition column (DATE(ts)=…), or filters a different column Execution details → bytes processed; dry run both forms Filter the partition column bare; add require_partition_filter=TRUE
7 Cannot read and write in different locations on load/CTAS Bucket and dataset regions differ gcloud storage buckets describe vs bq show --dataset Co-locate (recreate bucket or dataset); US multi-region is permissive, others aren’t
8 BigLake table missing files landed minutes ago Metadata cache within max_staleness window Compare SELECT COUNT(*) vs `gcloud storage ls wc -l`
9 Lake queries crawl; job stats show huge file counts Small-files disease from unsharded streaming spill Job execution details: files read vs bytes Compact to 256 MB–1 GB Parquet (Dataflow/Spark job or Iceberg rewrite)
10 RESOURCE_EXHAUSTED: AppendRows throughput Storage Write API regional throughput/connection quota Quotas page → BigQuery Storage Write API; error rate by region Request increase; enable multiplexing/auto-sharding; batch rows per append
11 Bill spikes overnight (editions or on-demand) Autoscale max too high + runaway transform; or viewer-refreshed dashboards full-scanning facts INFORMATION_SCHEMA.JOBS ORDER BY total_slot_ms/total_bytes_billed DESC, grouped by principal Cap autoscale_max_slots; point BI at MVs/filtered views; per-user byte quotas
12 UPDATE or DELETE … would affect rows in the streaming buffer Legacy insertAll buffer rows are DML-immutable Table details → streaming buffer stats Wait for buffer flush, or (better) migrate the writer to Storage Write API
13 Dataform run green but tables stale Release config points at a stale branch/tag; schedule disabled Dataform → release configuration → compiled commit SHA Fix the release ref; alert on workflowInvocations age (this bit Trellico)

Error-string quick reference for the searchers:

Error (as seen) Layer Meaning First move
rateLimitExceeded BQ jobs Too many concurrent/recent operations of that type Backoff; check for a retry storm
quotaExceeded BQ A daily/table quota (loads, DML rate, bytes) Identify which quota in the message; redesign the hot path
resourcesExceeded BQ query Query needs more shuffle/memory than available Reduce skew, pre-aggregate, avoid giant ORDER BY without LIMIT
responseTooLarge BQ query Result >10 GB to a client Write results to a destination table
ALREADY_EXISTS on AppendRows Storage Write API Offset already committed — duplicate send Treat as success; advance offset
OUT_OF_RANGE on AppendRows Storage Write API Offset skips ahead of stream end Client bug: resync from committed offset
SCHEMA_MISMATCH_EXTRA_FIELDS BQ subscription Message has fields the table lacks drop_unknown_fields=true or evolve the table schema
PERMISSION_DENIED: bigquery.tables.getData IAM Caller can see metadata but not read Grant dataViewer at dataset/table; check policy-tag Fine-Grained Reader for masked columns
Access Denied … VPC Service Controls Perimeter Request crosses a service perimeter Ingress/egress rule or access level — see VPC Service Controls
Table … does not have a schema External table Autodetect failed on messy files Declare schema explicitly; quarantine the bad file

Best practices

  1. Decide the region once, first, in writing. Buckets, datasets, connections, Dataflow, reservations all co-locate; a late region change is a full migration.
  2. require_partition_filter = TRUE on every fact table over ~10 GB. Convert cost incidents into failed queries at zero runtime cost.
  3. Choose delivery semantics per table, by financial blast radius. Exactly-once (or gated dedupe) for money; at-least-once for behavioral streams. Write the choice into the table’s docs.
  4. Raw zone is append-only and lifecycle-managed; never grant humans write on it. It’s your replay source and your audit answer.
  5. Consumers get tables, never buckets. The day a BI tool reads gs:// directly, fine-grained governance is fiction.
  6. Gate promotion on DQ scans in the DAG — quality checks that only feed dashboards are decoration.
  7. Compact relentlessly. Streaming spill produces small files; schedule compaction (or use managed Iceberg’s background optimization) before listings dominate query time.
  8. Dedupe jobs are production code: monitor their freshness, not just their exit codes. A silently-unscheduled MERGE is how Trellico happened.
  9. Give BI a reservation (or byte quotas) and everything else its own. Workload isolation via reservations/assignments turns “the analyst broke prod ETL” into an impossibility.
  10. Check physical vs logical storage billing quarterly with TABLE_STORAGE; >2× compression means the physical model halves that line.
  11. Schema-validate at the topic (Pub/Sub schemas) so garbage is rejected at publish, not discovered at query.
  12. Everything in Terraform, including reservations, connections, policy-tag taxonomies and scan definitions. Console-created governance evaporates in the next reorg.

Security notes

Least-privilege for this architecture is mostly service-account hygiene plus policy tags, and it’s enumerable:

Principal Grant Scope Never grant
Dataflow worker SA roles/dataflow.worker, pubsub.subscriber (sub), bigquery.dataEditor (target dataset), storage.objectAdmin (staging/temp bucket) Per-pipeline SA, per-resource Project-wide editor — the classic anti-pattern
BigLake connection SA roles/storage.objectViewer Lake buckets only Bucket write; it’s a read bridge
Pub/Sub service agent roles/bigquery.dataEditor Target dataset for BQ subscriptions
Dataform SA bigquery.jobUser (project), dataEditor (marts + staging datasets) Datasets it builds Access to raw PII datasets it doesn’t transform
Analysts bigquery.dataViewer on curated/marts + Fine-Grained Reader only per approved tag Dataset-level dataViewer on raw/staging; bucket roles of any kind
BI service account dataViewer on marts, jobUser, its own reservation Marts only Access to base facts if views can serve it

Beyond IAM: encryption is on by default; add CMEK across bucket + dataset + topic + Dataflow when a classification demands key custody (revocation is your kill switch). Network isolation: Dataflow workers on private IPs in a Shared VPC subnet, and the whole estate — BigQuery, GCS, Pub/Sub, Dataflow APIs — inside a VPC Service Controls perimeter so stolen credentials can’t exfiltrate to an attacker’s project (perimeter design here). Column protection is policy tags + masking; row protection is row-access policies; deletion is partition expiry plus DPDP/GDPR-driven DELETE with the time-travel window tuned down on sensitive datasets. And audit: BigQuery + GCS Data Access logs into a locked sink — “who read the PII column last quarter” must be a query, not a shrug.

Cost & sizing

The unit prices that matter (us-central1 list, indicative — regional prices vary, INR at ≈₹85/$):

Meter Price Free tier Note
BQ on-demand query $6.25/TiB scanned 1 TiB/month Scan bytes = layout quality
BQ Enterprise slot-hour (PAYG) $0.06 100 slots 24×7 ≈ $4,380/mo (≈₹3.7L)
BQ storage (logical) $0.02/GiB active, $0.01 long-term 10 GiB Long-term = 90 days untouched
BQ storage (physical) $0.04/GiB active, $0.02 long-term On compressed bytes; incl. time travel/fail-safe
Storage Write API $0.025/GiB 2 TiB/month Half of legacy insertAll ($0.05/GB)
Pub/Sub throughput $40/TiB 10 GiB/month Publish and delivery each metered
Pub/Sub BQ subscription ~$50/TiB Includes the BigQuery write
Dataflow streaming ~$0.069/vCPU-hr + memory + Streaming Engine Batch vCPU cheaper (~$0.056)
GCS Standard $0.020/GB-mo 5 GB (us regions) Nearline/Coldline/Archive: see lake section
BI Engine per GiB-hour reservation Small reservations go far with preferred tables
Dataplex DQ/profiling per DCU-hour of scan processing Scoped scans (incremental on partitions) keep it small
Composer environment ~$350–450+/mo floor vs Workflows at ~$0 floor

Worked monthly estimate for the Trellico-scale platform (5,000 events/s ≈ 12.4 TiB/month streaming, 25 TiB lake, 40 TiB/month scanned by BI + ELT):

Line item Math USD/mo ≈INR/mo
Pub/Sub (publish + subscribe) 24.8 TiB × $40 $992 ₹84,000
Dataflow streaming (2 pipelines, ~6 vCPU avg + SE) workers + Streaming Engine ~$510 ₹43,000
Storage Write API (12.4 − 2 free) TiB ≈ 10,650 GiB × $0.025 $266 ₹23,000
BQ storage (grows; month-6 ≈ 70 TiB logical, physical model @3.5× compression) ~20 TiB physical × $0.04/GiB $820 ₹70,000
GCS lake (25 TiB blended classes) mostly Standard + Nearline ~$420 ₹36,000
Query compute — option A: on-demand (40 − 1) TiB × $6.25 $244 ₹21,000
Query compute — option B: Enterprise autoscale (0 baseline, ~50-slot average 12 h/day) 50 × $0.06 × 365 h $1,095 ₹93,000
Orchestration (Workflows + Scheduler) steps ≈ free tier ~$5 ₹450
Total (with option A) ≈$3,260 ≈₹2.8L

That option A/B gap is the sizing lesson in one row: at 40 TiB/month scanned, on-demand wins by 4×; the crossover arrives near 175 TiB/month, or earlier if you need editions-gated features or concurrency guarantees. Decide from data, not vibes — summing total_bytes_billed over 30 days in INFORMATION_SCHEMA.JOBS_BY_PROJECT gives your real TiB/month, and total_slot_ms percentiles size the candidate reservation.

Right-sizing levers in priority order: partition filters (routinely a 10–100× scan cut), materialized views for top dashboards, physical storage billing where compression >2×, lifecycle raw to colder classes, at-least-once for tolerant streams — and only then reservation tuning.

Interview & exam questions

Mapped to the Professional Data Engineer (PDE) and Professional Cloud Architect (PCA) blueprints.

  1. Q: What makes a “lakehouse” different from a lake plus a warehouse? A: One physical copy of data serving both open-engine and SQL access under one governance model. On GCP: GCS holds open formats, BigLake projects BigQuery’s table security/semantics onto those files, and native BigQuery tables cover hot serving — so there is no synchronization pipeline between “lake” and “warehouse” to drift. (PDE: storage design.)
  2. Q: When do you pick a BigLake table over an external table? A: Almost always. External tables make the querying user need GCS access and re-list objects per query; BigLake delegates GCS access to a connection service account, enabling row/column security, masking and metadata caching. Plain external tables are for quick one-off federation. (PDE.)
  3. Q: Walk through exactly-once from producer to BigQuery. A: Producer publishes with retries (dupes possible) → Pub/Sub delivers at-least-once → Dataflow deduplicates redeliveries by message ID and processes effectively-once within checkpointed bundles → the Storage Write API committed-stream sink writes with offsets, so retried appends are rejected (ALREADY_EXISTS) rather than duplicated. End-to-end it’s effectively exactly-once into the table; producers must still be idempotent at the business-key level for cross-restart safety. (PDE: pipeline semantics — a favorite.)
  4. Q: Default stream vs committed vs pending streams — when for each? A: Default: at-least-once streaming with zero stream management. Committed + offsets: exactly-once streaming. Pending: batch semantics — appends invisible until an atomic commit, replacing load jobs for programmatic producers. (PDE.)
  5. Q: Partitioning vs clustering — how do you decide? A: Partition on the coarse pruning dimension (almost always event date; 10,000-partition cap in mind), cluster on up to 4 high-cardinality filter/join columns in query order. Partitioning gives hard pruning plus partition-scoped operations (decorators, expiry); clustering gives block-level pruning within scans and free background maintenance. Canonical fact: PARTITION BY event_date CLUSTER BY customer_id. (PDE/PCA.)
  6. Q: A query on a date-partitioned table scans the full table despite a date filter. Why? A: The predicate doesn’t hit the partition column bare — e.g. filtering DATE(event_ts) when the table partitions on event_date, or applying a function/cast to the partition column. Rewrite the predicate; enforce with require_partition_filter. (PDE troubleshooting.)
  7. Q: On-demand vs editions — what’s the actual decision function? A: Compare measured TiB scanned × $6.25 against the slot-hours an equivalent autoscaled reservation would bill (1 always-on Enterprise slot ≈ $43.8/mo ≈ 7 TiB scanned). Editions also buy concurrency guarantees, workload isolation via assignments, and gated features (fine-grained security, BI Engine, ML). Mixed estates — BI on a reservation, ad-hoc on-demand — are standard. (PCA cost design.)
  8. Q: Where does Dataplex actually enforce anything? A: Data-quality scans enforce only if pipelines gate on their results; policy tags + data policies enforce column access/masking at query time; zone types enforce format/schema conformance on discovery. Lineage and catalog are observability. Design DAGs so promotion depends on scan success. (PDE governance.)
  9. Q: How do you share curated data with another business unit without copying it? A: Analytics Hub: publish the dataset as a listing in an exchange; the subscriber’s linked dataset is a zero-copy read-only reference billed to their compute. Same-region requirement — replicate the dataset first for cross-region sharing. (PCA.)
  10. Q: Composer or Workflows for orchestration? A: Composer for many interdependent DAGs, backfills/catchup, sensor-heavy waits, and a team fluent in Airflow — at a $350+/mo floor. Workflows + Scheduler for event-driven glue and small estates at near-zero cost. The gate: when backfill orchestration becomes weekly pain, Composer pays for itself. (PCA.)

Quick check

  1. Name the two “doors” into a BigQuery fact table in this architecture and the delivery guarantee of each.
  2. What single table option turns unpruned dashboard queries into errors instead of bills?
  3. Why does a BigLake table sometimes not show files that landed two hours ago, and what are the two fixes?
  4. You need Spark and BigQuery to read and BigQuery to write one curated table in your bucket. Which table flavour?
  5. At 30 TiB scanned/month with spiky concurrency, on-demand or Enterprise autoscale — and what’s the one-line math?

Answers

  1. Batch loads (free shared pool or PIPELINE reservation; atomic per job) and the Storage Write API (at-least-once via default stream; exactly-once via committed streams with offsets).
  2. require_partition_filter = TRUE (paired with predicates on the bare partition column).
  3. Metadata caching: queries may serve listings up to max_staleness old. Fix by calling BQ.REFRESH_EXTERNAL_METADATA_CACHE after landing, or shrinking/removing max_staleness.
  4. A BigQuery managed Iceberg table — BigQuery-writable (DML + Storage Write API), stored as Parquet + Iceberg metadata in your GCS bucket, snapshot-readable by Spark.
  5. On-demand: 30 × $6.25 ≈ $188/mo, versus ≈$1,000+ for a meaningful autoscaled Enterprise reservation — on-demand wins until scan volume approaches ~175 TiB/month or you need editions-gated features/isolation.

Glossary

Next steps

GCPBigQueryDataflowPub/SubBigLakeDataplexData LakehouseStreaming
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading