All writing
March 15, 2025·7 min read

dbt Dimensional Modeling for Streaming Pipelines

Dimensional modeling principles don't change just because your data comes from Kafka. How I structured the dbt layer in AthleteOS to stay clean as the schema evolved.

  • dbt
  • Data Modeling
  • Kafka
  • Snowflake

When people hear "streaming pipeline," they often assume that means real-time dashboards and no data warehouse. The reality is that most streaming use cases still need a warehouse layer for historical analysis, model training, and reporting. The data just gets there faster.

In AthleteOS, Apache Flink consumes from two Kafka topics, joins streams in real time, and writes to Snowflake every 30 seconds via the Snowflake Kafka connector. dbt then runs on a schedule to transform raw Snowflake tables into clean dimensional models. Here's how I structured that layer.

The raw layer

Flink writes to two raw Snowflake tables:

  • raw.pose_events — one row per MediaPipe BlazePose frame: session_id, timestamp_ms, keypoints (JSON), form_score, flagged_errors (array)
  • raw.biometric_readings — one row per WHOOP API poll: session_id, timestamp_ms, hrv, strain, recovery_score, heart_rate

These tables are append-only. Flink never updates or deletes — it only inserts. This is an important property to preserve in the dbt layer: I never build models that require row-level updates to the raw tables.

Staging models

The staging layer handles type casting, null handling, and renaming. One staging model per raw source:

-- models/staging/stg_pose_events.sql
select
    session_id,
    to_timestamp(timestamp_ms / 1000) as event_at,
    try_parse_json(keypoints)         as keypoints,
    form_score::float                 as form_score,
    flagged_errors                    as flagged_errors,
    _ingested_at                      as ingested_at
from {{ source('raw', 'pose_events') }}
where session_id is not null

Nothing clever happens in staging. If you're doing business logic in staging, it's in the wrong layer.

Intermediate models

The intermediate layer is where the streaming-specific complexity lives. Because Flink writes every 30 seconds, a single training session produces ~180 pose event records per hour. The intermediate layer aggregates these into session-level metrics:

-- models/intermediate/int_session_pose_metrics.sql
select
    session_id,
    min(event_at)                          as session_start_at,
    max(event_at)                          as session_end_at,
    datediff('minute',
        min(event_at), max(event_at))      as duration_mins,
    count(*)                               as total_readings,
    avg(form_score)                        as avg_form_score,
    min(form_score)                        as min_form_score,
    array_agg(distinct value)              as all_errors
from {{ ref('stg_pose_events') }},
     lateral flatten(input => flagged_errors) f
group by session_id

The key insight: the intermediate layer converts streaming cardinality (one row per 30s) into analytical cardinality (one row per session). Everything downstream works with sessions, not events.

Mart models

The mart layer joins intermediates into the final fact and dimension tables consumed by the application and analytics tools:

-- models/marts/fct_sessions.sql
select
    p.session_id,
    p.session_start_at,
    p.session_end_at,
    p.duration_mins,
    p.avg_form_score,
    p.min_form_score,
    p.all_errors,
    b.avg_hrv,
    b.avg_heart_rate,
    b.max_strain,
    b.entry_recovery_score
from {{ ref('int_session_pose_metrics') }}  p
left join {{ ref('int_session_biometrics') }} b
    using (session_id)

The left join matters here. Some sessions have pose data but no biometric data (WHOOP API was offline). The model should still produce a row for those sessions rather than silently dropping them.

Incremental models for high-cardinality sources

The raw tables grow continuously. Full refreshes become expensive fast. I use incremental models for everything in the staging and intermediate layers:

{{ config(
    materialized='incremental',
    unique_key='session_id',
    on_schema_change='append_new_columns'
) }}

select ...
from {{ source('raw', 'pose_events') }}
{% if is_incremental() %}
    where _ingested_at > (select max(_ingested_at) from {{ this }})
{% endif %}

on_schema_change='append_new_columns' is important for streaming workloads. Flink schema evolution can add new fields to the Kafka topic, and this setting prevents dbt from failing when new columns appear in the source — it just adds them to the model.

Testing

dbt tests catch data quality issues that Flink and the Kafka connector can introduce:

models:
  - name: fct_sessions
    columns:
      - name: session_id
        tests:
          - unique
          - not_null
      - name: avg_form_score
        tests:
          - not_null
          - accepted_range:
              min_value: 0
              max_value: 1
      - name: duration_mins
        tests:
          - not_null
          - accepted_range:
              min_value: 1
              max_value: 300

I run these tests in the Airflow DAG that triggers dbt, downstream of the dbt run step. Failed tests halt downstream jobs — no broken data reaches the application layer.

The principle that held

Kimball's dimensional modeling principles are 30 years old and they hold for streaming data just as well as batch. The only adjustment is the intermediate aggregation step that converts streaming cardinality into analytical cardinality. Everything else — staging → intermediate → mart, incremental loading, testing at mart boundaries — transfers directly.