How dbt Works: Compile, Run, and the DAG
The real mechanics of dbt run: Jinja compilation, ref()/source() resolution, the dependency graph and topological sort, the difference between compiling and running, what dbt deliberately does not do, and a full worked example tracing a three-model chain.
What Actually Happens When You Type `dbt run`
Module 01 described dbt at the conceptual level: a tool that turns SQL models into tables and views. This module opens the hood. Understanding the exact sequence of steps behind dbt run is what lets you debug a failing run by reasoning about the mechanism instead of guessing.
When you run dbt run, four distinct phases happen in order. First, dbt reads your project: every .sql file under models/, every YAML configuration file, and the project's dbt_project.yml. Second, it compiles each model — resolving Jinja templating, most importantly ref() and source() calls, into plain, warehouse-executable SQL. Third, it builds a dependency graph (the DAG) from the resolved references discovered during compilation, and computes a valid execution order via a topological sort. Fourth, it executes each model's compiled SQL against the warehouse, in that computed order, typically as a CREATE TABLE AS SELECT or CREATE OR REPLACE VIEW AS statement depending on the model's configured materialization.
Beginner model: dbt run just "runs my SQL files."
Production model: dbt run parses the whole project, resolves every model's Jinja into plain SQL, builds a dependency graph from those resolved references, sorts that graph into a safe execution order, and only then sends each model's compiled SQL to the warehouse — one statement at a time, in dependency order, not all at once and not in file order.
1. READ — parse every .sql file and every YAML config in the project
2. COMPILE — resolve Jinja (ref(), source(), macros) into plain SQL per model
3. GRAPH — build the DAG from resolved dependencies, topologically sort it
4. EXECUTE — run each model's compiled SQL against the warehouse, in that order
(CREATE TABLE AS SELECT / CREATE OR REPLACE VIEW AS, per model)Each model is, in almost all cases, executed as its own independent statement — not wrapped together with every other model in one giant all-or-nothing database transaction. If model 5 of 10 fails, dbt reports that failure, models 1 through 4 remain built in the warehouse exactly as they were left, and (depending on flags) dbt may skip or continue to models that don't depend on the failed one. There is no automatic rollback of the whole run.
dbt compile possible as its own useful command, covered in Part 02, and it's what lets dbt validate the entire dependency graph for correctness (catching a circular reference, for example) before it ever sends a single query to your warehouse.A real console run, annotated phase by phase
It helps to see the four phases reflected in dbt's actual terminal output, rather than only as an abstract list. Here is a realistic dbt run against a small FreshCart project with the phase boundaries called out.
$ dbt run
Running with dbt=1.8.0
Registered adapter: snowflake=1.8.0
# ── phases 1 & 2: read + compile happen here, before any output ──
Found 6 models, 14 tests, 2 sources, 0 exposures, 0 metrics
# ── phase 3: DAG built, topological sort computed (silent, internal) ──
# ── phase 4: execution, in dependency order ──
1 of 6 START sql view model staging.stg_orders ................ [RUN]
1 of 6 OK created sql view model staging.stg_orders ........... [CREATE VIEW in 0.52s]
2 of 6 START sql view model staging.stg_customers .............. [RUN]
2 of 6 OK created sql view model staging.stg_customers ......... [CREATE VIEW in 0.48s]
3 of 6 START sql view model staging.stg_payments ................ [RUN]
3 of 6 OK created sql view model staging.stg_payments ........... [CREATE VIEW in 0.51s]
4 of 6 START sql table model marts.fct_daily_revenue ............ [RUN]
4 of 6 OK created sql table model marts.fct_daily_revenue ....... [CREATE TABLE in 1.91s]
5 of 6 START sql table model marts.dim_customers ................ [RUN]
5 of 6 OK created sql table model marts.dim_customers ........... [CREATE TABLE in 1.22s]
6 of 6 START sql table model marts.fct_payment_summary ........... [RUN]
6 of 6 OK created sql table model marts.fct_payment_summary ...... [CREATE TABLE in 0.97s]
Finished running 6 view models, 3 table models in 0 hours 0 minutes and 5.61 seconds.
Completed successfully
Done. PASS=6 WARN=0 ERROR=0 SKIP=0 TOTAL=6Notice the three staging models (1-3) all run before any of the three downstream mart models (4-6) — exactly the ordering the DAG and topological sort from Parts 03 and 04 guarantee — even though nothing in this output visibly says "now building the dependency graph." That work happened silently during phases 2 and 3, before a single line of the execution output above was printed.
Compiling vs Running — Two Genuinely Different Operations
dbt compile and dbt run are not two names for the same thing, and mixing them up is one of the most common early misunderstandings. dbt compile performs only the read-and-compile phases from Part 01: it resolves every model's Jinja templating into plain SQL and writes the resulting SQL to disk, under a directory called target/compiled/, mirroring your project's folder structure. Nothing is sent to the warehouse. No table or view is created, updated, or even queried.
dbt run performs that same compile step and then continues into the graph-building and execute phases — it actually sends each compiled statement to the warehouse and creates or updates real tables and views. In other words, every dbt run does a full compile internally; dbt compile is simply a way to stop right after that step, on purpose, without touching the warehouse at all.
select
order_date,
sum(amount_usd) as total_revenue_usd,
count(distinct order_id) as order_count
from {{ ref('stg_orders') }}
where order_status = 'completed'
group by order_dateselect
order_date,
sum(amount_usd) as total_revenue_usd,
count(distinct order_id) as order_count
from "analytics"."dbt_prod"."stg_orders"
where order_status = 'completed'
group by order_dateNotice the {{ ref('stg_orders') }} call has been replaced with the fully-qualified, environment-correct table name — in this case a production schema called dbt_prod. In a developer's own sandbox environment, compiling that same file would resolve ref('stg_orders') to that developer's own personal schema instead, which is exactly the environment-portability benefit of never hardcoding table names, covered again in Part 03.
| dbt compile | dbt run | |
|---|---|---|
| Resolves Jinja / ref() / source() | Yes | Yes |
| Writes plain SQL to target/compiled/ | Yes | Yes (as a side effect of running) |
| Executes SQL against the warehouse | No | Yes |
| Creates/updates tables or views | No | Yes |
| Safe to run repeatedly with no side effects | Yes — read-only against the warehouse | No — it changes warehouse state each time |
| Typical use | Debugging generated SQL, previewing what would run | Actually building your models |
Why dbt compile is a genuinely useful debugging tool
When a model's Jinja is complex — a macro generating a dynamic column list, a {% for %} loop building repetitive CASE statements, several layers of nested macros — it can be difficult to predict exactly what final SQL will be produced just by reading the template. Running dbt compile and opening the corresponding file under target/compiled/ shows you the literal, final SQL that would be executed, with zero risk to the warehouse, before you commit to actually running it.
dbt compile before assuming the bug is in your business logic. A large share of "the model produced the wrong numbers" issues turn out to be "the Jinja produced different SQL than I thought it would," and compiling shows you the truth directly.A worked example: a macro that compiles differently than expected
Suppose a model uses a macro to generate a list of columns to sum, and the resulting numbers look wrong. Rather than guessing at the macro's logic, compiling the model shows exactly what SQL it actually produced.
select
region,
{{ dbt_utils.star(from=ref('stg_orders'), except=['order_id', 'region']) }}
from {{ ref('stg_orders') }}
group by regionselect
region,
customer_id,
order_status,
order_placed_at,
amount_usd
from "analytics_staging"."stg_orders"
group by regionReading the compiled output immediately reveals the actual bug: the macro expanded into a plain column list, not a set of aggregations, so grouping by region alongside ungrouped columns like customer_id is what the warehouse is actually being asked to do — which is invalid in a strict SQL dialect and silently picks an arbitrary value per group in a lenient one. Nothing about reading the original Jinja-templated model would have made this obvious; the compiled SQL made it obvious in seconds.
ref(), source(), and How the DAG Gets Built
The DAG (directed acyclic graph) is dbt's internal map of every dependency in your project. Its nodes are the objects dbt manages — models, sources, seeds (static reference data loaded from CSV files), and snapshots (point-in-time captures of slowly changing records). Its edges are the dependencies declared through two Jinja functions: ref(), which points at another model, and source(), which points at a raw table dbt did not create.
ref() — referencing another model
Writing {{ ref('stg_orders') }} inside a model tells dbt two things at once: at compile time, resolve this into the fully-qualified name of the stg_orders model in the current environment (a developer's personal schema, a CI schema, or production, depending on where dbt is being invoked from); and register a dependency edge in the DAG — this model cannot run until stg_orders has run successfully.
source() — referencing raw, un-dbt-managed data
source() serves the same dependency-tracking purpose as ref(), but for tables dbt itself never created — the raw tables landed by an ingestion tool. Sources are declared once in a YAML file naming the raw database, schema, and table, and referenced from models with {{ source('freshcart_raw', 'orders') }}. This still creates a node and an edge in the DAG — a source node has no upstream dependencies of its own within dbt, but anything referencing it via source() correctly depends on it.
sources:
- name: freshcart_raw
database: raw
schema: freshcart_raw
tables:
- name: orders
- name: customers
- name: paymentssources:
freshcart_raw.orders ──┐
freshcart_raw.customers ┼──> stg_orders ──┐
│ │
└──> stg_customers ┼──> fct_daily_revenue
│
freshcart_raw.payments ──> stg_payments ──┘
Edges point in the direction of "depends on".
fct_daily_revenue cannot run until stg_orders, stg_customers,
and stg_payments have all run successfully.
stg_orders cannot run until freshcart_raw.orders (a source, not
a model dbt built) exists in the warehouse.Because both ref() and source() are resolved and recorded during compilation — the same phase Part 02 covered — the DAG dbt builds is always derived directly from the actual code, not from a diagram someone drew by hand and forgot to update. Add a new ref() call to a model, and the DAG updates itself the next time dbt compiles, with no separate step required.
ref('model_b') and model B contains ref('model_a'), directly or through a longer chain, dbt detects the cycle while building the graph and raises a compilation error rather than guessing at an order or running one of them with stale data. A valid topological sort — covered next in Part 04 — is only possible on a graph with no cycles.Topological Sort — How dbt Decides What Runs First
Once the DAG is built, dbt needs to convert it into a single, valid, linear execution order — a list of models to run one after another (or, where safe, in parallel) such that every model appears after everything it depends on. The algorithm that produces this ordering from a dependency graph is called a topological sort, and it is a well-known technique from graph theory, not something dbt invented — the same kind of algorithm underlies build tools like Make resolving compilation order for source files.
A topological sort is only possible on a directed acyclic graph — a graph with no cycles, which is exactly what Part 03's circular-dependency check guarantees dbt always has before it gets to this step. Intuitively, the algorithm repeatedly picks any node that has no remaining unsatisfied dependencies, places it next in the execution order, and removes it from consideration, continuing until every node has been placed.
Nodes and their dependencies:
stg_orders depends on: freshcart_raw.orders (source)
stg_customers depends on: freshcart_raw.customers (source)
stg_payments depends on: freshcart_raw.payments (source)
fct_daily_revenue depends on: stg_orders, stg_customers, stg_payments
Step 1: sources have no dbt-managed dependencies -> considered "ready" immediately
Step 2: stg_orders, stg_customers, stg_payments each depend only on a
source -> all three become ready as soon as their source exists
Step 3: fct_daily_revenue depends on all three staging models -> only
becomes ready once ALL THREE have completed successfully
One valid execution order dbt might compute:
1. stg_orders
2. stg_customers
3. stg_payments
4. fct_daily_revenue
Because stg_orders, stg_customers, and stg_payments share no
dependency on each other, dbt can also run them concurrently
(bounded by the --threads setting) rather than strictly sequentially --
the topological sort guarantees a valid ORDER, not that everything
must run one at a time.That last point matters in practice: dbt's --threads configuration controls how many models it will attempt to run concurrently. The topological sort tells dbt which models are safe to run at the same time (because neither depends on the other) versus which must wait — it does not force everything into a single-file queue.
| Concept | What it means in dbt |
|---|---|
| Node | A model, source, seed, or snapshot |
| Edge | A dependency, created by a ref() or source() call |
| Acyclic | No model can (directly or indirectly) depend on itself — dbt errors out if it detects one |
| Topological sort | The algorithm that converts the graph into a valid run order |
| --threads | How many independent models dbt will execute concurrently within that valid order |
--select flag and graph operators — --select stg_orders+ runs stg_orders and everything downstream of it; --select +fct_daily_revenue runs fct_daily_revenue and everything upstream it depends on. These operators are only meaningful because the DAG already exists as real, computed structure — they are graph traversals, not string matching on file names.What happens when one model in the middle fails
The topological sort computes a valid order assuming every model succeeds. In practice, a model partway through a run sometimes fails — a warehouse timeout, a syntax error introduced by a bad merge, a test-adjacent constraint violation. Understanding exactly what dbt does next is important for reasoning about a partially-completed run.
1 of 4 OK created sql view model staging.stg_orders ......... [CREATE VIEW in 0.51s]
2 of 4 OK created sql view model staging.stg_customers ........ [CREATE VIEW in 0.44s]
3 of 4 ERROR creating sql table model marts.fct_daily_revenue .. [ERROR in 0.88s]
4 of 4 SKIP relation marts.fct_customer_ltv ..................... [SKIPPED]
Completed with 1 error and 1 skip:
Database Error in model fct_daily_revenue (models/marts/fct_daily_revenue.sql)
Numeric value 'N/A' is not recognized
Done. PASS=2 WARN=0 ERROR=1 SKIP=1 TOTAL=4stg_orders and stg_customers already succeeded and remain built in the warehouse exactly as created — nothing rolls those back. fct_daily_revenue failed, and because fct_customer_ltv depends on it (directly through ref()), dbt marks it as SKIPPED rather than attempting to run it against a dependency that didn't successfully build — this is the topological sort's ordering guarantee actively protecting you from building on top of a known-bad result, not a separate feature bolted on.
What dbt Deliberately Does Not Do
Module 01 established that dbt does not extract or load data. This module adds two more boundaries that matter specifically for understanding how dbt "runs" in a real production environment: scheduling and orchestration.
dbt does not schedule itself
dbt Core has no built-in concept of "run this every morning at 6 AM." It is a command-line tool that does work only at the moment it is invoked — dbt run executes once and then the process exits. Getting dbt to run on a recurring schedule requires something external to trigger it: a cron job on a server, a scheduled task in a CI/CD system, an Airflow DAG with a task that shells out to (or uses a dedicated operator to call) dbt, or dbt Cloud's own hosted job scheduler — which is one of the concrete things you are paying for when you choose dbt Cloud over self-managing dbt Core.
# 1. A simple cron entry on a server with dbt Core installed
0 6 * * * cd /opt/freshcart_dbt && dbt run >> /var/log/dbt_run.log 2>&1
# 2. An Airflow task (conceptually — exact operator syntax varies)
dbt_run_task = BashOperator(
task_id="run_dbt_models",
bash_command="cd /opt/freshcart_dbt && dbt run",
)
# scheduled via the surrounding Airflow DAG's schedule_interval
# 3. dbt Cloud's built-in scheduler — configured entirely in the UI,
# no external cron or Airflow needed; dbt Cloud runs its own
# hosted dbt Core execution on the schedule you configuredbt does not orchestrate cross-tool workflows
A related, broader boundary: dbt only orchestrates the models within its own project — it has no native concept of "wait for the Fivetran sync to finish before running" or "trigger a Looker cache refresh after this run completes." Coordinating dbt with the tools around it (ingestion finishing, downstream BI refreshing) is exactly the kind of cross-tool sequencing a dedicated orchestrator like Airflow is built for — dbt is typically one task (or a handful of tasks) inside a larger Airflow DAG that also has tasks for triggering ingestion and downstream refreshes.
| Responsibility | Owned by dbt? | Actually owned by |
|---|---|---|
| Deciding execution order within a dbt project | Yes | dbt's own DAG and topological sort |
| Triggering dbt to run on a schedule | No | cron, Airflow, CI/CD, or dbt Cloud's scheduler |
| Waiting for an upstream ingestion sync to finish first | No | An orchestrator (e.g. Airflow) coordinating dbt alongside other tools |
| Refreshing a BI tool's cache after a dbt run | No | The BI tool's own scheduler, or an orchestrator task after the dbt task |
Materializations — Table, View, and What SQL They Actually Produce
When Part 01 said dbt sends each model to the warehouse as a CREATE TABLE AS SELECT or CREATE OR REPLACE VIEW AS, the specific choice between those (and a couple of other options) is controlled by a model's materialization — a per-model configuration setting. Understanding materializations is what makes the abstract "dbt runs your SELECT" description concrete: it explains exactly what object ends up in your warehouse and what SQL dbt generates to produce it.
| Materialization | What dbt generates | When it fits |
|---|---|---|
| view (often the default) | CREATE OR REPLACE VIEW ... AS <your SELECT> | Lightweight models, staging layers — no storage cost, always reflects current underlying data, but re-runs the SELECT on every query against it |
| table | CREATE TABLE ... AS SELECT (often via a temp/swap pattern to avoid downtime) | Expensive-to-compute or frequently-queried models, like large mart tables — storage cost, but fast reads |
| incremental | INSERT/MERGE of only new or changed rows into an existing table, instead of rebuilding it fully | Very large fact tables where a full rebuild every run is too slow or expensive |
| ephemeral | Not created in the warehouse at all — inlined as a CTE into whatever model references it | Small, reusable pieces of logic that don't need their own physical object |
-- models/marts/fct_daily_revenue.sql
{{ config(materialized='table') }}
select
order_date,
sum(amount_usd) as total_revenue_usd
from {{ ref('stg_orders') }}
group by order_dateThis is the last piece connecting compilation back to real warehouse objects: dbt resolves the ref() call as before, wraps the resulting SELECT in whatever DDL the configured materialization calls for, and that wrapped statement is exactly what gets executed in the fourth phase from Part 01.
Incremental models — a closer look, since they work differently from the rest
Table and view materializations rebuild the entire result from scratch on every run — fine for moderate data volumes, but wasteful once a fact table holds hundreds of millions of rows and only a small slice of it changes between runs. An incremental model instead runs its full SELECT only on the first build; on every subsequent run, it runs a much narrower query that only processes new or changed rows, then merges or inserts just those rows into the existing table.
{{
config(
materialized='incremental',
unique_key='event_id'
)
}}
select
event_id,
user_id,
event_type,
event_at
from {{ ref('stg_events') }}
{% if is_incremental() %}
where event_at > (select max(event_at) from {{ this }})
{% endif %}The {% if is_incremental() %} block is itself Jinja, resolved at compile time exactly like ref() and source() — on the very first run (when the target table doesn't exist yet), is_incremental() evaluates to false and the whole history is selected; on every later run, it evaluates to true and the extra where clause limits the query to rows newer than whatever is already in the table, referenced via the special {{ this }} variable pointing back at the model's own existing output.
| Materialization | What changes on a re-run |
|---|---|
| view | Nothing is stored — the SELECT re-runs live every time the view is queried |
| table | Entire result set is fully recomputed and replaces the existing table |
| incremental | Only new/changed rows (per the is_incremental() logic) are computed and merged into the existing table |
A Complete Worked Example: Source → Staging → Mart
Bringing every piece from this module together, here is a full three-model chain traced through compilation and execution, start to finish, for FreshCart's orders pipeline.
Step 1 — the raw source, already landed by ingestion
Fivetran has already landed a raw orders table into raw.freshcart_raw.orders, entirely outside of dbt's involvement, on its own schedule. This table has messy, application-native column names and includes soft-deleted test orders that should never reach analytics.
sources:
- name: freshcart_raw
database: raw
schema: freshcart_raw
tables:
- name: ordersStep 2 — the staging model, cleaning the raw data
select
ord_id as order_id,
cust_id as customer_id,
ord_stat as order_status,
cast(ord_placed_ts as timestamp) as order_placed_at,
amt_cents / 100.0 as amount_usd
from {{ source('freshcart_raw', 'orders') }}
where is_deleted = falseStep 3 — the mart model, aggregating for the business
{{ config(materialized='table') }}
select
date_trunc('day', order_placed_at) as order_date,
sum(amount_usd) as total_revenue_usd,
count(distinct order_id) as order_count
from {{ ref('stg_orders') }}
where order_status = 'completed'
group by 1Step 4 — compilation
Running dbt compile (or the compile phase inside dbt run) resolves the source() call in stg_orders into the literal raw table name, and resolves the ref() call in fct_daily_revenue into the environment-correct name of the stg_orders object — here, assume it materializes as a view in a schema called analytics_staging.
select
ord_id as order_id,
cust_id as customer_id,
ord_stat as order_status,
cast(ord_placed_ts as timestamp) as order_placed_at,
amt_cents / 100.0 as amount_usd
from "raw"."freshcart_raw"."orders"
where is_deleted = falseselect
date_trunc('day', order_placed_at) as order_date,
sum(amount_usd) as total_revenue_usd,
count(distinct order_id) as order_count
from "analytics_staging"."stg_orders"
where order_status = 'completed'
group by 1Step 5 — DAG and execution order
From these two resolved references, dbt's DAG records: stg_orders depends on the freshcart_raw.orders source; fct_daily_revenue depends on stg_orders. The topological sort from Part 04 produces the only valid order: stg_orders first, fct_daily_revenue second.
Step 6 — execution against the warehouse
$ dbt run --select stg_orders fct_daily_revenue
Running with dbt=1.8.0
1 of 2 START sql view model analytics_staging.stg_orders ..... [RUN]
1 of 2 OK created sql view model analytics_staging.stg_orders [CREATE VIEW in 0.61s]
2 of 2 START sql table model analytics.fct_daily_revenue ...... [RUN]
2 of 2 OK created sql table model analytics.fct_daily_revenue [CREATE TABLE in 1.84s]
Completed successfully
Done. PASS=2 WARN=0 ERROR=0 SKIP=0 TOTAL=2Notice the output confirms the exact order the DAG required — stg_orders completed before fct_daily_revenue even started — and shows each model's actual materialization (view, then table) matching the configuration from Part 06. This is the entire mechanism from Parts 01 through 06, applied to one concrete, realistic pipeline.
What would change if a fourth model joined the chain
Suppose a fourth model, fct_customer_ltv, is added, referencing both fct_daily_revenue and a new stg_customers staging model. Nothing about howstg_orders or fct_daily_revenue is written needs to change — the DAG simply grows a new node and new edges the moment fct_customer_ltv's ref() calls are compiled, and the topological sort recomputes a valid order that places fct_customer_ltv after both of its dependencies. Adding a new model is a purely additive operation on the dependency graph — it never requires manually re-sequencing models that already exist, which is exactly the scalability property the DAG is built to provide.
Common Misconceptions About How dbt Works
Five beliefs that trip people up once they move past "what dbt is" and start reasoning about its actual mechanics.
Why This Actually Matters — Three Real Scenarios
At Airbnb:
An analytics engineer on a listings-quality team makes a change to a shared staging model and, before merging, wants to see exactly what SQL will be generated for the several downstream mart models that reference it — without touching the warehouse and without waiting for a full dbt run against a large project. Running dbt compile --select stg_listings+ and reading the generated files under target/compiled/ lets them verify the join logic resolved correctly in seconds, entirely safely, before ever executing anything for real.
At JetBlue:
A data platform team debugging a nightly pipeline failure sees that a downstream flight-delay mart model failed with a "relation does not exist" error. Because they understand the DAG and topological sort, they immediately check whether the specific staging model it depends on actually succeeded earlier in the same run, rather than assuming the mart model's own SQL is broken — and they find the real cause: a --select flag in that night's CI job had accidentally excluded the staging model from the run entirely.
At Faire:
A team migrating a large events table to an incremental materialization to cut nightly run time discovers, a few weeks later, that a small number of late-arriving events — records that show up in the source system a day or two after their actual event timestamp — are being permanently missed, because the incremental filter only looked forward from the last processed timestamp and never re-checked a trailing window. Understanding exactly how the is_incremental() logic and the {{ this }} reference work, as covered in Part 06, is what let the team diagnose this precisely as a filter-logic problem and fix it by widening the incremental window with a small lookback buffer, rather than reverting to a full table rebuild out of caution.
In a system design / analytics-engineering interview:
A candidate is asked what would happen if dbt tried to run a mart model before its staging dependency finished successfully. A weak answer says "dbt handles that automatically" without explaining how. A strong answer explains the actual mechanism — the DAG built from resolved ref() calls, the topological sort computed from that graph, and the guarantee that a model is never scheduled for execution until every node it depends on has completed — which is precisely the depth covered in Part 03 and Part 04.
Interview Questions You Should Be Able to Answer
1. Walk me through exactly what happens when you run `dbt run`.
As Part 01 covers step by step, dbt first reads every .sql file in the project's models/ directory along with the project's YAML configuration. For each model, it resolves any Jinja templating — most importantly ref() calls (references to other models) and source() calls (references to raw tables) — into the fully-qualified, environment-correct table names those functions point to, producing plain SQL.
From those resolved references, dbt builds a dependency graph — the DAG, covered in Part 03 — where each model is a node and each ref()/source() call is a directed edge. It then performs a topological sort on that graph to compute a valid execution order: every model only appears after everything it depends on. Finally, dbt executes each compiled SQL statement against the warehouse in that order, typically as a CREATE TABLE AS SELECT or CREATE OR REPLACE VIEW AS, depending on how the model is materialized.
2. What is the difference between `dbt compile` and `dbt run`, and when would you use compile on its own?
dbt compile performs only the first half of what dbt run does: it resolves Jinja templating and ref()/source() calls into plain SQL and writes the result to files under target/compiled/, but it never sends any of that SQL to the warehouse. Nothing is created, updated, or queried. dbt run does that same compilation step and then actually executes the compiled SQL against the warehouse.
As Part 02 explains, this makes dbt compile a debugging tool: if you're not sure exactly what SQL your Jinja is generating — say, a macro isn't producing the join condition you expect — you run dbt compile, open the corresponding file in target/compiled/, and read the literal SQL that would have been run, without touching the warehouse or waiting on a real execution.
3. Explain the DAG in dbt — what are the nodes, what are the edges, and what guarantee does it give you?
As covered in Part 03, the DAG's nodes are the objects dbt manages: models, sources, seeds (static CSV data loaded by dbt), and snapshots (point-in-time captures of slowly changing data). Its edges are the dependencies declared through ref() calls (model-to-model) and source() calls (model-to-raw-source). The graph is acyclic by construction — dbt will raise a compilation error if you accidentally create a circular dependency, because a cycle would make a valid execution order impossible to compute.
The guarantee the DAG gives you, enforced through the topological sort described in Part 04, is that a model will never be run until every model and source it depends on has already completed successfully. This is what lets you build a three-layer pipeline — staging models on top of raw sources, then intermediate or mart models on top of staging — and trust that dbt will never try to build the mart model before its staging dependencies exist.
4. If dbt doesn't schedule itself, how does it actually run on a recurring basis in production?
dbt Core, run from the command line, only does anything the moment you invoke it — it has no concept of "run this every day at 6am" built into the tool itself. As Part 05 covers, something external has to trigger it on that schedule. Common options are a cron job on a server that shells out to dbt run, an Airflow DAG with a task that triggers a dbt run (often via a dedicated operator), a scheduled CI/CD pipeline (a GitHub Actions workflow on a cron trigger, for instance), or dbt Cloud's own built-in job scheduler, which is one of the main things you're paying for when you use dbt Cloud instead of self-managing dbt Core's scheduling yourself.
This is also why "orchestration" and "transformation" are treated as separate concerns in a modern data stack: dbt's job is only to correctly transform data once triggered, in the right dependency order; deciding when and how often that trigger fires is a scheduler's job, sitting one layer above dbt.
5. What is the difference between an incremental model and a regular table model, and when would you reach for one over the other?
As Part 06 covers, a table materialization fully recomputes its entire result set from the SELECT statement on every single run, discarding and rebuilding the table from scratch. An incremental model instead only fully rebuilds on its very first run; on every subsequent run, it computes a much narrower query — typically filtered to only new or changed rows — and merges just those rows into the table that already exists, using the special is_incremental() Jinja check and a {{ this }} reference back to the model's own current state.
The trade-off is complexity versus cost: a table materialization is simpler to reason about — the model's output is always exactly what the SELECT would produce, in full, right now — while an incremental model requires more careful logic (choosing a correct incremental filter, handling late- arriving or updated historical rows) in exchange for dramatically lower compute cost on very large fact tables where reprocessing the full history on every run would be prohibitively slow or expensive. In an interview, the strongest answer names both the mechanism and the trade-off, rather than treating "incremental" as simply the fast option to always prefer.
6. Trace a three-model chain — a raw source, a staging model, and a mart model — through compilation and execution.
As worked through in detail in Part 07: a raw orders table lands via ingestion into a raw schema; it is declared in dbt as a source(), not a model, because dbt did not create it. A staging model, stg_orders, contains select ... from {{ source('freshcart_raw', 'orders') }}, doing light cleaning — renaming columns, casting types, filtering out soft-deleted rows. A mart model, fct_daily_revenue, contains select ... from {{ ref('stg_orders') }} group by ..., aggregating the cleaned staging data into a business-ready table.
At compile time, dbt resolves the source() call in stg_orders into the literal raw.freshcart_raw.orders table reference, and resolves the ref() call in fct_daily_revenue into the literal, environment-correct name of the stg_orders table or view. The DAG built from those two edges guarantees stg_orders is fully built before fct_daily_revenue even starts, and at runtime dbt executes the compiled CREATE VIEW stg_orders AS ... first, then the compiled CREATE TABLE fct_daily_revenue AS ... second.
Common Mistakes Beginners Make
Hardcoding schema.table names instead of using ref() and source()
Writing `select * from raw.freshcart_raw.orders` directly inside a model, instead of `select * from {{ source('freshcart_raw', 'orders') }}`, breaks dbt's ability to build an accurate dependency graph and means the hardcoded name will silently be wrong in a different environment (dev vs prod schemas). See Part 01 and Part 03.
Assuming dbt compile actually changes anything in the warehouse
Running dbt compile to "test" a model and then being surprised nothing changed in the warehouse — it is not supposed to. Compile only resolves Jinja and writes plain SQL to target/compiled/; you need dbt run to actually execute anything. See Part 02.
Expecting dbt to detect and run only downstream models automatically after a change, with no selector
By default, `dbt run` with no arguments runs every model in the project, not just ones affected by your recent edit. Beginners are sometimes surprised a `dbt run` after touching one file takes as long as a full run. Selecting a subset (covered alongside DAG mechanics in Part 04) requires an explicit --select flag with the right graph operator (e.g. `+model_name` to include everything upstream of it).
Creating an accidental circular dependency between two models
Model A references model B with ref(), and model B also references model A with ref(). This makes a valid execution order impossible to compute, and dbt raises a compilation error refusing to build the DAG rather than guessing. See Part 03.
Believing a scheduled Airflow DAG or cron job IS "dbt's scheduler"
Conflating the orchestration layer (whatever triggers dbt on a schedule) with dbt itself. dbt Core has no concept of a schedule; the trigger is always external, whether that's Airflow, cron, CI, or dbt Cloud's own scheduler. See Part 05.
Treating an incremental model's filter logic as a minor detail rather than the core of its correctness
Copying an incremental model's is_incremental() filter from another project without checking whether late-arriving or updated rows in your own data need a lookback window. Getting this filter wrong doesn't cause a visible error — it silently and permanently drops rows that never get reprocessed. See Part 06.
Errors You'll Actually Hit
`Compilation Error: Found a cycle: model.freshcart.model_a --> model.freshcart.model_b --> model.freshcart.model_a`This means two (or more) models reference each other through ref(), directly or via a longer chain, making a valid topological sort impossible. Fix it by restructuring the logic so the dependency only flows one direction — usually one of the two models has logic that actually belongs upstream of the other, not alongside it.
`Database Error: Relation "STG_ORDERS" does not exist` on a model that clearly ran earlier in the same dbt runThis usually means the earlier model was materialized as a view in a schema the current model's connection cannot see, or an intermittent warehouse replication delay between the CREATE and the subsequent SELECT (more common on some warehouses under specific isolation settings). Confirm the model actually succeeded in dbt's run output before assuming it is a dependency-ordering bug.
`dbt run` completes successfully but a downstream model still shows stale dataIf the downstream model is materialized as a table (not a view) and something skipped a dependency's rebuild — for example you ran `dbt run --select stg_orders` and forgot the downstream model also needed rebuilding — the table simply was not touched this run. Check exactly which models were selected in the run's output list against what you expected.
`Compilation Error: 'source' is undefined` inside a modelThis is a Jinja/templating error, not a database error — it happens when {{ source(...) }} is called somewhere dbt's Jinja context does not have that function available, most often because of a syntax mistake elsewhere in the file (an unclosed {% %} block above it) that breaks how the rest of the file is parsed. Check the file for a stray or missing Jinja tag before the failing line.
A scheduled dbt Cloud job or CI job simply never triggers on the expected day/timeThis is almost never a dbt bug — it is a scheduler configuration issue (timezone mismatch in a cron expression, an Airflow DAG that is paused, a dbt Cloud job schedule left disabled after being edited). Since dbt itself has no concept of a schedule (Part 05), any "it didn't run when expected" issue lives entirely in whatever triggers it, not in dbt's own logic.
`Database Error: Duplicate row detected during DML operation` on an incremental model's merge stepThis happens when the unique_key configured for an incremental model does not actually uniquely identify rows in the incoming data — the underlying MERGE statement dbt generates cannot decide which incoming row should update which existing row when more than one incoming row shares the same key. Check that the chosen unique_key genuinely has no duplicates in the source data, per run.
Putting It Together: Debugging a Real Failed Run
As a final worked example, here is how the mechanics from this entire module come together when diagnosing a real production failure, rather than each covered in isolation.
A nightly dbt run, triggered by an Airflow task (Part 05), fails partway through with an error on fct_daily_revenue. The on-call analytics engineer's first move, using the reasoning from Part 07's SKIP behavior, is to check whether the failure is in fct_daily_revenue itself or in one of its upstream dependencies that silently failed first — the run output (Part 01) shows exactly which models passed, failed, and were skipped, so this takes seconds, not guesswork.
3 of 6 OK created sql view model staging.stg_orders ......... [CREATE VIEW in 0.49s]
4 of 6 ERROR creating sql table model marts.fct_daily_revenue .. [ERROR in 0.71s]
Database Error in model fct_daily_revenue (models/marts/fct_daily_revenue.sql)
Numeric value 'N/A' is not recognizedstg_orders succeeded, so the problem is specifically in fct_daily_revenue's own logic or the data it's reading — not a missing dependency. The next move, following the habit from Part 02, is dbt compile --select fct_daily_revenue to see the exact SQL that was sent to the warehouse, since the raw error message alone doesn't say which column or expression produced the bad numeric value.
select
order_date,
sum(amount_usd) as total_revenue_usd,
count(distinct order_id) as order_count
from "analytics_staging"."stg_orders"
where order_status = 'completed'
group by 1The compiled SQL looks correct on its face — the bug must be in the underlying data, specifically in amount_usd, which is supposed to always be numeric. Tracing back to stg_orders's own logic (Part 07's worked example) reveals the actual root cause: a recent, unannounced change in the raw source system started sending the literal string "N/A" in the amount field for a small number of orders instead of leaving it null, and the staging model's cast wasn't defensive against that case.
dbt compile revealing the literal SQL actually sent to the warehouse (Part 02) rather than requiring the engineer to guess at what the Jinja produced. None of this required special tooling beyond dbt itself.The permanent fix, once the root cause is understood, is to make stg_orders's cast defensive — for example, using a try_cast or an explicit case when amount_cents ~ '^[0-9]+$' then ... else null end pattern depending on the warehouse's SQL dialect — combined with adding a not_null or custom test on the resulting column so that any future recurrence of malformed source data fails loudly at the staging layer, immediately, rather than surfacing three models downstream as a cryptic numeric error with no obvious connection to its actual cause.
🎯 Key Takeaways
- ✓`dbt run` reads the project, compiles Jinja/ref()/source() into plain SQL, builds a DAG from those resolved dependencies, topologically sorts it, then executes each model against the warehouse in that order.
- ✓`dbt compile` performs only the read-and-compile step and writes the resolved SQL to target/compiled/ without touching the warehouse — an essential, side-effect-free debugging tool for anything involving Jinja.
- ✓The DAG's nodes are models, sources, seeds, and snapshots; its edges are ref()/source() calls — and it guarantees a model never runs until everything it depends on has run successfully.
- ✓dbt has no built-in scheduler and does not orchestrate other tools — something external (cron, Airflow, CI, or dbt Cloud's scheduler) must trigger it, and a separate orchestrator coordinates it alongside ingestion and BI tools.
- ✓A model's materialization (view, table, incremental, ephemeral) determines exactly what DDL/DML dbt generates and executes for it.
- ✓Tracing a real source → staging → mart chain end to end shows every mechanic in this module working together: source() and ref() resolution, DAG construction, topological sort, and materialization-driven execution.
Discussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.