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.
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 is | In 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 time | dbt 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 engine | dbt produces SQL text. The warehouse plans and runs it. | PostgreSQL |
| A long-running service | Every 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.
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 Core | dbt Fusion engine | dbt platform (formerly dbt Cloud) | |
|---|---|---|---|
| What it is | The original Python engine and CLI | A newer engine rewritten in Rust | A hosted service: IDE, scheduler, docs, CI, catalog |
| How it reads SQL | As 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 |
| License | Apache 2.0 | Different terms from Core; check the current licence | Commercial SaaS |
| Your lab | ✔ dbt-postgres==1.9.0 in the Airflow image | Check adapter support before switching | Not 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:
-
Parse the command line.
The
dbtexecutable is a Click application. Click matches therunsubcommand and collects every flag (--select,--vars,--no-use-colors, …) into a singleflagsobject. Flags can also come from environment variables (DBT_*) and fromflags:indbt_project.yml. The same entry point is available to Python asdbtRunner, 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 -
Load the project and the profile.
A chain of decorators runs before the task starts:
preflight → profile → project → runtime_config → manifest. Between them they readdbt_project.yml, choose a target fromprofiles.yml, render the Jinja in those YAML files (this is whereenv_var()is resolved), apply--vars, and combine everything into oneRuntimeConfigobject. Ifdbt debugfails, it fails here. dbt/config/project.py · dbt/config/profile.py · dbt/config/runtime.py -
Register the adapter.
The profile's
type: postgrestells dbt which plugin to import. The adapter factory loadsdbt-postgres, registers itsPostgresAdapterclass and its connection manager, and adds the adapter's own macro package (with things likepostgres__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) -
Parse the project into a manifest (Jinja pass 1).
ManifestLoadervisits every file in the project, not just the ones you selected. It renders each model's Jinja in a special parse context. Hereref()andsource()don't resolve to real tables; they only record dependencies.config()records settings, andexecuteisFalse. The result is theManifestobject, written totarget/manifest.json. To save time on the next run, dbt also savestarget/partial_parse.msgpackand re-parses only the files that changed. dbt/parser/manifest.py · dbt/parser/models.py · dbt/contracts/graph/manifest.py -
Build the graph.
The
Linkerturns each node'sdepends_onlist into edges of anetworkx.DiGraph. dbt then checks it for cycles, which is where "Found a cycle" errors come from. In your project the chain isstg.* → int_* → int_dimemployee_prepared → dimemployee → rpt_load_summary. dbt/compilation.py (Linker) · dbt/graph/graph.py -
Select nodes and build a queue.
The selector engine evaluates
--select dimemployeeagainst the graph (methods such aspath:,tag:,source:andstate:, and operators such as+and@). The result is a set of unique IDs, for examplemodel.dwlab.dimemployee. dbt wraps the selected sub-graph in aGraphQueue, 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 -
Compile and execute each node (Jinja pass 2).
The task (
RunTaskfordbt run) starts a thread pool withthreadsworkers. For each node it takes from the queue, aModelRunnerrenders the node's Jinja again, this time in the runtime context:ref()returns real relations,executeisTrueandis_incremental()can query the database. The output is written totarget/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 totarget/run/. dbt/task/runnable.py · dbt/task/run.py · dbt/context/providers.py -
Write artifacts and exit.
dbt runs any
on-run-endhooks, writesrun_results.jsonwith 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. Yourrecord_dbt_stepcallback goes further and counts results to write them toaudit.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 feature | Pass 1 — parse (every node) | Pass 2 — execute (selected nodes) |
|---|---|---|
execute | False | True |
ref('x') / source(...) | Records a dependency and returns a placeholder relation | Returns the real relation, e.g. "dwlab"."int"."x" |
config(...) | Saves the settings into the node | Already known, so it's effectively ignored |
run_query(...) | Doesn't touch the database; returns None | Runs 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
--varsare one of those inputs. Yourrun_idchanges 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 samerun_idand can reusepartial_parse.msgpack, as long as they share the sametarget/folder. Changes toprofiles.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 yourgenerate_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
GraphQueueorder the nodes. In stage 3,--select path:models/intlets dbt order all sixint_*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
| Phase | Component | Covered in |
|---|---|---|
| 01–03 | CLI, config, adapter registration | Ch. 02 Package anatomy · Ch. 03 CLI & configuration |
| 04 | Parser, manifest, partial parsing | Ch. 04 The parser & the manifest |
| 05–06 | Graph, selectors, queue | Ch. 05 The DAG & node selection |
| 07 (compile) | Jinja environment, context, macro resolution | Ch. 06 Compilation & the Jinja engine |
| 07 (execute) | Tasks, runners, threads, materializations, adapters | Ch. 07 The execution engine |
| 08 | Artifacts, state, deferral, orchestration | Ch. 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
--selectsays. - 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
--varsturns 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?
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.
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.
(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?
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'.
(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.