Datatrail
Blog / Guides 10 min read

dbt Dependency Graph: How to See It, Use It, and Where It Stops

Last updated July 2026 · Datatrail

Lineage map
Lineage mapped from query history. Read-only connection.
0

Read-only connection. Datatrail never moves or mutates your data.

The dbt dependency graph is the DAG dbt builds from every ref() and source() call in your project. You can see it by running dbt docs generate && dbt docs serve, query it programmatically from target/manifest.json, or select against it on the command line with graph operators like dbt build --select my_model+. It is accurate, it is free, and it stays current with your code, because dbt derives it from the code rather than asking anyone to maintain it.

It also has a hard boundary: the graph knows what dbt knows. Raw tables nobody modeled, queries running outside the project, and the dashboards at the end of the chain are all invisible to it. This guide covers how to read and use the graph properly, then where it stops and what to do about it.

How dbt builds the graph

dbt does not scan your warehouse to work out dependencies. It reads your code. Every time you write {{ ref('stg_stripe_charges') }} in a model, dbt records an edge from that model to this one during compilation, and every {{ source('stripe', 'charges') }} records an edge from a declared source. Those edges, collectively, are the DAG.

This is why the graph is trustworthy in a way hand-drawn diagrams never are. The edge exists because the code says so, and the code is what runs. It is also why ref() is not optional styling. The moment someone hardcodes from analytics.stg_stripe_charges instead of using ref(), the model still works and the edge disappears. dbt will happily build that model in the wrong order, because as far as the DAG is concerned it has no parents. Hardcoded table references are the single most common way a dbt graph quietly becomes wrong.

The three ways to see it

Visually. Run dbt docs generate to compile the project and produce target/catalog.json and target/manifest.json, then dbt docs serve to open the site locally. Click any model and use the lineage graph view at the bottom right. It is genuinely good for a project you are learning, and it gets unusable somewhere north of a few hundred models, where the graph becomes a hairball.

On the command line. This is the version most engineers actually use daily, through graph operators:

# everything downstream of a model, including itself
dbt build --select stg_stripe_charges+

# everything upstream of a model
dbt build --select +fct_orders

# both directions, one hop each
dbt build --select 1+fct_orders+1

# everything downstream of what you changed vs the deployed state
dbt build --select state:modified+ --state ./prod-artifacts

That last one is the good one. Combined with a stored production manifest, state:modified+ is how you build only what your pull request affects instead of rebuilding the warehouse on every commit, and it is the backbone of most sensible dbt CI setups.

Programmatically. target/manifest.json contains the whole graph. The nodes object holds every model, test, seed, and snapshot; the parent_map and child_map objects hold the edges as plain adjacency lists. If you want to answer a question dbt does not answer out of the box, such as which models have no tests and more than five children, that file is where you go. Load it into a graph library and it is a normal DAG traversal problem.

Reading the graph without drowning in it

A few things the graph will tell you if you look, which most teams never ask it:

  • Fan-in on a staging model. A staging model with forty children is a load-bearing wall. It deserves tests, an owner, and a conversation before anyone touches it.
  • Models with no children. Either a genuine terminal output feeding BI, or something nobody uses and everybody still pays to build nightly. The graph cannot tell those apart, which is the first real hint about its boundary.
  • Depth. A chain eleven models deep is usually four models of logic and seven models of someone avoiding a refactor. Depth costs you build time and debugging time on every incident.
  • Cross-domain edges. When the finance mart reaches directly into a marketing staging model, that is an architectural decision nobody made on purpose.

Where the graph stops

Everything above is real value for free. Now the boundary, because this is what catches teams out during an incident.

The dbt graph is built from dbt code, so it contains exactly what dbt code declares. Three categories of dependency live outside it, and they are, reliably, the categories that break things.

1. Everything upstream of your sources

Your sources are where dbt's world begins. A source() declaration points at a table and says trust me, this exists. What lands that table, and whether it landed correctly this morning, is outside the graph entirely.

That matters because most incidents start there. The Fivetran connector that stopped syncing. The external dataset collected from the web whose provider changed its structure without telling anyone. The upstream API that added a field and dropped another. dbt's source freshness checks help if you configure them, and they tell you a source is stale, not what changed inside it or which of your columns depended on the part that moved.

2. Everything that is not a dbt model

In every real warehouse there is a layer of stuff that predates dbt or lives beside it. The scheduled query an analyst wrote in 2023 that still rebuilds a table nightly. The Python job that writes a table directly. The materialized view someone created in a console. The stored procedure nobody wants to touch.

None of it appears in the DAG. Worse, it can sit in the middle of a chain: a dbt model feeds a table that a scheduled query rewrites that another dbt model reads. The graph shows you two disconnected islands and gives you no hint that they are connected in production.

3. Everything downstream of your models

The dbt graph ends at your marts. The dashboard, the reverse-ETL sync, the spreadsheet someone built on a scheduled export: these are where humans notice breakage, and dbt has no visibility into any of them.

Exposures exist to fix this, and they work, and they are hand-maintained. Someone has to write the YAML for every dashboard and keep it accurate as the BI layer changes. In practice exposure coverage in most projects is somewhere between partial and aspirational, and the exposures that exist tend to be the ones somebody added after an incident. A hand-maintained list of downstream consumers has the same failure mode as a hand-maintained diagram: it is right the day you write it.

4. Columns

Worth stating separately, because it decides whether the graph can answer your actual question. The dbt DAG is table-level. It records that fct_orders depends on stg_stripe_charges, not that fct_orders.revenue derives from stg_stripe_charges.amount.

So when you are about to drop one column and you run dbt build --select stg_stripe_charges+, the answer is every model downstream of the table, which might be forty models, when the truth is that three of them read the field you care about. dbt Core does not do column-level lineage. dbt Cloud does, through the Explorer on paid tiers, scoped to the project. Our explainer on column-level lineage covers the difference in depth.

Extending the graph past dbt

Three practical options, in increasing order of effort and completeness.

Maintain exposures rigorously. If your BI layer is small and stable, defining exposures for the dashboards that matter genuinely closes the downstream gap. Make it part of the definition of done for a new dashboard, not a cleanup task. This works right up to the scale where nobody can remember every dashboard.

Parse the warehouse query log yourself. Every warehouse records what ran. Snowflake gives you ACCOUNT_USAGE.QUERY_HISTORY and, more usefully, ACCESS_HISTORY, which has already resolved the objects and columns each query touched. BigQuery gives you INFORMATION_SCHEMA.JOBS with referenced_tables and 180 days of retention. Join that against your manifest and you can see the non-dbt jobs that read and write your tables. It is a real project with a real maintenance tail, mostly because SQL parsing is harder than it looks, but the data is sitting there.

Use a graph built from both. This is what Datatrail does. It reads your dbt manifest, so models, sources, and exposures stay first-class, and it parses warehouse query history alongside it, so the scheduled query, the un-modeled bronze table, and the Looker dashboard land in the same map. Because the parsing resolves columns rather than tables, stg_stripe_charges.currency_code traces to the three things that read it instead of the forty that read the table. Then impact analysis gives you the pre-merge answer state:modified+ cannot: not which models rebuild, but which dashboards break.

The honest summary

The dbt dependency graph is one of the best things about dbt. It is free, it is derived from code so it does not rot, and the graph operators make selective builds and CI genuinely pleasant. If your entire warehouse is a dbt project and your BI layer is covered by exposures, it may be all the lineage you need, and you should not buy anything.

The gap opens when the question crosses the project boundary: what landed this source, what breaks in Looker, which specific column does this feed. Those are the questions that show up during incidents and refactors, which is exactly when you cannot afford to be reading SQL by hand. See dbt lineage in Datatrail for how the two graphs merge, or our comparison of data lineage tools, which covers the free and open-source options honestly alongside the paid ones.

See how your data flows, end to end

Connect your warehouse read-only and map lineage, freshness, and downstream impact before a change breaks a dashboard. Transparent pricing, no card to start.