CertSafari
    DBT Analytics Engineering· Lessons

    Domain 1 · Lesson 8/31

    Python models in dbt: the model() function, configuration and materializations

    Creating Python Models

    16 min read
    3.23% of exam
    4 sources
    Published 12 Oct 2026
    Docs as of 12 Oct 2026

    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.

    A Python model documented, configured and tested in an ordinary properties fileyaml
    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_null

    The 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?

    Sources12

    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.

    The shape every Python model takes: read upstream, transform, return a DataFramepython
    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_df

    The 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.

    DataFrame types a model() function may return, by platform
    PlatformPython backendAllowed return types
    SnowflakeSnowparkSnowpark or pandas DataFrame
    BigQueryBigFrames or DataprocBigFrames, pandas or Spark DataFrame
    DatabricksPySparkSpark, 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?

    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?

    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")
    
        ...

    Sources13

    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.

    Step 1: put target, var and env_var values into config through properties YAMLyaml
    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') }}"
    Step 2: read those values in the model with dbt.config.get()python
    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?

    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)

    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?

    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.

    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.

    BigQuery submission methods for Python models
    submission_methodRuns onStrengths and setupThird-party packages
    bigframesBigQuery DataFrames, using a Colab Enterprise runtimepandas- and scikit-learn-style APIs with no Spark setup and no infrastructure to managepackages config, pip-installed before the model runs
    serverlessDataproc Serverless, as a PySpark jobSimpler but slower; limited configuration; pandas, numpy and scikit-learn pre-installedCustom container image hosted in Artifact Registry
    clusterAn existing Dataproc clusterFull control and faster runtimes; needs dataproc_cluster_nameInitialization 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.

    Sources4

    Exam traps

    Each one states something that sounds right. Open it to see what is actually true.

    1. 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. 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. 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. 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. 1.
      “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. 2.
      “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. 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

    Ready to test yourself?

    Practise the 11 questions on this subdomain.

    Spotted a mistake, or was something unclear? Tell us.