Chapter 01

The Big Picture

Before looking at individual modules, you need a mental model of the whole system. dbt Core is three things running in one Python process: a compiler, a scheduler and an executor. Almost everything else in this tutorial is detail about one of those three.

~25 min read dbt Core 1.9+ Lab: dwlab / dimemployee

What dbt Core actually is

dbt Core is an open-source (Apache 2.0) Python command-line application. You give it a folder of SQL select statements, YAML files and Jinja macros. It works out the order they depend on each other, turns each one into the DDL and DML your warehouse understands, and sends that SQL to the warehouse over a database connection.

The important point is in that last step: dbt never processes your data itself. Rows don't pass through the Python process. All of the computation runs inside PostgreSQL (or Snowflake, BigQuery and so on). dbt's job is to generate and order SQL, so the "T" in ELT happens where the data already lives.

What dbt Core is not

People sometimes think dbt is…What it actually isIn your lab, this is done by
A data mover (extract/load)dbt only connects to the target warehouse. It can't read from SQL Server and write to Postgres.Airflow tasks that copy AdventureWorks into the stg schema
A scheduler for timedbt schedules within one invocation (DAG order and threads). It has no concept of "every night at 2 am".The Airflow DAG dwlab_dimemployee
A query enginedbt produces SQL text. The warehouse plans and runs it.PostgreSQL
A long-running serviceEvery dbt … command starts a new process that loads, works and exits.Each Airflow BashOperator starts its own process

The three-part architecture

Inside the process, the work splits into three areas of responsibility. Keep this split in mind: when something goes wrong, the first question to ask is which of the three it happened in.

