Vertex Data

dbt Project Audit: What a Useful One Actually Covers

What a real dbt project audit includes, how to do it, and the artifacts you should get back—mapped to severity and a sequenced remediation plan.

Eric Provencio, Principal Analytics Engineer at Vertex Data Consulting
Eric Provencio
7 min read

Your dbt repo grew fast, people are nervous about run times and warehouse cost, and nobody can say exactly what breaks if a model slips an hour. Here’s the rubric we use when a team asks for a dbt project audit. You’ll see exactly what to inspect, the queries and code to run, and the artifacts you should receive. If your team has the time, use this as a checklist and fix it yourselves. If not, this is the scope to hold us—or any partner—to. The deliverable is a severity-ranked plan with diffs and PRs, not a slide deck.

Map the DAG and the critical path

Start with lineage, but don’t stop at a pretty graph. You need timings, fan-in/out, and where parallelism is blocked. Export manifest.json and run a quick analysis to find the longest path and high-degree nodes that throttle throughput. Compare that against scheduler limits (dbt Cloud, Airflow) and warehouse concurrency.

Example: compute a crude critical path from manifest.json using Python. Feed it model timings from your last prod job (dbt artifacts + scheduler logs) to get realistic durations.

import json
from collections import defaultdict, deque

with open('target/manifest.json') as f:
    m = json.load(f)

# Build DAG
parents = defaultdict(set)
children = defaultdict(set)
for node_id, n in m['nodes'].items():
    if n['resource_type'] == 'model':
        for p in n.get('depends_on', {}).get('nodes', []):
            if p.startswith('model.'):  # narrow to model deps
                parents[node_id].add(p)
                children[p].add(node_id)

# Longest path length (topo DP)
indeg = {n: len(parents[n]) for n in parents}
queue = deque([n for n, d in indeg.items() if d == 0])
length = {n: 1 for n in indeg}
while queue:
    u = queue.popleft()
    for v in children[u]:
        length[v] = max(length.get(v, 1), length[u] + 1)
        indeg[v] -= 1
        if indeg[v] == 0:
            queue.append(v)

print(sorted(length.items(), key=lambda x: x[1], reverse=True)[:10])

Flag models that appear on most downstream paths and those with huge fan-in. That’s your first remediation queue: break up blocking transforms, add ephemeral layers to reduce materialization, and revisit scheduler parallelism caps. If this section resonates, keep this rundown of slow dbt runs handy.

Materializations and incremental strategy that won’t bite in week two

Materialization misfits cause most production pain: overusing views on heavy joins, incremental models that can’t backfill, or tables rebuilt daily without need. Catalog each model’s materialization and partitioning; check that it matches data volume and SLA.

Good defaults look like this:

-- models/fct_orders.sql
{{ config(
    materialized='incremental',
    unique_key='order_id',
    on_schema_change='sync_all_columns',
    incremental_strategy='merge',
    tags=['fact']
) }}

with src as (
    select * from {{ ref('stg_orders') }}
    {% if is_incremental() %}
      where updated_at > (select coalesce(max(updated_at), '1900-01-01') from {{ this }})
    {% endif %}
)
select * from src

Then validate that full_refresh actually works on production volumes. Teams skip this and discover a 6-hour surprise when they need to fix a bad merge. Also standardize ephemeral models for small, reusable logic to cut I/O. For trade-offs between view, table, incremental, and ephemeral, see our reference on dbt materializations and the deeper strategies in incremental models that hold up in prod.

Finally, ensure warehouse-specific features (cluster keys in Snowflake, sort keys/partitioning elsewhere) are applied via configs, not ad-hoc SQL. Keep using dbt for DDL where possible so changes are tracked and repeatable.

Expensive models and warehouse cost: find, fix, and prevent

Cost control begins with identifying the models that drive the most compute. In Snowflake, tag dbt sessions so you can roll costs up by model. In your profiles.yml, set a query tag that includes the node name:

# profiles.yml (Snowflake)
query_tag: "dbt|{{ target.name }}|{{ node.name }}"

Now aggregate execution time and bytes scanned by model. This query surfaces your top offenders:

-- Top models by total elapsed time (Snowflake)
select
  split_part(query_tag, '|', 3) as model,
  count(*) as runs,
  sum(total_elapsed_time)/1000 as seconds
