What you will be able to do
- Decide when a transformation should be a Python model rather than a SQL model
- Write a model(dbt, session) function that reads upstream data with dbt.ref() and dbt.source() and returns a DataFrame
- Configure a Python model correctly, including passing var, env_var and target values through properties YAML
- Pick a supported materialization for a Python model and use dbt.is_incremental and dbt.this
- Choose a BigQuery submission method and install packages on it correctly
Key concept
Remote execution of Python models — In a dbt Python model, dbt only defines the code. The data platform runs it in its own Python runtime and saves the returned DataFrame as a table. Anything the code needs, from libraries to compute, has to exist on the platform, not on the machine that runs dbt.
1.When a Python model beats a SQL model
A Python model is for the work SQL cannot express cleanly. Training or deploying a data science model, running a statistics routine, or parsing awkward dates with a library such as dateutil used to need separate infrastructure and its own scheduler. A Python model turns that step into a node in the dbt project instead, with tests, documentation and lineage like any other model.
From the outside, a Python model looks like a SQL model. It takes almost all of the same configuration options: you can test and document it, add tags and meta, and grant other users access to its results. Selection works the same way too: by name, file path, configuration, position upstream or downstream of another model, or modification compared with a previous project state. The properties YAML for a Python model is the same YAML you already write for SQL.
models:
- name: my_python_model
# Document within the same codebase
description: My transformation written in Python
# Configure in ways that feel intuitive and familiar
config:
materialized: table
tags: ['python']
# Test the results of my Python transformation
columns:
- name: id
# Standard validation for 'grain' of Python results
data_tests:
- unique
- not_nullThe biggest difference is where the code runs. dbt never runs a Python model locally. It sends the code to the data platform, which runs it in its own Python runtime. That is why only some adapters support Python models: the platform needs a fully featured Python runtime. Snowflake, BigQuery and Databricks are the supported platforms. In practice, a library installed on your laptop does nothing for the model. The library has to be available where the platform runs the code.
Use Python only for the step that needs it. SQL models can ref() a Python model, and a Python model can read SQL models. So a typical project stages and joins data in SQL, hands one prepared table to a Python model for the feature engineering or scoring, and goes back to SQL downstream.
Checkpoint 1 of 9· Check yourself
An analyst runs dbt run --select churn_model from a laptop that has scikit-learn installed. churn_model is a Python model on Snowflake. Where does its Python code run?
dbt keeps model definition separate from model execution. The platform runs all of a Python model's code, so the laptop's libraries play no part.
“None of it is run by dbt locally.”Source: docs.getdbt.com
2.The model(dbt, session) function and its return value
Every Python model is a .py file in the models/ folder that defines one function, model(), with two parameters. **dbt is a class that dbt compiles separately for each model. It is how the code reaches the project and the DAG. session** is the platform's connection to its Python backend. The model uses it to read tables as DataFrames and to write DataFrames back. PySpark normally exposes its session as a global called spark, but dbt always passes it in explicitly as session so the signature is the same on every platform.
import ...
def model(dbt, session):
my_sql_model_df = dbt.ref("my_sql_model")
final_df = ... # stuff you can't write in SQL!
return final_dfThe returned DataFrame does the job of a SQL model's final select. The function must return exactly one DataFrame, and when the model runs, dbt saves that DataFrame's full result as a table in the warehouse. DataFrame operations are evaluated lazily, so in development you can look at intermediate results with .show() or .head() before deciding what to return. When you run dbt run --select python_model, dbt builds both arguments and passes them in. Your job is only to write the function.
| Platform | Python backend | Allowed return types |
|---|---|---|
| Snowflake | Snowpark | Snowpark or pandas DataFrame |
| BigQuery | BigFrames or Dataproc | BigFrames, pandas or Spark DataFrame |
| Databricks | PySpark | Spark, pandas or pandas-on-Spark DataFrame |
dbt does not use Jinja to render Python models. A Python model has no {{ }} blocks and no access to the full global Jinja context that SQL models enjoy. Everything it knows about the project arrives through the dbt argument, and that limit shapes how you configure Python models, covered further down.
Checkpoint 2 of 9· Check yourself
A Databricks Python model builds two DataFrames: a cleaned fact table and a summary of it. The developer wants both saved by this one model. What does dbt accept?
The function must return exactly one DataFrame, and that DataFrame becomes the model's table. Two outputs means two models.
“The model() function must return a single DataFrame.”Source: docs.getdbt.com
Checkpoint 3 of 9· Exam question
A dbt Python model on Snowflake is supposed to append only new order rows on each run, but every run reprocesses the entire source table instead: ```python def model(dbt, session): dbt.config(materialized="table") df = dbt.ref("stg_orders") if dbt.is_incremental: max_from_this = f"select max(updated_at) from {dbt.this}" df = df.filter(df.updated_at >= session.sql(max_from_this).collect()[0][0]) return df ``` Which line is responsible for the full reprocessing, and how should it change?
Correct answer: A — The `dbt.config(materialized="table")` call should be changed to `dbt.config(materialized="incremental")`, because the table materialization always rebuilds the full result set on every invocation.
- A. Correct: with `materialized="table"`, dbt drops and rebuilds the entire result on every run regardless of the `is_incremental` check, so the block filtering to new rows never has an incremental table to compare against. Switching to `materialized="incremental"` is what makes the `dbt.is_incremental` branch meaningful.
- B. Incorrect: `dbt.ref()` is the correct way to reference another model, including staging models, and it fully participates in dbt's dependency graph. Swapping it for `dbt.source()` would be wrong here since `stg_orders` is a model, not a raw source table, and would not fix the reprocessing behavior.
- C. Incorrect: `dbt.is_incremental` is the documented way to check, inside the model body, whether the current run should filter to new rows; there is no `dbt.this.is_incremental()` callable. The reprocessing is caused by the materialization setting, not this condition.
- D. Incorrect: Snowpark DataFrames support `.filter()` natively for row-level filtering, so nothing here requires dropping into raw SQL. The full reprocessing is caused by the materialization choice, not by how the filter is expressed.
Sources1
3.Reading upstream data with dbt.ref() and dbt.source()
A Python model joins the DAG through the same two functions SQL models use, called as methods on the dbt object. dbt.ref() reads another model, whether SQL or Python, and dbt.source() reads a raw source table. Each edge they create carries the same meaning as its SQL counterpart, described in [ref versus source dependencies](lesson:1.1). Here, though, the calls return DataFrames pointing at the upstream source, model, seed or snapshot, not a table name dropped into SQL text.
The returned object is the platform's native DataFrame, so a model that wants pandas has to convert it. The Snowflake tutorial's models call .to_pandas() on the result of dbt.ref("int_lap_times_years") before using pandas groupby and rolling methods. Lineage runs the other way as well: a downstream SQL model can select * from {{ ref('my_python_model') }} as it would any table.
There is one hole in that symmetry. A Python model cannot reference an ephemeral model. An ephemeral model is never built as a database object, so it has nothing a DataFrame could point at. If a Python model needs logic that currently lives in an ephemeral model, give that upstream model a materialization that produces a relation.
Checkpoint 4 of 9· Fill the gap
This model reads one upstream model and one raw source table. Which method completes the second read?
def model(dbt, session):
# DataFrame representing an upstream model
upstream_model = dbt.ref("upstream_model_name")
# DataFrame representing an upstream source
upstream_source = dbt. ? ("upstream_source_name", "table_name")
...Raw source tables are read with dbt.source(source_name, table_name), and models with dbt.ref(). Both return DataFrames.
4.Configuring Python models and passing in vars, env vars and target
You can configure a Python model in the same three places as a SQL model, and the usual [configuration precedence](lesson:1.6) applies:
- dbt_project.yml, for many models at once
- a properties .yml file in the models/ directory
- the model's .py file, by calling dbt.config(), the Python equivalent of the {{ config() }} macro
dbt.config() is deliberately limited. It accepts literal strings, booleans and numbers, plus dynamic configuration, and nothing else: no function calls and no nested data structures. The reason is parsing. dbt analyzes those arguments statically while it parses the project, before any Python runs. Anything more complex belongs in the config property of a properties YAML file.
That makes YAML the bridge for runtime context. To branch on var, env_var or target, set each one as a config value in a properties file, where Jinja is allowed. Then read it back inside the function with dbt.config.get(). The example below limits fct_orders to 500 rows whenever the target is dev.
models:
- name: my_python_model
config:
materialized: table
target_name: "{{ target.name }}"
specific_var: "{{ var('SPECIFIC_VAR') }}"
specific_env_var: "{{ env_var('SPECIFIC_ENV_VAR') }}"def model(dbt, session):
target_name = dbt.config.get("target_name")
specific_var = dbt.config.get("specific_var")
specific_env_var = dbt.config.get("specific_env_var")
orders_df = dbt.ref("fct_orders")
# limit data in dev
if target_name == "dev":
orders_df = orders_df.limit(500)Custom values stored under meta have their own accessor. dbt.config.meta_get("custom_value") returns a single key. dbt.config.get("meta") returns the whole dictionary, so write dbt.config.get("meta", {}).get("custom_value") for the case where no meta is configured. Since dbt 1.8, config values also resolve inside Python f-strings. Before that, dbt.config.get() inside an f-string printed None.
Checkpoint 5 of 9· Check yourself
A Python model must skip an expensive scoring step when the environment variable SKIP_SCORING is set. Which setup works?
Python models don't render Jinja, and dbt.config() takes only literals. Values that come from env_var, var or target go through properties YAML config and are read back with dbt.config.get().
“we require setting them through a dedicated properties YAML file config”Source: docs.getdbt.com
Checkpoint 6 of 9· Exam question
Your team is configuring a dbt Python model that needs pinned package versions and a specific materialization. Which of the following are valid ways to apply that configuration? (Select 3)(Select 3)
Correct answers: A, B, F — Call `dbt.config(materialized="table", packages=["pandas==1.5.3"])` as the first statement inside the `model()` function, since dbt parses the literal arguments passed to that call.; Add a `config:` block under a `models:` entry for the Python model in a `.yml` properties file, setting `materialized` and `packages` there instead of inside the function body.; Set `materialized` and `packages` under the model's path in `dbt_project.yml`, since project-level configuration in that file also applies to Python models scoped under that path.
- A. Correct: calling `dbt.config()` with literal string, boolean, or list arguments directly inside the model function is one of the documented ways to configure a Python model, and dbt parses those literal values at compile time.
- B. Correct: configuring `materialized` and `packages` through a `config:` block in a YAML properties file is a documented alternative to calling `dbt.config()` in the file, and it applies identically to Python models.
- C. Incorrect: Python model files are not run through dbt's Jinja compiler the way `.sql` files are, so `{{ config(...) }}` syntax has no effect inside a `.py` file. Configuration in Python models is set through `dbt.config()`, YAML, or `dbt_project.yml` only.
- D. Incorrect: `dbt.config()` only accepts literal values such as strings, booleans, and numbers, not arbitrary dynamically constructed dictionaries or objects. Passing computed settings this way is not a supported configuration path.
- E. Incorrect: Python models do not use Jinja, so `env_var()` calls inside a `.py` file are not evaluated. Environment-driven values must be resolved through YAML configuration and read back with `dbt.config.get()`.
- F. Correct: `dbt_project.yml` is one of the three documented configuration surfaces for dbt models, and setting `materialized` or `packages` for a model path there applies to Python models the same way it does for SQL models.
Sources1
5.Supported materializations: table and incremental
Python models support only two of the [core materializations](lesson:1.2): table, the default, and incremental. A view has no place to store a DataFrame computed in a Python runtime, and the ephemeral pattern, which inlines SQL into a downstream query, cannot carry Python code either. If a model's job fits in a view, it belongs in SQL.
For incremental models, the dbt object provides the two things the logic depends on. dbt.is_incremental reports whether the current run is incremental, and dbt.this gives the location of the model's own table, broken down as dbt.this.database, dbt.this.schema and dbt.this.identifier. These are the Python counterparts of the is_incremental() macro and {{ this }}. The usual pattern is to set materialized="incremental", take the upstream DataFrame, and, when dbt.is_incremental is true, filter it against what is already stored in dbt.this, so the run processes only new rows. How those rows are then merged or appended is a question of [incremental strategy](lesson:1.11).
Checkpoint 7 of 9· Check yourself
A team sets materialized: view on a Python model to avoid storing another table. What should they do instead?
Python models support only table (the default) and incremental. Ephemeral is ruled out too, since Python models cannot even reference ephemeral models.
“table (default)”Source: docs.getdbt.com
Checkpoint 8 of 9· Exam question
A data science team currently scores customer churn risk with scikit-learn in a nightly cron script that runs outside the warehouse, using features already produced by SQL staging models. They want the scoring logic version-controlled and visible in the same lineage graph as the rest of the warehouse. Propose the most dbt-idiomatic way to do this.
Correct answer: A — Create a Python model that calls `dbt.ref()` on the relevant SQL staging models, fits the scikit-learn transformation inside `model(dbt, session)`, and returns the scored DataFrame as the model's output.
- A. Correct: a Python model that references the staging models with `dbt.ref()` and runs the scikit-learn logic inside `model(dbt, session)` brings the scoring step into the DAG, under version control, and testable like any other model, which is exactly what Python models exist for.
- B. Incorrect: leaving the scoring logic in an external cron job and only wrapping its output as a source keeps the transformation itself outside the dbt project, so it still has no lineage tracking, tests, or version control inside dbt.
- C. Incorrect: scikit-learn's statistical modeling functions have no direct SQL or stored-procedure equivalent, so this rewrite is not generally feasible, and routing it through a macro at `on-run-end` would bypass normal model dependency tracking anyway.
- D. Incorrect: `ephemeral` materialization is not supported for dbt Python models, so this approach is not something dbt can actually build; a Python model must materialize as `table` or `incremental` instead.
Sources1
6.Running Python models on BigQuery: submission methods and packages
On BigQuery, the platform-specific choice is how the code is submitted. The adapter reads data from BigQuery, computes the result either natively with BigQuery DataFrames or on Dataproc, and writes it back. You choose with submission_method, set either in the profile or on a single model.
| submission_method | Runs on | Strengths and setup | Third-party packages |
|---|---|---|---|
| bigframes | BigQuery DataFrames, using a Colab Enterprise runtime | pandas- and scikit-learn-style APIs with no Spark setup and no infrastructure to manage | packages config, pip-installed before the model runs |
| serverless | Dataproc Serverless, as a PySpark job | Simpler but slower; limited configuration; pandas, numpy and scikit-learn pre-installed | Custom container image hosted in Artifact Registry |
| cluster | An existing Dataproc cluster | Full control and faster runtimes; needs dataproc_cluster_name | Initialization actions, or pip/conda cluster properties |
The packages setting is where teams get caught. It lists Python packages to pip install before the model runs, but it only takes effect with submission_method: bigframes. Dataproc, serverless or cluster, ignores it without any error or warning, so a model moved from BigFrames to Dataproc keeps a packages list that installs nothing. On Dataproc, packages have to come from a custom container image or from cluster initialization actions. submission_method can also be left out when dataproc_cluster_name is declared.
Checkpoint 9 of 9· Match them up
Match each BigQuery submission method to its defining trait
Tap a term, then the definition that fits it.
BigFrames runs pandas and scikit-learn code with no infrastructure to manage. The two Dataproc options trade simplicity (serverless) against control and speed (cluster).
“serverless is simpler but slower with limited configuration and pre-installed packages (pandas, numpy, scikit-learn), while cluster offers full control and faster runtimes.”Source: docs.getdbt.com
Sources4
Exam traps
Each one states something that sounds right. Open it to see what is actually true.
1.dbt runs a Python model's code on the machine where dbt is invoked and uploads the result.Why is that wrong?
The data platform runs all of the code remotely. Libraries and compute must exist on the platform.
Covered in When a Python model beats a SQL model
2.dbt.config() inside a .py file can take var(), env_var() or other function calls, just like the SQL config() macro.Why is that wrong?
dbt.config() accepts only literals (and dynamic configuration), because dbt parses it statically. Runtime values go in properties YAML and are read back with dbt.config.get().
Covered in Configuring Python models and passing in vars, env vars and target
3.A Python model can dbt.ref() any upstream model, including an ephemeral one.Why is that wrong?
Ephemeral models cannot be referenced from Python models. The upstream model needs a materialization that builds a relation.
Covered in Reading upstream data with dbt.ref() and dbt.source()
4.Listing libraries in the packages config installs them for any BigQuery Python model.Why is that wrong?
packages only works with the bigframes submission method. Dataproc ignores it silently and needs a custom image or initialization actions.
Covered in Running Python models on BigQuery: submission methods and packages
Sources
Every claim above is drawn from one of these pages, quoted as it was written on the date shown.
- 1.https://docs.getdbt.com/docs/build/python-modelsOfficial docs
“Python transformations defined in dbt are models in your project with all the same capabilities around testing, documentation, and lineage.”
↩︎ When a Python model beats a SQL model“Python models for Snowflake, BigQuery, and Databricks are supported in dbt v2.”
↩︎ When a Python model beats a SQL model“dbt Python models have access to almost all of the same configuration options as SQL models.”
↩︎ When a Python model beats a SQL model“A class compiled by dbt unique to each model”
↩︎ The model(dbt, session) function and its return value“The session is needed to read in tables as DataFrames, and to write DataFrames back to tables.”
↩︎ The model(dbt, session) function and its return value“When you run a Python model, the full result of the final DataFrame will be saved as a table in your data warehouse.”
↩︎ The model(dbt, session) function and its return value“dbt Python models don't use Jinja to render compiled code.”
↩︎ The model(dbt, session) function and its return value“These methods return DataFrames pointing to the upstream source, model, seed, or snapshot.”
↩︎ Reading upstream data with dbt.ref() and dbt.source()“Referencing ephemeral models is currently not supported”
↩︎ Reading upstream data with dbt.ref() and dbt.source()“Within the model's .py file, using the dbt.config() method”
↩︎ Configuring Python models and passing in vars, env vars and target“dbt statically analyzes the arguments to config() while parsing your model without executing your Python code.”
↩︎ Configuring Python models and passing in vars, env vars and target“Then access them in your Python model using the dbt.config.meta_get() method”
↩︎ Configuring Python models and passing in vars, env vars and target“Determining if the current model's run is incremental: dbt.is_incremental”
↩︎ Supported materializations: table and incremental“Accessing the database location of the current model: dbt.this()”
↩︎ Supported materializations: table and incremental“Python models support these materializations:”
↩︎ Supported materializations: table and incremental“In a dbt Python model, all Python code is executed remotely on the platform.”
↩︎ Key concept“None of it is run by dbt locally.”
↩︎ Exam trap 1“It accepts only literal values (strings, booleans, and numeric types) and dynamic configuration.”
↩︎ Exam trap 2“Referencing ephemeral models is currently not supported”
↩︎ Exam trap 3“None of it is run by dbt locally.”
↩︎ Checkpoint“The model() function must return a single DataFrame.”
↩︎ Checkpoint“we require setting them through a dedicated properties YAML file config”
↩︎ Checkpoint“table (default)”
↩︎ Checkpoint - 2.https://docs.getdbt.com/docs/build/modelsOfficial docs
“Python models are useful for training or deploying data science models, complex transformations, or where a specific Python package meets a need”
↩︎ When a Python model beats a SQL model - 3.https://docs.getdbt.com/guides/dbt-python-snowparkOfficial docs
“casting it to a pandas data frame .to_pandas()”
↩︎ Reading upstream data with dbt.ref() and dbt.source() - 4.
“This process reads data from BigQuery, computes it either natively with BigQuery DataFrames or Dataproc, and writes the results back to BigQuery.”
↩︎ Running Python models on BigQuery: submission methods and packages“BigQuery DataFrames can execute pandas and scikit-learn.”
↩︎ Running Python models on BigQuery: submission methods and packages“submission_method is not required when dataproc_cluster_name is declared.”
↩︎ Running Python models on BigQuery: submission methods and packages“It only works with submission_method: bigframes. Dataproc (serverless and cluster) ignores it without an error or warning.”
↩︎ Exam trap 4“serverless is simpler but slower with limited configuration and pre-installed packages (pandas, numpy, scikit-learn), while cluster offers full control and faster runtimes.”
↩︎ Checkpoint