Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT

Variables and Environments

The dev/staging/prod pattern, the target context variable, vars in dbt_project.yml versus --vars on the CLI, var() defaults, env_var() for secrets, the real difference between vars and env_var, and custom per-environment schema naming.

65 min September 2026
// Part 01 — The Problem Environments Solve

The Same Code Must Run Safely in More Than One Place

Every real dbt project eventually needs to run in more than one place: a developer iterating on their laptop, a continuous integration job validating a pull request, and a scheduled production job that stakeholders actually depend on for real numbers. All three of these need to run essentially the same dbt project code — the same models, the same tests, the same macros — but they absolutely cannot all point at the same database, schema, or warehouse. A developer testing a risky change to a core model should never be able to accidentally overwrite the productionfct_orders table that a live dashboard queries every morning.

The standard pattern for solving this is a small set of named environments — conventionallydev, staging (sometimes called ci), and prod — each pointing at a different database, schema, or warehouse, while running the exact same project code. The project itself does not need a different copy per environment. What changes between environments is where that code's output lands, and in some cases, small pieces of behavior that legitimately need to differ by environment — a lower row limit while iterating in dev, for instance.

The rule this whole module supports: never test against production data by accident. A developer should be free to run dbt run repeatedly while iterating on a model, drop and rebuild tables, and even get something badly wrong, without any risk of touching what a real stakeholder is looking at in a live dashboard. Environments are the mechanism that makes this true by construction, rather than by discipline or convention alone.

dbt implements this environment pattern through profiles and targets. A profiles.yml file (kept outside the dbt project itself, usually in~/.dbt/profiles.yml locally, or configured directly in dbt Cloud) defines one or more named targets under a profile — each target specifying its own database connection details: account, warehouse, database, schema, and credentials. Which target is active for a given invocation of dbt is what actually determines whether you are running against dev, staging, or prod — the project's SQL files themselves never hardcode any of this.

~/.dbt/profiles.yml — one profile, three targets
freshmart:
  target: dev
  outputs:

    dev:
      type: snowflake
      account: freshmart_account
      user: "{{ env_var('DBT_USER') }}"
      password: "{{ env_var('SNOWFLAKE_PASSWORD') }}"
      role: transformer
      database: analytics_dev
      warehouse: dev_wh
      schema: dbt_jsmith
      threads: 4

    staging:
      type: snowflake
      account: freshmart_account
      user: "{{ env_var('DBT_USER') }}"
      password: "{{ env_var('SNOWFLAKE_PASSWORD') }}"
      role: transformer
      database: analytics_staging
      warehouse: ci_wh
      schema: dbt_ci
      threads: 8

    prod:
      type: snowflake
      account: freshmart_account
      user: "{{ env_var('DBT_USER') }}"
      password: "{{ env_var('SNOWFLAKE_PASSWORD') }}"
      role: transformer
      database: analytics_prod
      warehouse: prod_wh
      schema: analytics
      threads: 16

Every field that differs between these three targets — the database, the warehouse, the schema, even the thread count for parallelism — is exactly the kind of thing that should never be hardcoded inside a model's SQL. A model file that says select * from analytics_prod.raw.ordershas broken this entire pattern, because now that model can only ever run against production, regardless of which target is actually active. dbt's source() and ref()functions exist precisely so a model never needs to know which database or schema it is running against — that decision is made entirely by which target is active, external to the model's SQL.

// Part 02 — The target Variable

target.name: Letting Model Logic Know Which Environment Is Running

Most of the time, a model should not need to know or care which target is active — that is the whole point of using ref() and source() instead of hardcoded table names. But there are legitimate cases where a model or macro's behavior genuinely needs to branch based on environment, and dbt exposes this through a built-in Jinja context variable calledtarget, with target.name giving you the name of the currently active target as a plain string — 'dev', 'staging', or 'prod', matching whatever the target is actually named in profiles.yml.

The classic use case: limiting data volume in dev for faster iteration

A common and genuinely useful pattern is limiting how much data a model processes while a developer is iterating locally, since running a full historical transformation over years of production data on every save-and-rerun cycle is slow and usually unnecessary for checking whether the SQL logic itself is correct.

models/staging/stg_orders.sql — a dev-only row limit
select
    order_id,
    customer_id,
    order_status,
    order_total,
    ordered_at
from {{ source('app_db', 'orders') }}

{% if target.name == 'dev' %}
  -- Only look at the last 3 days of data while iterating locally --
  -- keeps local runs fast without changing the model's logic at all
  where ordered_at >= dateadd('day', -3, current_timestamp())
{% endif %}

Notice precisely what this does and does not do: it does not change what the model computes for staging or prod at all — the {% if %} block is entirely absent from the compiled SQL in those environments, and the model runs over the full source data exactly as it would without this conditional. Only in dev does the extra WHERE clause get compiled in, cutting the volume down dramatically for a faster local development loop.

target attributeWhat it gives you
target.nameThe active target's name as a string — 'dev', 'staging', 'prod', or whatever your profiles.yml calls it.
target.schemaThe schema configured for the active target — useful for macros that need to reference the schema dynamically rather than hardcoding it.
target.databaseThe database configured for the active target.
target.typeThe adapter type of the active target — 'snowflake', 'bigquery', 'postgres', and so on — occasionally useful for a macro that needs to branch on warehouse-specific SQL syntax.
target branches on environment identity, not on arbitrary configuration
target.name tells you which named target is running — a proxy for "which environment is this." It is not a general-purpose configuration mechanism for arbitrary values you want to pass into a run; that job belongs to vars, covered next in Part 03. Reach fortarget.name specifically when the thing that needs to change really is "which environment am I in," not "what value did someone pass in for this run."

A second common pattern: environment-specific materialization

