Wissen

dbt mit Data Contracts: synchronisieren statt nachpflegen

Wer Data Contracts in ein dbt-Projekt einführt, pflegt am Ende meist dasselbe Schema zweimal: einmal im Contract, einmal im dbt-YAML. Die beiden laufen sofort auseinander. datacontract dbt sync erspart dir das Abtippen: Der Befehl schreibt den Contract direkt in ein bestehendes dbt-Projekt hinein.

Dafür brauchst du zwei Befehle:

# alle Data Contracts im aktuellen Verzeichnis finden und
# sicherstellen, dass sie in den dbt-Modell-YAMLs hinterlegt sind
datacontract dbt sync

# alle Tests aus dem Contract mit dbt ausführen und die Ergebnisse ausgeben
datacontract dbt test

Lässt du beide in der CI laufen, ist der Contract kein Dokument mehr, das irgendwer parallel zum Code aktuell halten muss. Er wird zur Single Source of Truth für dein dbt-Projekt. In diesem Artikel schauen wir uns das an einem einfachen orders-Modell an.

Der Ausgangspunkt

Wir gehen davon aus, dass du schon ein dbt-Projekt hast. Falls du bei null anfängst, schau dir vorher vielleicht unseren Data Product Builder an.

Wir starten hier mit diesem orders-Modell auf Databricks:

models/orders.sql
select
    order_id,
    internal_flag,
    customer_id,
    status,
    amount,
    ordered_at,
    email
from {{ ref('stg_orders') }}

dbt sync braucht keine vorhandenen Properties-Dateien — fehlen sie, werden sie einfach angelegt. In unserem Fall hat aber schon jemand die Spalten dokumentiert:

Das orders-Modell in dbt docs: Jede Spalte hat eine Beschreibung, die Typen kommen von Databricks als integer, string, decimal(10,2) und timestamp, und die Spalte Data Tests markiert order_id als unique und not-null, customer_id als not-null mit einem relationships-Test und die übrigen Spalten als not-null.
version: 2

models:
  - name: orders
    description: One row per order.
    columns:
      - name: order_id
        description: Unique identifier for the order.
        data_tests:
          - unique
          - not_null
      # Internes Ops-Feld, bewusst nicht Teil des Contracts.
      - name: internal_flag
        description: Fulfilment queue marker used by the ops team.
        data_tests:
          - not_null
      - name: customer_id
        description: Identifier of the customer who placed the order.
        data_tests:
          - not_null
          - relationships:
              to: ref('customers')
              field: customer_id
      - name: status
        description: Current status of the order.
        data_tests:
          - not_null
      - name: amount
        description: Total order amount.
        data_tests:
          - not_null
      - name: ordered_at
        description: Timestamp the order was placed.
        data_tests:
          - not_null
      - name: email
        description: Customer email address captured at order time.
        data_tests:
          - not_null

In unserem Beispiel definiert das dbt-Projekt seine Modelle bereits in Properties-Dateien. dbt sync bearbeitet sie direkt (oder legt neue an, wenn es keine gibt).

Wie sagen wir den Consumers unseres dbt-Projekts jetzt verbindlich zu, wie diese Daten aussehen? Genau dafür gibt es Data Contracts: ein eigenständiges, versioniertes Dokument mit Schema, Typen, Qualitätsregeln und den Zuständigkeiten dafür — lesbar auch für Consumers ohne Zugriff auf unser Repo und ohne dbt-Kenntnisse, und gleichzeitig maschinell prüfbar. Legen wir also aus dem, was wir haben, einen Data Contract an.

Einen Data Contract anlegen

Den ersten Contract muss niemand von Hand schreiben. datacontract import dbt liest dbts manifest.json und leitet Spalten, Beschreibungen, Primary Keys, Pflichtfelder und Foreign Keys aus den Tests und Constraints ab, die ohnehin schon im Projekt stehen. Wir lassen dbt also erst das Manifest bauen und importieren daraus:

dbt parse

datacontract import dbt \
  --source target/manifest.json \
  --model orders \
  --id orders \
  --output orders.odcs.yaml

Ab hier gehört der Contract uns. Wir werfen internal_flag raus — ein Ops-Feld, auf das sich Consumers ohnehin nicht verlassen sollten — und ergänzen Details: das Warehouse, in dem die Daten liegen, die vier möglichen status-Werte, präzisere Datentypen, die Währung von amount oder Länge und Format der email.