from snowflake.account_usage.query_history
where query_tag like 'dbt|%'
  and start_time > dateadd(day, -7, current_timestamp())
  and query_type in ('CREATE_TABLE_AS_SELECT','INSERT','MERGE')
group by 1
order by seconds desc
limit 20;

Then drill into why: poor join predicates, unnecessary selects, over-broad time windows. Use our SQL tuning guide for columnar warehouses alongside Snowflake cost optimization practices to fix the real causes. A quick comparison to keep teams honest:

SymptomLikely causeFirst fix
Hours-long fact rebuildTable materialization on huge daily rangeSwitch to incremental with proper predicates
Warehouse auto-scales middayFan-in model throttles parallelismSplit model; use ephemeral sub-steps
Duplicate rows in aggregatesMissing unique_key in incremental mergesSet unique_key and dedupe upstream

Don’t guess dollar figures—measure on your own account. If you run on Snowflake Inc., warehouse credit usage and query history give you enough to prioritize.

Tests, data validation, and observability that catch real issues

Audit test coverage by counting models, columns, and actual test types in use. You want not-null and unique on keys, relationship tests between staging and facts, and value-range checks on critical column values. Add freshness to data sources where available.

version: 2
models:
  - name: fct_orders
    columns:
      - name: order_id
        tests: [not_null, unique]
      - name: status
        tests:
          - accepted_values:
              values: ['pending','shipped','cancelled']

For row-count drift and late-arriving data, create an audit model that writes lightweight metrics to an audit table each run:

-- models/_audit/fct_orders_rowcount.sql
{{ config(materialized='incremental', unique_key='audit_ts') }}
select current_timestamp as audit_ts, count(*) as rowcount from {{ ref('fct_orders') }}

Insert an audit record per run and alert on unexpected deltas. If you’re using audit_helper in dbt (sometimes called the dbt audit helper or simply the audit helper package), scope it to critical models and ensure the output tables are small and partition-pruned. Keep custom tests maintainable; one-line tests beat a thousand-line macro that nobody can debug. This is data validation tied to run outcomes, not generic monitoring.

Ownership matters: who triages failing tests during business hours? Capture that in YAML and your on-call rota. That’s how data quality becomes a team sport, not a backlog.

Documentation, ownership, and approachable code (Jinja and macros)

Docs aren’t fluff. They’re how a new dbt developer decides whether to change a model at 5pm. Require descriptions, owners, and SLA hints in schema files:

version: 2
models:
  - name: dim_customer
    description: "One row per customer; joins usage metrics"
    meta:
      owner: analytics@company.com
      sla: "ready by 7am PT"
    columns:
      - name: customer_id
        description: "Surrogate key from stg_customers"

Format code for Jinja readability first, compiled SQL second. Long expressions belong in CTEs; repeat logic belongs in macros. Keep complex dbt macros small and composable:

{% macro safe_divide(numer, denom) -%}
    case when {{ denom }} = 0 then null else {{ numer }} / {{ denom }} end
{%- endmacro %}

Use package patterns that humans can trace. If a macro needs a README to understand inputs and outputs, write it. For patterns worth copying, see our notes on dbt macros and Jinja. Also, document governance touchpoints: mandatory PR reviews, data steward approvals for semantic changes, and who updates BI descriptions when logic moves.

CI/CD, environments, and run reliability (Cloud vs Core)

Every change should build affected models and run tests before merge. Whether you use dbt Cloud or dbt Core, the workflow is the same: on PR, build the subgraph changed by the diff. Enforce mandatory PR reviews.