Some teams also use target.name to make a model materialize as a lightweight view in dev (fast to create, cheap to throw away and recreate) but as a full table in prod (where query performance for downstream consumers matters more than build speed).

Conditionally choosing a materialization by environment
{{
    config(
        materialized = 'table' if target.name == 'prod' else 'view'
    )
}}

select
    customer_id,
    sum(order_total) as lifetime_value
from {{ ref('fct_orders') }}
group by 1

This keeps local development fast — a view compiles nearly instantly and always reflects the latest upstream logic without a rebuild — while ensuring the version stakeholders actually query in production gets the query-performance benefits of a materialized table.

// Part 03 — Project and Run-Level Variables

vars: in dbt_project.yml, and --vars on the Command Line

Where target.name is specifically about environment identity, dbt varsare a general-purpose mechanism for passing configuration values into models and macros — values that are not secrets, and are not tied to which environment is running, but that still need to be configurable without editing SQL. A project-level default is set in dbt_project.ymlunder a top-level vars: key, and any individual invocation of dbt can override that default for just that one run using the --vars command-line flag.

dbt_project.yml — project-level defaults
name: 'freshmart'
version: '1.0.0'
config-version: 2

vars:
  start_date: '2020-01-01'
  payment_methods: ['credit_card', 'paypal', 'gift_card', 'bank_transfer']
  enable_new_discount_logic: false

Inside a model or macro, these values are read with the var() Jinja function, which takes the variable's name and, critically, an optional second argument giving a default value to fall back on if the variable is not set anywhere at all — not in dbt_project.yml, and not overridden via --vars.

Reading a var with a safe default
select
    order_id,
    customer_id,
    order_total,
    ordered_at
from {{ ref('stg_orders') }}
where ordered_at >= '{{ var("start_date", "2000-01-01") }}'

Overriding start_date for one specific run, without touchingdbt_project.yml at all, is done with --vars on the command line, passed as an inline YAML dictionary:

Overriding a var for a single invocation
dbt run --select stg_orders --vars '{"start_date": "2024-06-01"}'

This is genuinely useful for a one-off backfill or a targeted re-run without permanently changing the project's default behavior for every other invocation — the override in --varsonly applies to that specific command, and the next ordinary dbt run falls back to whatever dbt_project.yml declares.

Precedence (highest wins)Source
1. --vars on the CLIApplies only to that one invocation of dbt; overrides everything else for that run.
2. vars: in dbt_project.ymlThe project-wide default, used whenever --vars does not override it.
3. The default argument to var()Only used if the variable is not set in either of the above — the last-resort fallback.

Why the default argument to var() matters more than it looks

Calling var("start_date") with no second argument at all will raise a compilation error the moment start_date is not defined anywhere — which is exactly the right behavior for a variable that a model genuinely cannot run correctly without. But for anything where a sensible fallback exists, providing a default is what keeps a model safe to run in a context where nobody thought to set that variable at all — a new CI job that doesn't know about every var a mature project has accumulated, or a teammate running a model locally for the first time without a fully populated dbt_project.yml in front of them.

A boolean feature-flag var, with a safe default
{% if var('enable_new_discount_logic', false) %}
  -- new promotional discount stacking rules
  case
    when d.discount_type = 'stacked' then d.discount_amount * 1.1
    else d.discount_amount
  end as final_discount_amount
{% else %}
  d.discount_amount as final_discount_amount
{% endif %}

Defaulting enable_new_discount_logic to false means any invocation of dbt that doesn't explicitly opt in — an unrelated CI job, an old scheduled run definition nobody has updated yet — gets the safe, existing behavior rather than accidentally picking up unfinished new logic simply because nobody remembered to set the flag for that particular run.

vars are for project configuration, not secrets
A vars: value lives in dbt_project.yml, which is ordinary, committed source code — visible to anyone with repository access, and to anyone browsing the project's git history. Never put a password, API key, or any other credential in a vars: block. That is exactly the job Part 04's env_var() exists for.
// Part 04 — env_var() for Secrets and Infrastructure

env_var(): Reading Real Operating-System Environment Variables

env_var() is a completely different mechanism from var(), even though the names look similar. env_var() reads an actual operating-system-level environment variable — something set outside of dbt entirely, by your shell, your CI system's secrets manager, or your orchestration tool — rather than anything declared inside the dbt project's own YAML files. This makes it the correct tool for exactly one job: getting secrets and infrastructure-specific values into dbt without ever committing them to source control.

profiles.yml — using env_var() to keep credentials out of the file entirely
freshmart:
  target: prod
  outputs:
    prod:
      type: snowflake
      account: freshmart_account
      user: "{{ env_var('DBT_USER') }}"
      password: "{{ env_var('SNOWFLAKE_PASSWORD') }}"
      role: transformer
      database: analytics_prod
      warehouse: prod_wh
      schema: analytics
      threads: 16

Nowhere in this file is an actual password. {{ env_var('SNOWFLAKE_PASSWORD') }}tells dbt to look up an environment variable literally named SNOWFLAKE_PASSWORD at run time, wherever dbt happens to be running — a developer's own shell (where they've exported it in their own local environment, never committed anywhere), or a CI system's secrets store injected as an environment variable just for that job's execution. The actual secret value never touches the dbt project's files at all, and profiles.yml stays safe to keep in version control (or is itself excluded from the repository entirely, which many teams also do as an extra layer of caution).

Setting the environment variable before running dbt, locally
export DBT_USER=jsmith
export SNOWFLAKE_PASSWORD=a-real-secret-value-never-committed

dbt run --target prod

