dbt Models skill for Claude Code

Declared grain, tested keys, idempotent increments. Claude organizes dbt projects into source-backed staging views, reusable intermediate models and documented fct and dim marts. Every mart states its grain and enforces it with tests, incremental models get a unique key plus a late-data lookback, and changes are built with dbt build on the modified subgraph, then reconciled against the source system.

  • namedbt-models
  • size257 lines · ≈2,150 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds and reviews dbt projects — sources with freshness, staging/intermediate/marts layering, ref and source instead of hardcoded tables, declared grain with unique and not_null tests, unit tests, model contracts, incremental and microbatch models with lookback windows, and slim CI with state:modified+ and defer. Use when writing or refactoring dbt models, schema.yml or sources.yml files, when an incremental model produces duplicates or misses late data, when dbt build is slow in CI, or when documenting a warehouse layer.

Try asking

  • Set up staging and mart models for our Stripe and Shopify sources in dbt
  • Our incremental orders model has duplicates and misses late updates. Fix it
  • dbt build takes 40 minutes in CI. Make it only build what changed

Use it when

  • You are writing or refactoring dbt models and YAML
  • An incremental model duplicates or drops rows
  • A warehouse layer needs tests, contracts, and documentation
  • dbt CI runs are slow or rebuild everything

Skip it when

  • You need orchestration, scheduling, and retries around dbt — use data-pipelines instead
  • You are writing one-off analytical queries — use analytics-sql instead

How to check the result

dbt build on the changed models passes every test, the grain test holds, row counts match the previous version or the difference is explained, and one day of totals reconciles with the source.

The common mistake it prevents

Building an incremental model without a unique_key, so the lookback overlap inserts the same rows again on every run.

~/.claude/skills/dbt-models/SKILL.md

--- name: dbt-models description: Builds and reviews dbt projects — sources with freshness, staging/intermediate/marts layering, ref and source instead of hardcoded tables,… license: MIT ---

A dbt project is in good shape when every model has a declared grain that a test enforces, raw tables are touched only through source() in staging models, marts are built from ref() with explicit column lists, incremental models are idempotent with a late-data lookback, and CI builds only what changed.

Ground rules

  • One staging model per source table: rename, cast, light cleaning. No joins, no business logic.
  • Never hardcode schema.table names; use source() and ref() so lineage and environments work.
  • State the grain (“one row per order line”) in the model description and test it.
  • No select * in marts; explicit columns make contracts and reviews possible.
  • --full-refresh on large production incremental models rebuilds history and can be expensive or lossy (if sources have aged out); require user confirmation.
  • Run dbt build (run + test in DAG order), not dbt run alone.

Workflow

- [ ] 1. Inspect: dbt version, adapter, project layout, existing conventions
- [ ] 2. Declare sources with freshness
- [ ] 3. Staging models (1:1 with sources)
- [ ] 4. Intermediate models for reusable joins/logic
- [ ] 5. Marts (fct_/dim_) with grain, contract, tests, docs
- [ ] 6. Choose materializations; incremental where justified
- [ ] 7. Build the changed subgraph; verify row counts and tests

1. Inspect

dbt --version
dbt debug                                  # connection and profile
dbt ls --resource-type model | head -50
dbt ls -s +fct_orders                      # upstream lineage
cat dbt_project.yml packages.yml

Layout and naming

models/
├── staging/
│   └── stripe/
│       ├── _stripe__sources.yml
│       ├── _stripe__models.yml
│       ├── stg_stripe__charges.sql
│       └── stg_stripe__customers.sql
├── intermediate/
│   └── int_orders__joined_payments.sql
└── marts/
    ├── finance/fct_orders.sql
    └── core/dim_customers.sql
LayerPrefixMaterializationContains
Stagingstg_<source>__<entity>viewRenames, casts, dedupe of source quirks
Intermediateint_<entity>__<verb>ephemeral or view (table if heavy)Joins, reshaping reused by marts
Martsfct_<process>, dim_<entity>table or incrementalBusiness-ready, documented, tested

2. Sources

# models/staging/stripe/_stripe__sources.yml
version: 2
sources:
  - name: stripe
    database: raw
    schema: stripe
    loaded_at_field: _loaded_at
    freshness:
      warn_after: { count: 6, period: hour }
      error_after: { count: 24, period: hour }
    tables:
      - name: charges
      - name: customers

Recent dbt versions also accept freshness and loaded_at_field under config:; follow whatever the project’s dbt version documents. Check with dbt source freshness.

3. Staging model

-- models/staging/stripe/stg_stripe__charges.sql
with source as (
    select * from {{ source('stripe', 'charges') }}
),
renamed as (
    select
        id                                  as charge_id,
        customer                            as customer_id,
        amount / 100.0                      as amount_usd,
        lower(status)                       as status,
        cast(created as timestamp)          as created_at,
        _loaded_at
    from source
    where livemode = true                   -- drop Stripe test-mode rows
)
select * from renamed