version: 1.0.0
kind: DataContract
apiVersion: v3.1.0
id: orders
name: orders_demo
status: draft
schema:
- name: orders
  physicalType: view
  description: One row per order.
  logicalType: object
  physicalName: orders
  properties:
  - name: order_id
    description: Unique identifier for the order.
    primaryKey: true
    primaryKeyPosition: 1
    logicalType: string
    required: true
    unique: true
  - name: internal_flag
    description: Fulfilment queue marker used by the ops team.
    logicalType: string
    required: true
  - name: customer_id
    description: Identifier of the customer who placed the order.
    customProperties:
    - property: references
      value: customers.customer_id
    logicalType: string
    required: true
  - name: status
    description: Current status of the order.
    logicalType: string
    required: true
  - name: amount
    description: Total order amount.
    logicalType: string
    required: true
  - name: ordered_at
    description: Timestamp the order was placed.
    logicalType: string
    required: true
  - name: email
    description: Customer email address captured at order time.
    logicalType: string
    required: true
customProperties:
- property: dbt_version
  value: 1.11.9
version: 1.0.0
kind: DataContract
apiVersion: v3.1.0
id: orders
name: Orders
status: active
description:
  purpose: One row per customer order, from checkout through fulfilment.
  usage: Order volume and revenue reporting, and any downstream model that needs order state.
  limitations: Rebuilt nightly, so not suitable for real-time use. Excludes baskets that never became orders.
servers:
  - server: production
    type: databricks
    catalog: playground
    schema: orders_demo
schema:
  - name: orders
    physicalName: orders
    description: One row per order.
    properties:
      - name: order_id
        description: Unique identifier for the order.
        logicalType: integer
        primaryKey: true
        required: true
      - name: customer_id
        description: Identifier of the customer who placed the order.
        logicalType: integer
        required: true
        relationships:
          - to: customers.customer_id
      - name: status
        description: Current status of the order.
        logicalType: string
        required: true
        customProperties:
          - property: enum
            value:
              - placed
              - shipped
              - completed
              - returned
      - name: amount
        description: Total order amount in USD.
        logicalType: number
        physicalType: DECIMAL(10,2)
        required: true
      - name: ordered_at
        description: Timestamp the order was placed.
        logicalType: timestamp
        required: true
      - name: email
        description: Customer email address captured at order time.
        logicalType: string
        required: true
        logicalTypeOptions:
          maxLength: 255
          pattern: "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"

Den Contract ins Projekt synchronisieren

Jetzt wäre es verlockend, diese Verfeinerungen von Hand in die dbt-Properties-Dateien zurückzutragen. Das geht besser — probieren wir dbt sync aus:

datacontract dbt sync
orders.odcs.yaml: Synced 1 model: updated 1 YAML file, wrote 2 singular SQL tests.
  ~ models/orders.yml
  + tests/datacontract_cli/orders/orders__1_0_0__orders__email__length.sql
  + tests/datacontract_cli/orders/orders__1_0_0__orders__email__pattern.sql
orders.odcs.yaml: 1 item in the project is not declared in the contract; pass --prune to remove:
  - orders: column `internal_flag`
Run `datacontract dbt test` to execute the generated tests.

Was ist passiert? Die Data Contract CLI hat im aktuellen Verzeichnis und darunter nach Data Contracts gesucht (*.odcs.yaml) und dabei sowohl unseren neuen Contract als auch die vorhandene Properties-Datei models/orders.yml gefunden. Alles, was im Contract steht, aber nicht in der Datei, wurde ergänzt — dazu etwas Metainformation.

Zwei Checks — Länge und Muster der E-Mail-Adresse — ließen sich nicht abbilden, ohne zusätzliche Pakete ins dbt-Projekt zu holen. Statt diese Abhängigkeit einzugehen, hat dbt sync dafür Singular Tests in SQL erzeugt; die sehen wir uns gleich an.

Standardmäßig löscht dbt sync nichts aus den Properties-Dateien, was der Data Contract nicht ausdrücklich überschreibt. Das gilt für Kommentare und selbst ergänzte Tests, aber auch für Spalten wie unser internal_flag, das im Contract fehlt. Der Hinweis in der Ausgabe zeigt den Weg: Mit --prune fliegt die Spalte raus.

Schauen wir in die geänderte Properties-Datei. Dort stehen jetzt zum Beispiel die zulässigen Werte für status, die aktualisierte Beschreibung von amount und automatisch erzeugte, lesbare Check-Namen.

version: 2

