Januar 12, 2026

Feature Store mit dbt erstellen

In diesem Beitrag soll ein einfacher (offline) Feature Store erstellt werden. Features sollen versioniert, getestet und nachvollziehbar in einer Datenbank abgelegt werden und können dann für das Training Machine Learning Projekten genutzt werden. Ausgehend von Quelldaten werden dabei saubere und reproduzierbare Transformationen erzeugt. Die Features selbst werden als Tabellen bzw. Views in BigQuery angelegt.

dbt

Was ist dbt?

dbt (data build tool) ist ein Framework, um Transformationen im Data Warehouse zu definieren – hauptsächlich in SQL. dbt übernimmt dabei die Dinge, die in „SQL-Skript-Sammlungen“ oft fehlen:

  • Models: SQL-Dateien, die in Tabellen/Views inkrementelle Tabellen im Warehouse übersetzt werden.
  • DAG (Dependency Graph): dbt erkennt Abhängigkeiten über ref() und baut daraus einen gerichteten Graphen. Dadurch weiß dbt, welche Modelle in welcher Reihenfolge laufen müssen.
  • Jinja-Templating: SQL wird durch Jinja erweitert ({{ ref(...) }}, {{ source(...) }}, Macros, Variablen).
  • Tests: deklarative Datenqualitätsprüfungen (z. B. not_null, unique) und erweiterbar über Packages.
  • Dokumentation: dbt generiert Dokumentation samt Lineage-Graph direkt aus dem Projekt.
  • Versionierung: dbt-Projekte sind Code und können in Git-Workflows eingebunden werden.

dbt wird entwickelt von dbt labs. Die offizielle Dokumentation findet man hier.

Wie funktioniert dbt?

Folgendes passiert wenn man dbt run startet:

  1. dbt liest dbt_project.yml, Profile/Connection, alle Models/YAML-Dateien.
  2. dbt kompiliert SQL (Jinja wird „gerendert“) → compiled SQL liegt in target/compiled.
  3. dbt baut den DAG (Abhängigkeiten über ref, source).
  4. dbt führt die Models in der richtigen Reihenfolge aus und erzeugt im Warehouse Views/Tabellen.
  5. dbt schreibt Artefakte (manifest.json, run_results.json) nach target/.

Beispielprojekt mit BigQuery, dbt und GitHub

Als Beispielprojekt verwenden wir dbt zusammen mit BigQuery. GitHub verwenden wir um den Code zu versionieren.

GCP-Projekt anlegen

Zunächst wird in der GCP Console ein neues Projekt erstellt und die BigQuery API aktiviert.

gcloud auth login
gcloud components update
gcloud projects create gochxdbt2025
gcloud config set project gochxdbt2025
gcloud auth application-default set-quota-project gochxdbt2025
gcloud services enable bigquery.googleapis.com

Außerdem benötigen wir für die Ausführung von dbt einen Service Account:

gcloud iam service-accounts create dbt-runner --display-name="dbt runner"

Dem Service Account weisen wir noch entsprechende BigQuery Rollen zu:

  • roles/bigquery.jobUser (Jobs ausführen)
  • roles/bigquery.dataEditor (Tabellen/Views schreiben)
gcloud projects add-iam-policy-binding gochxdbt2025 --member="serviceAccount:dbt-runner@gochxdbt2025.iam.gserviceaccount.com" --role="roles/bigquery.jobUser"

gcloud projects add-iam-policy-binding gochxdbt2025 --member="serviceAccount:dbt-runner@gochxdbt2025.iam.gserviceaccount.com" --role="roles/bigquery.dataEditor"

Dann müssen wir noch ein Keyfile erzeugen, welches wir zur Authentifizierung verwenden. Den Befehl sollte man am besten von dem Pfad ausführen den später für das File verwendet werden soll (alternativ: den entsprechenden Pfad im Befehl angeben).

gcloud iam service-accounts keys create dbt-sa.json --iam-account=dbt-runner@gochxdbt2025.iam.gserviceaccount.com

Daten

Als Beispiel-Projekt erzeugen wir eine einfache Datenbank in BigQuery. Die Daten sind künstlich erzeugt und sollen die typische Struktur einer einfachen Online-Shops darstellen. Pro Kunde und Stichtag gibt es eine Zeile.

Quellen (raw):

  • raw.customers (customer_id, signup_date)
  • raw.orders (order_id, customer_id, order_date, revenue)
  • raw.events (customer_id, event_ts, event_type)

