GGG
◂ posts6 min left

dbt6 min

How to be more ambitious with AI when working with dbt

published
reading time
6 min
topics
dbt, llms, data quality

Every analytics engineer knows what a well-kept dbt project looks like: a primary key test on every model, business rules written as tests, a description on every column, and dashboards that can't break without the build noticing.

Few projects have all of it. Nobody disagreed with the work. It just took too long to type, and it always lost to the next request from the business.

That excuse is gone. Everyone is writing dbt models with large language models anyway. With good agentic engineering and a proper long voice prompt, an LLM can do all of this work too, incredibly fast, and you stay in control: the boundaries come from what you know, and the build checks every line against them.

Here's how to be more ambitious with dbt now.

  • Test all of your exposures: nothing downstream breaks silently.
  • Write the boundaries down once: a shared, audited AGENTS.md.
  • Install your warehouse's CLI: the LLM checks the data instead of guessing.
  • Test every key: every core and mart model declares its grain.
  • Test the business logic: what the business assumes, written where it can fail.
  • Describe every column: the metadata an LLM reads before it writes a query.

Test all of your exposures

If you take one thing from this post, take this one. Data teams are shipping more and more, and a renamed column can pass every dbt test and still break a dashboard.

An LLM sees the repo, not the BI tool, so the BI tool's dependencies have to live in the repo. Here's how we do it with Metabase:

  • Exposures down to the column: every dashboard, every card, and the models and columns each card uses.
  • Generated, never hand-written: a script rebuilds the file from Metabase every night and opens a pull request when it changes.
  • A guard on every branch build: an on-run-end hook fails the build when it deletes or renames a column a card uses, and names the card and dashboard. Production skips it.
exposures:
  - name: buy_box_monitoring
    type: dashboard
    depends_on:
      - ref('mart_listings__buy_box_by_asin_market')
    config:
      meta:
        cards:
          - id: 101
            name: Buy box status by market
            models:
              mart_listings__buy_box_by_asin_market:
                - asin
                - market
                - buy_box_status

When an LLM renames buy_box_status, it finds out from the build in the same session, not from a colleague on Monday. And it can't fix it by editing the YAML: either the old name stays, or the dashboard changes first.

It doesn't matter where the columns go. Our Excel workbooks, fed by reverse ETL syncs, get the same treatment: each one pins the columns it reads, with their types, and a test fails the build when a pinned column disappears or changes type.

exposures:
  - name: excel_deal_planning_tool
    type: analysis
    depends_on:
      - ref('mart_deals__plan_by_week_product')
    config:
      meta:
        columns:
          mart_deals__plan_by_week_product:
            iso_week: string
            planned_units: any

This used to be a project nobody had time for. Today it's quite trivial to build something robust:

Make every Metabase dashboard and Excel workbook we feed a dbt exposure, down to the column. Generate the Metabase ones from the API, not by hand. Add a check that fails any branch build that deletes or renames a column one of them uses, and names what breaks. Prove it: rename a real column on a branch, show me the failure, then revert.

Write the boundaries down once

A long voice prompt carries what you know for one session. AGENTS.md carries it for every session, for everyone. Ours holds rules like these:

- Do not hand-edit synced exposures; regenerate them with the sync script.
- Before you open a PR, run the Metabase sync and report what it found.
- Never remove a column pin to make CI pass: agree it with the owner first.

This is where it stops being a personal trick and becomes a team decision. Everyone on the team already writes dbt with an LLM. Without a shared AGENTS.md, each person's LLM follows that person's habits. With one, every LLM writes like the team.

Treat it like code:

  • Align as a team: invest the time to agree on what goes in.
  • Change it through review: a pull request, with a line on why.
  • Keep it clean: one place per rule, no contradictions. A messy file gets followed loosely.
  • Audit it: rules go stale. Review it on a schedule, and delete as readily as you add.

The audit is a good job for an LLM too:

Read AGENTS.md and every rules file. List contradictions, duplicates, and rules that point to files or macros that no longer exist, then where the repo breaks our own conventions. Don't change anything. Propose a shorter version.

Install your warehouse's CLI

Give the LLM bq for BigQuery, or the equivalent for Azure or whatever you run. It dramatically improves the models, and the LLM can write the tests itself.

Without a CLI, it guesses the grain from column names. With one, it checks: duplicates on a key, columns that are ever null, distinct values before accepted_values, totals against the source.

Scope the credentials (read on production, write only to a dev dataset), and have it use --maximum_bytes_billed on big tables.

Test every key

The target is 100% coverage on core and mart: a primary key test, and not_null on the key columns. That's what dashboards, applications, and LLMs read.

The key is the grain, and the grain decides whether a join is right or silently doubled. A fan-out doesn't throw an error. It makes revenue look better.

models:
  - name: core_sales__orders_by_day_market
    tests:
      - dbt_utils.unique_combination_of_columns:
          arguments:
            combination_of_columns: [order_date, market]

Doing this for every model, by hand, never happened. Now it's one prompt:

Give every core and mart model a primary key test and not_null on its key. Don't guess the grain: prove it with bq. If a model has duplicates on the key you'd expect, don't change the key. List it with example rows. Run dbt test at the end and list the models you couldn't key.

That last list is the valuable one. Each model on it is a question about your data nobody had asked.

Test the business logic

Key tests catch broken joins, not a discount higher than the base price. Business-logic tests write down what the business assumes is always true:

tests:
  - dbt_utils.expression_is_true:
      arguments:
        expression: "discounted_price_lc <= base_price_lc"
  - dbt_utils.expression_is_true:
      arguments:
        expression: "live_price_lc = COALESCE(discounted_price_lc, base_price_lc)"

These are the tests an LLM won't write on its own, because the rule lives in someone's head. So say the rules out loud:

Add business-logic tests to the pricing models. A discounted price is never above the base price. The live price is the discounted price when there is one, else the base price. Suggest other rules a finance person would assume, but only list them. Run everything and show me what fails. Never loosen a failing test.

Every failure means either the data is wrong, or the rule was never as true as everyone thought. Both are worth knowing.

Describe every column

Column descriptions used to be for the next analyst. Now they're the context an LLM reads before it queries your models. A good one says what the name can't: unit, currency, how it's derived, what a null means.

- name: live_price_lc
  description: Live price in local currency (discounted_price when present, else base_price).

Fill in every missing column description in core and mart. Read the SQL behind each column, query it with bq if unsure, and write one line on unit, currency, derivation, and nulls. Same column name, same description. Flag existing ones that look wrong; don't rewrite them.

Fast, and still in control

None of this is new. What's new is that the cost is gone: the work that always lost to other priorities now takes a prompt and a review.

That's the ambition. You ask for the whole job instead of a slice, because the boundaries are in place, and you only review what fails.

Pick the job you've been putting off because it would take too long to type. Then say it out loud.