env_var() also accepts an optional default value as a second argument, exactly likevar() does — but reaching for a default here deserves more caution than it does with project vars. A default for a genuine secret like a password almost never makes sense (there is no safe fallback for a missing credential), whereas a default for a non-secret, infrastructure-level setting — a warehouse size, a target concurrency limit — can be perfectly reasonable.

env_var() with a sensible non-secret default
{{ env_var('SNOWFLAKE_WAREHOUSE_SIZE', 'X-SMALL') }}
If dbt Cloud or your CI runner errors that an env var is undefined, that is the point
Unlike var() with no default, an undefined env_var() call with no default raising a hard, immediate error is a feature, not friction — for a secret or credential, failing loudly and immediately is dramatically preferable to silently falling back to some placeholder value and connecting to the wrong warehouse, or failing with a much more confusing downstream error.
// Part 05 — vars vs env_var(), the Real Difference

Two Mechanisms That Look Similar and Solve Different Problems

Beginners very commonly conflate vars and env_var() because both are ways of getting an external value into a dbt run, and both have a similar-looking function-call syntax in Jinja. The actual distinction is about where the value lives and what kind of value it should ever be used for, and getting this wrong has real consequences — usually a secret ending up somewhere it should never be.

vars / var()env_var()
Where the value is definedInside the dbt project itself — dbt_project.yml, or a --vars flag passed to the dbt command.Outside the dbt project entirely — the operating system's environment, set by a shell, CI secrets manager, or orchestrator.
Is it visible in version control?Yes, by design — vars: in dbt_project.yml is committed source code, meant to be readable by anyone with repo access.No — the actual value never appears in any file dbt reads from the repository; only the variable's name appears.
What it should be used forProject-level configuration: date ranges, feature flags, thresholds, lists of accepted values — anything that is fine for a teammate to read directly in the codebase.Secrets and infrastructure specifics: passwords, API keys, account identifiers, anything that must never be committed.
Overriding for one run--vars '{"key": "value"}' on the CLI, scoped to that single invocation.Exporting the variable in the shell (or your CI job's secret injection) before invoking dbt — dbt itself has no CLI flag for this.
Typical location it's usedInside models, macros, and tests — anywhere ordinary project logic needs a configurable value.Almost exclusively inside profiles.yml, and occasionally inside a macro that genuinely needs an infrastructure value like a warehouse name.
A one-line test you can apply to any value
Ask: "would I be comfortable with every engineer on this project seeing this value directly in a pull request diff?" If yes, it belongs in vars. If no — if it is a credential, a key, or anything that grants access to something — it belongs behind env_var(), full stop, with no exceptions made for convenience.

A subtler distinction worth internalizing: vars are dbt-native and dbt-aware — dbt itself resolves precedence between dbt_project.yml and --vars, andvar() is a first-class part of the Jinja context dbt provides. env_var(), by contrast, is dbt's bridge out to something entirely outside of dbt's own configuration system — the operating system's process environment, which dbt neither controls nor validates beyond simply reading it. This is exactly why env_var() is the right (and really the only correct) tool for anything that must be managed by infrastructure and secrets tooling rather than by the dbt project's own configuration files.

// Part 06 — Per-Environment Schema Naming

generate_schema_name: Sending Dev Runs to Per-Developer Schemas

By default, when a model does not set an explicit schema config, dbt builds it into whatever schema is configured on the active target in profiles.yml. This is fine for prod, where everything landing cleanly in one shared, well-known schema is exactly what you want. It becomes a problem in dev the moment more than one developer is working against the same database: if every developer's target schema is the same, one developer's in-progress, possibly broken model can silently collide with — or overwrite — another developer's tables of the exact same name.

The standard fix is a custom schema config on a model combined with overriding dbt's built-in generate_schema_name macro — the macro dbt actually calls, for every single model, to compute the schema it should build into. Overriding it project-wide lets you route dev runs into a per-developer schema (commonly named after the developer, likedbt_jsmith) while prod runs land in the clean, shared target schema with no suffix at all.

macros/generate_schema_name.sql — the standard override pattern
{% macro generate_schema_name(custom_schema_name, node) -%}

    {%- set default_schema = target.schema -%}

    {%- if target.name == 'prod' -%}

        {#- In prod, ignore any custom schema config entirely and always
            build into the clean target schema -#}
        {{ default_schema }}

    {%- else -%}

        {#- In dev and staging, build into a schema unique to this developer
            or run, so nobody's in-progress work collides with anyone else's -#}
        {{ default_schema }}_{{ target.name }}

    {%- endif -%}

{%- endmacro %}

With target.schema set to dbt_jsmith in one developer's owndev target (as configured back in Part 01's profiles.yml), and this override in place, that developer's models land in dbt_jsmith_dev — clearly separated from a teammate's own dbt_asharma_dev schema, and both entirely separate fromanalytics, the clean prod schema every dashboard actually queries.

Environmenttarget.schema (from profiles.yml)Actual schema built into
jsmith's devdbt_jsmithdbt_jsmith_dev
asharma's devdbt_asharmadbt_asharma_dev
staging / CIdbt_cidbt_ci_staging
prodanalyticsanalytics (unchanged — prod branch skips the suffix entirely)

This connects directly to the schema configuration options a model itself can set, covered in the materializations module — a model's own schema: config (via custom_schema_namein the macro above) can still request a specific sub-schema for organizational reasons, and this macro decides how that request is actually honored differently per environment, rather than replacing model-level schema configuration entirely.

Why not just give every developer their own separate database instead?
Some teams do exactly that, and it works too — a fully separate database per developer is an even stronger isolation boundary than a per-developer schema within one shared dev database. The schema approach in this Part is the more common middle ground because it is cheaper to provision (schemas, unlike full databases, are usually free and instant to create in most warehouses) while still fully preventing the table-name collisions that are the actual problem being solved.
// Part 07 — A Complete Worked Example

The Same Codebase, Three Environments, What Actually Differs at Each Layer

Bringing every mechanism in this module together: here is exactly what changes, and what stays identical, as the same dbt project runs across dev, CI, and prod for a fictional grocery-delivery company's order pipeline.

dbt_project.yml — shared across every environment, never changes per environment
name: 'freshmart'
version: '1.0.0'
config-version: 2

vars:
  start_date: '2020-01-01'
  payment_methods: ['credit_card', 'paypal', 'gift_card', 'bank_transfer']
  enable_new_discount_logic: false
models/staging/stg_orders.sql — one file, behaves differently only via target.name and var()
select
    order_id,
    customer_id,
    order_status,
    order_total,
    ordered_at
from {{ source('app_db', 'orders') }}
where ordered_at >= '{{ var("start_date") }}'

{% if target.name == 'dev' %}
  and ordered_at >= dateadd('day', -3, current_timestamp())
{% endif %}
LayerdevCI / stagingprod
Which database/schema (Part 01, profiles.yml)analytics_dev, schema dbt_jsmithanalytics_staging, schema dbt_cianalytics_prod, schema analytics
Row volume processed (Part 02, target.name)Last 3 days only — fast local iterationFull history — CI must validate against realistic volumeFull history — the real production dataset
start_date used (Part 03, var())2020-01-01, unless a developer overrides via --vars for a specific test2020-01-01 — CI never overrides project defaults2020-01-01 — the trusted, unmodified project default
Credentials (Part 04, env_var())Developer's own exported SNOWFLAKE_PASSWORD, never committedInjected by the CI system's secrets manager as a job-scoped env varInjected by the orchestrator's secrets manager, a different credential than dev/CI use
Actual schema built into (Part 06, generate_schema_name)dbt_jsmith_dev — isolated per developerdbt_ci_staging — isolated from any individual developer's workanalytics — the clean, shared schema every dashboard queries
Feature flag (Part 03, enable_new_discount_logic)Overridable per-run via --vars while testing new logicFalse by default — CI validates existing behavior unless a PR explicitly overrides itFalse — new logic only ships to prod once explicitly flipped after validation

What is worth noticing across this whole table: not one line of any model's SQL had to change to get this behavior across three environments. Every difference is driven entirely by which target is active (target.name, target.schema), which variables are set at the project or CLI level (var()), and which secrets are present in the surrounding process environment (env_var()) — exactly the separation of concerns this module set out to build. A developer can run this exact codebase locally, break things repeatedly, and never once put production data or credentials at risk.

This is what 'the same code, safely, everywhere' actually looks like in practice
None of these mechanisms individually is complicated. What makes the pattern work is using each one for exactly the job it is meant for — target.name for environment identity, vars for project configuration, env_var() for secrets — rather than reaching for whichever one happens to be familiar and stretching it to cover a job it was not designed for.
// Part 08 — dbt Cloud Environment Variables

How env_var() Maps Onto dbt Cloud's Environments and Jobs UI

Everything in Part 04 assumed env_var() reads a value set some external way — a shell export, a CI secrets manager. Teams running on dbt Cloud instead of self-hosted orchestration get a purpose-built version of exactly that mechanism: an Environment Variablespanel in the dbt Cloud project settings, where a variable's name and value are entered directly in the UI, then read inside the project with the exact same {{ env_var('...') }}call covered in Part 04. Nothing about how a model or profiles.yml reads the value changes — what changes is where the value is set and who can see it.

Aspectdbt Cloud environment variable
Where it is setProject Settings → Environment Variables in the dbt Cloud UI, not a shell or a CI YAML file.
ScopingSet once per environment (Development, Staging, Production) — dbt Cloud automatically resolves the correct value for whichever environment a given job is running in.
Read inside the projectIdentical to any other env_var() call — env_var('SNOWFLAKE_ACCOUNT') looks exactly the same whether it came from dbt Cloud's UI or a self-hosted CI secret.
VisibilityA variable's value is never shown in job logs or the compiled SQL preview once it is designated as a secret — see the naming convention below.

The one dbt-Cloud-specific convention worth knowing: prefixing a variable's name withDBT_ENV_SECRET_ tells dbt Cloud to treat its value as sensitive — it is redacted from run logs and from any compiled SQL shown in the UI, even in contexts where compiled SQL is normally visible for debugging. A variable without that prefix is still only settable by someone with project-admin access, but its value is not specially redacted from logs, so it is the wrong place for an actual credential.

Referencing a dbt Cloud environment variable from profiles.yml-equivalent connection config
-- dbt Cloud manages the underlying profiles.yml equivalent for you when
-- you connect a warehouse through its UI, but a var referenced in project
-- code (e.g. inside a macro building a connection string, or a seed
-- config) uses the identical syntax as any other env_var() call:

{{ env_var('DBT_ENV_SECRET_SNOWFLAKE_PASSWORD') }}

-- Set in dbt Cloud's Project Settings → Environment Variables, scoped to
-- the Production environment specifically -- a Development-environment
-- job reading the same variable name gets whatever value was set for
-- Development instead, without any change to the project's own code.

This gives dbt Cloud projects the same dev/staging/prod separation this whole module has been building toward, but configured through a UI instead of separate profiles.yml targets and shell exports — the same target.name-driven branching in Part 02 and the sameenv_var() secret-handling discipline in Part 04 still apply underneath; only the mechanism for actually setting the values differs.

A default value set in the UI still behaves like var()'s default argument
dbt Cloud lets a project-level environment variable carry a default value alongside its per-environment overrides. This mirrors the same principle from Part 03's var()default: a sensible fallback keeps a job from failing outright if a specific environment forgot to set an override, while a genuine secret should still have no meaningful default at all, exactly as Part 04 argues for env_var() generally.
// Part 09 — Slim CI with --defer and --state

Building Only What Changed: Comparing a PR's Models Against Production State

Every mechanism so far in this module (targets, vars, env_var(), per-developer schemas) answers "how do dev, CI, and prod stay safely separate." This Part answers a related but distinct question that only comes up once a project has grown large: a CI job that rebuilds the entire project on every pull request, to validate even a one-line change to a single model, becomes slow and expensive in direct proportion to how big the project has gotten — exactly the same problem incremental models solve for a single table, but here the "table" is the whole CI run.

Slim CI is the standard fix: instead of building every model from scratch in CI, dbt is told which models actually changed in this pull request, builds only those (plus whatever depends on them), and for everything else it silently reads straight from the equivalent object already sitting in the production schema — without rebuilding it, and without it ever leaving production. Two flags make this possible together: --state, which points dbt at a previous manifest.json (typically production's) to diff the current project against, and --defer, which tells dbt that any upstream model this PR does not rebuild should resolve its {{ ref() }} calls against that deferred manifest's already-built relations instead of failing because the CI schema never built them.

A slim CI invocation — the actual command most teams run
# 1. Pull production's manifest.json down as the comparison baseline
#    (dbt Cloud does this automatically for you; self-hosted CI needs an
#    explicit step to fetch the artifact from the last successful prod run)

dbt build \
  --select state:modified+ \
  --defer \
  --state ./prod-manifest

Reading this command piece by piece: state:modified+ is a selector meaning "every model whose compiled SQL, config, or referenced macros differ from the version recorded in./prod-manifest, plus everything downstream of those models" (the trailing+ is the same graph operator used elsewhere in dbt's selector syntax). --defertells dbt that any model not selected — meaning it is identical to production and does not need rebuilding — should have its ref() calls resolved against production's actual built relation from --state's manifest, rather than an object that was never built in this CI run's own temporary schema at all.

Without slim CIWith slim CI (state:modified+ and --defer)
A 400-model project rebuilds all 400 models in every PR's CI job.Only the 3 models actually changed by this PR, plus their downstream dependents, get rebuilt.
CI run time scales with total project size, regardless of PR size.CI run time scales with how much a given PR actually touched — a one-line fix stays fast even in a huge project.
Unchanged models still get rebuilt from source data in a throwaway CI schema.Unchanged models are read directly from production's already-built, already-validated relation via --defer.
A large project makes CI progressively slower and more expensive as it grows.CI cost tracks PR size, not project size — the same asymmetry incremental models exploit for individual tables, applied to an entire CI run.

This connects directly back to target.name-based branching from Part 02: a CI job using slim CI typically still runs against its own isolated staging/citarget and schema for the models it does rebuild — --defer only changes howunselected models are resolved, it does not mean CI writes into production. The two mechanisms compose: environment separation keeps CI's own writes isolated, while slim CI keeps CI from having to redundantly rebuild everything production already has correctly built.

--defer reads production, it never writes to it
A common early worry is that --defer somehow lets a CI job touch production data. It does not — deferral is strictly read-only: an unselected model's ref() resolves to production's relation purely so a selected, changed model further downstream in the same CI run has something real to join against. CI still only ever writes into its own isolated CI schema, exactly as Part 01 and Part 06 describe.

The one thing that makes slim CI actually work is having a trustworthy manifest.jsonto diff against in the first place — which is why dbt Cloud automatically stores the manifest from every successful production run specifically so the next CI job can compare against it, and why a self-hosted setup needs its own equivalent step (uploading the prod manifest as a build artifact after every successful production deploy, then downloading it at the start of the next CI job) for--state to have anything meaningful to point at.

// Part 10 — How dbt Actually Finds and Resolves a Profile

--profile, --target, and --profiles-dir: the Full Resolution Order

Every earlier Part in this module assumed a single, obvious answer to "which target is active" — usually whatever target: defaults to in profiles.yml, per Part 01. In practice, several different flags and files can all influence that answer at once, and a project with more than one profile, or a CI system invoking dbt with explicit overrides, needs the actual resolution order to reason about which target a given invocation will use.

Precedence (highest wins)What it setsTypical source
1. --target flag on the CLIOverrides which target inside the active profile is used for this one invocation only.dbt run --target prod, passed explicitly by a CI job or a developer doing a one-off prod-targeted check.
2. target: in profiles.ymlThe profile's own declared default target, used when nothing above overrides it.The freshmart profile in Part 01's profiles.yml declaring target: dev as its default.
(No native env-var override)dbt-core has no built-in DBT_TARGET variable — an env var can only select a target if profiles.yml itself is written to read one via env_var(), e.g. target: "{{ env_var('DBT_TARGET', 'dev') }}".A deliberate profiles.yml pattern, not automatic dbt behavior.

A closely related but separate question is which profile (as opposed to which target within a profile) is active at all — relevant the moment more than one dbt project, or more than one named profile, exists on the same machine or in the same CI environment.

dbt_project.yml declares which profile a project expects to use
name: 'freshmart'
version: '1.0.0'
config-version: 2

profile: 'freshmart'   # must match a top-level key in profiles.yml

vars:
  start_date: '2020-01-01'

profile: 'freshmart' in dbt_project.yml tells dbt which top-level key to look up inside profiles.yml — the same profiles.yml shape shown back in Part 01, where freshmart: was the top-level key holding the dev,staging, and prod targets. This matters distinctly fromtarget: profile selects which company/project's whole set of targets to use, while target selects which one of that set's targets is active. A monorepo running two separate dbt projects, or a consultant working across multiple clients' projects on one laptop, needs both dimensions resolved correctly and independently.

--profile overrides dbt_project.yml's own profile: declaration, exactly like --target does for targets
# Normally resolves via dbt_project.yml's profile: 'freshmart' declaration:
dbt run

# Explicitly override which profile's targets to use for this invocation,
# without editing dbt_project.yml -- useful when testing a project against
# a differently-named profile temporarily:
dbt run --profile freshmart_sandbox --target dev

Finally, profiles.yml itself has to be found somewhere on disk, and that location is resolved independently of both profile and target. By default dbt looks in ~/.dbt/profiles.yml, but --profiles-dir (or theDBT_PROFILES_DIR environment variable) can point it anywhere else entirely — common in CI, where a job may write a freshly generated profiles.yml into a temporary, job-scoped directory rather than relying on a persistent home directory that might not even exist in an ephemeral container.

A CI job assembling its own profiles.yml at a custom path, entirely from secrets
mkdir -p /tmp/dbt_ci_profile

cat <<EOF > /tmp/dbt_ci_profile/profiles.yml
freshmart:
  target: staging
  outputs:
    staging:
      type: snowflake
      account: freshmart_account
      user: "${SNOWFLAKE_CI_USER}"
      password: "${SNOWFLAKE_CI_PASSWORD}"
      role: transformer
      database: analytics_staging
      warehouse: ci_wh
      schema: dbt_ci
      threads: 8
EOF

dbt run --profiles-dir /tmp/dbt_ci_profile

Assembling profiles.yml at run time from CI secrets like this, rather than committing any version of it to the repository at all, is a common pattern precisely because it keeps every connection detail — including ones that are not exactly "secret" but are still environment-specific, like warehouse or schema — entirely out of version control, generated fresh for each job from whatever the CI system's own secret store already holds.

Flag / settingControlsWhere it typically comes from
profile: in dbt_project.yml, or --profileWhich top-level profiles.yml entry (which company/project's full target set) is used.Committed in dbt_project.yml for the normal case; --profile only for a deliberate one-off override.
target: in profiles.yml, or --targetWhich named target within the active profile (dev/staging/prod) is used.profiles.yml default for local dev; --target explicitly passed by CI and deploy jobs.
--profiles-dir or DBT_PROFILES_DIRWhich directory on disk dbt looks in for profiles.yml at all.~/.dbt/ by default for a developer; an ephemeral, job-scoped path in most CI systems.
dbt debug shows you exactly what actually resolved, for any invocation
Rather than reasoning through this precedence chain by hand for a confusing invocation, dbt debug prints the fully resolved profile, target, and connection details dbt actually settled on — which profiles.yml file it read, which profile and target it selected, and whether the resulting connection succeeds. It is the fastest way to confirm a suspicion like "is this CI job actually running against staging or did it silently fall back to something else."

A worked resolution trace, end to end

Putting the whole chain together for one concrete invocation makes the precedence order concrete rather than abstract. Suppose a CI job runs the following, in a container that has no~/.dbt/profiles.yml at all, and whose generated profiles.yml declarestarget: "{{ env_var('DBT_TARGET', 'dev') }}" instead of a hardcoded target name:

One invocation, fully resolved step by step
DBT_TARGET=staging dbt run --profiles-dir /tmp/dbt_ci_profile

# Step 1 -- which profiles.yml does dbt read at all?
#   --profiles-dir was passed explicitly -> /tmp/dbt_ci_profile/profiles.yml
#   (the default ~/.dbt/profiles.yml is never even considered)
#
# Step 2 -- which profile (top-level key) inside that file?
#   No --profile flag was passed, so dbt falls back to dbt_project.yml's
#   own profile: 'freshmart' declaration
#
# Step 3 -- which target inside that profile?
#   No --target flag was passed. dbt itself has no built-in DBT_TARGET
#   variable, but THIS profiles.yml was deliberately written with
#   target: "{{ env_var('DBT_TARGET', 'dev') }}" -- so dbt resolves that
#   Jinja expression, reads DBT_TARGET=staging from the environment, and
#   the profile's own target: setting evaluates to 'staging'
#
# Resolved: profiles.yml at /tmp/dbt_ci_profile, profile 'freshmart',
# target 'staging' -- because this project chose to wire target: through
# env_var(), not because dbt reads DBT_TARGET automatically.

Every one of these three questions — which file, which profile, which target — is resolved completely independently of the other two, which is exactly why a confusing "why did this run against the wrong environment" incident is worth tracing through all three separately withdbt debug rather than assuming any one setting alone explains the outcome.

Multiple profiles in one repository is a real, if less common, pattern
A monorepo housing more than one dbt project — or a single project deliberately supporting more than one named profile for different purposes (e.g. a lightweight sandbox profile for local experimentation, distinct from the main team profile) — leans on exactly this sameprofile:/--profile distinction. Nothing about targets, vars, or env_var() changes in that setup; only which top-level profiles.yml entry supplies the target set in the first place.
// Misconceptions

Five Misconceptions About dbt Variables and Environments

✕ ""vars and env_var() are basically interchangeable ways to pass a value into dbt""
They resolve values from completely different places and are meant for different kinds of value. vars are defined inside the dbt project itself (dbt_project.yml, or --vars) and are visible in version control — appropriate for project configuration. env_var() reads an actual operating-system environment variable set outside the project entirely, and is the only correct place for secrets and credentials (Part 03, Part 04, Part 05).
✕ ""A password can go in vars: in dbt_project.yml as long as the repo is private""
A private repository is not a secrets manager — anyone with any level of repo access, including CI logs, forks, or a future access grant, would see it, and it stays permanently in git history even if removed later. Credentials belong exclusively behind env_var(), read from a real secrets manager or CI-injected environment variable, never committed as plain YAML (Part 04, Part 05).
✕ ""target.name is just another variable, similar to var()""
target.name specifically identifies which named environment (profiles.yml target) is currently active — it is not a general configuration mechanism. Reach for target.name when logic genuinely needs to branch on environment identity; reach for var() when you need a configurable project value unrelated to which environment is running (Part 02, Part 03).
✕ ""Every developer sharing the same dev database and schema is fine as long as they communicate""
Relying on communication to avoid table-name collisions does not scale and fails silently the moment it does not happen — one developer's broken in-progress model can overwrite another's tables of the same name. The generate_schema_name override in Part 06 solves this structurally, routing each developer into their own schema automatically, with no coordination required.
✕ ""var() should always be called without a default, so missing configuration fails loudly""
That is the right call only for a variable a model truly cannot run correctly without. For anything with a sensible fallback, omitting the default just means any run that doesn't happen to set that variable — a new CI job, a teammate's first local run — breaks unnecessarily. Providing a default via var('name', default) is what keeps a project resilient to incomplete configuration in exactly the contexts where that matters most (Part 03).
// What This Looks Like at Work

What This Looks Like on Day One

At HubSpot: a new data engineer's first pull request accidentally hardcodes a reference to analytics_prod.raw.contacts directly inside a model, instead of usingsource(). It works fine on their laptop because their personal credentials happen to have read access to prod for an unrelated reason, and nobody notices in code review at a glance. The fix, once caught, is not just removing the hardcoded reference — it is confirming the model uses source() and ref() exclusively, so which database it actually reads from is controlled entirely by the active target, per Part 01, and cannot silently point at production again regardless of whose local credentials happen to be configured.

At Klaviyo: the platform team is investigating why full local model runs take over twenty minutes for engineers actively iterating on transformation logic, even for a change confined to one small model. Following the pattern in Part 02, they add a target.name == 'dev'row-volume limit to the heaviest upstream staging models, cutting typical local dev-loop time from twenty minutes to under thirty seconds, with zero change to what staging or prod actually compute — the limit is compiled out entirely outside of dev.

At Ramp: a security review flags that profiles.yml, committed years earlier when the project was small, still contains a Snowflake password in plain text for the prod target — nobody had gotten around to migrating it. The fix follows Part 04 exactly: replacing the hardcoded password with {{ env_var('SNOWFLAKE_PASSWORD') }}, rotating the actual credential (since the old one must be assumed compromised the moment it was ever committed), and configuring the CI system's secrets manager to inject the new value as a job-scoped environment variable — with the added benefit that the credential can now be rotated going forward without touching a single file in the repository.

// Interview Prep

5 Interview Questions — With Complete Answers

Explain the actual difference between dbt vars and env_var(), and how you would decide which one to use for a given value.
Per Part 03, Part 04, and Part 05, vars are defined inside the dbt project itself — a project-level default in dbt_project.yml, optionally overridden per-invocation with --vars — and are visible in version control, since dbt_project.yml is ordinary committed source code. env_var() instead reads an actual operating-system environment variable, set entirely outside the dbt project by a shell, a CI secrets manager, or an orchestrator, and the actual value never appears in any file dbt reads from the repository. The decision test I use: would I be comfortable with every engineer on the project seeing this value directly in a pull request diff? If yes — a date range, a feature flag, a list of accepted payment methods — it belongs in vars. If no — anything that is a credential or grants access to something — it belongs behind env_var(), with no exceptions made for convenience, since a private repository is not a substitute for a real secrets manager.
How does target.name let the same model behave differently in dev versus prod, and what is a concrete example?
target.name is a built-in Jinja context variable giving the name of whichever target is currently active, per Part 02 — 'dev', 'staging', 'prod', matching whatever profiles.yml names them. Wrapping a portion of a model's SQL in a {% if target.name == 'dev' %} block means that logic is only compiled into the final SQL when the dev target is active; it is entirely absent from the compiled SQL run against any other target. The clearest concrete example is limiting row volume for faster local iteration — adding a WHERE clause restricting a staging model to the last few days of data only when target.name == 'dev', while staging and prod continue processing the full historical dataset exactly as they would without that conditional at all. This is a genuinely common pattern because it makes the local development loop dramatically faster without changing what production actually computes.
Why does var() accepting a default value matter, and when would you deliberately not provide one?
Per Part 03, var('name', default) falls back to the given default if the variable is not set anywhere — neither in dbt_project.yml's vars: block nor via a --vars override for that specific run. Providing a sensible default is what keeps a model safe to run in a context where nobody thought to set that variable — a new CI job unaware of every var a mature project has accumulated, or a teammate's first local run before they've fully configured their environment. I would deliberately omit the default only for a variable a model genuinely cannot run correctly without — something where any fallback value would be actively wrong rather than just suboptimal. For anything with a reasonable fallback, omitting the default just means the project becomes unnecessarily fragile to incomplete configuration, breaking runs that could have proceeded safely with a sensible assumed value.
Walk me through why a team would override the generate_schema_name macro, and what problem it actually solves.
By default, per Part 06, a model with no explicit schema config builds into whatever schema is set on the active target. This is fine in prod, where one shared, clean schema is exactly the goal, but in dev it means every developer building against the same target schema can silently collide with, or overwrite, a teammate's tables of the same name — a structural risk, not one that better communication reliably prevents. The standard fix overrides generate_schema_name to branch on target.name: prod ignores any custom schema config and always builds into the clean target schema, while dev and staging append something like the target name onto the default schema, routing each developer's models into their own isolated schema, such as dbt_jsmith_dev. This solves the collision problem structurally, with no coordination required between developers, and ties back to a model's own schema config from the materializations module, which this macro decides how to actually honor per environment.
A junior engineer asks why they can't just hardcode --vars overrides permanently into their local profiles.yml instead of using dbt_project.yml's vars: block. What do you tell them?
The core issue is scope and visibility, per Part 03 and Part 05. dbt_project.yml's vars: block is the project's shared, version-controlled default — every developer, CI job, and production run sees the same starting values unless they deliberately override them, which is exactly the behavior you want for genuine project configuration like a start_date or a feature flag. profiles.yml, by contrast, is local, per-developer machine configuration that is typically not even committed to the repository at all — it is where connection details and credentials live, not project logic. Putting a --vars-style override permanently into an individual's profiles.yml means that override is invisible to every other developer, to CI, and to production, silently diverging that one person's local behavior from what the rest of the team and the actual pipeline run — exactly the kind of inconsistency dbt_project.yml's shared vars: block exists to prevent.
// Common Mistakes

Five Mistakes That Compromise Environment Safety

Hardcoding a database or schema name directly inside a model instead of using source()/ref()
Per Part 01, this permanently ties a model to one specific environment regardless of which target is actually active, defeating the entire purpose of having separate dev/staging/prod targets in profiles.yml.
Putting a credential or API key inside vars: in dbt_project.yml
vars: is committed, version-controlled project configuration, visible to anyone with any level of repository access, and permanently retained in git history even after removal. Secrets belong exclusively behind env_var(), per Part 04 and Part 05.
Calling var() with no default for a value that has a perfectly reasonable fallback
This makes a project needlessly fragile to incomplete configuration — a new CI job or a teammate's first local run breaks entirely rather than proceeding safely with a sensible assumed value, per Part 03.
Letting every developer build into the same dev schema with no per-developer isolation
Without a generate_schema_name override routing each developer's runs into their own schema, one developer's broken in-progress model can silently overwrite a teammate's tables of the same name — a structural problem, not one solved reliably by communication alone (Part 06).
Confusing target.name (environment identity) with var() (general project configuration)
Using target.name to smuggle in arbitrary configuration values, or using a var to try to detect which environment is running, both work against the grain of what each mechanism is actually for and produce logic that is harder to reason about than using the right tool for the job (Part 02, Part 03, Part 05).
// Error Library

Variable and Environment Errors — And Exactly Why They Happen

Compilation Error: Required var 'start_date' not found in config
Cause: A model called var('start_date') with no default argument, and start_date is not set in either dbt_project.yml's vars: block or a --vars override for this invocation.
Fix: Either add start_date to dbt_project.yml's vars: block with a sensible project-wide default, per Part 03, or — if this run genuinely needs a one-off value — pass it explicitly with dbt run --vars '{"start_date": "..."}'.
Env var required but not provided: 'SNOWFLAKE_PASSWORD'
Cause: profiles.yml calls env_var('SNOWFLAKE_PASSWORD') with no default, and that environment variable is not currently set in the shell or CI job actually invoking dbt.
Fix: Export the environment variable before running dbt (export SNOWFLAKE_PASSWORD=...) locally, or confirm the CI/orchestration system's secrets manager is actually configured to inject that specific variable name for this job, per Part 04.
A model built into the wrong schema than expected — for example, a developer's work landing in the shared prod schema
Cause: Either the generate_schema_name override from Part 06 is missing or has a bug in its target.name branching logic, or the developer's own profiles.yml target is misconfigured and pointing at the prod database/schema instead of their intended dev target.
Fix: Confirm which target is actually active for the run (dbt debug shows the resolved connection details), and separately review the generate_schema_name macro's logic against the table in Part 06 to confirm it produces the expected schema name for that target.name.
A feature that was only meant to be tested in dev unexpectedly ran in a scheduled production job
Cause: A var-based feature flag like enable_new_discount_logic was flipped to true directly in dbt_project.yml's vars: block — which applies to every environment identically — rather than being scoped to a specific run via --vars during testing.
Fix: Remember that dbt_project.yml's vars: block is a single, shared default across all environments unless something environment-specific overrides it; use --vars scoped to a specific dev or CI invocation while testing, and only change the committed project-wide default once the feature has been validated and is ready for every environment.
A model runs correctly locally but fails or behaves differently the moment it runs in CI
Cause: Almost always a hidden dependency on something present in the developer's local environment but not replicated in CI — an environment variable set in a personal shell profile and never added to the CI system's secrets configuration, or a var override habitually passed via --vars locally that CI's scheduled invocation does not include.
Fix: Audit every env_var() and var() call the model (and anything it depends on) actually makes, and confirm each one is either given a safe default or explicitly configured in the CI environment — do not assume something present on a developer's machine is present everywhere dbt might run.

🎯 Key Takeaways

  • The dev/staging/prod pattern runs the exact same dbt project code against different targets in profiles.yml — different databases, schemas, and warehouses — so a developer can never accidentally overwrite production data.
  • target.name (and target.schema, target.database, target.type) lets model or macro logic branch on which environment is currently running — the classic use is limiting row volume in dev for a faster local development loop.
  • vars: in dbt_project.yml sets project-wide default values read via var('name', default) in models and macros; --vars on the CLI overrides those defaults for one specific invocation without touching the committed project defaults.
  • env_var() reads an actual operating-system environment variable, entirely outside the dbt project's own files — the only correct mechanism for secrets and credentials, most commonly used inside profiles.yml.
  • The real difference between vars and env_var() is where the value lives and what it should hold: vars are committed, dbt-native project configuration; env_var() is dbt's bridge out to infrastructure-managed secrets that must never be committed.
  • Overriding generate_schema_name lets dev and staging runs land in isolated, per-developer schemas (like dbt_jsmith_dev) while prod always builds into one clean, shared schema — solving developer collisions structurally rather than by relying on communication.
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...