Knowledge

dbt with Data Contracts: Stop drifting, start syncing

Introducing data contracts into a dbt project usually ends with the same schema maintained twice: once in the contract, once as dbt YAML. The two start drifting apart immediately. datacontract dbt sync closes that copying gap: it merges the contract into an existing dbt project, in place.

Two commands do the work:

# find all data contracts in the current directory
# and ensure they are specified in your dbt model YAML files
datacontract dbt sync

# run all tests from the contract using dbt and report the results
datacontract dbt test

If we integrate those into CI, the contract stops being a document people are supposed to keep in step with the code. It becomes the direct source of truth for your dbt project. In this article, we'll see how it works on a simple orders model.

The starting point

Let's assume that you have already set up a dbt project. If you are starting from scratch instead, you might want to take a look at our Data Product Builder first.

In our case, we start with this orders model on Databricks:

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

dbt sync does not require properties files to exist (if they don't, they'll just get generated). But in this case, we find that someone already documented the columns:

The orders model in dbt docs: every column has a description, the types read off Databricks as integer, string, decimal(10,2) and timestamp, and the Data Tests column marks order_id unique and not-null, customer_id not-null with a relationships test, and the remaining columns 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
      # Internal ops field, deliberately outside the contract.
      - 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 our example, we are working with a dbt project that already defined its models in properties files. dbt sync will edit them in-place (or generate new properties files if they don't exist).

Now, how to guarantee the shape of this data to consumers of our dbt project? Data contracts are the answer: a standalone, versioned document that states the schema, the types, the quality rules and who is accountable for them — readable by a consumer who has no access to our repo and no reason to learn dbt, and checkable by a machine. Let's start by adding a data contract from what we have.

Adding a data contract

There's no reason to write the first contract by hand. datacontract import dbt reads dbt's manifest.json and infers the columns, descriptions, primary keys, required fields and foreign keys from the tests and constraints already in the project. So we let dbt produce the manifest first, then import from it:

dbt parse

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

From here, the contract is ours to shape. We drop internal_flag, an ops field that consumers have no business relying on, and add further specifics: the warehouse the data lives in, the four possible status values, more specific data types, the currency of the amount, or the length and shape of the 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]+$"

Syncing the contract into the project

After changing the contract, it might be tempting to manually port back the refinements to the dbt properties files. But we can do better: let's try out dbt sync!

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.

What happened? The Data Contract CLI first searched for data contracts in the current directory and its subdirectories (*.odcs.yaml). It found our newly added contract, as well as the existing properties file models/orders.yml. All information that was specified in the contract but not in the file got added, together with some meta information.

Two of the checks, namely the length and the pattern for emails, could not be expressed without adding external packages to the dbt project. To avoid that dependency, dbt sync generated singular SQL tests instead, which we'll see later.

By default, it does not delete anything from the properties files that is not explicitly overridden in the data contract. This applies to comments and manually added tests, but also to columns like our internal_flag that are missing from the contract. As the hint in the console output suggests, we can consider the --prune option if we want that column removed.

Let's take a look at the modified properties file. For instance, you can now find the accepted values for status, the updated description for amount, or auto-generated human-readable check names.

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
      # Internal ops field, deliberately outside the contract.
      - 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
      # Internal ops field, deliberately outside the contract.
      - 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

Running dbt sync a second time now does nothing at all:

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.

The tests we get, including the ones dbt can't express

The dbt project tree: models/ holds customers.sql, orders.sql, orders.yml and stg_orders.sql; tests/ holds the generated datacontract_cli/orders/ directory with the two email check files, alongside the hand-written assert_returned_orders_have_amount.sql.
Generated tests live in their own directory. The rest of the project gets modified in-place.

Every rule in the contract maps to a real dbt test: required to not_null, primaryKey to unique, an enum to accepted_values, and a relationships entry to dbt's relationships test with the target rewritten to a ref(). Sync writes the ones the project is missing and adopts the ones it already has.

But some properties like maxLength and pattern have no generic dbt test. Therefore, they get expressed as singular SQL tests:

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)

Running the tests

Now that the contract is in sync with our dbt project, we could just use dbt's built-in dbt test command to verify the shape of our data. But now that we are working with data contracts, datacontract dbt test provides a wrapper command with some extra features: it scopes to one or more data contracts, displays human-friendly check names, changes its return code depending on the check results, and offers to export the check results.

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.

Seed a bad status value and the same command reports it, in the contract's own words rather than dbt's:

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

Making it true, not just intended

Everything so far still relies on people remembering to run sync. That's not a source of truth, that's a habit. If we hand the job to CI instead, it stops being optional.

There's no --check flag, and there doesn't need to be one: sync is idempotent, so re-syncing and diffing is the check. If someone edited a contract-owned field in the dbt YAML directly, sync overwrites it back and the working tree is dirty. If nobody did, sync changes nothing and the diff is empty.

.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
A failing GitHub Actions run: the step Fail if the dbt project has drifted from the contracts is expanded, showing git diff reverting the hand-edited description and data_type FLOAT back to the contract's DECIMAL(10,2), ending in exit code 1.
Someone edited the dbt YAML instead of the contract. The build goes red on that commit, with a diff naming the field.

Drift is no longer something we discover months later when a consumer's dashboard breaks. It's a red build, on the commit that caused it, with a diff naming exactly which field disagreed. Changing the schema now means changing the contract, because changing anything else doesn't survive CI.

Publishing the results for data consumers

A contract that only exists in a team's repo is visible only to the team. But we can fix that: add --publish to send each test run to Entropy Data. Anyone interested in using the orders table can see for themselves if the guarantees they were promised are holding, over time, without having to ask.

The Orders data contract in Entropy Data: purpose, usage and limitations, and the schema with every column's description, type and required flag, with a red mark on status where the latest run failed.
The Data Contract Tests view in Entropy Data: a bar chart of passed and failed checks per run over twelve days, and the checks from the latest run listed by their contract wording rather than dbt node names.
What data consumers see in Entropy Data: the contract itself, and every run of its checks over time.

Summary

Introducing data contracts into a hand-maintained dbt project leads to drift between the two. The fix isn't more discipline, it's removing the second copy: keep the schema in the contract, sync it into the project, and let CI refuse any commit where the two disagree. What you get in return is a dbt project that is still yours, but transparent in its guarantees for consumers.

Sign up on Entropy Data for free, or explore the clickable demo.