Lesson 08 dbt Intro
Theme:

Homework 08 — Solutions

Lesson 08 · Data Engineering with Python · Worked solutions

Solution to Task 1 — Intermediate model with relationships test

models/intermediate/int_actor_event_summary.sql:

-- models/intermediate/int_actor_event_summary.sql
WITH per_type AS (
  SELECT
    actor,
    type,
    COUNT(*) AS n
  FROM {{ ref('stg_events') }}
  GROUP BY actor, type
),
top_type AS (
  SELECT
    actor,
    type,
    ROW_NUMBER() OVER (PARTITION BY actor ORDER BY n DESC) AS rn
  FROM per_type
)
SELECT
  a.actor,
  a.total_events,
  (SELECT COUNT(DISTINCT type)
   FROM {{ ref('stg_events') }} e
   WHERE e.actor = a.actor) AS n_distinct_types,
  (SELECT type FROM top_type t
   WHERE t.actor = a.actor AND t.rn = 1) AS most_common_type
FROM {{ ref('stg_actors') }} a

models/intermediate/schema.yml:

version: 2
models:
  - name: int_actor_event_summary
    description: "One row per actor with event-count, distinct-type count, and most common type."
    columns:
      - name: actor
        tests:
          - unique
          - not_null
          - relationships:
              to: ref('stg_actors')
              field: actor

Addition to dbt_project.yml under models: github_events::

    intermediate:
      +materialized: view

Common mistake

Declaring the relationships test on the parent (stg_actors) instead of the child (int_actor_event_summary). The test belongs to the child column — "every value in this child column must exist in the parent’s field." Putting it on the parent has no effect: stg_actors isn’t the model with orphan-row potential. The error mode is silent: the test runs but never catches anything because it’s pointing at the wrong model.

Solution to Task 2 — incremental materialization

models/marts/mart_daily_event_counts.sql with incremental config:

{{ config(materialized='incremental', unique_key=['event_date', 'type']) }}

SELECT
  event_date,
  type,
  COUNT(*) AS n_events,
  COUNT(DISTINCT actor) AS n_actors
FROM {{ ref('stg_events') }}
{% if is_incremental() %}
WHERE event_date > (
  SELECT COALESCE(MAX(event_date), '1970-01-01'::date)
  FROM {{ this }}
)
{% endif %}
GROUP BY event_date, type
ORDER BY event_date, type

First run from scratch:

DBT_PROFILES_DIR=$(pwd) dbt build --full-refresh

Second run (incremental):

DBT_PROFILES_DIR=$(pwd) dbt build

The second run completes near-instantly. The log shows 0 rows affected because no new event_date values arrive between runs.

Why it’s overkill here: The course’s GitHub events Parquet is in the low hundreds of megabytes; a full rebuild on DuckDB takes a few seconds at most. The break-even for incremental is roughly “the model takes more than 5 minutes to fully rebuild AND new rows arrive faster than the rebuild cadence.” At 100 GB+ source tables where a full nightly rebuild takes 30+ minutes, incremental becomes essential. At the current scale, it adds operational complexity (back-fill semantics, late-arriving rows, schema-change handling) for zero throughput benefit.

Common mistake

Forgetting unique_key when declaring an incremental model. Without it, dbt build on subsequent runs does a bare INSERT without any deduplication. If a late-arriving event lands inside a previously-processed event_date partition, you get duplicate (event_date, type) rows in the mart silently. With unique_key set, dbt translates the incremental run into a MERGE or delete-insert operation (adapter-dependent) that handles deduplication automatically. Always declare unique_key for incremental models even when you “know” duplicates won’t happen — the data always surprises you eventually.

Solution to Task 3 — Singular freshness test

tests/recent_data_present.sql:

-- tests/recent_data_present.sql
-- Fails the build if the most-recent event_date in the daily mart is
-- more than 7 days behind today. Returns rows only when the invariant is violated.
SELECT
  MAX(event_date) AS latest,
  CURRENT_DATE AS today,
  CURRENT_DATE - MAX(event_date) AS days_stale
FROM {{ ref('mart_daily_event_counts') }}
HAVING CURRENT_DATE - MAX(event_date) > 7

To let the test pass against an aging Parquet snapshot during the lab: change > 7 to > 1000. You have effectively disabled the threshold — accept this for the lab and note that a real production SLA would keep the tight threshold and instead refresh the data on a schedule.

Common mistake

Writing the singular test to return rows unconditionally — for example, SELECT MAX(event_date) FROM mart_daily_event_counts with no HAVING clause. That always returns one row (there is always a max), so the test always fails, so dbt build is permanently broken. A singular test must return rows only when the invariant is violated. The HAVING ... > 7 clause is what makes the test correctly silent on fresh data and noisy only on stale data. Before adding the HAVING, run the bare SELECT to understand the output — then gate on the failure condition.

You’ve now built the canonical dbt sweet-spot: SQL models with a DAG, tests on every layer, and one materialization knob per model. Lesson 09 takes the same dbt project deeper into DuckDB as the local development warehouse — the project itself doesn’t move.