# .github/workflows/dbt-ci.yml
on: [pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
      - run: pip install dbt-snowflake
      - name: Build changed graph
        run: |
          dbt deps
          dbt parse
          dbt build --select state:modified+ --defer --state .

If you’re on dbt Cloud, mirror this with a PR job and environment-specific credentials; its audit log is useful for tracking who changed what and when. If you’re on Core, store environment vars in your secret manager and wire jobs in your orchestrator. Either way, fail fast on schema changes, permissions, and broken references. For depth on catching issues pre-merge, we maintain a hands-on guide to dbt CI/CD that stops bad code before prod.

Small note: keep a weekly full_refresh job in non-prod, so you actually test disaster-recovery paths and avoid rot.

Unused, duplicate, and stale assets: cut noise, reclaim cost

Most repos accumulate dead models and orphaned tables. Identify:

  • Models not referenced by exposures, downstream models, or BI.
  • Tables never queried in 30+ days.
  • Duplicate logic (same SQL across two models).

In Snowflake, join table usage against dbt-tagged relations to find tables with no reads:

-- Possible orphans (Snowflake)
with tables as (
  select table_schema, table_name
  from information_schema.tables
  where table_schema ilike 'TRANSFORM%'
), usage as (
  select distinct
         direct_object_name as table_name,
         direct_object_schema as table_schema
  from snowflake.account_usage.access_history
  where event_time > dateadd(day, -30, current_timestamp())
)
select t.*
from tables t
left join usage u using (table_schema, table_name)
where u.table_name is null
order by 1,2;

Before dropping, search your repo for references and check BI semantic layers. Deprecate in YAML first so changes show up in docs:

models:
  - name: dim_customer_legacy
    config: {enabled: false}
    description: "DEPRECATED: replaced by dim_customer"

Keep a running CHANGELOG. Your audit data should include a list of candidates with proposed actions and owners.

Deliverables and a severity framework that drives action

A useful engagement ends with code and a sequenced plan. You should expect:

  • Annotated lineage and critical-path map with timings.
  • Per-model sheet: materialization, size, duration, tests, owner, and findings.
  • Cost and performance report: top N models by runtime and by bytes scanned.
  • CI/CD review with concrete PRs or job/export configs.
  • Refactor diffs for 3–5 high-impact models (not pseudocode).

We score findings on Impact, Effort, and Confidence to sequence work. Example slice:

FindingImpactEffortOwnerNext step
Switch fct_orders to incrementalHighMediumAEPR #123: tested on staging
Split kpi_daily into two modelsMediumLowAEPR #124; add ephemeral CTE
Add relationship tests to dim_date joinsLowLowAEPR #125; owner on-call rotates

We turn that into a 2–4 week plan with owners and review gates. If you want outside help, our dbt repo performance service runs this playbook end-to-end, including team pairing and handoff.

Short FAQ for leaders

What does “dbt project” mean?

The repo (models, seeds, snapshots, macros), plus profiles and job configs that define how transformations are built and run.

What is dbt vs Snowflake?

dbt is the transformation framework; Snowflake is the warehouse that executes the SQL. dbt compiles and orchestrates; Snowflake runs the queries.

Is dbt ETL or ELT?

ELT. Load first with an ingestion tool, then transform in-warehouse with dbt.

Is dbt the same as SQL?

No. dbt uses SQL plus Jinja to manage dependencies, environments, and deployment.

Already have dbt tests and packages?

Great. The audit checks depth and critical coverage, not just counts. We’ll trim noisy tests.

Are you optimizing your SQL?

Yes—per-model reviews and warehouse-aware patterns are core to the audit.

Comparing dbt Core and dbt Cloud?

Same fundamentals. Cloud adds UI, scheduler, and enterprise controls; Core fits when you own orchestration.

Data quality ownership extends beyond engineering?

Yes. Assign model owners and escalation paths; include business stewards on semantic changes.

Jinja readability vs compiled SQL?

Optimize for Jinja clarity. Small macros, named CTEs, and consistent style.

Approachable macros?

Keep them pure and documented; avoid hidden side effects.

Full refresh of production data?

Test it regularly in non-prod; schedule occasional prod full_refresh windows.

Mandatory PR reviews?

Yes. Block merges without at least one reviewer and a passing build.


If you need a partner to move fast, we’ve shipped platforms at scale. Start with the audit; you’ll leave with code and a plan. When you’re ready, see our notes on incremental durability and wire CI from our CI guide. Then decide if you want us alongside for the push.

About the author

Eric Provencio Analytics engineer who has built and run production data platforms for Disney, Hulu, Nike, Peloton, Gopuff, and Kaplan. Founded Vertex Data Consulting to do the deep work most data teams never find time for: dbt Cloud migrations, repo performance, Airflow reliability, and AI agents that actually touch the stack.

Keep reading

Start a conversation

Tell us what's slowing your data team down.

We maintain a small client roster on purpose. If we're the wrong fit, we'll say so — and usually we know somebody who isn't.

  • Replies within 2 business days
  • NDA before specifics
  • Fixed-scope first engagement, retainer if it works
What do you want help with? *

We reply within 2 business days if there's a fit. No newsletters, ever.