dbt: BigQuery Adapter
Quote
“Serverless is a simple but powerful concept when it comes to gigabyte- to petabyte-scale data analysis. It’s a relatively hard engineering problem.”
— Jordan Tigani (founding engineer of BigQuery)
Summary
dbt-bigqueryis the first-party dbt adapter for BigQuery, and this note defines the BigQuery-specific profile, partitioning, clustering, incremental, SQL, materialization, and cost-control patterns required to run analytical models efficiently and predictably on a serverless warehouse.Adapter setup and authentication
- Installs pinned
dbt-coreanddbt-bigqueryversions, confirms adapter registration, and notes that BigQuery relies on API credentials rather than host-level database drivers.- Configures
profiles.ymlfor service-account JSON keys, local ADC withoauth, and service-account impersonation for production-safe access patterns.Storage layout and incremental design
- Uses
partition_byandcluster_bydeliberately, including date and integer-range partitioning, partition granularity choices, andrequire_partition_filteron marts.- Compares
mergeandinsert_overwrite, with guidance on when partition replacement is more efficient than row-level upserts.Warehouse tuning and SQL patterns
- Covers thread count versus slot consumption,
priority, and job labels for cost attribution, then maps key BigQuery SQL features such asSAFE_DIVIDE,DATE_TRUNC,DATE_ADD,STRUCT,ARRAY, and manualMERGEDML.- Extends the adapter discussion to materialized views, BI Engine behavior, and external tables staged through
dbt_external_tables.Operations and safety
- Warnings: enforce partition filters on marts, tune
threadsagainst slot availability, avoid raw keyfile sprawl when Workload Identity or impersonation is available, and do not deploy large tables without partitioning and clustering.- Recommendations table: the partition granularity guide and cost-visibility label patterns define the safe default operating posture.
- Cost controls:
maximum_bytes_billed, dry runs, and billing labels are the note’s explicit spend-governance mechanisms.
Glossary
dbt-bigquery
The first-party dbt adapter that maps dbt models and materializations to BigQuery DDL, DML, and job configuration.
It matters here because all partitioning, clustering, incremental, and cost controls in the note are adapter features exposed through dbt config.
First-party coverage
This adapter tracks dbt Core more closely than community adapters. Even so, BigQuery-specific runtime behavior still needs deliberate validation in the warehouse, not just in compiled SQL.
profiles.yml
The dbt profile file that stores target definitions and adapter-specific connection parameters.
It matters here because project, dataset, auth method, location, threads, timeout, and query priority are all controlled there.
Runtime control plane
In BigQuery, profile choices shape both identity and spend. Treat the file as an execution policy surface, not as a narrow credential wrapper.
Service account JSON key
A downloaded private key file that allows a dbt process to authenticate as a Google Cloud service account.
It matters here because it is the simplest non-interactive auth method for CI, but also the least desirable long-term credential pattern.
Long-lived secret material
JSON keys are easy to copy, hard to rotate everywhere, and frequently over-scoped. Prefer attached identities or impersonation when the platform supports them.
Application Default Credentials
Google’s standard mechanism for discovering local or attached credentials automatically in client libraries and CLI tools.
It matters here because local dbt development on BigQuery often uses
method: oauthbacked by ADC rather than embedded key paths.Developer context leaks
ADC follows the active local identity state. If developers switch accounts or projects casually, dbt runs can silently target the wrong BigQuery environment.
Service account impersonation
A Google Cloud access pattern where one principal temporarily mints short-lived credentials for a target service account.
It matters here because it enables production access without distributing the production account’s private key.
IAM boundary enforcement
The safety of impersonation depends on tightly scoped
roles/iam.serviceAccountTokenCreatorgrants. Broad token-creator permissions collapse the security boundary.
Partitioning
BigQuery’s table layout feature that divides data into partitions so queries can prune storage before scanning bytes.
It matters here because partitioning is the primary performance and cost control for date-driven analytical marts.
Foundational, not optional
Large analytical tables without partitioning become full-scan targets very quickly. Add it during model design instead of treating it as a later tuning step.
partition_by
The dbt model config that declares the partition column, data type, and granularity for a BigQuery table.
It matters here because the adapter converts this config directly into the table layout that governs pruning, incremental strategy, and scan cost.
Physical design knob
This is not just metadata. It changes how the table is stored and which incremental patterns remain economical as data volume grows.
require_partition_filter
A BigQuery table setting that rejects queries which do not filter on the partition column.
It matters here because it protects mart tables from accidental full scans by analysts, dashboards, or ad hoc queries.
Apply selectively
This safety net is appropriate for marts but can break internal or exploratory queries on staging models. Use it where query discipline matters, not everywhere indiscriminately.
Clustering
BigQuery’s within-partition sort organization on one or more columns.
It matters here because it complements partition pruning by making repeated filter and join predicates cheaper inside each partition.
Secondary optimization layer
Clustering helps only after partitioning is reasonable. It refines scan efficiency within partitions; it does not replace partition design.
merge
The default BigQuery incremental strategy that uses BigQuery
MERGEDML for row-level upserts.It matters here because it is the general-purpose choice when the model must reconcile changed rows by
unique_key.Still compute-heavy
MERGEis correct for many models, but large targets can still consume substantial slots and bytes. Do not confuse logical convenience with low-cost execution.
insert_overwrite
A partition-replacement incremental strategy that rewrites entire affected partitions instead of performing row-by-row matching.
It matters here because it is often more efficient than
mergewhen source corrections arrive as complete partition-aligned batches.Partition semantics required
This strategy is safe only when the data naturally replaces whole partitions. If corrections happen at row granularity inside a partition, use
mergeinstead.
Slots
BigQuery compute capacity units consumed by queries while they execute.
It matters here because dbt thread count multiplies concurrent query demand, and slot pressure determines whether runs start promptly or queue.
Threads amplify spend
A high
threadssetting does not mean lightweight parallelism. Each concurrent query can consume substantial slots, so overshooting thread count creates contention and cost spikes.
priority
The BigQuery job execution mode that chooses between immediate interactive execution and queued batch execution.
It matters here because production dbt runs often need to trade latency for reduced contention with analyst workloads.
Scheduling policy choice
batchis not slower SQL; it is a queueing policy. Use it when predictable shared-capacity behavior matters more than immediate start time.
Labels
Key-value metadata attached to BigQuery jobs and objects for cost attribution and administrative tracking.
It matters here because dbt model and layer labels let billing exports and
INFORMATION_SCHEMAqueries trace warehouse spend back to specific workloads.FinOps hook point
Without consistent labels, cost analysis collapses into project-level averages. Labels make dbt activity observable at the model and team level.
SAFE_DIVIDE
A BigQuery SQL function that returns
NULLinstead of raising an error when division would fail, such as division by zero.It matters here because it is one of the key BigQuery-native expressions the note uses to contrast adapter-specific SQL idioms.
Portable intent, different syntax
The analytical intent matches SQL Server’s
NULLIF-based safe division pattern, but the implementation is BigQuery-specific and belongs behind adapter-aware abstractions in shared projects.
STRUCTandARRAY
BigQuery nested data types for representing grouped fields and repeated values inside a single row.
It matters here because they enable BigQuery-native analytical models but break portability to adapters such as SQL Server.
Adapter boundary marker
Once nested types enter a model, that model is effectively BigQuery-only unless you isolate it in adapter-specific folders or conditional logic.
Materialized view
A persisted BigQuery query result that refreshes automatically and can accelerate repeated analytical reads.
It matters here because the note treats materialized views as an operational extension that dbt manages indirectly rather than as a native dbt materialization.
Not a normal dbt model
BigQuery materialized views behave like warehouse objects with their own refresh constraints. Manage them deliberately through operations or hooks rather than assuming full parity with table models.
maximum_bytes_billed
A BigQuery job setting that caps how many bytes a query is allowed to process before it fails.
It matters here because it is the note’s explicit fail-fast control against accidental runaway scan costs.
Protective failure mode
This setting intentionally breaks expensive queries instead of letting them complete. That is desirable in production because a hard failure is usually cheaper than an unnoticed full-table scan.
BigQuery Adapter Installation
pip install dbt-core==1.8.* dbt-bigquery==1.8.*
# Confirm the adapter is registered
dbt --version
# Should show: - bigquery: 1.8.xdbt-bigquery depends on google-cloud-bigquery. No additional system-level drivers are required — authentication is handled through ADC or explicit credentials.
profiles.yml
Service Account JSON (CI / GCE without Workload Identity)
# ~/.dbt/profiles.yml
financial_index:
target: dev
outputs:
dev:
type: bigquery
method: service-account
project: fi-data-dev
dataset: dbt_dev
keyfile: /secrets/dbt-sa-key.json # path to downloaded SA key
location: US
threads: 8
timeout_seconds: 600
priority: interactive
prod:
type: bigquery
method: service-account
project: fi-data-prod
dataset: dbt_prod
keyfile: "{{ env_var('DBT_BQ_KEYFILE') }}"
location: US
threads: 16
timeout_seconds: 1800
priority: batchPrefer attached identities over JSON keys on GCE
On Compute Engine or GKE, prefer attached service accounts or explicit service-account impersonation instead of downloadable JSON keys. That removes key-rotation overhead and reduces long-lived secret sprawl.
Application Default Credentials (local development)
local:
type: bigquery
method: oauth
project: fi-data-dev
dataset: dbt_dev_yourname
location: US
threads: 4
timeout_seconds: 300Before running: gcloud auth application-default login --scopes=https://www.googleapis.com/auth/bigquery.
Service Account Impersonation
impersonated:
type: bigquery
method: oauth
project: fi-data-prod
dataset: dbt_prod
impersonate_service_account: dbt-runner@fi-data-prod.iam.gserviceaccount.com
location: US
threads: 8
timeout_seconds: 600Requires the caller’s identity to have roles/iam.serviceAccountTokenCreator on the target SA. Useful for developers who need prod-read access without holding a prod SA key locally.
Partitioning
Partitioning is the most impactful BigQuery optimization for time-series financial data. Every mart table keyed by score_date, as_of_date, or trade_date should be partitioned.
-- models/mart/mart_esg_scores.sql
{{
config(
materialized = 'table',
partition_by = {
"field": "score_date",
"data_type": "date",
"granularity": "month" -- day | month | year
},
cluster_by = ["isin", "provider_code"],
require_partition_filter = true,
labels = {"domain": "esg", "layer": "mart"}
)
}}
select
score_id,
isin,
provider_code,
score_date,
environmental_score,
social_score,
governance_score,
composite_score
from {{ ref('int_esg_scores_validated') }}Partition Granularity Guide
| Granularity | Use when | Partition count |
|---|---|---|
day | High-frequency data updated daily; date-range queries on narrow windows | Up to 4,000 partitions |
month | Monthly index rebalancing, ESG scores updated monthly | Manageable; good default |
year | Historical archives queried by year | Very few partitions; less pruning benefit |
Require partition filter on marts
Enabling
require_partition_filter = trueon mart tables prevents accidental full-table scans from BI tools. Any query that does not include a filter on the partition column will be rejected with an error. Use it on analyst-facing marts and on large incrementals only after checking the generated SQL still satisfies the partition requirement.
Safe pattern
Set
require_partition_filter = trueonly inconfig()blocks for mart and incremental models. Leave staging models without this setting. In dbt config:require_partition_filter = trueat the mart layer, omit it entirely in staging model configs.
Integer Range Partitioning
For tables without a natural date column — e.g., a universe table partitioned by index code hash:
{{
config(
materialized = 'table',
partition_by = {
"field": "index_id",
"data_type": "int64",
"range": {
"start": 0,
"end": 1000,
"interval": 100
}
}
)
}}BigQuery Clustering
Clustering sorts data within each partition by the specified columns. BigQuery automatically re-clusters as data accumulates. Clustering is free and has no maintenance overhead.
{{
config(
materialized = 'incremental',
partition_by = {"field": "score_date", "data_type": "date", "granularity": "month"},
cluster_by = ["isin", "provider_code", "score_type"]
-- Up to 4 cluster columns; order matters — most selective first
)
}}Clustering vs partitioning
Partitioning prunes at the storage level before any bytes are scanned. Clustering prunes within a partition — it is a secondary optimization. Always partition first, then cluster on the most common filter/join columns.
Incremental Strategy
merge (default)
BigQuery’s native MERGE DML. Safe and correct for most use cases.
{{
config(
materialized = 'incremental',
unique_key = 'score_id',
incremental_strategy = 'merge',
partition_by = {"field": "score_date", "data_type": "date", "granularity": "month"},
cluster_by = ["isin", "provider_code"]
)
}}
with new_scores as (
select *
from {{ ref('stg_esg_raw_scores') }}
{% if is_incremental() %}
where score_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)
{% endif %}
)
select * from new_scoresThe adapter generates:
MERGE INTO `fi-data-prod.dbt_prod.mart_esg_scores` AS DBT_INTERNAL_DEST
USING (select * from new_scores) AS DBT_INTERNAL_SOURCE
ON DBT_INTERNAL_SOURCE.score_id = DBT_INTERNAL_DEST.score_id
WHEN MATCHED THEN UPDATE SET ...
WHEN NOT MATCHED THEN INSERT ...insert_overwrite (partition swap)
Replaces entire partitions atomically. More efficient than MERGE for large partition-aligned loads — no per-row comparison overhead.
{{
config(
materialized = 'incremental',
incremental_strategy = 'insert_overwrite',
partition_by = {"field": "score_date", "data_type": "date", "granularity": "month"},
-- No unique_key required — entire partitions are replaced
)
}}
select *
from {{ ref('stg_esg_raw_scores') }}
{% if is_incremental() %}
-- Only process partitions for months that have new/changed data
where DATE_TRUNC(score_date, MONTH) IN (
select DISTINCT DATE_TRUNC(score_date, MONTH)
from {{ ref('stg_esg_raw_scores') }}
where _PARTITIONDATE >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)
)
{% endif %}When to use insert_overwrite
- Source data arrives in complete monthly batches (ESG providers often send full-month corrections).
- Reprocessing historical partitions is common.
- The table is too large for MERGE to be economical (MERGE scans the full target table for non-partitioned MERGE keys).
Avoid
insert_overwritewhen you need row-level upsert semantics within a partition.
BigQuery Slot Estimation and Thread Tuning
BigQuery slots are units of compute. In on-demand projects, dbt queries compete for shared BigQuery compute capacity, and each query consumes slots proportional to its complexity and data volume.
# profiles.yml thread settings
prod:
threads: 16 # dbt parallelism — concurrent queries
priority: batch # batch | interactiveinteractive: queries compete for slots immediately; subject to fair-use limits; higher priority.batch: queries are queued; start within 24 hours; no slot reservation required. Use for scheduled dbt production runs to avoid slot contention with analysts.
Threads vs BigQuery slots
threads: 16means dbt submits 16 queries concurrently. Each of those queries may consume hundreds or thousands of slots. Setting threads too high on a shared project can cause slot exhaustion and query queuing. Start withthreads: 8and increase after confirming slot availability via the BigQuery Admin Console.
Safe starting configuration
Begin with
threads: 8andpriority: batchin production profiles. Monitor slot utilisation in the BigQuery Admin Console (INFORMATION_SCHEMA.JOBS_BY_PROJECT) for at least one full pipeline cycle before increasing thread count. Reserve slots via BigQuery Reservations if you need guaranteed capacity.
BigQuery Labels for Cost Attribution
Labels propagate to BigQuery job metadata and appear in Cloud Billing exports. Mandatory for multi-team environments.
{{
config(
materialized = 'table',
labels = {
"dbt_model": "mart_esg_scores",
"domain": "esg",
"layer": "mart",
"team": "data-engineering",
"cost_center": "cc-1234"
}
)
}}Labels can also be set at the project level in dbt_project.yml:
# dbt_project.yml
models:
financial_index:
+labels:
project: financial-index
managed_by: dbt
mart:
+labels:
layer: mart
staging:
+labels:
layer: stagingQuery BigQuery INFORMATION_SCHEMA to track model-level cost:
SELECT
labels.value AS dbt_model,
SUM(total_bytes_processed) / POW(10, 12) AS tb_processed,
SUM(total_slot_ms) / 1000 / 3600 AS slot_hours
FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT,
UNNEST(labels) AS labels
WHERE labels.key = 'dbt_model'
AND DATE(creation_time) = CURRENT_DATE()
GROUP BY 1
ORDER BY 2 DESC;BigQuery SQL Patterns
BigQuery SQL — SAFE_DIVIDE
-- Avoids ZeroDivisionError at the SQL engine level — returns NULL instead
SELECT
isin,
SAFE_DIVIDE(environmental_score, composite_score) AS env_weight,
-- Equivalent in SQL Server: environmental_score * 1.0 / NULLIF(composite_score, 0)
SAFE_DIVIDE(social_score, composite_score) AS social_weight
FROM {{ ref('int_esg_scores_validated') }}BigQuery SQL — DATE_TRUNC
-- Truncate to period start
SELECT
DATE_TRUNC(score_date, MONTH) AS score_month,
DATE_TRUNC(score_date, QUARTER) AS score_quarter,
DATE_TRUNC(score_date, YEAR) AS score_year,
DATE_TRUNC(score_date, WEEK) AS score_week_start -- Monday
FROM {{ ref('stg_esg_raw_scores') }}
-- Date arithmetic with INTERVAL
DATE_ADD(score_date, INTERVAL 1 MONTH)
DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)
DATE_DIFF(end_date, start_date, DAY)BigQuery SQL — STRUCT and ARRAY
Useful for packing provider-level score breakdowns without a separate table:
SELECT
isin,
score_date,
STRUCT(
environmental_score AS e,
social_score AS s,
governance_score AS g
) AS esg_components,
ARRAY_AGG(
STRUCT(provider_code, composite_score)
ORDER BY composite_score DESC
) AS provider_scores
FROM {{ ref('int_esg_scores_validated') }}
GROUP BY isin, score_date, esg_componentsSTRUCT/ARRAY limitations
Nested types work well for analytical queries but are not compatible with
dbt-sqlserver. Any model using STRUCT/ARRAY must live in a BigQuery-specific folder or be guarded bytarget.typechecks. See dbt-cross-adapter-patterns for the dispatch pattern.
BigQuery SQL — MERGE DML (manual)
When the dbt incremental MERGE is not granular enough, write explicit MERGE in a post-hook or operation:
MERGE `fi-data-prod.dbt_prod.dim_index_constituents` AS target
USING (
SELECT isin, index_code, weight, effective_date
FROM `fi-data-prod.dbt_staging.stg_index_constituents`
WHERE effective_date = CURRENT_DATE()
) AS source
ON target.isin = source.isin
AND target.index_code = source.index_code
AND target.effective_date = source.effective_date
WHEN MATCHED AND target.weight != source.weight THEN
UPDATE SET target.weight = source.weight,
target.updated_at = CURRENT_TIMESTAMP()
WHEN NOT MATCHED THEN
INSERT (isin, index_code, weight, effective_date, updated_at)
VALUES (source.isin, source.index_code, source.weight,
source.effective_date, CURRENT_TIMESTAMP())Materialized Views and BI Engine
BigQuery Materialized Views
-- models/mart/mv_esg_monthly_avg.sql
{{
config(
materialized = 'materialized_view'
)
}}
SELECT
DATE_TRUNC(score_date, MONTH) AS score_month,
provider_code,
isin,
AVG(composite_score) AS avg_composite_score,
COUNT(*) AS score_count
FROM {{ ref('mart_esg_scores') }}
GROUP BY 1, 2, 3BI Engine acceleration
BI Engine accelerates queries on supported tables and materialized views in the same region as the BI Engine reservation. Keep mart datasets and BI Engine reservations aligned by location, and verify support for nested-heavy queries against current BigQuery platform limits.
Cost Control
maximum_bytes_billed
Hard cap per query. Queries exceeding the limit fail with an error rather than incurring unexpected charges.
# profiles.yml
prod:
maximum_bytes_billed: 107374182400 # 100 GB in bytesSet this in production to prevent runaway full-table scans during dbt runs. A model that accidentally drops its partition filter will fail fast rather than billing for a full-table scan.
Dry Run via bq CLI
Before running an expensive model, estimate bytes:
bq query --dry_run --use_legacy_sql=false \
'SELECT * FROM `fi-data-prod.dbt_prod.mart_esg_scores`
WHERE score_date >= "2024-01-01"'
# Output: Query successfully validated. Assuming the tables are not modified,
# running this query will process 2147483648 bytes.In dbt, use dbt compile to get the rendered SQL, then pipe it to bq query --dry_run.
BigQuery External Tables with Hive Partitioning
For raw ESG provider files landed in GCS with a Hive-style path structure (see data-loading-and-export for the upstream loading patterns that produce these files):
gs://fi-raw-data/esg_scores/provider=msci/score_year=2024/score_month=01/scores.parquet
# models/sources.yml
sources:
- name: ext_esg
schema: raw_external
tables:
- name: esg_scores_gcs
external:
location: "gs://fi-raw-data/esg_scores/*"
options:
format: parquet
hive_partition_uri_prefix: "gs://fi-raw-data/esg_scores"
require_hive_partition_filter: falseManage BigQuery external tables through dbt-external-tables source definitions, not through a normal dbt model materialization. Add the package to packages.yml:
packages:
- package: dbt-labs/dbt_external_tables
version: 0.12.1Then define the source metadata in sources.yml and run dbt run-operation stage_external_sources.