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:
- dbt liest
dbt_project.yml, Profile/Connection, alle Models/YAML-Dateien. - dbt kompiliert SQL (Jinja wird „gerendert“) → compiled SQL liegt in
target/compiled. - dbt baut den DAG (Abhängigkeiten über
ref,source). - dbt führt die Models in der richtigen Reihenfolge aus und erzeugt im Warehouse Views/Tabellen.
- dbt schreibt Artefakte (
manifest.json,run_results.json) nachtarget/.
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.comAuß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.comDaten
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 + Labelchurned_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 dbtIn diesem Environment installieren wir dbt und die Erweiterung für BigQuery:
pip install dbt-core dbt-bigquerydbt 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 dbt2025Beim 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 debugdbt 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.ymlSources 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: eventsStaging 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: tableStaging ausführen
Wir führen die Staging-Transformationen aus mit
dbt run --select stagingdbt 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 martsTests
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_valuesfürchurned_next_30d(nur 0/1)relationships(z. B. orders.customer_id muss in customers existieren)not_nullunduniqueauf 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 depsDie Tests starten wir nun mit:
dbt test --select mart_customer_featuresDokumentation: 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 serveErgebnis 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 deinerdbt-sa.json(komplette JSON als String)BQ_PROJECT→ z. B.gochxdbt2025BQ_DATASET→ z. B.analyticsBQ_LOCATION→ z. B.EUoderUS
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