DBTScale & Expertise

Python Models and dbt Beyond SQL

How analytics engineers turn raw warehouse tables into trusted models — pick a topic on the left and its full breakdown loads here: the mental model, the compiled SQL dbt actually issues, live runs you can drive yourself, and the failure modes that quietly ship wrong numbers.

18 min readTopics chapter readerLevel · Hard
01 · Five columns, one honest split

The Five Columns ML Platform Wants

dbt hands your code to a remote runtime as an opaque string and gets a relation back. It never reads your Python.

4 min · Topic 1 of 8

Chapter 20 declared what a number means. This chapter asks the same question about a transformation: when does it belong in SQL, and when does reaching for Python actually win?

ML Platform has asked for a churn feature table — one row per listener, five columns, computed off Chapter 9's fct_listens. The word "feature engineering" makes people reach for pandas. Commit to a language on all five columns below before the widget reveals anything.

ML Platform has asked for five columns, one row per listener — commit on all five before anything is revealedThe grain is one row per user_id, the reference date is 2026-07-19, and the window is the 28 days ending there — wide enough to admit all 12 rows of Chapter 9’s fct_listens, so no boundary reasoning gets in the way of the contract. For each column, say whether you would compute it in a SQL model or reach for a dbt Python model.
listens_28dHow many times did this listener press play in the last 28 days?
active_days_28dOn how many distinct days were they active?
completion_rate_28dWhat share of their listens ran to completion?
recency_weighted_listens_hl7Weight each listen by exponential decay with a 7-day half-life, then sum.
p_silent_14dGiven their inter-listen gaps, what is the probability they go 14 days silent?
All five have to be committed. No partial reveals.
Core mental model

A dbt Python model is not "dbt running Python." It is dbt generating a stored procedure or a job submission whose body is your function, wrapping it in a machine-written shim, and materializing the returned DataFrame through the ordinary SQL materialization path. Everything that feels missing — Jinja, --empty, unit tests, views, column-level lineage — is missing for one reason: dbt hands your code to a remote runtime as an opaque string and gets a relation back. It never reads your Python.

Why it matters

Most of what gets called "ML feature engineering" is plain SQL. Counts, ratios, recency, windowed aggregates, entropy, OLS slope, streaks, MAD z-scores and — the one that catches everyone — exponential decay weights are all set-based: EXP, LN, POWER, REGR_SLOPE and MEDIAN exist in Snowflake, BigQuery and Databricks alike. The irreducible thing is iterating a numerical optimisation to a convergence criterion over a group, which is an argmax, and SQL has neither an argmax nor a convergence predicate. Get this split wrong and you pay for it in every guarantee Chapter 7 through Chapter 14 spent thirteen chapters building.

dbt Python model
A .py file in models/ defining exactly one function named model(dbt, session) that returns a DataFrame. dbt materializes the returned frame as a table (or an incremental table) and nothing else.
The irreducible case
A numerical optimisation iterated to convergence over a group — an argmax with a stopping rule. Every other feature in this chapter is a set-based aggregate a warehouse already knows how to plan and parallelise.
p_silent_14d
The probability a listener goes 14 days without a listen, from a two-parameter Weibull fit over their inter-listen gaps. The only column here that genuinely needs Python — and the only one that returns NULL on Wavelength's shipped data.
Half-life decay weight
A listen aged d days contributes 2^(-d/7) with a 7-day half-life. It sounds like numpy. It is POWER(0.5, d / 7.0) inside a SUM.
The five columns, and the ruling on each
ColumnVerdictBecause
listens_28dSQLcount(*)
active_days_28dSQLcount(distinct listen_date)
completion_rate_28dSQLavg(case when is_completed then 1 else 0 end)
recency_weighted_listens_hl7SQLsum(power(0.5, datediff('day', listen_date, asof) / 7.0)) — the flagship trap
p_silent_14dPythonscipy.stats.weibull_min.fit(x, floc=0) — an argmax with a convergence test

Four of five stay in SQL, which is the same verdict Chapter 1’s fit wizard already grades as a pass: "mostly SQL, with a little Python for one or two steps." This chapter demonstrates it with code rather than re-running the wizard.

Common mistake

Hearing "exponential decay weights" and reaching for a Python model without checking whether the warehouse has POWER. You copy every qualifying row of a fact table into one process to compute a sum(). The answer is identical to the digit — 4.133 / 3.030 / 2.421 either way — and you have traded --empty, unit tests, column-level lineage and the optimizer for it. Section 2 runs both versions side by side.

Trusting a feature column because its arithmetic is simple. u_4's completion_rate_28d reads 0.400 and leans on ls_507, the row carrying Chapter 9's drift bug (ep_1008's duration lands in fct_listens as 1,980 ms instead of 1,980,000). Here the 90% threshold happens to clear under both durations, so is_completed is true either way — the right number for the wrong reason. Move one notch to avg(pct_completed) and u_4 reads 18,743.5 where the truth is 75.3. A feature table inherits every upstream defect silently, and a churn model would learn from the poisoned column without a single test going red.

Better habit

Before writing a Python model, write the SQL you think is impossible. Half the time it compiles.

Say out loud which single operation in the feature is not expressible as a set operation. If you cannot name one, the whole node is SQL.

Check the warehouse function list before the pandas docs — POWER, EXP, LN, MEDIAN and REGR_SLOPE cover more "ML feature engineering" than most people expect.

DataFrames, Spark and pandas are recapped here, not taught here

A dbt Python model returns a DataFrame — Snowpark, PySpark or pandas, depending on your warehouse — and dbt materializes it as a table. What a DataFrame is, why nothing happens until something forces execution, and what a shuffle costs are the Apache Spark KB’s subject; pandas itself belongs to the Python KB. Everything in this chapter is about the seam: the contract dbt asks your function to honour, and what that seam costs you in the dbt guarantees you already have.

DataFrames and Datasets — Apache Spark KB
Whether the feature is correct is Data Modeling’s chapter

Point-in-time correctness, as-of joins, label leakage, training/serving skew and what a feature store is for are argued in full there and are not re-argued here. This chapter assumes the feature definition is already correct and asks only where the code that computes it should live.

ML feature and feature-store modeling — Data Modeling KB
One boundary this chapter refuses to cross

The obvious second parent for a churn feature set is dim_subscription — tenure, plan, price. It is not used, and the reason is arithmetic rather than taste: fct_listens keys the person as user_id (u_4, u_7, u_9) and dim_subscription keys them as listener_id (L-9001 through L-9006), with no crosswalk anywhere in this project. The join returns zero rows. Chapter 7 set the precedent when it refused to build a test across the sh_11-versus-101 boundary rather than re-key either side; this chapter does the same, and everything dim_subscription would have contributed is trivial SQL anyway.

"Would you build ML features in dbt?"

The weak answer is "yes, dbt supports Python models." The strong answer starts by splitting the feature set: name the columns that are set-based aggregates and stay in SQL, then name the single operation that is not — usually an iterative fit, an argmax, or a convergence criterion. Then close with the part interviewers rarely hear: the Python node should ref the narrowest pre-aggregated relation you can build, because everything you push down into SQL is work the optimizer does in parallel instead of work one process does alone.

Remember this

Four of five "ML features" are SQL; the fifth is one function call over four rows. Push everything expressible down into SQL, and let the Python node ref the narrowest pre-aggregated relation it possibly can.