Build a star-schema datamart from scratch using dbt-core and Snowflake, based on IBU biathlon race results (2024-2025 season, 18,503 rows).
- Python 3.12+
- Snowflake account (provided by instructor)
- Git installed
- Terminal / VS Code
- Install uv (Python package manager):
curl -LsSf https://astral.sh/uv/install.sh | sh - Install project dependencies:
uv sync
This branch (main) contains the fully completed course. To start the lab, checkout the first branch:
git checkout step/01-init-projEach step of the course is a git branch. Checkout the next branch to see the solution and get the next exercise:
git checkout step/02-star-schema # Next step (contains step 1 solution)
# etc.- Step 1: Init project & Connect to Snowflake
- Step 2: Star Schema (staging, dimensions, fact)
- Step 3: Jinja & macros
- Step 4: Built-in dbt tests
- Step 5: Documentation & packages
- Step 6: Final mart table (analytics)
- Step 7: CI/CD with GitHub Actions
Project initialized with dbt-core + Snowflake connection via profiles.yml and env_var(). Data loaded with scripts/load_to_snowflake.py.
Goal: Create the first dbt model and define a source.
Tasks:
- Create the folder structure:
models/ └── staging/ ├── _sources.yml └── stg_race_results.sql - Define the source in
_sources.yml:version: 2 sources: - name: raw database: "{{ env_var('SNOWFLAKE_DATABASE') }}" schema: "{{ env_var('SNOWFLAKE_SCHEMA') }}" tables: - name: races_results_raw
- Write
stg_race_results.sql:- Reference the source with
{{ source('raw', 'races_results_raw') }} - Rename columns to snake_case
- Cast types (e.g.,
km→ FLOAT,rank→ INT,is_team→ BOOLEAN) - Filter out invalid records (
IRM IS NULL)
- Reference the source with
- Run and verify:
uv run dbt run --select stg_race_results
Key concepts:
{{ source() }}— referencing raw tables_sources.yml— declaring source tables- Staging models: clean, rename, cast — no business logic
stg_naming convention
Goal: Build the star schema dimension tables.
Tasks:
- Create dimension models in
models/marts/:dim_athletes.sql— unique athletes with nationalitydim_events.sql— unique events with location and leveldim_races.sql— unique races with discipline and distance
- Use
{{ ref('stg_race_results') }}to reference the staging model - Use
SELECT DISTINCTto deduplicate from the flat source - Run all models:
uv run dbt run
Key concepts:
{{ ref() }}— referencing other models (creates dependencies)- Dimension tables: descriptive attributes, no metrics
- Star schema: dimensions describe the "who/what/where/when"
- dbt builds models in dependency order automatically
Goal: Build the central fact table connecting all dimensions.
Tasks:
- Create
models/marts/fct_results.sql:- Select measurable columns: rank, shootings, run_time, total_time, etc.
- Include foreign keys: athlete_id, race_id, event_id
- Discuss grain: one row = one athlete in one race
- Run the full pipeline:
uv run dbt run
- Check the execution order in terminal output
Key concepts:
- Fact tables: measurable events, foreign keys to dimensions
- Grain — what does each row represent?
- The full pipeline runs staging → dimensions → fact in order
Questions:
- What is the difference between
source()andref()? - Why do we use
SELECT DISTINCTin dimension tables but not in the fact table? - What happens if you run
dbt run --select fct_resultswithout running staging first?
Goal: Learn Jinja templating by creating reusable macros to solve real data quality issues.
Theory — Jinja basics:
{{ }}— expressions: output a value (variable, function call, macro){% %}— statements: control flow (if, for, macro, set){# #}— comments: ignored in compiled SQL- dbt compiles Jinja → pure SQL before sending to Snowflake
Tasks:
- Run
cleaned_measures_raw.sql— a naive attempt to cast columns:uv run dbt run --select cleaned_measures_raw
- Surprise: dbt says SUCCESS — the view is created in Snowflake!
- Now try to query it:
uv run dbt show --select cleaned_measures_raw
- Error! The view exists in Snowflake but the SQL inside fails when queried.
Why? With
materialized: view, dbt runsCREATE VIEW AS SELECT .... Snowflake accepts the DDL without executing the SELECT — it only validates syntax, not data. When you (or dbt show) actually query the view, Snowflake runs the SELECT and hits the casting error. This is a key difference between views and tables: a table would fail immediately atdbt runbecause the SELECT is executed to materialize the data.
- Review
macros/parse_iso_timestamp.sql— a macro that parses ISO timestamps - See how it's used in
dim_races.sqlforstart_time - See the compiled SQL:
uv run dbt compile --select dim_races cat target/compiled/dbt_biathlon/models/marts/dim_races.sql
Now it's your turn! Open models/intermediate/cleaned_measures.sql and the macro files — follow the TODO comments.
Exercise 1: Complete macros/clear_km_relay.sql
- Goal: extract the km value from relay format (e.g.,
4x6→6) - Hint: use
RIGHT(),LENGTH(), andPOSITION('x' IN ...) - Don't forget to add an
ASalias!
Exercise 2: Complete macros/select_spares.sql
- Goal: split the
shootingscolumn (0+1) into two columns:shootings(prone) andshootings_spare(standing) - Hint: use
LEFT(),RIGHT(),LENGTH(), andPOSITION('+' IN ...) - The macro must output two columns separated by a comma
Exercise 3: Update models/intermediate/cleaned_measures.sql
- Replace the TODO placeholders with calls to your macros
- Run and verify:
uv run dbt run --select cleaned_measures
Exercise 4 Update the fct_results ref for cleaned_measures
uv run dbt run --select fct_resultsKey concepts:
- Macros = reusable SQL functions written in Jinja
dbt compile— see the generated SQL without running it- DRY principle: write once, use everywhere
- Jinja is a Python templating language — dbt runs it at compile time
- Intermediate models handle data quality issues between staging and marts
Questions:
- What is the difference between
{{ }}and{% %}? - Why is
dbt compileuseful for debugging? - Why did
cleaned_measures_raw.sqlfail butstg_race_results.sqldidn't?
Goal: Add data quality tests to the models.
Tasks:
- Create
models/marts/_schema.ymlwith tests for all mart models - Use the 4 built-in test types:
unique— no duplicate valuesnot_null— no NULL valuesaccepted_values— column values must be in a listrelationships— foreign key integrity (value exists in another model)
- Run the tests:
uv run dbt test - Observe which tests pass and which fail — discuss why
- Try
dbt build(run + test together):uv run dbt build
- Run the tests:
uv run dbt test - The
not_nulltest onfct_results.rankfails. Why? - Investigate: what rows have a NULL rank? Look at the
irmcolumn — what does it contain? (DNF, DNS, LAP, DSQ...) - Fix the test so it passes: rank can be NULL, but only when IRM is not NULL. Hint: the
not_nulltest accepts awhereconfig.
Add tests for stg_race_results in a new models/staging/_schema.yml. What columns should be tested?
After fixing the rank test with where: "irm IS NULL", you may still have a few edge cases (para-biathlon athletes with no rank and no IRM). Instead of failing the pipeline, you can set tolerance thresholds:
- not_null:
where: "irm IS NULL"
warn_if: ">0"
error_if: ">10"warn_if: ">0"— emit a warning if any rows fail (pipeline continues)error_if: ">10"— only error if more than 10 rows fail (pipeline stops)
Try adding this to your rank test. This is useful when you accept known edge cases in the data without breaking the build.
Key concepts:
- 4 built-in tests:
unique,not_null,accepted_values,relationships - Tests are SQL queries — a test fails if it returns rows
_schema.yml— model documentation + tests in one filedbt build=dbt run+dbt testin dependency order- Tests catch data quality issues early
Questions:
- What SQL does dbt generate for a
uniquetest? - Why might a
relationshipstest fail? - When should you use
dbt buildvsdbt run+dbt testseparately?
Goal: Generate and explore project documentation.
Tasks:
- Add descriptions to
models/marts/_schema.ymlfor all models and columns - Add descriptions to
models/staging/_sources.yml - Install dbt packages (needed for documentation generation):
uv run dbt deps
- Generate and serve the documentation:
uv run dbt docs generate uv run dbt docs serve
- Open the browser and explore:
- Model pages: descriptions, columns, SQL
- DAG view (bottom-right icon): visualize the dependency graph
- Observe: source → staging → dimensions/fact
- Check
dbt_project.yml— notice thepersist_docsconfig:This tells dbt to push your YAML descriptions directly into Snowflake as+persist_docs: relation: true columns: true
COMMENTon views and columns. - Run
dbt runand then check in Snowflake: your views now have descriptions visible in the UI!
Key concepts:
- Documentation is defined alongside the code in YAML
dbt docs generatecompiles a static website- The DAG shows the full data lineage
- How
ref()andsource()build the DAG automatically persist_docssyncs descriptions to the warehouse — documentation lives in both dbt and Snowflake
Goal: Install external packages and use their macros/tests.
Tasks:
- Create
packages.ymlat the project root:(Packages were already installed in 5.1 withpackages: - package: dbt-labs/dbt_utils version: [">=1.0.0", "<2.0.0"] - package: calogica/dbt_expectations version: [">=0.10.0", "<1.0.0"]
dbt deps.) - Use
dbt_utils.generate_surrogate_keyinfct_results.sqlto create aresult_id - Add tests from
dbt_expectationsin_schema.yml(e.g.,expect_column_values_to_be_between) - Run
uv run dbt build— observe the failing test onshooting_total - Exercise: Fix the
expect_column_values_to_be_betweentest onshooting_total:- The current
max_valueis wrong. How many shots can an athlete miss in a race? - Research biathlon rules: how many shooting stages are there? How many targets per stage?
- Consider all race formats (sprint, individual, pursuit, mass start, relay legs)
- Update the
max_valueto make the test pass
uv run dbt build
- The current
Key concepts:
packages.yml+dbt deps— dependency management for dbt- dbt_utils: utility macros (surrogate keys, pivots, etc.)
- dbt_expectations: Great Expectations-style tests in dbt
- Community packages extend dbt without writing custom code
Questions:
- What does
generate_surrogate_keyproduce? Why is it useful? - Where are installed packages stored?
- How do community tests differ from built-in tests?
Goal: Create analytical tables joining the star schema.
Complete models/marts/mart_athlete_ski_performance.sql — How does each athlete perform per discipline?
Hints:
- Join
fct_resultswithdim_athletesanddim_racesusingref() - Filter out rows where
rank IS NULL(IRM results) - Group by athlete + discipline
- Aggregate:
COUNT(*),AVG(shooting_total),AVG(rank) - One row = one athlete per discipline
Tasks:
- Complete the mart model (follow the TODO)
- Run and verify:
uv run dbt run --select mart_athlete_ski_performance
- Query in Snowflake: Which nation dominates which discipline?
Complete models/marts/mart_athlete_season_evolution.sql — How does an athlete's ranking evolve over the season?
For each athlete and race, compute their cumulative average rank ordered by race date. This shows if an athlete is improving or declining throughout the season.
Hints:
- You need a window function — this cannot be done with GROUP BY alone!
AVG(rank) OVER(PARTITION BY athlete_id ORDER BY start_time ROWS UNBOUNDED PRECEDING)- Also add
ROW_NUMBER() OVER(...)to count races completed so far - Join
fct_resultswithdim_athletesanddim_races - Filter out rows where
rank IS NULL
Tasks:
- Complete the mart model (follow the TODO)
- Run and verify:
uv run dbt run --select mart_athlete_season_evolution
- Query in Snowflake: Pick an athlete — is their cumulative average rank going up or down?
Key concepts:
- Mart tables: business-facing, ready for consumption
- Joins across the star schema via
ref() - Window functions: compute aggregates without collapsing rows
PARTITION BY= the "group",ORDER BY= the sequence,ROWS UNBOUNDED PRECEDING= cumulative- The full pipeline: source → staging → intermediate → dims/fact → mart
Goal: Automate dbt build and tests on every push.
Tasks:
- Open
.github/workflows/dbt_ci.ymland complete the two TODO steps- Think about which commands you used during the course
- Triggers on push/PR to
mainand manually viaworkflow_dispatch - Uses GitHub Secrets for Snowflake credentials
- Add Snowflake secrets in GitHub: Settings → Secrets → Actions
SNOWFLAKE_ACCOUNT,SNOWFLAKE_USER,SNOWFLAKE_PASSWORDSNOWFLAKE_ROLE,SNOWFLAKE_WAREHOUSE,SNOWFLAKE_DATABASE,SNOWFLAKE_SCHEMA
- Trigger the workflow manually: go to Actions tab → select "dbt CI" → click "Run workflow"
Key concepts:
- CI/CD — automated quality gates on every push
- GitHub Secrets — secure credential management
dbt buildin CI ensures models compile, run, and pass testsworkflow_dispatchallows triggering the pipeline manually from the GitHub UI- Every push triggers the pipeline automatically
Questions:
- Why do we use GitHub Secrets instead of a
.envfile in CI? - What happens if a dbt test fails in CI?
- How would you add a separate production target?
uv run dbt debug # Test connection
uv run dbt run # Run all models
uv run dbt run --select <model> # Run one model
uv run dbt test # Run all tests
uv run dbt build # Run + test in order
uv run dbt compile # Compile Jinja → SQL (no execution)
uv run dbt docs generate # Generate documentation
uv run dbt docs serve # Serve docs on localhost
uv run dbt deps # Install packages