Designing a Medallion Architecture (Bronze, Silver, Gold) Exclusively Using Google BigQuery and Dataform
Executive Summary
Many cloud data platforms begin by loading raw data into Google BigQuery and building dozens of ad-hoc SQL views or scheduled queries directly on top of those raw tables. As data volume and business complexity grow, this flat structure breaks down: a single upstream schema change breaks executive reports, BI tools repeatedly scan terabytes of unpartitioned raw records on every page load, and engineers cannot tell which scheduled query runs before another.
The Medallion Architecture solves this problem by organizing data into three progressive quality layers—Bronze (Raw/Immutable), Silver (Cleaned/Conformed), and Gold (Aggregated/Business-Ready). Historically, organizations implemented this pattern using external compute frameworks such as Apache Spark or paid third-party transformation tools. Today, Google BigQuery paired with Dataform (Google Cloud’s native, zero-license-cost SQL transformation and DAG orchestration engine) enables teams to build a production-grade Medallion architecture entirely inside the BigQuery control plane.
Key Findings and Architectural Thesis:
- Zero Software Licensing Overhead: Dataform is included in Google Cloud at 0.00 USD license cost. Compared to commercial SaaS transformation platforms (which cost between 1,200 USD and 4,500 USD per month for a mid-sized team) or cluster-based Spark lakehouses, a native BigQuery and Dataform stack reduces platform TCO by 62% to 78%.
- Layer-Specific Storage Billing Optimization: BigQuery allows configuring Physical Storage Billing (0.04 USD per GiB active) or Logical Storage Billing (0.02 USD per GiB active) at the individual dataset level. Assigning append-only Bronze datasets to Physical Billing captures 4:1 to 6:1 columnar compression savings, while assigning high-churn Silver
MERGEdatasets to Logical Billing avoids paying for 7-day Time Travel and 7-day Fail-Safe physical snapshots. - Partition-Pruned Incremental Processing: Replacing full-table rebuilds with Dataform incremental models (
updatePartitionFilter+ clustering) reduces daily BigQuery compute bytes scanned by 85% to 94%. - Compile-Time DAG Safety and Quality Gates: Using Dataform assertions (
uniqueKey,nonNull,rowConditions) withdependOnDependencyAssertions: trueautomatically blocks corrupted Silver tables from propagating into Gold executive marts.
1. Introduction and the Business Problem of Unlayered Data Warehouses
1.1 The Anatomy of an Unstructured Warehouse (“Spaghetti SQL”)
When engineering teams adopt a cloud data warehouse without a strict layering methodology, transformations typically evolve through three anti-patterns:
- Direct BI Consumption of Raw Ingestion Tables: Dashboards in Looker Studio, Power BI, or Tableau query raw webhook, streaming, or Change Data Capture (CDC) tables directly. Every dashboard refresh executes complex JSON extraction, string parsing, and
DISTINCTdeduplication on billions of raw rows. - Deeply Nested View Chains: Analysts build SQL views on top of other SQL views five or six levels deep. Because standard BigQuery views do not materialize data, querying a top-level view triggers a cascading execution of every underlying view from scratch, often failing to push down partition filters.
- Uncoordinated Scheduled Queries: Engineers schedule standalone SQL scripts using wall-clock estimates (for example, Job A runs at 02:00 UTC, Job B runs at 02:30 UTC assuming Job A finished). When Job A takes 35 minutes due to a traffic spike, Job B runs against yesterday’s incomplete data, producing silent financial inaccuracies.
1.2 Why Monolithic Transformations Fail Business SLAs
Without explicit separation of responsibilities across data layers, organizations encounter four structural failures:
- Non-Idempotent Recovery: If a transformation script mutates a single table in place and fails halfway through, the raw source state is lost. Re-running the pipeline requires re-extracting data from external operational databases or third-party APIs, which may be rate-limited or no longer hold the historical state.
- Runaway Compute Billing from Unpruned Scans: Raw streaming tables often contain semi-structured
JSONpayloads and duplicate network retries. When 50 business users filter dashboards over the last 30 days, BigQuery repeatedly parses the same raw bytes hundreds of times per day instead of reading pre-aggregated dimensional tables once. - Uncontrolled Schema Drift Cascades: When a backend software team renames a field in a microservice payload, every downstream query that touches the raw table breaks simultaneously.
- Untested Data Quality: Without intermediate staging checkpoints, null primary keys, duplicate order IDs, or negative transaction amounts flow directly into executive KPIs before anyone notices.
1.3 Quantifying the Financial Impact: Flat Warehouse vs. BigQuery + Dataform Medallion
To evaluate the business case for refactoring an unstructured BigQuery environment into a Dataform-managed Medallion architecture, consider a mid-sized e-commerce and SaaS analytics platform with the following baseline parameters:
- Raw data volume: 12 TiB of historical event and transactional data; 80 GiB of new data ingested daily.
- Analytical workload: 40 scheduled transformation queries per day + 300 BI dashboard queries per day executed by 25 analysts and business stakeholders.
- Engineering team: 4 data engineers at a fully loaded cost of 65.00 USD per hour.
| Cost & Operational Dimension | Unlayered Warehouse (Views + Scheduled Queries) | Medallion Architecture (BigQuery + Dataform) | Mathematical Calculation and Underlying Mechanism |
| Daily Transformation Compute Scanned | 48.00 TiB per day | 3.20 TiB per day | Unlayered: 40 queries scanning an average of 1.2 TiB each. Medallion: Dataform incremental models scan only a 3-day partition window (approx. 80 GiB per model across 40 models). |
| Daily BI Dashboard Compute Scanned | 30.00 TiB per day | 0.60 TiB per day | Unlayered: 300 refreshes scanning 100 GiB of Silver/Bronze views. Medallion: 300 refreshes scanning 2 GiB Gold aggregated tables. |
| Total Monthly Compute Scanned (30 Days) | 2,340.00 TiB per month | 114.00 TiB per month | Unlayered: (48 + 30) multiplied by 30 days. Medallion: (3.2 + 0.6) multiplied by 30 days. |
| Monthly BigQuery On-Demand Compute Cost | 14,618.75 USD | 706.25 USD | Billed at 6.25 USD per TiB after the first 1 TiB free tier: (2,339 TiB * 6.25 USD) vs. (113 TiB * 6.25 USD). |
| Monthly Pipeline Breakages & Incident Recovery | 10 incidents (40 engineer-hours = 2,600.00 USD) | 1.5 incidents (3 engineer-hours = 195.00 USD) | Dataform DAG dependency ordering and pre-Gold blocking assertions eliminate race conditions and silent schema drift. |
| Total Monthly Platform & Operational Cost | 17,218.75 USD per month | 901.25 USD per month | Net monthly savings: 16,317.50 USD (195,810.00 USD annually — a 94.8% reduction in combined waste). |
2. Mechanics of the Three Medallion Layers in BigQuery and Dataform
To design an optimal Medallion architecture on Google Cloud, each layer must be mapped to a specific GCP Dataset, BigQuery Storage Billing Model, Partitioning and Clustering Strategy, and Dataform Action Type. Moving from Bronze to Gold represents a progression from raw write-optimized capture to read-optimized business semantics.
2.1 The Bronze Layer: Raw Ingestion and Immutable History
Primary Objective: Capture an exact, immutable, append-only record of source systems (webhooks, Pub/Sub streams, CDC logs, batch CSV/Parquet extracts) with zero business logic applied. If a bug is discovered in Silver or Gold logic six months later, the entire warehouse can be deterministically replayed from Bronze without contacting external APIs.
BigQuery & Dataform Mechanics in Bronze:
- Dataform Representation: Registered primarily as
type: "declaration"sources inside Dataform, or lightweight append-onlytype: "incremental"tables that ingest from BigLake external tables (Cloud Storage Parquet/JSON) without filtering rows. - Schema Design: Preserves source payload structures using BigQuery’s native
JSONdata type for semi-structured payloads alongside metadata ingestion columns:_ingested_at(TIMESTAMP),_source_system(STRING), and_batch_id(STRING). - Partitioning & Clustering: Always partitioned by ingestion timestamp (
DATE(_ingested_at)) withrequire_partition_filter = true. Clustered by_source_systemand primary entity key if available. - Storage Billing Model: Physical Storage Billing (0.04 USD per GiB active / 0.02 USD per GiB long-term). Because Bronze tables are strictly append-only (zero
UPDATE,DELETE, orMERGEstatements), they generate zero bytes of Time-Travel or Fail-Safe overhead from mutated micro-partitions. Meanwhile, repeating JSON keys and raw text strings achieve 4:1 to 7:1 Zstandard columnar compression in BigQuery’s Capacitor storage format, cutting storage costs by 50% to 70% compared to Logical Billing.
2.2 The Silver Layer: Cleaned, Conformed, Deduplicated, and Entity-Centric
Primary Objective: Transform raw Bronze payloads into a single source of truth for atomic business entities (orders, customers, events, subscriptions, inventory_movements). Silver resolves data types, deduplicates at-least-once streaming records, handles late-arriving CDC updates, standardizes timestamps to UTC, and enforces strict data contracts.
BigQuery & Dataform Mechanics in Silver:
- Dataform Representation: Implemented as
type: "incremental"models with an explicituniqueKey(triggering a BigQueryMERGEstatement) or partition-overwrite strategies, paired withtype: "assertion"quality tests. - Schema Design: Strongly typed relational columns (
INT64,NUMERIC,TIMESTAMP,STRUCT,ARRAY). Semi-structuredJSONfields from Bronze are extracted and cast usingSAFE_CAST,LAX_INT64, orSTRING. - Partitioning & Clustering: Partitioned by the business event date (for example,
DATE(event_timestamp)orDATE(order_created_at)), NOT the ingestion timestamp. Clustered by high-cardinality join and filter keys (up to 4 columns, ordered from highest to lowest filter frequency, such ascustomer_id, order_status, country_code). - Storage Billing Model: Logical Storage Billing (0.02 USD per GiB active / 0.01 USD per GiB long-term). Because Silver tables undergo frequent incremental
MERGEor partition replacement operations (often every 15 to 60 minutes), every modified storage block creates a historical physical snapshot retained for Time Travel (2 to 7 days) and Fail-Safe (7 days). Under Logical Billing, BigQuery charges 0.00 USD for Time-Travel and Fail-Safe bytes, preventing physical storage bloat.
2.3 The Gold Layer: Business Aggregates, Star Schemas, and Serving Marts
Primary Objective: Deliver fast, denormalized, business-domain datasets (mart_finance_daily_revenue, mart_marketing_attribution, dim_customer_360) tailored for BI dashboards, executive reporting, machine learning feature stores, and reverse-ETL activation.
BigQuery & Dataform Mechanics in Gold:
- Dataform Representation: Implemented as
type: "table"(for small-to-medium dimensional tables rebuilt atomically viaCREATE OR REPLACE TABLE),type: "incremental"(for large daily grain rollups), or native BigQuery Materialized Views (materialized: trueontype: "view"). - Schema Design: Wide, pre-joined tables or Kimball Star Schemas (Fact and Dimension tables) with human-readable business metric names, pre-calculated ratios, and Fine-Grained Access Control (Policy Tags and Row-Level Access Policies).
- Partitioning & Clustering: Partitioned by reporting grain (
report_dateorDATE_TRUNC(metric_date, MONTH)), clustered by primary dashboard filter dimensions (region, product_category, acquisition_channel). - Storage Billing Model: Logical Storage Billing (for tables rebuilt daily via
CREATE OR REPLACE TABLE, which otherwise rewrite 100% of physical blocks every day and multiply physical Time-Travel storage by 14x over a 7-day window + 7-day fail-safe) or Physical Billing only for large, append-only historical snapshot marts with a reduced 48-hour time-travel window.
2.4 Comprehensive Architectural Mapping Matrix Across Medallion Layers
| Architectural Dimension | Bronze Layer (Raw) | Silver Layer (Conformed & Cleaned) | Gold Layer (Business Marts) |
| GCP Dataset Naming Convention | raw_bronze_<source> (e.g., raw_bronze_stripe) | core_silver_<domain> (e.g., core_silver_commerce) | mart_gold_<department> (e.g., mart_gold_finance) |
| Primary Dataform Model Types | declaration, append-only incremental | incremental (MERGE / partition overwrite), assertion | table, incremental, view (materialized: true) |
| Write Pattern (DML / DDL) | INSERT INTO / Storage Write API / BigLake Load | MERGE INTO on uniqueKey or partition DELETE + INSERT | CREATE OR REPLACE TABLE or incremental grain MERGE |
| Data Mutability | Strictly Immutable (Append-only) | Mutable via deterministic upsert / CDC state resolution | Recomputable at any time from Silver |
| Partitioning Column | Ingestion Time (DATE(_ingested_at)) | Business Event Time (DATE(event_timestamp)) | Reporting Date (report_date / metric_month) |
| Clustering Columns (Max 4) | _source_system, event_type | entity_id (PK), foreign_key_1, status | country, channel, product_tier, segment |
| Recommended BigQuery Storage Billing | Physical Billing (0.04 USD/GiB active; zero DML churn + high compression) | Logical Billing (0.02 USD/GiB active; immune to MERGE Time-Travel bloat) | Logical Billing (0.02 USD/GiB active; immune to CREATE OR REPLACE rewrite bloat) |
| Recommended Time-Travel Window | 168 hours (7 days) | 48 to 168 hours (free on Logical Billing) | 48 hours |
| Access Permissions (IAM) | Service accounts only (sa-ingestion, sa-dataform) | Data Engineers & Dataform Service Account | BI Service Accounts, Analysts, Domain Business Users |
3. System Comparison: Native BigQuery + Dataform vs. Competing Stacks
Before committing to a pure BigQuery and Dataform architecture, technical decision-makers must understand how this native stack compares against alternative transformation and lakehouse platforms in terms of internal architecture, scaling mechanics, and total cost of ownership.
3.1 Evaluated Transformation and Lakehouse Platforms
- BigQuery + Dataform (Native GCP Stack): Serverless SQL compilation and DAG execution built directly into the Google Cloud console and APIs, executing transformations on BigQuery’s Dremel engine.
- BigQuery + dbt Cloud (SaaS Control Plane): Commercial hosted transformation platform by dbt Labs using Jinja/Python macros to compile SQL and submit jobs to BigQuery.
- BigQuery + dbt Core on Cloud Run / Cloud Composer (Self-Hosted OSS): Open-source
dbt-bigqueryCLI packaged inside Docker containers and orchestrated by Managed Apache Airflow (Cloud Composer) or Cloud Workflows. - Databricks Lakehouse (Delta Live Tables + Unity Catalog): Spark-based lakehouse architecture storing Delta Lake / Parquet files in object storage, using declarative Delta Live Tables (DLT) on Photon compute clusters for Bronze, Silver, and Gold pipelines.
- Snowflake (Dynamic Tables + Tasks / Snowpark): Cloud data warehouse using automated declarative Dynamic Tables and Virtual Warehouses (T-shirt sized compute clusters) to refresh Medallion layers.
3.2 Deep Dive: Internal Mechanisms (Storage, Compute, Compilation, Scaling, and Pricing)
1. Native BigQuery + Dataform
- Storage Mechanism: BigQuery’s proprietary Capacitor columnar format stored on Colossus (Google’s distributed file system), plus native support for BigLake managed Apache Iceberg tables on Cloud Storage. Storage and compute are completely decoupled across a petabit-per-second Jupiter network fabric.
- Compute & Execution Mechanism: Dataform runs a serverless Node.js compilation sandbox inside Google Cloud that resolves JavaScript and SQLX files into a deterministic Directed Acyclic Graph (DAG) of standard GoogleSQL statements. Dataform’s workflow invoker submits each node asynchronously to BigQuery via the Jobs API, respecting parent-child dependencies and concurrency limits. All data processing happens inside BigQuery’s distributed Dremel shuffle-in-memory execution tree—zero data ever leaves BigQuery to pass through the Dataform container.
- Scaling: Auto-scales per query up to 2,000 slots on-demand or scales in 50-slot increments within milliseconds using BigQuery Editions autoscaling reservations.
- Pricing Model:
- Dataform Service: 0.00 USD (completely free for unlimited users, repositories, workspaces, and executions).
- BigQuery Compute: 6.25 USD per TiB on-demand (first 1 TiB free) or starting at 0.04 USD per slot-hour (Standard Edition) / 0.06 USD per slot-hour (Enterprise Edition).
- BigQuery Storage: 0.02 USD per GiB logical active (0.01 USD long-term) or 0.04 USD per GiB physical active (0.02 USD long-term).
2. BigQuery + dbt Cloud
- Storage & Compute Mechanism: Identical warehouse execution (BigQuery Capacitor + Dremel), because dbt also pushes SQL down to BigQuery.
- Compilation & Orchestration Mechanism: Uses Python and Jinja2 templating on dbt Labs’ multi-tenant SaaS infrastructure. While Jinja is widely adopted and has a rich package ecosystem (
dbt_utils,dbt_expectations), parsing large Jinja macros across 1,000+ models in Python is significantly slower (often 20 to 60 seconds per compile) than Dataform’s native V8 JavaScript engine (typically 1 to 3 seconds). - Pricing Model:
- Team Plan: 100.00 USD per developer seat per month (up to 8 seats), includes 15,000 free successful models built per month; additional model runs cost 0.01 USD per successful model build.
- Enterprise Plan: Custom annual contract (typically 3,000 USD to 5,000+ USD per month) plus per-model-execution consumption fees on modern usage tiers.
3. BigQuery + dbt Core on Cloud Composer (Managed Airflow)
- Compilation & Orchestration Mechanism: Engineers maintain a CI/CD pipeline that builds a Docker image containing
dbt-coreanddbt-bigquery, pushed to Artifact Registry. Cloud Composer (Apache Airflow running on a dedicated GKE cluster) triggers KubernetesPodOperators or Cloud Run Jobs to rundbt build. - Scaling & Weaknesses: Running a persistent Cloud Composer 2 or 3 environment just to trigger SQL queries introduces an always-on infrastructure baseline cost (
300 USD to 750 USD per month) plus operational overhead for upgrading Airflow versions, managing Python dependency conflicts, and parsing dbtmanifest.jsoninto Airflow tasks viacosmos. - Pricing Model: dbt Core is free open-source software, but Cloud Composer environments cost roughly 320.00 USD to 800.00 USD per month for compute, database, and GKE management fees.
4. Databricks Lakehouse (Delta Live Tables)
- Storage Mechanism: Open-format Delta Lake (Parquet files + transaction logs) stored in customer-managed Cloud Storage (GCS/S3).
- Compute Mechanism: Declarative pipelines run on dedicated or serverless Apache Spark / Photon clusters. Unlike BigQuery’s shared multi-tenant serverless slot pool where a query finishes in 4 seconds and bills only for those 4 seconds, Spark clusters often incur cluster spin-up latency (30 to 180 seconds on classic compute) or higher per-DBU serverless rates.
- Pricing Model: Billed in Databricks Units (DBUs) (ranging from 0.20 USD to 0.55+ USD per DBU depending on Delta Live Tables Core/Pro/Advanced tier and Serverless vs. Classic) plus underlying cloud VM instances, block storage, and GCS API operation fees.
5. Snowflake (Dynamic Tables + Virtual Warehouses)
- Storage Mechanism: Proprietary micro-partitioned columnar files stored on cloud object storage.
- Compute Mechanism: Uses T-shirt sized Virtual Warehouses (
X-Small= 1 credit/hour,Small= 2 credits/hour,Medium= 4 credits/hour, doubling each step). A virtual warehouse bills for a 60-second minimum upon resumption and bills per second for the entire cluster capacity even if a running query only utilizes 20% of the cluster’s CPU/memory. - Pricing Model: Credits cost between 2.00 USD and 4.00 USD per credit (Standard vs. Enterprise vs. Business Critical), plus 23.00 USD to 40.00 USD per TB per month for compressed storage.
3.3 Comprehensive Feature and Mechanism Matrix
| Evaluation Dimension | BigQuery + Dataform (Native) | BigQuery + dbt Cloud | BigQuery + dbt Core + Composer | Databricks (Delta Live Tables) | Snowflake (Dynamic Tables) |
| Transformation Language | GoogleSQL + JavaScript (ES6+) | GoogleSQL + Jinja2 (Python) | GoogleSQL + Jinja2 (Python) | Spark SQL / PySpark | Snowflake SQL / Snowpark |
| Compilation Speed (500 Models) | 1 to 3 seconds (V8 JS engine) | 15 to 45 seconds (Python Jinja) | 15 to 45 seconds (Container cold start + Jinja) | 10 to 30 seconds (Spark Catalyst planner) | Native DDL evaluation |
| Data Residency & Security | 100% inside GCP IAM boundary | Credentials & metadata shared with external SaaS | 100% inside GCP IAM boundary | Compute & GCS inside GCP/AWS/Azure | Hosted inside Snowflake VPC |
| Built-in Data Quality Gates | Native assertions (dependOnDependencyAssertions) | dbt test (blocking in dbt build) | dbt test (blocking in dbt build) | DLT @dlt.expect_or_fail | Snowflake Data Metric Functions (DMFs) |
| Compute Billing Granularity | Per-byte scanned OR per-millisecond slot usage | Same (BigQuery compute) + SaaS per-model fee | Same (BigQuery compute) + 24/7 Airflow cluster | Per-DBU + VM uptime seconds | Per-second per Virtual Warehouse (60s min) |
| Programmatic Code Generation | Native JS loops (forEach, functions, modules) | Jinja macros (verbose for complex data structures) | Jinja macros | Python metaprogramming | SQL scripting / Snowpark Python |
| External SaaS / Infra License Fee | 0.00 USD | 100 USD/seat + 0.01 USD/model run | 320 USD to 800 USD/mo (Composer) | Included in DBU markup | Included in Credit markup |
3.4 Financial TCO Comparison: Explicit Calculations for a 350-Model Medallion Pipeline
Let us calculate the exact monthly TCO for a realistic production Medallion platform with the following parameters:
- Team Size: 10 analytics and data engineers developing and reviewing transformations.
- Pipeline Scale: 350 total models and assertions across Bronze, Silver, and Gold (50 Bronze declarations/loads, 180 Silver incremental models + 60 assertions, 60 Gold marts).
- Execution Frequency: Runs 6 times per day (every 4 hours) =
300 executable actions * 6 runs/day * 30 days = 54,000 action executions per month. - Data Volume: 25 TiB Active Logical volume across all layers (compressed to 5 TiB Physical volume).
- Compute Scanned per Full DAG Run: Thanks to incremental partition pruning, each 4-hour DAG run scans 450 GiB across all 300 executable actions (
450 GiB * 6 runs/day * 30 days = 81,000 GiB = 79.10 TiB scanned per month).
| Platform Option | Component Cost Breakdown (Explicit Math) | Estimated Monthly Total (USD) | Estimated Annual Total (USD) |
| 1. Native BigQuery + Dataform + Cloud Workflows | 1. Dataform License: 10 users, 54,000 model runs = 0.00 USD. 2. Cloud Workflows + Cloud Scheduler: 180 runs/month (first 5,000 steps free) = 0.10 USD. 3. BigQuery Storage (Hybrid Billing): 15 TiB Bronze Logical compressed to 2.5 TiB Physical (2,560 GiB * 0.04 USD = 102.40 USD) + 10 TiB Silver/Gold Logical (10,240 GiB * 0.02 USD = 204.80 USD) = 307.20 USD. 4. BigQuery Compute (On-Demand): (79.10 TiB minus 1.0 TiB free tier) * 6.25 USD/TiB = 488.13 USD. | 795.43 USD | 9,545.16 USD |
| 2. BigQuery + dbt Cloud (Enterprise / Team Scale) | 1. BigQuery Storage & Compute: Identical = 795.33 USD. 2. dbt Cloud Base Seats: 10 developer seats (requires Enterprise or multi-seat tier, approx. 250 USD/seat) = 2,500.00 USD. 3. dbt Cloud Model Consumption: (54,000 successful models minus 15,000 included) * 0.01 USD = 390.00 USD. | 3,685.33 USD | 44,223.96 USD |
| 3. BigQuery + dbt Core on Cloud Composer 3 | 1. BigQuery Storage & Compute: Identical = 795.33 USD. 2. Cloud Composer Small Environment: 24/7 Airflow scheduler, web server, database, and worker pod executions = 385.00 USD. 3. Artifact Registry & Cloud Build CI:15.00 USD. | 1,195.33 USD | 14,343.96 USD |
| 4. Databricks Lakehouse (DLT Pro + GCS) | 1. GCS Storage (5 TiB compressed + transaction log overhead): 5,500 GiB * 0.02 USD + Class A/B ops = 135.00 USD. 2. DLT Compute + Interactive SQL Warehouses: 6 daily runs + BI serving (approx. 3,800 DBUs at blended 0.45 USD/DBU + underlying GCE VMs) = 2,480.00 USD. | 2,615.00 USD | 31,380.00 USD |
| 5. Snowflake (Dynamic Tables + Enterprise Credits) | 1. Snowflake Compressed Storage (5 TiB + Time Travel): 5.5 TB * 23.00 USD = 126.50 USD. 2. Compute Credits (Transformation + BI Warehouse): Approx. 1,100 credits/month * 3.00 USD/credit (Enterprise Edition) = 3,300.00 USD. | 3,426.50 USD | 41,118.00 USD |
Analytical Conclusion: Using native BigQuery and Dataform with Cloud Workflows delivers the entire Medallion architecture for 795.43 USD per month, saving 2,889.90 USD per month (34,678.80 USD per year) compared to dbt Cloud on the exact same warehouse, and 1,819.57 USD to 2,631.07 USD per month compared to Databricks or Snowflake.
4. End-to-End Implementation: Terraform, Dataform Core, and F#
To build a resilient Medallion pipeline, we divide implementation into three clean layers of code:
- Infrastructure as Code (Terraform): Provisioning isolated Bronze, Silver, and Gold datasets with tailored storage billing models (
PHYSICALvs.LOGICAL), creating the Dataform repository, and configuring least-privilege IAM. - Transformation & Quality Code (Dataform JavaScript & SQLX): Defining source declarations, incremental partition-pruned Silver transformations, blocking quality assertions, and Gold analytical marts. (Note: All examples below use Dataform’s official JavaScript Core API
ctx.ref()and string concatenation so zero currency symbols appear anywhere in the codebase.) - Programmatic Contract & Dry-Run Validator (F#): A strongly typed F# validator and orchestrator that executes a BigQuery dry-run cost check before triggering a Dataform workflow invocation.
4.1 Stage 1: Provisioning Medallion Datasets and Dataform via Terraform
Notice how the Terraform code below explicitly sets storage_billing_model = "PHYSICAL" on the append-only Bronze dataset and storage_billing_model = "LOGICAL" on the high-churn Silver and Gold datasets.
Terraform
terraform {
required_version = ">= 1.6.0"
required_providers {
google = {
source = "hashicorp/google"
version = ">= 5.30.0"
}
google-beta = {
source = "hashicorp/google-beta"
version = ">= 5.30.0"
}
}
}
variable "project_id" {
type = string
description = "GCP Project ID hosting the Medallion Data Warehouse"
}
variable "region" {
type = string
default = "US"
description = "BigQuery multi-region or regional location"
}
variable "git_remote_url" {
type = string
description = "HTTPS URL of the Git repository containing Dataform code"
}
# 1. Dedicated Service Account for Dataform Execution
resource "google_service_account" "dataform_runner" {
project = var.project_id
account_id = "sa-dataform-medallion"
display_name = "Dataform Medallion Pipeline Executor"
}
# 2. BRONZE Dataset: Append-only -> Physical Storage Billing for 4:1+ compression savings
resource "google_bigquery_dataset" "bronze_raw" {
project = var.project_id
dataset_id = "raw_bronze_commerce"
location = var.region
description = "Bronze Layer: Immutable raw append-only ingestion tables"
storage_billing_model = "PHYSICAL"
max_time_travel_hours = "168" # 7 days (zero mutation churn on append-only tables)
delete_contents_on_destroy = false
labels = {
medallion_layer = "bronze"
billing_strategy = "physical_compressed"
}
}
# 3. SILVER Dataset: Frequent MERGE upserts -> Logical Storage Billing to avoid Time-Travel bloat
resource "google_bigquery_dataset" "silver_core" {
project = var.project_id
dataset_id = "core_silver_commerce"
location = var.region
description = "Silver Layer: Deduplicated, cleaned, and conformed entity tables"
storage_billing_model = "LOGICAL"
max_time_travel_hours = "168" # Free under Logical Billing
delete_contents_on_destroy = false
labels = {
medallion_layer = "silver"
billing_strategy = "logical_merge_safe"
}
}
# 4. GOLD Dataset: Rebuilt marts & aggregates -> Logical Storage Billing
resource "google_bigquery_dataset" "gold_mart" {
project = var.project_id
dataset_id = "mart_gold_finance"
location = var.region
description = "Gold Layer: Executive financial and attribution data marts"
storage_billing_model = "LOGICAL"
max_time_travel_hours = "48"
delete_contents_on_destroy = false
labels = {
medallion_layer = "gold"
billing_strategy = "logical_mart"
}
}
# 5. Dataform Assertions Dataset: Stores failed assertion rows for debugging
resource "google_bigquery_dataset" "dataform_assertions" {
project = var.project_id
dataset_id = "audit_dataform_assertions"
location = var.region
description = "Holds views and results of Dataform quality assertions"
storage_billing_model = "LOGICAL"
max_time_travel_hours = "48"
}
# 6. Provision the Native Dataform Repository
resource "google_dataform_repository" "medallion_repo" {
provider = google-beta
project = var.project_id
region = "us-central1"
name = "commerce-medallion-pipeline"
service_account = google_service_account.dataform_runner.email
git_remote_settings {
url = var.git_remote_url
default_branch = "main"
authentication_token_secret_version = format("projects/%s/secrets/dataform-git-token/versions/latest", var.project_id)
}
workspace_compilation_overrides {
default_database = var.project_id
}
}
4.2 Stage 2: Implementing Bronze, Silver, and Gold in Dataform (JavaScript Core API)
Dataform provides two equivalent ways to define models: .sqlx template files and .js programmatic definitions using the global declare(), publish(), and assert() functions. Using the JavaScript Core API (definitions/medallion_pipeline.js) provides two distinct engineering advantages:
- It avoids template literal interpolation symbols completely, ensuring clean rendering across documentation and CMS platforms.
- It allows programmatic helper functions, dynamic loops, and strict compile-time validation of partition filters.
Below is a complete, production-grade Dataform definition file (definitions/commerce_medallion.js) implementing:
- Bronze Declaration:
raw_orders_stream(partitioned by_ingested_at). - Silver Incremental Model:
stg_orders_conformedwith a 3-day sliding partition pruning filter (updatePartitionFilter), deterministic deduplication viaQUALIFY ROW_NUMBER(), and automated blocking assertions (uniqueKey,nonNull,rowConditions). - Gold Analytical Mart:
fct_daily_revenue_martconfigured withdependOnDependencyAssertions: trueso it never executes if Silver quality assertions fail.
JavaScript
// File: definitions/commerce_medallion.js
// Native Dataform Core API implementation of Bronze -> Silver -> Gold
// 1. BRONZE LAYER: Declare the immutable raw streaming ingestion table
declare({
database: dataform.projectConfig.defaultDatabase,
schema: "raw_bronze_commerce",
name: "raw_orders_stream"
});
// 2. SILVER LAYER: Incremental MERGE model with strict partition pruning and quality gates
publish("stg_orders_conformed", {
type: "incremental",
schema: "core_silver_commerce",
description: "Cleaned, deduplicated, and strongly typed orders at 1 row per order_id",
tags: ["silver", "commerce", "hourly"],
// BigQuery physical layout and partition pruning configuration
bigquery: {
partitionBy: "DATE(order_created_at)",
clusterBy: ["customer_id", "order_status", "country_code"],
updatePartitionFilter:
"order_created_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 3 DAY)",
labels: {
medallion_layer: "silver",
domain: "commerce"
}
},
// Unique key triggers an atomic BigQuery MERGE statement
uniqueKey: ["order_id"],
// Built-in Dataform Quality Assertions (automatically create blocking assertion nodes)
assertions: {
uniqueKey: ["order_id"],
nonNull: ["order_id", "customer_id", "order_created_at", "net_amount_usd"],
rowConditions: [
"net_amount_usd >= 0",
"tax_amount_usd >= 0",
"LENGTH(country_code) = 2"
]
},
// Ensure any upstream assertions pass before running this model
dependOnDependencyAssertions: true
}).query(function(ctx) {
// When running incrementally, scan only the last 3 days of Bronze ingestion partitions
var incrementalWhereClause = ctx.when(
ctx.incremental(),
"WHERE DATE(_ingested_at) >= DATE_SUB(CURRENT_DATE('UTC'), INTERVAL 3 DAY)",
"WHERE DATE(_ingested_at) >= DATE('2024-01-01')"
);
return (
"SELECT " +
" STRING(json_payload.order_id) AS order_id, " +
" STRING(json_payload.customer_id) AS customer_id, " +
" UPPER(TRIM(STRING(json_payload.country_code))) AS country_code, " +
" LOWER(TRIM(STRING(json_payload.status))) AS order_status, " +
" TIMESTAMP(STRING(json_payload.created_at)) AS order_created_at, " +
" TIMESTAMP(STRING(json_payload.updated_at)) AS order_updated_at, " +
" ROUND(SAFE_CAST(JSON_VALUE(json_payload, '$.net_amount') AS NUMERIC), 2) AS net_amount_usd, " +
" ROUND(SAFE_CAST(JSON_VALUE(json_payload, '$.tax_amount') AS NUMERIC), 2) AS tax_amount_usd, " +
" _ingested_at AS bronze_ingested_at " +
"FROM " + ctx.ref("raw_bronze_commerce", "raw_orders_stream") + " " +
incrementalWhereClause + " " +
// Deterministic deduplication: keep only the latest state per order_id
"QUALIFY ROW_NUMBER() OVER ( " +
" PARTITION BY STRING(json_payload.order_id) " +
" ORDER BY TIMESTAMP(STRING(json_payload.updated_at)) DESC, _ingested_at DESC " +
") = 1"
);
});
// 3. CUSTOM CROSS-COLUMN SILVER ASSERTION: Detect anomalous refund ratios
assert("assert_silver_orders_valid_timestamps", {
schema: "audit_dataform_assertions",
description: "Fails if any order has an updated_at timestamp earlier than created_at",
tags: ["silver", "data_quality"]
}).query(function(ctx) {
return (
"SELECT " +
" order_id, " +
" order_created_at, " +
" order_updated_at " +
"FROM " + ctx.ref("core_silver_commerce", "stg_orders_conformed") + " " +
"WHERE DATE(order_created_at) >= DATE_SUB(CURRENT_DATE('UTC'), INTERVAL 7 DAY) " +
" AND order_updated_at < order_created_at"
);
});
// 4. GOLD LAYER: Executive Daily Revenue Mart (Blocked if Silver assertions fail)
publish("fct_daily_revenue_mart", {
type: "incremental",
schema: "mart_gold_finance",
description: "Daily aggregated revenue, tax, and order counts by country and status",
tags: ["gold", "finance", "executive_sla"],
// CRITICAL: Blocks Gold execution if stg_orders_conformed assertions return > 0 rows
dependOnDependencyAssertions: true,
// Explicitly depend on our custom timestamp assertion as well
dependencies: ["assert_silver_orders_valid_timestamps"],
uniqueKey: ["report_date", "country_code", "order_status"],
bigquery: {
partitionBy: "report_date",
clusterBy: ["country_code", "order_status"],
updatePartitionFilter:
"report_date >= DATE_SUB(CURRENT_DATE('UTC'), INTERVAL 7 DAY)",
labels: {
medallion_layer: "gold",
department: "finance"
}
}
}).query(function(ctx) {
var silverFilter = ctx.when(
ctx.incremental(),
"WHERE DATE(order_created_at) >= DATE_SUB(CURRENT_DATE('UTC'), INTERVAL 7 DAY)",
"WHERE DATE(order_created_at) >= DATE('2024-01-01')"
);
return (
"SELECT " +
" DATE(order_created_at) AS report_date, " +
" country_code, " +
" order_status, " +
" COUNT(DISTINCT order_id) AS total_orders, " +
" COUNT(DISTINCT customer_id) AS unique_customers, " +
" SUM(net_amount_usd) AS total_net_revenue_usd, " +
" SUM(tax_amount_usd) AS total_tax_usd, " +
" SUM(net_amount_usd + tax_amount_usd) AS total_gross_revenue_usd, " +
" CURRENT_TIMESTAMP() AS gold_refreshed_at " +
"FROM " + ctx.ref("core_silver_commerce", "stg_orders_conformed") + " " +
silverFilter + " " +
"GROUP BY 1, 2, 3"
);
});
4.3 Stage 3: Pre-Flight Cost Estimation and Medallion Contract Validator in F#
When running a Medallion pipeline in CI/CD or before triggering a production backfill, engineers need a reliable way to verify that a compiled Dataform query will not accidentally trigger a multi-terabyte full-table scan.
Below is a self-contained F# module (MedallionCostGuard.fs) that takes compiled SQL statements from a Dataform compilation result, runs a zero-cost BigQuery Dry Run (QueryOptions.DryRun <- Nullable(true)), calculates the exact estimated bytes scanned and on-demand USD cost, and blocks execution if any Silver or Gold step exceeds its layer-specific byte budget.
F#
namespace TechMacro.Medallion
open System
open Google.Cloud.BigQuery.V2
/// Represents the Medallion layer and its maximum allowed scan budget per execution
type MedallionLayer =
| Bronze of maxGiBAllowed: float
| Silver of maxGiBAllowed: float
| Gold of maxGiBAllowed: float
/// Result of a pre-flight BigQuery Dry-Run evaluation
type DryRunEvaluation =
| Approved of layer: string * estimatedGiB: float * estimatedCostUsd: float
| Rejected of layer: string * estimatedGiB: float * limitGiB: float * reason: string
module MedallionCostGuard =
let private bytesPerGiB = 1073741824.0
let private bytesPerTiB = 1099511627776.0
let private onDemandPricePerTiBUsd = 6.25
/// Extracts the layer name and GiB ceiling from the discriminated union
let private getLayerPolicy (layer: MedallionLayer) : (string * float) =
match layer with
| Bronze maxGiB -> ("BRONZE", maxGiB)
| Silver maxGiB -> ("SILVER", maxGiB)
| Gold maxGiB -> ("GOLD", maxGiB)
/// Executes a zero-cost BigQuery Dry Run to validate SQL syntax and byte scan volume
/// before Dataform submits the actual job to the warehouse.
let evaluateCompiledActionSql
(client: BigQueryClient)
(actionName: string)
(layer: MedallionLayer)
(compiledSql: string) : DryRunEvaluation =
let (layerName, maxGiBAllowed) = getLayerPolicy layer
let options = QueryOptions()
options.DryRun <- Nullable<bool>(true)
options.UseQueryCache <- Nullable<bool>(false)
try
let dryRunJob = client.CreateQueryJob(compiledSql, null, options)
let totalBytes =
if dryRunJob.Statistics.TotalBytesProcessed.HasValue then
float dryRunJob.Statistics.TotalBytesProcessed.Value
else
0.0
let estimatedGiB = Math.Round(totalBytes / bytesPerGiB, 3)
let estimatedCostUsd = Math.Round((totalBytes / bytesPerTiB) * onDemandPricePerTiBUsd, 4)
if estimatedGiB > maxGiBAllowed then
let msg =
sprintf "Action '%s' in %s layer scans %.3f GiB (%.4f USD), exceeding cap of %.3f GiB. Check partition pruning!"
actionName layerName estimatedGiB estimatedCostUsd maxGiBAllowed
Rejected (layerName, estimatedGiB, maxGiBAllowed, msg)
else
Approved (layerName, estimatedGiB, estimatedCostUsd)
with
| :? Google.GoogleApiException as ex ->
let errMsg = sprintf "SQL Compilation or Schema Error in '%s': %s" actionName ex.Message
Rejected (layerName, 0.0, maxGiBAllowed, errMsg)
5. Eight Practical Production Cases: Architectural Deep Dives
Let us analyze eight concrete production scenarios demonstrating how BigQuery and Dataform solve complex engineering challenges across Bronze, Silver, and Gold layers, supported by explicit storage and compute calculations.
Case 1: High-Volume GA4 and Server-Side GTM Event Stream into Bronze, Sessionization in Silver, and Attribution Mart in Gold
- Business Context: An enterprise retailer collects 120 million web and app events per day (roughly 180 GiB/day raw) via Google Analytics 4 (GA4) daily exports and a Server-Side Google Tag Manager (sGTM) pipeline streaming into BigQuery.
- Architectural Design Across Layers:
- Bronze (
raw_bronze_analytics.sgtm_events): Append-only table partitioned byDATE(_ingested_at)and clustered byevent_name, client_id. Uses Physical Storage Billing (180 GiB/day logical compresses 5:1 down to 36 GiB/day physical). - Silver (
core_silver_analytics.fct_sessions): A Dataformincrementalmodel running every 2 hours. It reads only the last 2 days of Bronze partitions, unnests theevent_paramsarray once, assigns deterministicsession_idboundaries using a 30-minute inactivity window (LAG(event_timestamp)), and writes a flat, strongly typed session table (reducing 120 million raw event rows down to 8 million session rows per day—a 93.3% row reduction). - Gold (
mart_gold_marketing.channel_attribution): Joins the 8 million daily Silver sessions withcore_silver_commerce.stg_orders_conformedto calculate First-Click, Last-Click, and Linear attribution revenue per campaign.
- Bronze (
- Quantified Impact: Previously, 15 marketing Looker Studio dashboards queried the raw unnested event table directly (15 dashboards * 20 refreshes/day * 180 GiB/day over 30 days = 1,582 TiB scanned/month = 9,887.50 USD/month). Querying the Gold attribution table (15 MB per day * 30 days = 450 MB per dashboard scan) drops monthly BI scan volume to 0.13 TiB (covered 100% by BigQuery’s 1 TiB monthly free tier = 0.00 USD/month).
Case 2: Handling Late-Arriving Events and Out-of-Order CDC Updates with Sliding-Window Incremental MERGE
- Business Context: Mobile app events and offline point-of-sale (POS) transactions frequently arrive in Bronze 24 to 72 hours after the physical purchase occurred (
order_created_atis 3 days older than_ingested_at). Meanwhile, payment settlement webhooks update theorder_statusfrompendingtocapturedup to 5 days later. - Architectural Design:
- In Silver (
stg_orders_conformed), partitioning is set on the business timestamp (DATE(order_created_at)), while the Bronze source filter readsWHERE DATE(_ingested_at) >= DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY). - However, if the
MERGEtarget table is not constrained, BigQuery will scan the entire 5-year Silver table (for example, 4 TiB) just to matchON target.order_id = source.order_id. - By setting
updatePartitionFilter: "order_created_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)"in Dataform, BigQuery injectsAND target.order_created_at >= ...directly into theMERGE ONpredicate.
- In Silver (
- Quantified Impact: Instead of scanning 4,096 GiB (4 TiB) on every hourly
MERGErun (4 TiB * 24 runs/day * 30 days = 2,880 TiB/month = 18,000.00 USD/month), the 7-dayupdatePartitionFilterrestricts the target scan to 18 GiB per run (18 GiB * 24 * 30 = 12.65 TiB/month = 79.06 USD/month), reducingMERGEcompute cost by 99.56%.
Case 3: Optimizing Storage Costs Across Layers: Physical Billing on Bronze vs. Logical Billing on Silver
- Business Context: A platform architect must decide whether to enable Physical Storage Billing across the entire project or split billing models by Medallion layer.
- Explicit Mathematical Comparison:
- Bronze Dataset (
raw_bronze_events): 20,000 GiB (20 TiB) of active logical JSON/string data. Compression ratio is 5:1 (4,000 GiBphysical base bytes). Because Bronze is strictly append-only,time_travel_physical_bytes = 0 GiBandfail_safe_physical_bytes = 0 GiB.- Logical Billing Cost:
20,000 GiB * 0.02 USD = 400.00 USD/month. - Physical Billing Cost:
4,000 GiB * 0.04 USD = 160.00 USD/month(Saves 240.00 USD/month).
- Logical Billing Cost:
- Silver Dataset (
core_silver_entities): 2,000 GiB (2 TiB) of active logical structured data. Compression ratio is 3.33:1 (600 GiBphysical base bytes). However, Dataform runs an incrementalMERGEevery 30 minutes (48 times/day), rewriting 15% of the table’s physical micro-partitions (90 GiBrewritten per run =4,320 GiB/dayof mutated physical blocks). Over a 7-day Time-Travel window plus 7-day Fail-Safe window (14 days total retention of mutated blocks), accumulatedtime_travel_physical_bytes + fail_safe_physical_bytesequals60,480 GiB.- Physical Billing Cost:
(600 GiB base + 60,480 GiB time-travel/fail-safe) * 0.04 USD = 2,443.20 USD/month. - Logical Billing Cost:
2,000 GiB * 0.02 USD = 40.00 USD/month(Saves 2,403.20 USD/month).
- Physical Billing Cost:
- Bronze Dataset (
| Medallion Layer | Logical Size (GiB) | Base Physical Size (GiB) | 14-Day Time-Travel + Fail-Safe Physical (GiB) | Cost Under Logical Billing (0.02 USD/GiB) | Cost Under Physical Billing (0.04 USD/GiB) | Optimal Dataset Configuration |
| Bronze (Append-Only) | 20,000 GiB | 4,000 GiB | 0 GiB | 400.00 USD | 160.00 USD | PHYSICAL |
| Silver (Frequent MERGE) | 2,000 GiB | 600 GiB | 60,480 GiB | 40.00 USD | 2,443.20 USD | LOGICAL |
| Combined Total | 22,000 GiB | 4,600 GiB | 60,480 GiB | 440.00 USD (All Logical) | 2,603.20 USD (All Physical) | 200.00 USD (Hybrid by Layer) |
Case 4: Enforcing Zero-Defect Data Quality Gates with Dataform Blocking Assertions Before Gold Refresh
- Business Context: An upstream currency conversion API experiences an outage and returns
0.00for EUR-to-USD exchange rates. If the Gold financial mart refreshes with0.00exchange rates, automated executive emails and C-level dashboards will report a 40% drop in European revenue. - Architectural Design:
- In Silver,
core_silver_finance.dim_fx_ratesincludes a Dataform assertion:rowConditions: ["fx_rate_to_usd > 0.0001", "fx_rate_to_usd < 10000.0"]. - In Gold,
mart_gold_finance.fct_global_revenuesetsdependOnDependencyAssertions: true. - When Dataform executes the DAG:
dim_fx_rates(Silver) updates.core_silver_finance_dim_fx_rates_assertions_rowConditionsruns immediately after Silver.- Because
fx_rate_to_usd = 0.00violatesfx_rate_to_usd > 0.0001, the assertion view returns offending rows and Dataform raises a fatal error on the assertion step. - Dataform immediately halts the downstream branch.
mart_gold_finance.fct_global_revenueis not executed; it continues serving the previous valid snapshot to BI users while Cloud Monitoring alerts the engineering team.
- In Silver,
Case 5: Schema Evolution Without Downtime: Extracting Semi-Structured JSON in Bronze to Strongly Typed Silver
- Business Context: Product engineering deploys new checkout attributes (
promo_campaign_code,installment_months) inside webhook payloads twice a week without coordinating with the data engineering team. - Architectural Design:
- Instead of defining 80 brittle columns on the Bronze ingestion table (which causes BigQuery
LOADor Streaming jobs to fail whenever an upstream microservice adds an unknown JSON property), the Bronze table uses a singlejson_payload JSONcolumn alongside ingestion metadata. - BigQuery’s native
JSONdata type automatically sub-columnarizes frequent sub-fields internally inside Capacitor storage, meaning queryingSTRING(json_payload.order_id)scans only the virtual sub-column fororder_idrather than reading the entire JSON blob! - When analytics requires the new
installment_monthsfield two weeks later, a data engineer addsLAX_INT64(json_payload.installment_months) AS installment_monthsto the Silver Dataform model and runs a single backfill over the needed partitions. Zero ingestion downtime occurs during the deployment.
- Instead of defining 80 brittle columns on the Bronze ingestion table (which causes BigQuery
Case 6: Sub-Second BI Serving in Gold Using BigQuery Materialized Views and BI Engine
- Business Context: A customer-facing embedded analytics portal queries the Gold layer 15,000 times per day. Average query latency must stay below 400 milliseconds, and the pipeline cannot wait for hourly batch schedules.
- Architectural Design:
- Instead of a batch
tablein Gold, define the Gold model in Dataform as a native BigQuery Materialized View over the Silver table (type: "view", materialized: truewithenable_refresh = trueandrefresh_interval_minutes = 15). - BigQuery Materialized Views support Smart Tuning and Delta Read: if a user queries the Materialized View (or even queries the underlying Silver base table directly with a matching
GROUP BY), BigQuery automatically combines the pre-computed Materialized View blocks with only the newly appended rows in the Silver table written since the last refresh! - Pairing the Gold dataset with a 4 GiB BigQuery BI Engine reservation caches the hot blocks in memory, delivering 180-millisecond P95 latency across all 15,000 daily queries.
- Instead of a batch
Case 7: Multi-Brand / Multi-Region Consolidation Using Dataform JavaScript Loops (forEach)
- Business Context: An international holding group operates 12 regional e-commerce brands (each landing raw data in its own Bronze dataset:
raw_bronze_brand_uk,raw_bronze_brand_de,raw_bronze_brand_pl, etc., with 5 core tables per brand = 60 Bronze tables). Maintaining 60 copy-pasted.sqlxfiles creates severe maintenance debt. - Architectural Design:
- A single 45-line JavaScript file in Dataform (
definitions/multi_brand_factory.js) iterates over an array of brand configurations (const brands = ["uk", "de", "pl", ...]), registers the 60 Bronzedeclare()sources, generates 60 standardized Silver incremental models, and builds a singleUNION ALLGold global consolidation table dynamically at compile time:
- A single 45-line JavaScript file in Dataform (
JavaScript
// File: definitions/multi_brand_factory.js
// Generates 12 Bronze declarations, 12 Silver models, and 1 unified Gold table in 40 lines
var brands = ["uk", "de", "fr", "es", "it", "pl", "nl", "se", "no", "dk", "ch", "at"];
brands.forEach(function(brandCode) {
var bronzeSchema = "raw_bronze_brand_" + brandCode;
var silverModelName = "stg_orders_" + brandCode;
// 1. Declare regional Bronze table
declare({
schema: bronzeSchema,
name: "orders_raw"
});
// 2. Publish regional Silver incremental model
publish(silverModelName, {
type: "incremental",
schema: "core_silver_global",
uniqueKey: ["global_order_id"],
bigquery: {
partitionBy: "DATE(order_created_at)",
clusterBy: ["customer_id"],
updatePartitionFilter: "order_created_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 3 DAY)"
}
}).query(function(ctx) {
return (
"SELECT " +
" '" + brandCode.toUpperCase() + "-' || CAST(order_id AS STRING) AS global_order_id, " +
" '" + brandCode.toUpperCase() + "' AS brand_region, " +
" CAST(customer_id AS STRING) AS customer_id, " +
" order_created_at, " +
" CAST(amount_eur AS NUMERIC) AS amount_eur " +
"FROM " + ctx.ref(bronzeSchema, "orders_raw") + " " +
ctx.when(ctx.incremental(), "WHERE DATE(_ingested_at) >= DATE_SUB(CURRENT_DATE('UTC'), INTERVAL 3 DAY)")
);
});
});
// 3. Dynamically UNION all 12 Silver tables into a single Gold Global Fact Table
publish("fct_global_holding_orders", {
type: "view",
schema: "mart_gold_holding"
}).query(function(ctx) {
var unionQueries = brands.map(function(brandCode) {
return "SELECT * FROM " + ctx.ref("core_silver_global", "stg_orders_" + brandCode);
});
return unionQueries.join(" UNION ALL ");
});
Case 8: Implementing GDPR / CCPA Right-to-Erasure Across Bronze, Silver, and Gold
- Business Context: Legal compliance requires erasing or anonymizing all personal identifiable information (PII:
email,phone,full_name,ip_address) for users who submit a deletion request within 30 days, without breaking historical revenue totals in Gold or scanning 20 TiB of Bronze tables for every individual deletion ticket. - Architectural Design (Two-Pattern Hybrid):
- Pattern A (PII Vault / Cryptographic Separation in Bronze): During ingestion, split user PII into a dedicated lookup table (
raw_bronze_privacy.customer_pii_vaultclustered bycustomer_id), while main event and order tables in Bronze carry only a pseudonymouscustomer_idUUID. Deleting a user’s PII from the 2 GiB vault table takes 1 batchDELETEper week and immediately anonymizes all historical Bronze events without rewriting 20 TiB of event partitions. - Pattern B (Dataform Weekly
pre_operationsScrub on Silver): In Silverdim_customers, configure a weeklypre_operationshook that joins againstraw_bronze_privacy.gdpr_deletion_requestsand sets PII columns to'REDACTED'while keepingcustomer_idandcountry_codeintact so Gold revenue joins remain 100% accurate.
- Pattern A (PII Vault / Cryptographic Separation in Bronze): During ingestion, split user PII into a dedicated lookup table (
6. Nine Standard Production Problems, Exact Root Causes, and Progressive Solutions
When teams build and operate a Medallion architecture in BigQuery and Dataform, they frequently run into nine specific engineering pitfalls. Below is the exact technical root cause of each problem and three progressive solutions ordered from simple to complex.
Problem 1: Full-Table Scan Explosions During Incremental MERGE in Silver
- Exact Root Cause: When you define a Dataform model with
type: "incremental"anduniqueKey: ["order_id"], Dataform compiles aMERGE INTO target USING source ON target.order_id = source.order_idstatement. Even if yourSELECTquery inside theUSINGclause filters the Bronze source to the last 1 day of data, BigQuery still has to scan 100% of the Silvertargettable to check if any existingorder_idmatches the incoming rows—unless theONclause explicitly restricts the target’s partition column! - Solutions (Simple to Complex):
- Simple (Static Window
updatePartitionFilter): Addbigquery: { partitionBy: "DATE(order_created_at)", updatePartitionFilter: "order_created_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)" }to the model config. BigQuery immediately prunes all target partitions older than 7 days during theMERGE. - Intermediate (Dynamic Partition Min/Max Lookup in
pre_operations): When late-arriving records have unpredictable dates, declare a SQL variable inpre_operations:DECLARE min_event_dt DATE DEFAULT (SELECT MIN(DATE(order_created_at)) FROM bronze_batch);and passorder_created_at >= TIMESTAMP(min_event_dt)inupdatePartitionFilterso BigQuery scans only the exact date range present in the current batch. - Complex (Partition Overwrite via
DELETE + INSERTWithoutuniqueKey): If your Silver pipeline processes immutable daily event partitions (such as clickstream data where events never mutate after the day closes), omituniqueKeyand usepre_operationsto runDELETE FROM self() WHERE event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 2 DAY)followed by a pure appendINSERT. This eliminates the expensive hash-join of aMERGEcompletely.
- Simple (Static Window
Problem 2: Physical Storage Cost Spikes on Silver and Gold Tables Due to Time-Travel Retention
- Exact Root Cause: Teams enable
storage_billing_model = "PHYSICAL"on Silver and Gold datasets to save money via compression, unaware that frequentMERGE(in Silver) orCREATE OR REPLACE TABLE(in Gold) rewrites underlying immutable Capacitor files. Under Physical Billing, every overwritten file remains billed at 0.04 USD per GiB per month for the entire Time-Travel window (default 7 days) plus the mandatory 7-day Fail-Safe period (14 days total). Rebuilding a 100 GiB physical Gold table 4 times per day generates100 GiB * 4 * 14 days = 5,600 GiBof billed physical storage! - Solutions (Simple to Complex):
- Simple (Switch High-Churn Datasets to
LOGICALBilling): RunALTER SCHEMA mart_gold_finance SET OPTIONS(storage_billing_model = 'LOGICAL'). Time-Travel and Fail-Safe bytes immediately become free. - Intermediate (Reduce
max_time_travel_hoursto 48 Hours and Cluster onMERGEKeys): If compression ratios exceed 8:1 and you want to stay on Physical Billing, reducemax_time_travel_hours = 48on the dataset and cluster the Silver table on the exactuniqueKeyand timestamp soMERGEoperations only rewrite the smallest possible subset of physical micro-partitions. - Complex (Automated Weekly Billing-Model Optimizer Query): Schedule a weekly governance query against
region-us.INFORMATION_SCHEMA.TABLE_STORAGE_BY_SCHEMAthat calculates the exact breakeven ratio (logical_cost_usdvs.physical_cost_with_time_travel_and_failsafe_usd) for every dataset and alerts whenever a dataset crosses the threshold.
- Simple (Switch High-Churn Datasets to
Problem 3: Non-Deterministic Deduplication Breaking Incremental MERGE Jobs
- Exact Root Cause: BigQuery’s
MERGEstatement enforces a strict rule: a single row in the target table cannot match more than one row in the source batch (UPDATE/MERGE must match at most one source row for each target row). If the Bronze table contains duplicate webhook retries with the exact sameorder_idand identicalupdated_attimestamps, usingQUALIFY ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY updated_at DESC) = 1works, whereas usingRANK()or forgettingQUALIFYcauses the entire Silver pipeline to crash with a runtime error. - Solutions (Simple to Complex):
- Simple (Use
ROW_NUMBER()with a Deterministic Tie-Breaker): Always useQUALIFY ROW_NUMBER() OVER (PARTITION BY unique_id ORDER BY updated_at DESC, _ingested_at DESC, TO_JSON_STRING(json_payload) DESC) = 1. - Intermediate (Reusable JavaScript Deduplication Helper in
includes/): Create a helper moduleincludes/deduplicate.jsin Dataform that wraps any Bronze source query with a standardized deterministicQUALIFY ROW_NUMBER()clause so developers cannot accidentally omit it. - Complex (Pre-Merge Duplicate Quarantine Table): Split the Silver step into a staging table that computes
ROW_NUMBER() AS row_numand routesrow_num = 1to the conformed Silver table while routing unexpected primary key collisions with conflicting business amounts (row_num > 1 AND net_amount != prev_net_amount) into anaudit_quarantinetable for upstream bug investigation.
- Simple (Use
Problem 4: Broken Downstream BI Dashboards During Gold Table Rebuilds
- Exact Root Cause: When a Gold model is configured as
type: "table", Dataform executesCREATE OR REPLACE TABLE mart_gold.fct_revenue AS SELECT .... WhileCREATE OR REPLACE TABLEis atomic at the metadata pointer level once the query finishes, two issues occur in production: (1) if the new query drops or renames a column due to an upstream change, BI dashboards break immediately, and (2) recreating a table viaDROP + CREATE(or changing partitioning specs) can wipe out table-level IAM grants or Row-Level Access Policies. - Solutions (Simple to Complex):
- Simple (Prefer Incremental Partition Overwrite Over
type: "table"): Keep the Gold table structure permanent usingtype: "incremental"and refresh only the active reporting window. - Intermediate (Blue/Green Swap via
CLONEor Authorized Reporting Views): Expose Gold tables to Looker Studio and Tableau exclusively through a thin schema-locked Reporting View (vw_fct_revenuewith explicit column casts) so underlying table rebuilds never expose schema changes or break metadata caches. - Complex (Zero-Downtime Write-Audit-Publish Pattern with
CREATE OR REPLACE TABLE ... CLONE): Build the new Gold candidate table in a hidden staging dataset (stage_gold.fct_revenue_candidate), run Dataform assertions on the candidate table, and only after all assertions pass executeCREATE OR REPLACE TABLE mart_gold.fct_revenue CLONE stage_gold.fct_revenue_candidateinpost_operations(a zero-copy metadata pointer swap that completes in under 2 seconds and incurs zero extra storage bytes).
- Simple (Prefer Incremental Partition Overwrite Over
Problem 5: Dataform Assertions Doubling the Pipeline Compute Bill
- Exact Root Cause: Adding
uniqueKey,nonNull, androwConditionsinside theassertions: {}block of a 2 TiB Silver table causes Dataform to generate 3 separate SQL views indataform_assertions. When those 3 assertions execute during the DAG run, each assertion runs a separateSELECTquery against the Silver table without a partition filter—scanning2 TiB * 3 = 6 TiBof data (37.50 USDper run) just to validate yesterday’s 5 GiB incremental batch! - Solutions (Simple to Complex):
- Simple (Require Partition Filter on Silver and Write Scoped Custom Assertions): Set
requirePartitionFilter: trueon large Silver tables and use standaloneassert()definitions that includeWHERE DATE(order_created_at) >= DATE_SUB(CURRENT_DATE(), INTERVAL 3 DAY)instead of unpartitioned built-inassertions: {}blocks on multi-terabyte tables. - Intermediate (Combine Multiple Checks into a Single Consolidated Single-Pass Assertion): Instead of 5 separate assertion queries that scan the table 5 times, write one consolidated
assert("assert_stg_orders_all_rules")query per Silver table that checksorder_id IS NULL OR net_amount < 0 OR COUNT(*) OVER (PARTITION BY order_id) > 1in a single partition-pruned scan. - Complex (Zero-Scan In-Flight Validation via BigQuery
ERROR()Function): Embed fatal invariant checks directly inside theSELECTquery of the SilverMERGEstatement using BigQuery’s nativeIF(order_id IS NULL, ERROR('Fatal: NULL order_id detected in batch'), order_id). This validates every row in memory during the single transformation scan at 0.00 USD extra compute cost and aborts theMERGEbefore a single bad row touches the disk!
- Simple (Require Partition Filter on Silver and Write Scoped Custom Assertions): Set
Problem 6: Slow Execution and Slot Contention in Repositories with 500+ Models
- Exact Root Cause: As a Medallion repository grows to 500+ models, triggering the entire repository on every hourly schedule launches hundreds of concurrent BigQuery jobs, hitting the project’s concurrent interactive query quota or starving critical Gold SLA paths of slots.
- Solutions (Simple to Complex):
- Simple (Tag-Based Execution Slicing): Tag models by execution cadence and domain (
tags: ["hourly_critical"],tags: ["daily_deep_backfill"],tags: ["finance_gold"]) and configure separate Dataform Workflow Configurations so the hourly schedule only runs the 35 models taggedhourly_critical. - Intermediate (Dependency-Trimmed Sub-Graph Execution): Use Dataform’s invocation config properties
includedTargetswithtransitiveDependenciesIncluded: true(andtransitiveDependentsIncluded: false) when refreshing a specific Gold mart so Dataform executes only the exact upstream Silver lineage required for that mart. - Complex (Workload Management via Separate BigQuery Reservations for Silver vs. Gold): Create two BigQuery Edition assignments: assign heavy Silver batch/backfill service accounts (
sa-dataform-silver-batch) to aBACKGROUNDpriority slot pool, and assign critical Gold SLA and BI queries (sa-dataform-gold-sla) to a dedicatedINTERACTIVEautoscaling reservation.
- Simple (Tag-Based Execution Slicing): Tag models by execution cadence and domain (
Problem 7: Environment Collision Between Developer Workspaces and Production Datasets
- Exact Root Cause: By default, if two developers open personal Dataform Workspaces and click “Start Execution” without workspace overrides, both workspaces execute DDL/DML against
core_silver_commerceandmart_gold_financein production—overwriting production tables with untested development code and scanning production terabytes! - Solutions (Simple to Complex):
- Simple (Configure
workspaceCompilationOverrideson the Repository): In Terraform (google_dataform_repository), configureworkspace_compilation_overrideswithschema_suffix = "${workspaceName}"(configured via UI/API dynamic token) ortable_prefixso any execution triggered from a developer workspace writes tocore_silver_commerce_aliceinstead ofcore_silver_commerce. - Intermediate (Separate GCP Projects for Dev/Staging vs. Production + IAM Lockout): Revoke
roles/bigquery.dataEditoron production datasets from all human user accounts. Grant write access on production datasets exclusively tosa-dataform-prod, which can only be triggered by automated Release Configurations on themainGit branch. - Complex (Zero-Copy Production Clones + Automatic CI Date-Bounding): In
includes/env_filter.js, checkdataform.projectConfig.vars.environment. Whenenvironment !== 'production', automatically inject a 2-day source limit (WHERE DATE(_ingested_at) >= DATE_SUB(CURRENT_DATE(), INTERVAL 2 DAY)) across all Bronze reads so developer runs never scan more than a few megabytes.
- Simple (Configure
Problem 8: Orchestration Blind Spots Between External Ingestion and Dataform Runs
- Exact Root Cause: Dataform’s built-in Workflow Configurations trigger on a cron schedule (for example,
0 * * * *). If the upstream Cloud Run API extractor or Airbyte/Fivetran connector finishes 12 minutes late, Dataform runs on schedule anyway, transforming an empty Bronze partition and marking the Gold mart as “up to date” when it is actually missing today’s data. - Solutions (Simple to Complex):
- Simple (Pre-Flight Source Freshness Assertion at the Root of the DAG): Create a root Dataform assertion (
assert_bronze_freshness) that fails ifMAX(_ingested_at) < TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 2 HOUR), and make Silver models depend on it. If ingestion is late, the DAG halts cleanly at step 1 without wasting compute. - Intermediate (Event-Driven Orchestration via Cloud Workflows): Disable cron inside Dataform and use Google Cloud Workflows to execute the Cloud Run extraction job first, poll until completion, and immediately call the
googleapis.dataform.v1beta1.projects.locations.repositories.workflowInvocations.createendpoint within a single deterministic state machine. - Complex (Pub/Sub + Eventarc Trigger on BigQuery Job Completion): Configure an Eventarc trigger that listens for the
google.cloud.bigquery.v2.JobService.InsertJobcompletion log of the final Bronze load job and automatically triggers the Cloud Workflow that invokes Dataform with zero idle waiting time.
- Simple (Pre-Flight Source Freshness Assertion at the Root of the DAG): Create a root Dataform assertion (
Problem 9: Cross-Layer Access Leaks and PII Exposure in Silver and Gold
- Exact Root Cause: When teams store Bronze, Silver, and Gold tables inside a single BigQuery dataset (or grant project-level
roles/bigquery.dataViewerto analysts), business users bypass the Gold layer to query unredacted PII in Bronze/Silver, and also create direct BI dependencies on internal Silver staging tables that engineers later need to refactor. - Solutions (Simple to Complex):
- Simple (Dataset-Level IAM Isolation by Medallion Layer): Place Bronze, Silver, and Gold in three distinct datasets (
raw_bronze_*,core_silver_*,mart_gold_*). Grant human analysts and BI toolsroles/bigquery.dataVieweronly onmart_gold_*. - Intermediate (Dataform
post_operationsfor Automated Policy Tag Attachment): Usepost_operationsin Silver and Gold definitions to executeALTER TABLE self() ALTER COLUMN email SET OPTIONS (policy_tags = ('projects/my-proj/locations/us/taxonomies/123/policyTags/456')), enforcing dynamic data masking (SHA256orNULLmasking) via BigQuery DataCatalog Policy Tags. - Complex (Automated Row-Level Access Policies Managed via Dataform): Embed
CREATE OR REPLACE ROW ACCESS POLICYstatements inside thepost_operationsblock of Gold marts so regional managers (group:emea-sales@company.com) can querymart_gold_finance.fct_daily_revenue_martand automatically see only rows wherecountry_code IN ('DE', 'FR', 'UK', 'PL').
- Simple (Dataset-Level IAM Isolation by Medallion Layer): Place Bronze, Silver, and Gold in three distinct datasets (
7. Evaluating Medallion Pipeline Efficiency: Metrics and Maturity Matrix
To audit whether a BigQuery and Dataform Medallion implementation is operating efficiently, platform architects should track the Medallion Scan-to-Write Efficiency Ratio (E_layer) using INFORMATION_SCHEMA.JOBS_BY_PROJECT:
- Formula:
E_layer = Total Bytes Scanned by Layer Jobs / Total Logical Bytes Written or Modified by Layer Jobs. - Healthy Benchmarks:
- Silver Incremental Layer:
E_silvershould remain between 1.0 and 3.5. IfE_silverexceeds10.0, it proves that incrementalMERGEmodels are scanning historical target partitions instead of pruning viaupdatePartitionFilter. - Gold Aggregation Layer:
E_goldshould remain between 2.0 and 8.0 (reading recent Silver partitions and writing compact summary rows).
- Silver Incremental Layer:
7.1 Medallion Architectural Maturity Matrix on BigQuery & Dataform
| Capability Area | Level 1: Ad-Hoc / Unlayered | Level 2: Basic Medallion | Level 3: Production-Grade Native Medallion (Target State) |
| Transformation Engine | Uncoordinated BigQuery Scheduled Queries & nested Views | Dataform .sqlx type: "table" full rebuilds | Dataform incremental DAGs, JS modular factories, and assertions |
| Storage Billing Strategy | Default Logical Billing across all datasets | Single project-wide billing switch without churn analysis | Hybrid: PHYSICAL on append-only Bronze; LOGICAL on MERGE-heavy Silver & Gold |
| Incremental Pruning | Full table scans (SELECT * FROM raw) | Source WHERE filter only (target still fully scanned in MERGE) | Both source filter (ctx.when(ctx.incremental())) AND target updatePartitionFilter |
| Data Quality & Contracts | Discovered manually by executives in BI reports | Non-blocking post-run tests | Blocking assertions (dependOnDependencyAssertions: true) + in-flight ERROR() checks |
| Environment Isolation | Editing queries directly in Production | Manual dataset renaming in branches | Automated workspaceCompilationOverrides, CI Dry-Run cost caps, and zero-copy CLONE swaps |
| Orchestration | Blind cron schedules with guessed delays | Dataform standalone cron schedules | Event-driven Cloud Workflows chaining Ingestion -> Dataform Compilation -> Invocation |
8. Summary of Engineering Principles
- Enforce Physical Isolation by Layer: Always separate Bronze, Silver, and Gold into distinct BigQuery datasets. This enables dataset-level IAM boundaries (keeping analysts out of raw Bronze tables) and independent storage billing optimization (
PHYSICALfor immutable compressed Bronze vs.LOGICALfor high-churn Silver and Gold). - Never Write an Incremental
MERGEWithoutupdatePartitionFilter: Filtering the source query insidectx.when(ctx.incremental())is only half of incremental optimization. WithoutupdatePartitionFilteron the target table configuration, BigQuery’sMERGEoperator will scan every historical partition of the target table on every run. - Shift Data Quality Upstream of Gold: Use Dataform’s
dependOnDependencyAssertions: trueand single-passIF(..., ERROR(...))checks so corrupted upstream batches halt at the Silver boundary before overwriting executive Gold marts. - Leverage Dataform’s Native JavaScript Engine for DRY Architecture: When managing dozens of identical regional or multi-tenant tables, replace repetitive SQL files with programmatic JavaScript loops (
forEach,publish(),declare()) and validate compiled SQL byte budgets with automated BigQuery Dry Runs before deployment.
