Tutorial

How to build a data warehouse on BigQuery with dbt, step by step.

Start with three synthetic orders and finish with a daily mart you can reconcile by hand. This reference build makes every file explicit so the path from ingestion to a tested business metric stays visible.

1. Prepare an isolated BigQuery workspace

This walkthrough uses Python 3.11 and dbt Core 1.9 with the BigQuery adapter. It is a learning project, not a production ingestion service. You need a Google Cloud project with billing enabled, the BigQuery API enabled and the Google Cloud CLI installed. Queries, loads and storage may incur charges; the example does not promise a free deployment.

Create an empty dataset named warehouse_demo in the EU multi-region. Ask your project administrator for BigQuery Job User on the project and BigQuery Data Editor on that dataset. Use the same location in the profile. Replace YOUR_PROJECT_ID below with your own project ID. Authenticate locally using Application Default Credentials; keep credentials outside Git.

Terminal: create the project folders and authenticate · vendor docs
mkdir warehouse_demo
cd warehouse_demo
python3.11 -m venv .venv
. .venv/bin/activate
python -m pip install 'dbt-core==1.9.6' 'dbt-bigquery==1.9.2'
gcloud auth application-default login
mkdir -p seeds models/staging models/marts tests
printf '.venv/\ntarget/\nlogs/\nprofiles.yml\n' > .gitignore
dbt_project.yml · vendor docs
name: warehouse_demo
version: '1.0.0'
config-version: 2
profile: warehouse_demo
model-paths: [models]
seed-paths: [seeds]
test-paths: [tests]
models:
  warehouse_demo:
    staging:
      +materialized: view
    marts:
      +materialized: table
profiles.yml in the project directory · vendor docs
warehouse_demo:
  target: dev
  outputs:
    dev:
      type: bigquery
      method: oauth
      project: YOUR_PROJECT_ID
      dataset: warehouse_demo
      location: EU
      threads: 1
      job_execution_timeout_seconds: 300

2. Ingest a tiny, repeatable fixture

Save the CSV below as seeds/raw_orders.csv. Here dbt seed performs the load into BigQuery. Seeds are appropriate for this tiny demonstration; for live application data, use a managed connector or ingestion job and declare the loaded tables as dbt sources instead.

All amounts use one illustrative currency. There are two completed orders worth 125.50 on 2026-10-01, plus a cancelled order on 2026-10-02. Cancelled orders remain visible in staging but do not enter the completed-order fact.

seeds/raw_orders.csv · vendor docs
order_id,customer_id,order_date,amount,status
101,1,2026-10-01,85.00,completed
102,2,2026-10-01,40.50,completed
103,1,2026-10-02,20.00,cancelled

3. Separate staging, facts and reporting marts

Create these three SQL files at the paths shown. Staging sets names and types without hiding cancelled records. The fact has one row per completed order. The daily mart is the reporting interface; ref() tells dbt the dependency order. Everything lands in the isolated dataset for this example.

models/staging/stg_orders.sql · vendor docs
select
    order_id,
    customer_id,
    cast(order_date as date) as order_date,
    cast(amount as numeric) as amount,
    status
from {{ ref('raw_orders') }}
models/marts/fct_orders.sql · vendor docs
select order_id, customer_id, order_date, amount
from {{ ref('stg_orders') }}
where status = 'completed'
models/marts/daily_orders.sql · vendor docs
select
    order_date,
    count(*) as completed_orders,
    sum(amount) as completed_order_value
from {{ ref('fct_orders') }}
group by order_date

4. Protect the grain and reconcile the result

Save the YAML and singular SQL test below. This uses the top-level generic-test arguments supported by the pinned dbt 1.9 version. The key tests protect row grain, while the singular test checks the exact daily result of this fixture. Remove or rewrite the fixture-specific assertion before using real data.

Run debug first to diagnose credentials, project permissions and dataset location. Then seed and build. Expect daily_orders to contain exactly one row: 2026-10-01, 2, 125.50. A passing fixture does not establish production completeness or freshness.

models/schema.yml · vendor docs
version: 2
models:
  - name: stg_orders
    columns:
      - name: order_id
        tests: [not_null, unique]
      - name: customer_id
        tests: [not_null]
      - name: order_date
        tests: [not_null]
      - name: amount
        tests: [not_null]
      - name: status
        tests:
          - not_null
          - accepted_values:
              values: [completed, cancelled]
  - name: fct_orders
    columns:
      - name: order_id
        tests: [not_null, unique]
  - name: daily_orders
    columns:
      - name: order_date
        tests: [not_null, unique]
tests/daily_orders_matches_fixture.sql · vendor docs
select 1 as mismatch
where (select count(*) from {{ ref('daily_orders') }}) != 1
   or (select count(*) from {{ ref('daily_orders') }}
       where order_date = cast('2026-10-01' as date)
         and completed_orders = 2
         and completed_order_value = 125.50) != 1
Terminal: load, build and inspect · vendor docs
dbt debug --profiles-dir .
dbt seed --full-refresh --profiles-dir .
dbt build --profiles-dir .
dbt show --select daily_orders --profiles-dir .

5. Trigger a failure, then restore the pass

Change order 102 to order_id 101 in the CSV, then run dbt seed --full-refresh --profiles-dir . followed by dbt build --profiles-dir .. Expect the staging unique test to fail and dependent models to be skipped. Restore 102 and repeat both commands. Running tests alone does not reload the CSV.

Inspect target/run_results.json and target/compiled/ for the failing node and SQL. If a table cannot be found, check the project, dataset and location before changing SQL. If access is denied, check the authenticated identity and dataset permissions.

6. Turn the reference build into an owned pipeline

For production, separate raw ingestion from transformation, use dedicated development and production datasets, and run scheduled jobs with a service identity rather than a personal login. Define freshness expectations, retry behaviour, an alert owner and a recovery procedure before depending on the mart.

Start with full table builds while the data is small. Add partitioning or incremental processing only after measuring scan volume and defining how late updates and backfills are handled. Budget alerts help detect spend; they do not cap it. When finished experimenting, remove only the warehouse_demo dataset you created, including its tables, through the BigQuery console.

Questions

Related questions

Does dbt ingest application data into BigQuery?

In this example dbt seed loads a tiny CSV fixture. A production stack normally uses an ingestion tool to land application data; dbt then transforms and tests those loaded tables.

Is this a production-ready warehouse?

It is a runnable reference build. Production also needs source contracts, access controls, scheduling, monitoring, recovery and cost ownership.

The Divergence Point — monthly

Where the numbers stop agreeing, and what to do about it. One email a month on data quality, reconciliation and building warehouses that survive contact with a real business. No recycled LinkedIn posts.

Tell me what is broken.

A short call is usually enough to tell whether this is a two-week fix or a two-month one — and whether I am the right person for it.