Debug a dbt model in a notebook¶
duckrun.dbt_project() compiles a model with dbt and runs it on duckrun, handing you a DuckDB
relation — real types, and lazy.
from duckrun import dbt_project
p = dbt_project("dbt/", target="dev") # returns immediately; parses on first use
p.show("orders_enriched") # DuckDBPyRelation
p.sql("select * from {{ ref('stg_orders') }} where year = 2026")
p.compiled("orders_enriched") # the SQL text
p.ctes("orders_enriched") # ['base', 'allocated', 'final']
p.cte("orders_enriched", "allocated") # run only as far as that CTE
.pl() / .arrow() / .df() / .filter() / .limit() / .count() / .fetchall() come from the
relation, so there is no DataFrame library in duckrun's dependencies: .pl() needs polars and
.df() needs pandas installed; .arrow() and .fetchall() always work.
Why not dbt show¶
dbt show truncates, and --output json serializes through agate: decimals arrive as strings or
floats, dates as strings, and the DataFrame library guesses a type per column. It is also eager.
duckrun runs DuckDB in-process, so dbt only has to compile: a model's compiled SQL is DuckDB SQL
whose ref()s resolve to delta_scan views, and duckrun executes it.
rel = p.show("orders_enriched")
rel.types # real DuckDB types, end to end
rel.filter("customer = 'X'").limit(100).pl() # pushes into the delta_scan; nothing read before
Models materialized as view¶
A view model exists only in the session that built it, and a debug session is a different
process. The session therefore registers the manifest's view models as views, in dependency order,
before the query runs, so a .filter() still pushes down through the staging layer. They are
dropped whenever the project is re-parsed, so an edited view model is never read through its old
definition.
cte() — run one step¶
When a model runs clean but returns nonsense, run the CTEs one at a time and find where the row count or a key goes wrong:
p.ctes("orders_enriched") # ['base', 'allocated', 'final']
p.cte("orders_enriched", "base").count("*") # -> 41233, as expected
p.cte("orders_enriched", "allocated").count("*") # -> 38902, the join drops rows
p.cte("orders_enriched", "allocated").filter("share is null").pl()
The rewrite keeps the WITH list up to and including the named CTE and selects from it. The CTE
text is spliced out of the compiled SQL verbatim, so what runs is character-for-character what dbt
produced.
Which is_incremental() branch you are looking at¶
A model that branches on is_incremental() has two compiled forms, and the compiled SQL cannot tell
you which one you have. duckrun says:
>>> p.show("orders_incremental")
[duckrun] orders_incremental: is_incremental() = True -- this is the incremental branch,
i.e. the rows a run would write INTO the existing table, not the table's
contents. The other branch: incremental=False
p.compiled("orders_incremental", incremental=False) # dbt's --full-refresh
p.last_compile.incremental # True / False / None
None means the model does not branch, and nothing is printed. The answer comes from dbt itself
(compile both ways, compare), not a re-implementation of its rule. incremental=True cannot be
forced: it is already the default whenever the target table exists.
Ephemeral models¶
dbt injects an ephemeral model as a CTE named __dbt__cte__<name> into whatever selects from it,
so it shows up in the consumer's CTE list and both routes work:
p.ctes("mart") # ['__dbt__cte__stg_clean', 'base', 'final']
p.cte("mart", "__dbt__cte__stg_clean")
p.sql("select * from {{ ref('stg_clean') }}")
p.ctes("mart", ephemeral=False) # ['base', 'final'] — hides them from the listing only
Read-only¶
The session cannot write: its cursor has no route to delta-rs, so a write lands on the read-only
delta_scan view and DuckDB refuses it. COPY … TO and EXPORT DATABASE are refused too, since
they write files wherever the session's credentials reach. Scratch objects stay allowed; they live in
the in-memory catalog and never reach the lakehouse.
p.sql("delete from {{ ref('stg_orders') }}")
# DuckrunReadOnlyError: read-only debug session: this statement writes.
p.sql("create temp table candidates as select * from {{ ref('stg_orders') }} where amount > 1000")
p.sql("create or replace view v_check as select customer, count(*) from candidates group by 1")
A create that refs an ephemeral model cannot work, because dbt prepends a WITH clause that
only parses in front of a SELECT; build the relation first and call rel.create("candidates")
(or create_view), which makes the same kind of scratch table.
Read-only covers what the session executes — not dbt's compile
Compiling is a real dbt compile on dbt's own connection. A macro that runs SQL at compile
time ({% if execute %} with run_query(...)) still runs, exactly as under dbt compile.
Selectors¶
model is handed to dbt untouched, so any dbt selector works:
p.show("orders_enriched")
p.show("path:models/marts/orders_enriched.sql")
p.show("tag:daily") # …if it resolves to exactly one model
A selector matching several nodes lists them rather than picking one. A model's tests come back alongside it and are ignored, but a test named outright resolves, so a failing test can be read with real types:
Editing while you debug¶
The parsed manifest is kept warm but checked against the project's files on every call and re-parsed
the moment anything changed, so you never get the SQL from before your last edit. p.reload()
forces a re-parse for something the file check cannot see, such as an env_var changing in the
kernel.
The connection¶
The session runs on a DuckrunEnvironment built from your profile, reusing the one dbt just used
when the credentials match. Secrets, ATTACHed catalogs, catalog aliases and the lazy delta_scan
bind are therefore identical to a real run. Reuse also keeps it to one DuckDB instance; each pins
memory_limit to a large share of RAM, so a second one beside dbt's is an OOM in a Fabric notebook.
Reference¶
| Call | Returns |
|---|---|
dbt_project(project_dir=".", target=None, profiles_dir=None) |
a DbtProject |
p.show(model, incremental=None) |
DuckDBPyRelation |
p.sql(query) |
DuckDBPyRelation — ref()/source() rendered; None for a statement with no result set |
p.compiled(model, incremental=None) |
the compiled SQL, as text — print() it |
p.ctes(model, incremental=None, ephemeral=True) |
list of CTE names, in order; ephemeral=False leaves out the injected ones |
p.cte(model, name, incremental=None) |
DuckDBPyRelation for that step |
p.reload() |
re-parses now |
p.last_compile |
.model .sql .incremental .full_refresh .cte .node_id |
project_dir is the directory holding dbt_project.yml. profiles.yml is looked for next to it,
then in DBT_PROFILES_DIR, then ~/.dbt; pass profiles_dir only if it is elsewhere. A wrong
directory or target fails at dbt_project(...), not later inside a show().