5. Mart with grain, contract, tests

-- models/marts/finance/fct_orders.sql
with orders as (select * from {{ ref('stg_shop__orders') }}),
payments as (select * from {{ ref('int_orders__joined_payments') }})
select
    orders.order_id,
    orders.customer_id,
    orders.ordered_at,
    payments.amount_usd,
    payments.payment_method
from orders
left join payments using (order_id)
# models/marts/finance/_finance__models.yml
version: 2
models:
  - name: fct_orders
    description: One row per order. Revenue in USD at time of payment.
    config:
      contract: { enforced: true }
    columns:
      - name: order_id
        data_type: varchar
        data_tests: [unique, not_null]
      - name: customer_id
        data_type: varchar
        data_tests:
          - not_null
          - relationships:
              to: ref('dim_customers')
              field: customer_id
      - name: ordered_at
        data_type: timestamp
      - name: amount_usd
        data_type: numeric
      - name: payment_method
        data_type: varchar
        data_tests:
          - accepted_values:
              values: [card, bank_transfer, wallet]

data_tests: is the current key (tests: still works). Newer dbt versions prefer generic test arguments nested under arguments:; match the project’s version. Composite grain: dbt_utils.unique_combination_of_columns.

Unit test for tricky logic (dbt 1.8+):

unit_tests:
  - name: refunds_reduce_amount
    model: int_orders__joined_payments
    given:
      - input: ref('stg_stripe__charges')
        rows:
          - { charge_id: c1, order_id: o1, amount_usd: 100, status: succeeded }
          - { charge_id: c2, order_id: o1, amount_usd: -30, status: refunded }
    expect:
      rows:
        - { order_id: o1, amount_usd: 70 }

6. Materialization decision

SituationMaterialization
Staging, cheap transformsview
Mart queried often, rebuild < a few minutestable
Large append-mostly fact, rebuild too slow/expensiveincremental
Very large time-series facts, backfills by period (dbt 1.9+)incremental with microbatch strategy
Small reusable CTEephemeral (sparingly; hard to debug)

Incremental with merge and lookback:

{{ config(
    materialized='incremental',
    unique_key='order_id',
    incremental_strategy='merge',
    on_schema_change='append_new_columns'
) }}
select order_id, customer_id, status, amount_usd, updated_at
from {{ ref('stg_shop__orders') }}
{% if is_incremental() %}
where updated_at >= (select dateadd('hour', -3, max(updated_at)) from {{ this }})
{% endif %}

The 3-hour lookback catches late-arriving and same-timestamp rows; unique_key makes the overlap idempotent. Without unique_key, overlap creates duplicates. Date function syntax varies by adapter (dateadd on Snowflake/Redshift, timestamp_sub on BigQuery); dbt.dateadd() macro is cross-database.

7. Build and verify

dbt build -s +fct_orders                   # model, its parents, and all tests
dbt show -s fct_orders --limit 10
dbt build -s state:modified+ --defer --state ./prod-artifacts   # slim CI
dbt source freshness
dbt docs generate

Slim CI needs the production manifest.json in --state; --defer reads unchanged parents from production instead of rebuilding them.

Sanity checks after a change:

  • Row count vs. the previous version (dbt show --inline "select count(*) from {{ ref('fct_orders') }}", compare to prod).
  • Grain test passes; no new nulls in key columns.
  • Revenue/total reconciles with the source system for one recent day.

Review checklist

  • No hardcoded relations; every raw table via source()
  • Staging models have no joins
  • Each mart documents and tests its grain
  • Joins checked for fan-out (row count before/after)
  • Incremental models have unique_key and a lookback
  • Timestamps normalized to one time zone in staging
  • sqlfluff lint (dbt templater) passes if configured

Gotchas

  • A left join to a table with multiple rows per key fans out and inflates sums; join on the grain or aggregate first.
  • Changing incremental logic does not rewrite old rows; a full refresh or targeted backfill is needed (confirm first).
  • is_incremental() is false on the first run and on --full-refresh; test both paths.
  • Tests only on marts let bad data pass through staging unnoticed; test primary keys in staging too.
  • Snapshot (dbt snapshot) is for SCD type 2 of mutable sources; do not emulate it with incremental merges.
  • on_schema_change='fail' (default ignore) is safer for contracts; ignore silently drops new columns.

Output

**Models added/changed:** stg_stripe__charges (new), int_orders__joined_payments, fct_orders (incremental, merge, 3h lookback)
**Grain:** fct_orders one row per order_id (unique + not_null pass)
**Build:** dbt build -s +fct_orders — 14 models, 38 tests, 0 failures
**Reconciliation:** 2026-10-18 revenue 412,331.20 vs Stripe dashboard 412,331.20
**CI:** slim CI with state:modified+ --defer (12 min -> 3 min)

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc