The simplest way to run dbt Core from Airflow is a BashOperator that calls the dbt command. dbt lives in its own virtual environment, the task runs it in the project folder, and a non-zero exit code from dbt fails the task. The whole project runs as one task; to get one task per model, see Cosmos.

Copy and paste

1. Install dbt in its own virtual environment, next to Airflow rather than inside it:

python3 -m venv /opt/dbt/venv
/opt/dbt/venv/bin/pip install dbt-core dbt-snowflake   # or dbt-postgres, dbt-bigquery, dbt-duckdb...

2. One task that builds the project for the run date:

import pendulum
from airflow.sdk import dag
from airflow.providers.standard.operators.bash import BashOperator

DBT_BIN = "/opt/dbt/venv/bin/dbt"
DBT_PROJECT_DIR = "/opt/dbt/shop"

@dag(schedule="0 5 * * *", start_date=pendulum.datetime(2026, 1, 1, tz="UTC"), catchup=False)
def dbt_daily():
    BashOperator(
        task_id="dbt_build",
        bash_command=DBT_BIN + " build --target prod --vars '{run_date: \"{{ ds }}\"}'",
        cwd=DBT_PROJECT_DIR,
        env={"DBT_PROFILES_DIR": DBT_PROJECT_DIR},
        append_env=True,
    )

dbt_daily()

For the run of 15 May the task executes /opt/dbt/venv/bin/dbt build --target prod --vars '{run_date: "2026-05-15"}', and in the models {{ var("run_date") }} returns 2026-05-15.

Run and test as separate tasks:

dbt_args = {"cwd": DBT_PROJECT_DIR, "env": {"DBT_PROFILES_DIR": DBT_PROJECT_DIR}, "append_env": True}

dbt_run = BashOperator(task_id="dbt_run", bash_command=f"{DBT_BIN} run --select tag:daily", **dbt_args)
dbt_test = BashOperator(task_id="dbt_test", bash_command=f"{DBT_BIN} test --select tag:daily", **dbt_args)

dbt_run >> dbt_test

Why a separate virtual environment

Airflow and dbt depend on many of the same Python packages, and Airflow’s constraints file pins them to exact versions. Installing dbt on top of Airflow can move some of them and break one or the other. In its own environment dbt can be upgraded on its own schedule, and the task only needs the path to its executable.

How the task behaves

Credentials

Keep passwords out of profiles.yml: read them with env_var() in the profile, and fill the variables from an Airflow connection. env is a templated field and conn gives access to connections:

# profiles.yml
shop:
  target: prod
  outputs:
    prod:
      type: snowflake
      account: "{{ env_var('SNOWFLAKE_ACCOUNT') }}"
      user: "{{ env_var('SNOWFLAKE_USER') }}"
      private_key_path: /opt/dbt/keys/rsa_key.p8
      private_key_passphrase: "{{ env_var('DBT_ENV_SECRET_KEY_PASSPHRASE') }}"
      database: ANALYTICS
      warehouse: TRANSFORMING
      schema: MARTS
env={
    "DBT_PROFILES_DIR": DBT_PROJECT_DIR,
    "SNOWFLAKE_ACCOUNT": "{{ conn.snowflake_default.extra_dejson.account }}",
    "SNOWFLAKE_USER": "{{ conn.snowflake_default.login }}",
    "DBT_ENV_SECRET_KEY_PASSPHRASE": "{{ conn.snowflake_default.password }}",  # the key's passphrase
},

Snowflake no longer accepts passwords for service users, so the profile signs in with a key pair; see how to connect dbt to Snowflake with key-pair authentication.

Things to know

Tested with Airflow 3.3.2 and dbt Core 1.12 (DuckDB adapter).

References