models:
  - name: orders
    description: One row per order.
    columns:
      - name: order_id
        description: Unique identifier for the order.
        data_tests:
          - unique
          - not_null
      # Internes Ops-Feld, bewusst nicht Teil des Contracts.
      - name: internal_flag
        description: Fulfilment queue marker used by the ops team.
        data_tests:
          - not_null
      - name: customer_id
        description: Identifier of the customer who placed the order.
        data_tests:
          - not_null
          - relationships:
              to: ref('customers')
              field: customer_id
      - name: status
        description: Current status of the order.
        data_tests:
          - not_null
      - name: amount
        description: Total order amount.
        data_tests:
          - not_null
      - name: ordered_at
        description: Timestamp the order was placed.
        data_tests:
          - not_null
      - name: email
        description: Customer email address captured at order time.
        data_tests:
          - not_null
version: 2

models:
  - name: orders
    description: One row per order.
    columns:
      - name: order_id
        description: Unique identifier for the order.
        data_tests:
          - unique:
              description: Check that field order_id has no duplicate values
              config:
                meta:
                  datacontract_cli:
                    include_in_tests: true
                    check: orders__order_id__field_unique
                    contract_versions:
                      - 1.0.0
                    generated: false
          - not_null:
              description: Check that field order_id has no missing values
              config:
                meta:
                  datacontract_cli:
                    include_in_tests: true
                    check: orders__order_id__field_required
                    contract_versions:
                      - 1.0.0
                    generated: false
        data_type: INT
      # Internes Ops-Feld, bewusst nicht Teil des Contracts.
      - name: internal_flag
        description: Fulfilment queue marker used by the ops team.
        data_tests:
          - not_null
      - name: customer_id
        description: Identifier of the customer who placed the order.
        data_tests:
          - not_null:
              description: Check that field customer_id has no missing values
              config:
                meta:
                  datacontract_cli:
                    include_in_tests: true
                    check: orders__customer_id__field_required
                    contract_versions:
                      - 1.0.0
                    generated: false
          - relationships:
              to: ref('customers')
              field: customer_id
              description: Check that field customer_id references ref('customers').customer_id
              config:
                meta:
                  datacontract_cli:
                    include_in_tests: true
                    check: orders__customer_id__field_relationships
                    contract_versions:
                      - 1.0.0
                    generated: false
        data_type: INT
      - name: status
        description: Current status of the order.
        data_tests:
          - not_null:
              description: Check that field status has no missing values
              config:
                meta:
                  datacontract_cli:
                    include_in_tests: true
                    check: orders__status__field_required
                    contract_versions:
                      - 1.0.0
                    generated: false
          - accepted_values:
              values:
                - placed
                - shipped
                - completed
                - returned
              config:
                meta:
                  datacontract_cli:
                    check: orders__status__field_enum
                    include_in_tests: true
                    contract_versions:
                      - 1.0.0
                    generated: true
              description: Check that field status only contains enum values ['placed', 'shipped', 'completed', 'returned']
        data_type: STRING
      - name: amount
        description: Total order amount in USD.
        data_tests:
          - not_null:
              description: Check that field amount has no missing values
              config:
                meta:
                  datacontract_cli:
                    include_in_tests: true
                    check: orders__amount__field_required
                    contract_versions:
                      - 1.0.0
                    generated: false
        data_type: DECIMAL(10,2)
      - name: ordered_at
        description: Timestamp the order was placed.
        data_tests:
          - not_null:
              description: Check that field ordered_at has no missing values
              config:
                meta:
                  datacontract_cli:
                    include_in_tests: true
                    check: orders__ordered_at__field_required
                    contract_versions:
                      - 1.0.0
                    generated: false
        data_type: TIMESTAMP
      - name: email
        description: Customer email address captured at order time.
        data_tests:
          - not_null:
              description: Check that field email has no missing values
              config:
                meta:
                  datacontract_cli:
                    include_in_tests: true
                    check: orders__email__field_required
                    contract_versions:
                      - 1.0.0
                    generated: false
        data_type: STRING
    config:
      meta:
        datacontract_cli:
          contract_id: orders

Ein zweiter Lauf von dbt sync ändert jetzt nichts mehr:

orders.odcs.yaml: Synced 1 model: updated 0 YAML files.
orders.odcs.yaml: 1 item in the project is not declared in the contract; pass --prune to remove:
  - orders: column `internal_flag`
Run `datacontract dbt test` to execute the generated tests.

Die Tests, die dabei herauskommen — auch die, die dbt allein nicht abbilden kann