Output (analytics):

  • Staging Views (leicht bereinigt/typisiert)
  • Intermediate: int_customer_daily (Aggregationen je Kunde/Stichtag)
  • Mart: mart_customer_features (finale Features + Label churned_next_30d)

BigQuery

In BigQuery Studio (oder per CLI) legen wir die beiden Ebenen als Datasets an:

  • raw (Rohdaten, nur Laden, keine dbt-Writes)
  • analytics (dbt-Zielschema, staging, intermediate, marts)

Tabelle „customers“ (enthält Kunden-ID und Anmeldedatum):

create or replace table `gochxdbt2025.raw.customers` as
select 'C1' as customer_id, date '2024-01-01' as signup_date union all
select 'C2', date '2024-01-10' union all
select 'C3', date '2024-02-05';

Tabelle „orders“ (enthält Bestellungen in der Form Order-ID, Order-Datum und Umsatz):

create or replace table `gochxdbt2025.raw.orders` as
select 'O1' as order_id, 'C1' as customer_id, date '2024-02-01' as order_date, 120.0 as revenue union all
select 'O2', 'C1', date '2024-02-15', 60.0 union all
select 'O3', 'C2', date '2024-02-20', 200.0 union all
select 'O4', 'C2', date '2024-04-01', 80.0;

Tabelle „events“ (enthält Kunden-Aktionen in der Form Event-Zeitstempel und Event-Typ):

create or replace table `gochxdbt2025.raw.events` as
select 'C1' as customer_id, timestamp '2024-02-10 10:00:00+00' as event_ts, 'view' as event_type union all
select 'C1', timestamp '2024-02-11 12:00:00+00', 'add_to_cart' union all
select 'C2', timestamp '2024-02-19 08:00:00+00', 'view' union all
select 'C3', timestamp '2024-03-01 09:00:00+00', 'view';

GitHub Repository anlegen

Anschließend erstellen wir in GitHub ein neues Repository und klonen es lokal:

git clone http://github.com/<repo>.git
cd <repo>

.gitignore

Bevor wir auf Git pushen müssen wir unbedingt noch eine entsprechende .gitignore anlegen um zu verhindern, das Secrets auf Git gepushed werden.

# dbt build artifacts
target/
dbt_packages/

dbt2025/target/
dbt2025/dbt_packages/
dbt2025/logs/

# dbt logs
logs/
dbt2025/logs
*.log

# Credentials
*.json
profiles.yml
.env
.env.*

dbt installieren

Für die Nutzung von dbt erzeugen wie ein neues Python Environment (Details siehe hier ).
Mit Conda geht das so:

conda create --name dbt python=3.12
conda activate dbt

In diesem Environment installieren wir dbt und die Erweiterung für BigQuery:

pip install dbt-core dbt-bigquery

dbt Projekt initialisieren

Schließlich muss das Projekt noch initialisiert werden. Das macht man vom Ordner des Git-Repositories aus. Dort wird dann ein entsprechender Unterordner für das Projekt angelegt:

dbt init dbt2025
cd  dbt2025

Beim Setup wählst du:

  • Adapter: BigQuery
  • Auth: service_account
  • Keyfile: Pfad zur dbt-sa.json
  • project: gochxdbt2025
  • dataset: analytics
  • job_execution_timeout_seconds: 300
  • location: z. B. EU
  • threads: z. B. 4

Um die Verbindung zu testen, wechseln wir in den Projektordner und starten folgenden Befehl:

dbt debug

dbt prüft dann die komplette Konfiguration und verbindet sich auf die Datenbank. Wenn alles richtig eingerichtet ist, dann steht am Ende der Ausgabe „All checks passed!“

Projektstruktur: dbt Modelle anlegen

Zum sauberen Aufbau unseres Projektes wollen wir folgende Struktur erzeugen:

  • staging: 1:1 auf Source, nur Typen, Naming, leichte Bereinigung
  • intermediate (int_): Business-Logik in Bausteinen (Aggregationen, Joins)
  • marts: final, konsistent, konsumierbar (BI/ML/Reverse ETL)

Nun wechseln wir in den Ordner dbt2025/models und legen folgende Struktur an:

models/
  sources.yml
  staging/
    stg_customers.sql
    stg_orders.sql
    stg_events.sql
  marts/
    int_customer_daily.sql
    mart_customer_features.sql
    schema.yml

Sources definieren

Die Datei „sources.yml“ definiert die Quellen. Dadurch werden Rohdaten sauber referenziert und können entsprechend dokumentiert und getestet werden:

