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
- Exit code. dbt exits with a non-zero code when a model or a test fails, and the
BashOperatorfails the task. - Logs. Everything dbt prints, up to the final
Done. PASS=3 WARN=0 ERROR=0, appears in the task log. - Environment.
envreplaces the environment of the command. Withappend_env=Trueit is added to the existing one, soPATH,HOMEand the other variables stay available. - Working directory.
cwdruns the command in the project folder, so dbt findsdbt_project.ymlwithout--project-dir.
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
- Packages. If the project has a
packages.yml, rundbt depswhen you deploy, or as a first task. - Retries rerun everything. A retry runs the whole command again. When a single model fails, every model before it is rebuilt as well.
- Selection.
--selecttakes the usual dbt syntax:tag:daily,+orders,path:models/marts. - Jinja reads the braces first.
bash_commandis a template, so{{,{%and{#mean something to Jinja before bash sees them. A bash expression such as${#VAR}opens a Jinja comment and the task fails while rendering. Move that logic into a script, or wrap it in{% raw %}...{% endraw %}.
Tested with Airflow 3.3.2 and dbt Core 1.12 (DuckDB adapter).