Der dbt-Projektbaum: models/ enthält customers.sql, orders.sql, orders.yml und stg_orders.sql; tests/ enthält das generierte Verzeichnis datacontract_cli/orders/ mit den beiden E-Mail-Check-Dateien, daneben das von Hand geschriebene assert_returned_orders_have_amount.sql.
Generierte Tests liegen in einem eigenen Verzeichnis. Der Rest des Projekts wird direkt bearbeitet.

Jede Regel im Contract wird zu einem echten dbt-Test: required zu not_null, primaryKey zu unique, ein Enum zu accepted_values und ein relationships-Eintrag zu dbts relationships-Test, wobei das Ziel als ref() geschrieben wird. Was im Projekt fehlt, legt Sync an; was schon da ist, übernimmt es.

Für Properties wie maxLength und pattern gibt es allerdings keinen generischen dbt-Test. Sie landen deshalb als Singular Tests in SQL:

tests/datacontract_cli/orders/orders__1_0_0__orders__email__length.sql
-- AUTO-GENERATED by `datacontract dbt sync`. Do not edit.
-- Source contract: orders@1.0.0 (model: orders, check: email__length)
{{ config(meta={"datacontract_cli": {"check": "orders__email__field_length", "contract_versions": ["1.0.0"], "generated": true, "include_in_tests": true, "model": "orders", "field": "email", "description": "Check that field email has a length of at most 255"}}) }}
SELECT *
FROM {{ ref('orders') }}
WHERE "email" IS NOT NULL
  AND (LENGTH("email") > 255)

Die Tests ausführen

Jetzt, wo Contract und dbt-Projekt synchron sind, könnten wir die Daten einfach mit dbts eingebautem dbt test prüfen. Mit Data Contracts im Spiel bietet datacontract dbt test aber einen Wrapper mit ein paar Extras: Er läuft gezielt gegen einen oder mehrere Data Contracts, zeigt lesbare Check-Namen an, setzt den Exit-Code passend zum Ergebnis und kann die Ergebnisse exportieren.

datacontract dbt test orders.odcs.yaml
Contract orders@1.0.0/Users/you/orders-demo/orders.odcs.yaml
╭────────┬──────────────────────────────────────────────────────────────────────────────────────────────────┬─────────────┬─────────╮
│ Result  Check                                                                                             Field        Details │
├────────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼─────────────┼─────────┤
│ passed │ Check that field amount has no missing values                                                    │ amount      │         │
│ passed │ Check that field customer_id has no missing values                                               │ customer_id │         │
│ passed │ Check that field customer_id references ref('customers').customer_id                             │ customer_id │         │
│ passed │ Check that field email has no missing values                                                     │ email       │         │
│ passed │ Check that field email has a length of at most 255                                               │ email       │         │
│ passed │ Check that field email matches regex pattern ^[^@\s]+@[^@\s]+\.[^@\s]+$                          │ email       │         │
│ passed │ Check that field order_id has no missing values                                                  │ order_id    │         │
│ passed │ Check that field order_id has no duplicate values                                                │ order_id    │         │
│ passed │ Check that field ordered_at has no missing values                                                │ ordered_at  │         │
│ passed │ Check that field status only contains enum values ['placed', 'shipped', 'completed', 'returned'] │ status      │         │
│ passed │ Check that field status has no missing values                                                    │ status      │         │
╰────────┴──────────────────────────────────────────────────────────────────────────────────────────────────┴─────────────┴─────────╯
🟢 dbt tests passed. Ran 11 tests. Took 0.00406 seconds.

Schleus einen ungültigen status-Wert ein, und derselbe Befehl meldet ihn — im Wortlaut des Contracts statt in dem von dbt:

Contract orders@1.0.0/Users/you/orders-demo/orders.odcs.yaml
╭────────┬───────────────────────────────────────────────────────┬─────────────┬────────────────────────────────────────────────────╮
│ Result  Check                                                  Field        Details                                            │
├────────┼───────────────────────────────────────────────────────┼─────────────┼────────────────────────────────────────────────────┤
│ failed │ Check that field status only contains enum values     │ status      │ failures=1 | Got 1 result, configured to fail if   │
│        │ ['placed', 'shipped', 'completed', 'returned']        │             │ != 0                                               │
│ passed │ Check that field amount has no missing values         │ amount      │                                                    │
│ passed │ Check that field customer_id has no missing values    │ customer_id │                                                    │
│ passed │ Check that field customer_id references               │ customer_id │                                                    │
│        │ ref('customers').customer_id                          │             │                                                    │
│ passed │ Check that field email has no missing values          │ email       │                                                    │
│ passed │ Check that field email has a length of at most 255    │ email       │                                                    │
│ passed │ Check that field email matches regex pattern          │ email       │                                                    │
│        │ ^[^@\s]+@[^@\s]+\.[^@\s]+$                            │             │                                                    │
│ passed │ Check that field order_id has no missing values       │ order_id    │                                                    │
│ passed │ Check that field order_id has no duplicate values     │ order_id    │                                                    │
│ passed │ Check that field ordered_at has no missing values     │ ordered_at  │                                                    │
│ passed │ Check that field status has no missing values         │ status      │                                                    │
╰────────┴───────────────────────────────────────────────────────┴─────────────┴────────────────────────────────────────────────────╯
🔴 dbt tests failed, found the following errors:
1) status Check that field status only contains enum values ['placed', 'shipped', 'completed', 'returned']: failures=1 | Got 1
result, configured to fail if != 0

Damit es auch wirklich gilt

Bis hierher hängt alles daran, dass jemand daran denkt, sync auszuführen. Das ist keine Verbindlichkeit, das ist Disziplin. Übernimmt die CI diese Aufgabe, ist sie nicht mehr optional.

Ein --check-Flag gibt es nicht, und es braucht auch keins: Sync ist idempotent, also ist erneut synchronisieren plus git diff genau diese Prüfung. Hat jemand ein Feld, das dem Contract gehört, direkt im dbt-YAML geändert, schreibt Sync es zurück und das Arbeitsverzeichnis ist nicht mehr sauber. Hat es niemand getan, ändert Sync nichts und der Diff bleibt leer.

.github/workflows/dbt.yml
- name: Sync data contracts into the dbt project
  run: datacontract dbt sync contracts/*.odcs.yaml

- name: Fail if the dbt project has drifted from the contracts
  run: git diff --exit-code

- name: Run the contract's tests
  run: |
    datacontract dbt test contracts/*.odcs.yaml \
      --publish https://api.entropy-data.com/api/test-results
Ein fehlgeschlagener GitHub-Actions-Lauf: Der Schritt Fail if the dbt project has drifted from the contracts ist aufgeklappt und zeigt, wie git diff die von Hand geänderte Beschreibung und data_type FLOAT auf das DECIMAL(10,2) des Contracts zurücksetzt und mit Exit-Code 1 endet.
Jemand hat das dbt-YAML geändert statt den Contract. Der Build wird auf genau diesem Commit rot, mit einem Diff, der das Feld benennt.

Drift fällt nicht mehr erst Monate später auf, wenn das Dashboard eines Consumers kaputtgeht. Sie ist ein roter Build auf dem Commit, der sie verursacht hat, mit einem Diff, der genau das abweichende Feld zeigt. Das Schema zu ändern heißt ab jetzt, den Contract zu ändern — alles andere kommt nicht durch die CI.

Die Ergebnisse für Data Consumers sichtbar machen

Ein Contract, der nur im Repo eines Teams liegt, ist auch nur für dieses Team sichtbar. Mit --publish geht jeder Testlauf an Entropy Data. Wer die orders-Tabelle nutzen will, sieht dann selbst nach, ob die zugesagten Garantien über die Zeit halten — ganz ohne nachzufragen.

Der Orders-Data-Contract in Entropy Data: Purpose, Usage und Limitations sowie das Schema mit Beschreibung, Typ und Required-Flag jeder Spalte, mit einer roten Markierung bei status, wo der letzte Lauf fehlgeschlagen ist.
Die Ansicht Data Contract Tests in Entropy Data: ein Balkendiagramm der bestandenen und fehlgeschlagenen Checks pro Lauf über zwölf Tage sowie die Checks des letzten Laufs, benannt nach dem Wortlaut des Contracts statt nach dbt-Node-Namen.
Was Data Consumers in Entropy Data sehen: den Contract selbst und jeden Lauf seiner Checks im Zeitverlauf.

Fazit

Data Contracts in einem von Hand gepflegten dbt-Projekt führen zu Drift zwischen beiden. Die Lösung ist nicht mehr Disziplin, sondern die zweite Kopie abzuschaffen: Das Schema steht im Contract, wird ins Projekt synchronisiert, und die CI lehnt jeden Commit ab, bei dem beides auseinanderläuft. Dafür bekommst du ein dbt-Projekt, das weiterhin deins ist — mit Garantien, die für Consumers nachvollziehbar sind.

Melde dich kostenlos bei Entropy Data an, oder erkunde die klickbare Demo.