dbt Core as compiler, scheduler and executor between project files and the warehouse Your project models/*.sql models/*.yml macros/*.sql dbt_project.yml profiles.yml DBT CORE · ONE PYTHON PROCESS Compiler load config parse files render Jinja build manifest compile SQL Scheduler build the DAG select nodes dependency queue thread pool Executor run materialization execute hooks adapter calls collect results PostgreSQL tables · views · seqs target/ *.json artifacts SQL
Figure 1.1 — Project files go in on the left. Inside one process, dbt compiles, schedules and executes. SQL goes to the warehouse, and metadata goes to target/.

1 · The compiler

The compiler turns files into a data structure. It reads dbt_project.yml and profiles.yml, then visits every model, test, seed, snapshot, macro and YAML file and creates a Python object (a node) for each one. Together these objects form the manifest, an in-memory catalogue of the whole project. Later, just before a node runs, the compiler renders that node's Jinja into the final SQL.

2 · The scheduler

The scheduler decides what runs and in what order. Every ref() and source() recorded during parsing becomes an edge in a directed acyclic graph (DAG). Your --select / --exclude arguments cut a sub-graph out of it. A dependency-aware queue then hands nodes to worker threads as soon as their parents finish. The number of threads comes from threads: in your profile.

3 · The executor

The executor turns a compiled node into changes in the database. It doesn't have a fixed "create table" routine. Instead it finds the right materialization (view, table, incremental, snapshot or a custom one) and calls it. A materialization is a Jinja macro that calls the adapter, the database-specific plugin (here dbt-postgres), to run SQL inside transactions, inspect existing relations and rename or drop objects.

Under the hood

Materializations are written in Jinja, not Python. When dimemployee runs with materialized='incremental', dbt looks up a macro called materialization_incremental_postgres, or falls back to materialization_incremental_default, and calls it. You can override these macros or write your own (Jinja tutorial, chapter 12). That's why dbt is so extensible: much of its behaviour is templates you can see and replace.

dbt Core, dbt Fusion and the dbt platform

The name "dbt" now covers several products, so it helps to be precise about which one this tutorial describes.

dbt Coredbt Fusion enginedbt platform (formerly dbt Cloud)
What it isThe original Python engine and CLIA newer engine rewritten in RustA hosted service: IDE, scheduler, docs, CI, catalog
How it reads SQLAs text with Jinja. It doesn't understand the SQL itself.Parses and type-checks the SQL dialect (it "understands" the SQL)Runs one of the two engines for you
LicenseApache 2.0Different terms from Core; check the current licenceCommercial SaaS
Your lab✔ dbt-postgres==1.9.0 in the Airflow imageCheck adapter support before switchingNot used

This tutorial is about dbt Core. Fusion aims to stay compatible with the same project format (models, YAML, Jinja), so most of the Jinja tutorial still applies. The internals described here (the Python modules, threads and partial parsing) are specific to Core.

Version check

Your Dockerfile installs only dbt-postgres==1.9.0, and pip pulls in dbt-core as a dependency. Since 1.8, adapters are built on the separate dbt-adapters and dbt-common packages and no longer pin Core's minor version. So the Core version you got is whatever pip resolved when the image was built, and that may be newer than 1.9. (Your sources.yml uses the arguments: syntax for test inputs, which belongs to newer releases.) Pin dbt-core and the adapter explicitly so an image rebuild can't quietly change your Core version, and run dbt --version inside the worker container to see exactly what you have.

The life of a dbt run

Now let's slow one command down and follow it. This is what your Airflow worker runs in stage 4 of the DAG:

$ dbt --no-use-colors run --select dimemployee --vars '{"run_id": 42 }'

Between pressing Enter and the process exiting, dbt goes through eight phases:

The eight phases of a dbt run 01 CLI & flags dbt.cli.main 02 Project + profile RuntimeConfig 03 Register adapter adapters.factory 04 · JINJA PASS 1 Parse → Manifest ManifestLoader 05 Build the DAG Linker · networkx 06 Select & queue NodeSelector 07 · JINJA PASS 2 Compile + execute RunTask · ModelRunner 08 Write artifacts run_results.json
Figure 1.2 — The eight phases. Jinja is rendered twice: once for every node during parsing, and again only for the selected nodes during execution.
  1. Parse the command line. The dbt executable is a Click application. Click matches the run subcommand and collects every flag (--select, --vars, --no-use-colors, …) into a single flags object. Flags can also come from environment variables (DBT_*) and from flags: in dbt_project.yml. The same entry point is available to Python as dbtRunner, which is how tools such as Astronomer Cosmos call dbt without starting a separate process. dbt/cli/main.py · dbt/cli/flags.py · dbt/cli/requires.py
  2. Load the project and the profile. A chain of decorators runs before the task starts: preflight → profile → project → runtime_config → manifest. Between them they read dbt_project.yml, choose a target from profiles.yml, render the Jinja in those YAML files (this is where env_var() is resolved), apply --vars, and combine everything into one RuntimeConfig object. If dbt debug fails, it fails here. dbt/config/project.py · dbt/config/profile.py · dbt/config/runtime.py
  3. Register the adapter. The profile's type: postgres tells dbt which plugin to import. The adapter factory loads dbt-postgres, registers its PostgresAdapter class and its connection manager, and adds the adapter's own macro package (with things like postgres__create_table_as) to the list of macros the project can see. No database connection opens yet; connections are created lazily, the first time a thread needs one. dbt/adapters/factory.py (dbt-adapters) · dbt/adapters/postgres/ (dbt-postgres)
  4. Parse the project into a manifest (Jinja pass 1). ManifestLoader visits every file in the project, not just the ones you selected. It renders each model's Jinja in a special parse context. Here ref() and source() don't resolve to real tables; they only record dependencies. config() records settings, and execute is False. The result is the Manifest object, written to target/manifest.json. To save time on the next run, dbt also saves target/partial_parse.msgpack and re-parses only the files that changed. dbt/parser/manifest.py · dbt/parser/models.py · dbt/contracts/graph/manifest.py
  5. Build the graph. The Linker turns each node's depends_on list into edges of a networkx.DiGraph. dbt then checks it for cycles, which is where "Found a cycle" errors come from. In your project the chain is stg.* → int_* → int_dimemployee_prepared → dimemployee → rpt_load_summary. dbt/compilation.py (Linker) · dbt/graph/graph.py
  6. Select nodes and build a queue. The selector engine evaluates --select dimemployee against the graph (methods such as path:, tag:, source: and state:, and operators such as + and @). The result is a set of unique IDs, for example model.dwlab.dimemployee. dbt wraps the selected sub-graph in a GraphQueue, which only releases a node once all of its selected parents have finished. dbt/graph/selector.py · dbt/graph/selector_methods.py · dbt/graph/queue.py
  7. Compile and execute each node (Jinja pass 2). The task (RunTask for dbt run) starts a thread pool with threads workers. For each node it takes from the queue, a ModelRunner renders the node's Jinja again, this time in the runtime context: ref() returns real relations, execute is True and is_incremental() can query the database. The output is written to target/compiled/. The runner then calls the materialization macro, which runs pre-hooks, the main statement and post-hooks through the adapter. The final SQL that actually ran is written to target/run/. dbt/task/runnable.py · dbt/task/run.py · dbt/context/providers.py
  8. Write artifacts and exit. dbt runs any on-run-end hooks, writes run_results.json with a status, timing and message for each node, prints the summary line, closes connections and exits with code 0 (success), 1 (a node failed) or 2 (dbt itself crashed). Airflow only looks at that exit code. Your record_dbt_step callback goes further and counts results to write them to audit.etl_step. dbt/artifacts/ · dbt/task/runnable.py

The most useful fact in this chapter

--select does not make parsing smaller. Phases 1–5 always cover the whole project, and selection only takes effect at phase 6. That's why a broken YAML file in models/int/ fails dbt run --select dimsalesterritory, even though the two have nothing to do with each other.

Two Jinja passes: why execute exists

Phases 4 and 7 both render your model's Jinja, but in different contexts. Many confusing Jinja errors come from code that is written for pass 2 also being run during pass 1.

Jinja featurePass 1 — parse (every node)Pass 2 — execute (selected nodes)
executeFalseTrue
ref('x') / source(...)Records a dependency and returns a placeholder relationReturns the real relation, e.g. "dwlab"."int"."x"
config(...)Saves the settings into the nodeAlready known, so it's effectively ignored
run_query(...)Doesn't touch the database; returns NoneRuns the SQL and returns an agate table
is_incremental()Always False (the macro returns early when not execute)Checks the database: does {{ this }} exist, and is it a table?
{{ this }}Available (built from config)Available

Here is the problem in practice. Suppose you want dimemployee to log how many rows its input has:

✗ Fails during parsing

{% set q %}
  select count(*) from {{ ref('int_dimemployee_prepared') }}
{% endset %}

{% set n = run_query(q).columns[0].values()[0] %}
{{ log("rows in: " ~ n, info=True) }}

✓ Guarded with execute

{% set q %}
  select count(*) from {{ ref('int_dimemployee_prepared') }}
{% endset %}

{% if execute %}
  {% set n = run_query(q).columns[0].values()[0] %}
  {{ log("rows in: " ~ n, info=True) }}
{% endif %}

The version on the left fails in phase 4 with an error like 'None' has no attribute 'columns', because run_query returned None. It fails even when you aren't selecting dimemployee, since every node is parsed. In the version on the right, pass 1 skips the block but still records the ref() dependency. Pass 2 runs the query for real.

Why your dimemployee.sql is already safe

Your model uses {% if is_incremental() %} to switch between a full build and the merge. dbt's built-in is_incremental macro starts with an early return when not execute, so during parsing it always takes the full-build branch. That's harmless, because nothing is executed during parsing. At run time, it checks adapter.get_relation(), model.config.materialized == 'incremental' and the --full-refresh flag. You'll trace this macro line by line in the Jinja tutorial.

Mapping the architecture onto your lab

Your Airflow DAG calls dbt nine times per DAG run, once from each BashOperator, and each call starts a new process. Knowing the eight phases tells you what each call costs:

DBT_DIR  = "/opt/airflow/dbt/dwlab"
DBT_VARS = """--vars '{"run_id": {{ ti.xcom_pull(task_ids="start_run") }} }'"""

# stage 3
bash_command="dbt --no-use-colors run  --select path:models/int " + DBT_VARS
bash_command="dbt --no-use-colors test --select path:models/int " + DBT_VARS
# stage 4
bash_command="dbt --no-use-colors run  --select dimsalesterritory " + DBT_VARS
bash_command="dbt --no-use-colors run  --select dimemployee "      + DBT_VARS
  • Phases 1–3 happen in every call. Starting Python, importing dbt and loading config typically take a second or more, whatever you select.
  • Phase 4 depends on partial parsing. dbt does a full re-parse whenever certain inputs change between runs, and the values passed in --vars are one of those inputs. Your run_id changes on every DAG run, so expect the first dbt call of each DAG run to fully re-parse. Later calls in the same DAG run pass the same run_id and can reuse partial_parse.msgpack, as long as they share the same target/ folder. Changes to profiles.yml, dbt_project.yml, installed packages or the dbt version also force a full re-parse, and so do edits to override macros such as your generate_schema_name.
  • Phases 5–8 are where the actual work happens, and that work is limited to what you selected.
  • Ordering happens at two levels. Airflow orders the stages, and inside one call, dbt's DAG and GraphQueue order the nodes. In stage 3, --select path:models/int lets dbt order all six int_* views itself.

Try it yourself on the lab server

These commands are read-only. They parse and list, but don't build anything. Run them inside the worker container:

$ docker exec -w /opt/airflow/dbt/dwlab airflow_airflow-worker_1 dbt --version
$ docker exec -w /opt/airflow/dbt/dwlab airflow_airflow-worker_1 dbt --log-level debug parse
$ docker exec -w /opt/airflow/dbt/dwlab airflow_airflow-worker_1 dbt ls --select +dimemployee
$ docker exec -w /opt/airflow/dbt/dwlab airflow_airflow-worker_1 dbt ls --select +dimemployee --output json --output-keys unique_id depends_on

In the parse debug output, look for the lines that say whether partial parsing was used and, if not, why. Run parse twice in a row, then once with --vars '{"run_id": 99}', and compare the output.

Where the chapters go from here

PhaseComponentCovered in
01–03CLI, config, adapter registrationCh. 02 Package anatomy · Ch. 03 CLI & configuration
04Parser, manifest, partial parsingCh. 04 The parser & the manifest
05–06Graph, selectors, queueCh. 05 The DAG & node selection
07 (compile)Jinja environment, context, macro resolutionCh. 06 Compilation & the Jinja engine
07 (execute)Tasks, runners, threads, materializations, adaptersCh. 07 The execution engine
08Artifacts, state, deferral, orchestrationCh. 08 Artifacts & state

Key takeaways

  • dbt Core is a compiler + scheduler + executor in one short-lived Python process. Your data never passes through it.
  • Every invocation goes through the same eight phases, and phases 1–5 always cover the whole project, whatever --select says.
  • Jinja is rendered twice: for every node at parse time (execute = False) and for selected nodes at run time (execute = True).
  • Materializations are Jinja macros that call the adapter. That's what makes dbt extensible and portable between warehouses.
  • Partial parsing is your main lever on start-up time, and changing --vars turns it off for that run.

Exercises

1 · Does a selected run parse unselected models?

You run dbt run --select dimsalesterritory. Does dbt read and render int_employee_sales.sql? What happens if that file has a Jinja syntax error?

Answer

Yes. Parsing (phase 4) covers every file in the project, so the syntax error stops the run before any node executes, even though int_employee_sales wasn't selected. Selection only applies in phase 6. (With partial parsing, an unchanged file is loaded from the cache instead of re-rendered, but a file you just broke has changed, so it gets re-parsed and the error shows up.)

2 · The None error

A colleague's model fails with 'None' has no attribute 'columns' on every command, including dbt ls, which doesn't execute anything. Explain why, and give the one-line fix.

Answer

The model calls run_query(...).columns unguarded. Every command parses the project, and during parsing run_query returns None. Wrap the block in {% if execute %} … {% endif %}.

3 · Why is the first dbt task the slowest?

In the Airflow Gantt chart, the first dbt task of each DAG run (stage2_validate) takes noticeably longer to start doing work than the later dbt tasks. Give two reasons based on this chapter.

Answer

(1) Every task pays for phases 1–3: a new Python process, dbt imports and config loading. (2) run_id in --vars is new for this DAG run, so the first call fully re-parses. Later calls reuse partial_parse.msgpack because their --vars are the same. You can confirm this with --log-level debug.

4 · Who decides the order?

In stage 3, which component decides that int_employee_person is built before int_dimemployee_prepared: Airflow or dbt? In stage 4, which one decides that dimsalesterritory runs before dimemployee?

Answer

Stage 3 is a single dbt call with --select path:models/int, so dbt's DAG and GraphQueue order the int_* models using their ref() edges. In stage 4 the two models are separate dbt calls, so Airflow's task dependencies set their order, and dbt never sees both in one graph run.

5 · Where would you look?

For each symptom, name the phase (01–08) where it starts: (a) Could not find profile named 'dwlab'; (b) Found a cycle; (c) relation "int.int_employee_person" does not exist; (d) Compilation Error … macro 'record_dim_stats' takes no keyword argument 'scd2'.

Answer

(a) Phase 02, profile loading. (b) Phase 05, graph building. (c) Phase 07, execution: the warehouse rejected the SQL because a parent object wasn't built. (d) The macro is called from a post_hook, and hooks are rendered when they run, so this shows up in phase 07. The same mistake in a model's body would be caught during parsing in phase 04.