version: 2

sources:
  - name: raw
    database: gochxdbt2025
    schema: raw
    tables:
      - name: customers
      - name: orders
      - name: events

Staging Modelle

Für die Transformation von den Quelldaten in den Staging Layer werden entsprechende SQLs definiert. Im ersten Schritt werden dabei nur wenige einfache Transformationen vorgenommen. Dabei greift man in dbt im From-Teil der Abfrage auf Jinja-Templates zurück.

stg_customers.sql

select
  customer_id,
  signup_date
from {{ source('raw', 'customers') }}

stg_events.sql

select
  customer_id,
  event_ts,
  event_type
from {{ source('raw', 'events') }}

stg_orders.sql

select
  order_id,
  customer_id,
  order_date,
  cast(revenue as numeric) as revenue
from {{ source('raw', 'orders') }}

dbt_projects.yml

Die Datei dbt_project.yml dient dazu, die Materialisierung zu steuern. Staging wird dabei als „view“ angelegt (schnell, günstig, gut zu debuggen). Die marts werden als „table“ angelegt (stabil, schneller Zugriff und entkoppelt. Darüber hinaus gibt es noch „incremental“, wobei nur neue / aktualisierte Daten ergänzt werden.

In dbt_projects.yml passen wir so dies entsprechend im Bereich „models“ an:


# Name your project! 
name: 'dbt2025'
version: '1.0.0'

# This setting configures which "profile" dbt uses for this project.
profile: 'dbt2025'

# These configurations specify where dbt should look for different types of files.
# The `model-paths` config, for example, states that models in this project can be
# found in the "models/" directory. You probably won't need to change these!
model-paths: ["models"]
analysis-paths: ["analyses"]
test-paths: ["tests"]
seed-paths: ["seeds"]
macro-paths: ["macros"]
snapshot-paths: ["snapshots"]

clean-targets:         # directories to be removed by `dbt clean`
  - "target"
  - "dbt_packages"

# Configuring models
# Full documentation: https://docs.getdbt.com/docs/configuring-models
models:
  dbt2025:
    staging:
      +materialized: view
    marts:
      +materialized: table

Staging ausführen

Wir führen die Staging-Transformationen aus mit

dbt run --select staging

dbt kompiliert in diesem Schritt die Staging Modelle und erstellt im „analytics“ Dataset die entsprechenden Views. .

Intermediate-Layer: Aggregationen bauen

In unserem Beispiel sollen für jeden Kunden und jeden Stichtag (Order-Datum) weitere Features erzeugt werden:

  • orders_30d: Anzahl Bestellungen in den letzten 30 Tagen
  • revenue_30d: Umsatz in den letzten 30 Tagen
  • days_since_last_order: Recency

Wir verweisen dabei auf ref('stg_orders'), was mehr ist als nur ein Verweis auf den „Tabellennamem“:

  • dbt baut Abhängigkeiten automatisch (DAG)
  • dbt kann Schema/DB je Environment ändern, ohne SQL anzufassen
  • Lineage & Docs werden korrekt

Im Verzeichnis models/marts erstellen wir folgende Abfrage:

int_customer_daily.sql

with orders as (
  select * from {{ ref('stg_orders') }}
),

customer_days as (
  select distinct
    customer_id,
    order_date as as_of_date
  from orders
),

agg_30d as (
  select
    d.customer_id,
    d.as_of_date,
    countif(o.order_date between date_sub(d.as_of_date, interval 30 day) and d.as_of_date) as orders_30d,
    sum(if(o.order_date between date_sub(d.as_of_date, interval 30 day) and d.as_of_date, o.revenue, 0)) as revenue_30d,
    max(o.order_date) as last_order_date
  from customer_days d
  left join orders o
    on o.customer_id = d.customer_id
   and o.order_date <= d.as_of_date
  group by 1,2
)

select
  customer_id,
  as_of_date,
  orders_30d,
  revenue_30d,
  date_diff(as_of_date, last_order_date, day) as days_since_last_order
from agg_30d

Mart: finale Feature-Tabelle

Die finale Feature Tabelle wird abschließend als „mart“ erzeugt. Dabei ergänzen wir ein einfaches Label „churned_next_30d = 1“ wenn keine Orders in den 30 Tagen nach as_of_date passieren.

mart_customer_features.sql

with daily as (
  select * from {{ ref('int_customer_daily') }}
),

orders as (
  select * from {{ ref('stg_orders') }}
),

label as (
  select
    d.customer_id,
    d.as_of_date,
    case
      when countif(o.order_date > d.as_of_date and o.order_date <= date_add(d.as_of_date, interval 30 day)) = 0
      then 1 else 0
    end as churned_next_30d
  from daily d
  left join orders o
    on o.customer_id = d.customer_id
  group by 1,2
)

select
  d.customer_id,
  d.as_of_date,
  d.orders_30d,
  d.revenue_30d,
  d.days_since_last_order,
  l.churned_next_30d
from daily d
join label l
  using (customer_id, as_of_date)

Wir führen aus mit

dbt run --select marts

Tests

Im Ordner „marts“ erstellen wir außerdem noch eine Datei namens „schema.yml“, welche unsere Tests definiert.

dbt übersetzt jeden Test in eine SQL-Query, die „Bad Rows“ selektiert.

  • Wenn 0 Rows → Test ok
  • Wenn >0 Rows → Test fail (Build sollte dann in CI rot werden)

Zusätzliche sinnvolle Tests (empfohlen):

  • accepted_values für churned_next_30d (nur 0/1)
  • relationships (z. B. orders.customer_id muss in customers existieren)
  • not_null und unique auf natural keys
version: 2

models:
  - name: mart_customer_features
    columns:
      - name: customer_id
        tests: [not_null]
      - name: as_of_date
        tests: [not_null]
      - name: churned_next_30d
        tests: [not_null]
    tests:
      - dbt_utils.unique_combination_of_columns:
          arguments:
            combination_of_columns: [customer_id, as_of_date]

Damit die Tests laufen müssen wir noch entsprechende Pakete definieren. Dafür erzeugen wir im Hauptordner (dort wo die Datei „dbt_project.yml“ liegt) eine Datein namens „packages.yml“.

packages:
  - package: dbt-labs/dbt_utils
    version: [">=1.0.0", "<2.0.0"]

Dann installieren wir die Pakete mit:

dbt deps

Die Tests starten wir nun mit:

dbt test --select mart_customer_features

Dokumentation: Docs und Generate

dbt erstellt eine automatische Dokumentation:

  • Model- und Column-Docs (wenn Beschreibungen gepflegt sind)
  • Lineage Graph (wer hängt von wem ab)
  • Test Coverage
  • SQL Code & Compiled SQL

Mit „docs serve“ wird diese im Browser dargestellt.

dbt docs generate
dbt docs serve

Ergebnis abfragen

select *
from `gochxdbt2025.analytics.mart_customer_features`
where as_of_date >= '2024-02-01';

CI/CD

In der Praxis ist es oft sinnvoll mit „dbt build“ zu arbeiten. Das kombiniert:

  • run
  • test
  • snapshots (falls vorhanden)
  • seeds (falls vorhanden)

Um das dbt Setup mit GitHub Actions umzusetzen verwendet man zwei Workflows:

  • PR-Workflow (Slim CI): baut nur betroffene Models + Tests (schnell)
  • Main-Workflow: optional „Full Build“ (oder ebenfalls Slim, je nach Geschmack)

GitHub Secrets anlegen

Statt die Secrets direkt ins Repo zu packen erzeugen wir „profiles.yml“ zur Laufzeit und schreiben das Service-Account-JSON in eine Datei. Um das zu ermöglichen müssen im GitHub Repo unter Settings → Secrets and variables → Actions → New repository secret folgende Secrets angelegt werden:

  • BQ_SERVICE_ACCOUNT_JSON → Inhalt deiner dbt-sa.json (komplette JSON als String)
  • BQ_PROJECT → z. B. gochxdbt2025
  • BQ_DATASET → z. B. analytics
  • BQ_LOCATION → z. B. EU oder US

Workflow 1: Pull Request (Slim CI)

Erstelle Datei: .github/workflows/dbt_pr.yml

name: dbt PR (Slim CI)

on:
  pull_request:
    branches: ["main"]

jobs:
  dbt-build:
    runs-on: ubuntu-latest

    env:
      DBT_PROFILES_DIR: /home/runner/.dbt
      BQ_PROJECT: ${{ secrets.BQ_PROJECT }}
      BQ_DATASET: ${{ secrets.BQ_DATASET }}
      BQ_LOCATION: ${{ secrets.BQ_LOCATION }}
      PR_NUMBER: ${{ github.event.pull_request.number }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0  # wichtig für git diff / state

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dbt
        run: |
          python -m pip install --upgrade pip
          pip install dbt-core dbt-bigquery

      - name: Write BigQuery service account key
        run: |
          mkdir -p .keys
          echo '${{ secrets.BQ_SERVICE_ACCOUNT_JSON }}' > .keys/bq.json

      - name: Create dbt profiles.yml
        run: |
          mkdir -p "${DBT_PROFILES_DIR}"
          cat > "${DBT_PROFILES_DIR}/profiles.yml" << 'YAML'
          dbt2025:
            target: ci
            outputs:
              ci:
                type: bigquery
                method: service-account
                keyfile: .keys/bq.json
                project: ${BQ_PROJECT}
                dataset: ${BQ_DATASET}
                location: ${BQ_LOCATION}
                threads: 4
                job_execution_timeout_seconds: 300
          YAML

      - name: Install dbt deps
        working-directory: dbt2025
        run: dbt deps

      # Slim CI: nur Models bauen, die sich geändert haben (plus Downstream)
      # benötigt State aus main branch
      - name: Fetch main state artifacts (manifest)
        working-directory: dbt2025
        run: |
          git fetch origin main:refs/remotes/origin/main
          # Manifest vom letzten main Build wäre ideal als Artifact/Cache.
          # Minimal-Variante: wir erzeugen state aus origin/main, indem wir dbt parse auf main ausführen:
          mkdir -p /tmp/dbt_state_main
          git worktree add /tmp/main_worktree origin/main
          cd /tmp/main_worktree/dbt2025
          python -m pip install --upgrade pip
          pip install dbt-core dbt-bigquery
          mkdir -p /home/runner/.dbt
          # reuse profile and keyfile path works because worktree shares repo root? we keep keyfile relative;
          # easiest: copy keyfile + profile
          cp -r /home/runner/.dbt /tmp/main_worktree/
          cp -r ${GITHUB_WORKSPACE}/.keys /tmp/main_worktree/
          export DBT_PROFILES_DIR=/tmp/main_worktree/.dbt
          dbt deps
          dbt parse --target ci
          cp -r target/manifest.json /tmp/dbt_state_main/manifest.json
          cd ${GITHUB_WORKSPACE}/dbt2025
          dbt parse --target ci

      - name: dbt build (state:modified+)
        working-directory: dbt2025
        run: |
          dbt build --target ci \
            --select "state:modified+" \
            --state /tmp/dbt_state_main

      - name: Upload dbt artifacts
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: dbt-artifacts-pr
          path: |
            dbt2025/target
            dbt2025/logs

Workflow 2: Main Branch (Full Build + Manifest für Slim CI)

Erstelle Datei: .github/workflows/dbt_main.yml

name: dbt Main (Build + Publish Manifest)

on:
  push:
    branches: ["main"]
  workflow_dispatch: {}

jobs:
  dbt-build:
    runs-on: ubuntu-latest

    env:
      DBT_PROFILES_DIR: /home/runner/.dbt
      BQ_PROJECT: ${{ secrets.BQ_PROJECT }}
      BQ_DATASET: ${{ secrets.BQ_DATASET }}
      BQ_LOCATION: ${{ secrets.BQ_LOCATION }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dbt
        run: |
          python -m pip install --upgrade pip
          pip install dbt-core dbt-bigquery

      - name: Write BigQuery service account key
        run: |
          mkdir -p .keys
          echo '${{ secrets.BQ_SERVICE_ACCOUNT_JSON }}' > .keys/bq.json

      - name: Create dbt profiles.yml
        run: |
          mkdir -p "${DBT_PROFILES_DIR}"
          cat > "${DBT_PROFILES_DIR}/profiles.yml" << 'YAML'
          dbt2025:
            target: ci
            outputs:
              ci:
                type: bigquery
                method: service-account
                keyfile: .keys/bq.json
                project: ${BQ_PROJECT}
                dataset: ${BQ_DATASET}
                location: ${BQ_LOCATION}
                threads: 4
                job_execution_timeout_seconds: 300
          YAML

      - name: Install dbt deps
        working-directory: dbt2025
        run: dbt deps

      - name: dbt build (full)
        working-directory: dbt2025
        run: dbt build --target ci

      - name: Upload dbt artifacts (for Slim CI state)
        uses: actions/upload-artifact@v4
        with:
          name: dbt-manifest-main
          path: |
            dbt2025/target/manifest.json
            dbt2025/target/run_results.json
            dbt2025/target/catalog.json
            dbt2025/target/sources.json

      - name: Upload dbt logs
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: dbt-logs-main
          path: |
            dbt2025/logs
            dbt2025/target

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert