Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT

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.

60 min September 2026
// Part 01 — What Happens When You Run dbt run

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.

the four phases of dbt run, in 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.

Why the phases are separated
Splitting compile from execute is not an implementation detail — it's what makes 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.

output
$ 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=6

Notice 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.

// Part 02 — Compile vs Run

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.

the source model with Jinja — models/marts/fct_daily_revenue.sql
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_date
what dbt compile writes to target/compiled/marts/fct_daily_revenue.sql
select
    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_date

Notice 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 compiledbt run
Resolves Jinja / ref() / source()YesYes
Writes plain SQL to target/compiled/YesYes (as a side effect of running)
Executes SQL against the warehouseNoYes
Creates/updates tables or viewsNoYes
Safe to run repeatedly with no side effectsYes — read-only against the warehouseNo — it changes warehouse state each time
Typical useDebugging generated SQL, previewing what would runActually 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.

A habit worth building
Whenever a model's behavior surprises you and Jinja is involved, reach for 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.

models/marts/fct_regional_totals.sql (the source, with a macro call)
select
    region,
    {{ dbt_utils.star(from=ref('stg_orders'), except=['order_id', 'region']) }}
from {{ ref('stg_orders') }}
group by region
target/compiled/.../fct_regional_totals.sql (after dbt compile)
select
    region,
    customer_id,
    order_status,
    order_placed_at,
    amount_usd
from "analytics_staging"."stg_orders"
group by region

Reading 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.

// Part 03 — ref(), source(), and the DAG

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.

declaring a source — models/staging/sources.yml
sources:
  - name: freshcart_raw
    database: raw
    schema: freshcart_raw
    tables:
      - name: orders
      - name: customers
      - name: payments
the DAG, drawn conceptually, for a small project
sources:
  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.

Circular dependencies are caught, not silently ignored
If model A contains 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.
// Part 04 — Topological Sort and Execution Order

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.

a worked topological sort, using the DAG from Part 03
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.

ConceptWhat it means in dbt
NodeA model, source, seed, or snapshot
EdgeA dependency, created by a ref() or source() call
AcyclicNo model can (directly or indirectly) depend on itself — dbt errors out if it detects one
Topological sortThe algorithm that converts the graph into a valid run order
--threadsHow many independent models dbt will execute concurrently within that valid order
Selecting a subset of the DAG
Beyond running everything, dbt lets you target a slice of the graph with the --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.

a run where a middle model fails
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=4

stg_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.

// Part 05 — What dbt Does Not Do

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.

three common ways dbt gets triggered on a schedule
# 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 configure

dbt 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.

ResponsibilityOwned by dbt?Actually owned by
Deciding execution order within a dbt projectYesdbt's own DAG and topological sort
Triggering dbt to run on a scheduleNocron, Airflow, CI/CD, or dbt Cloud's scheduler
Waiting for an upstream ingestion sync to finish firstNoAn orchestrator (e.g. Airflow) coordinating dbt alongside other tools
Refreshing a BI tool's cache after a dbt runNoThe BI tool's own scheduler, or an orchestrator task after the dbt task
Why this separation is intentional, not a missing feature
Keeping scheduling and cross-tool orchestration out of dbt's own scope keeps dbt focused and composable — it works the same way whether it's triggered by a bare cron job on someone's laptop or as one task in a large Airflow DAG coordinating a dozen tools. Bolting a scheduler into dbt itself would mean reimplementing (and maintaining parity with) what dedicated orchestration tools already do well.
// Part 06 — Materializations: What Actually Gets Created

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.

MaterializationWhat dbt generatesWhen 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
tableCREATE 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
incrementalINSERT/MERGE of only new or changed rows into an existing table, instead of rebuilding it fullyVery large fact tables where a full rebuild every run is too slow or expensive
ephemeralNot created in the warehouse at all — inlined as a CTE into whatever model references itSmall, reusable pieces of logic that don't need their own physical object
configuring a model's materialization
-- 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_date

This 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.

an incremental model — models/marts/fct_events.sql
{{
  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.

MaterializationWhat changes on a re-run
viewNothing is stored — the SELECT re-runs live every time the view is queried
tableEntire result set is fully recomputed and replaces the existing table
incrementalOnly new/changed rows (per the is_incremental() logic) are computed and merged into the existing table
// Part 07 — Worked Example

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.

declaring the source — models/staging/sources.yml
sources:
  - name: freshcart_raw
    database: raw
    schema: freshcart_raw
    tables:
      - name: orders

Step 2 — the staging model, cleaning the raw data

models/staging/stg_orders.sql
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 = false

Step 3 — the mart model, aggregating for the business

models/marts/fct_daily_revenue.sql
{{ 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 1

Step 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.

target/compiled/.../stg_orders.sql
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 = false
target/compiled/.../fct_daily_revenue.sql
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 "analytics_staging"."stg_orders"
where order_status = 'completed'
group by 1

Step 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

output
$ 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=2

Notice 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.

// Misconceptions

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.

"dbt run and dbt compile do the same thing, compile is just a dry-run flag."
dbt compile only resolves Jinja and writes the resulting SQL to target/compiled/ — nothing is executed against the warehouse. dbt run does that same compilation step AND then actually executes the SQL. They are genuinely different operations, not two names for one thing — see Part 02.
"The DAG is just a diagram dbt draws for you, it doesn't actually affect anything at runtime."
The DAG is the actual execution plan. dbt builds it from resolved ref()/source() calls and uses it to compute a topological sort that determines the real order models are run in — it is not decorative, it is the mechanism. See Part 03 and Part 04.
"dbt schedules itself — you just set it up once and it runs automatically."
dbt Core has no built-in scheduler at all; something else (cron, Airflow, a CI job, or dbt Cloud's scheduler specifically) has to trigger dbt run on a schedule. dbt itself only executes when invoked. See Part 05.
"ref() is just a stylistic way to write a table name, equivalent to hardcoding the schema.table."
ref() is what makes the whole dependency graph possible. Hardcoding a schema.table name gives dbt no way to know one model depends on another, so it cannot guarantee run order or environment-correct table names. See Part 01 and Part 03.
"dbt run executes all your models in one single database transaction, all-or-nothing."
Each model is typically its own separate CREATE TABLE AS SELECT / CREATE VIEW AS statement, run independently in dependency order. If model 5 of 10 fails, models 1-4 already succeeded and remain built; dbt does not roll back the whole run. See Part 01 and Part 06.
// Real-World Stories

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 Prep

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

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.

// Error Library

Errors You'll Actually Hit

error
`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.

error
`Database Error: Relation "STG_ORDERS" does not exist` on a model that clearly ran earlier in the same dbt run

This 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.

error
`dbt run` completes successfully but a downstream model still shows stale data

If 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.

error
`Compilation Error: 'source' is undefined` inside a model

This 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.

error
A scheduled dbt Cloud job or CI job simply never triggers on the expected day/time

This 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.

error
`Database Error: Duplicate row detected during DML operation` on an incremental model's merge step

This 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.

// Part 08 — Debugging a Failed Run, End to End

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.

the relevant slice of the failed run's output
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 recognized

stg_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.

target/compiled/.../fct_daily_revenue.sql, after compiling to inspect it
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 1

The 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.

What made this diagnosable in minutes, not hours
Every piece of this diagnosis relied on a mechanic covered earlier in this module: the run output showing exactly which model failed and which succeeded (Part 01), the SKIP behavior confirming no upstream dependency silently failed first (Part 04), and 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.
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...