Everything you need to know about dbt™ schemas: how they work, why concatenation happens, and how to configure them properly for dev and prod.
Fabio Di Leta
·
Nov 20, 2025
·
5
min read
dbt™ schema configuration is straightforward once you understand the mental model. This guide covers everything: how schemas work, where models land, why concatenation happens, and how to control it all.
How dbt™ Schema Configuration Works
Start in profiles.yml. This is where you define your target schemas:
Run dbt run --target dev and every model lands in analytics_dev.dbt_john by default.
That's your base schema. Everything else builds from here.
Target Schema vs Custom Schema
Two different things. Here's what they actually are.
Target schema: Where dbt™ puts your models by default. Set in profiles.yml:
my_project:
target: dev
outputs:
dev:
schema: dbt_john # ← This is your target schema
my_project:
target: dev
outputs:
dev:
schema: dbt_john # ← This is your target schema
my_project:
target: dev
outputs:
dev:
schema: dbt_john # ← This is your target schema
Run dbt run --target dev and every model lands in dbt_john. That's it.
Custom schema: Your override. Set in the model or in other parts of your dbt™ project:
{{ config(schema='staging')}} # ← This is your custom schemaselect * from{{ source('f1','circuits')}}
{{ config(schema='staging')}} # ← This is your custom schemaselect * from{{ source('f1','circuits')}}
{{ config(schema='staging')}} # ← This is your custom schemaselect * from{{ source('f1','circuits')}}
When you set a custom schema, dbt™ doesn't just use it. It concatenates it with your target schema.
Example:
Target schema: dbt_john
Custom schema: staging
Where the model lands: dbt_john_staging
Not staging. dbt_john_staging.
Why? So five developers can work on the same project without destroying each other's work. You get dbt_john_staging, Jane gets dbt_jane_staging. Nobody collides.
In production, you want clean names. Override the macro to drop concatenation for prod targets only.
This is the generate_schema_name macro at work. Default behavior:
{% macro generate_schema_name(custom_schema_name, node) -%}{%- set default_schema = target.schema -%}{%- if custom_schema_name isnone -%}{{ default_schema }}{%- else -%}{{ default_schema }}_{{ custom_schema_name | trim }}{%- endif -%}{%- endmacro %}
{% macro generate_schema_name(custom_schema_name, node) -%}{%- set default_schema = target.schema -%}{%- if custom_schema_name isnone -%}{{ default_schema }}{%- else -%}{{ default_schema }}_{{ custom_schema_name | trim }}{%- endif -%}{%- endmacro %}
{% macro generate_schema_name(custom_schema_name, node) -%}{%- set default_schema = target.schema -%}{%- if custom_schema_name isnone -%}{{ default_schema }}{%- else -%}{{ default_schema }}_{{ custom_schema_name | trim }}{%- endif -%}{%- endmacro %}
That underscore in {{ default_schema }}_{{ custom_schema_name }} creates the concatenation.
Why? Developer isolation. Five people working on the same project need their own space. Jane gets dbt_jane_staging, you get dbt_john_staging. Nobody collides.
Controlling Schema Behavior: Development vs Production
Production needs clean schemas. staging, not analytics_staging. Override the macro.
Create macros/get_custom_schema.sql:
{% macro generate_schema_name(custom_schema_name, node) -%}{%- set default_schema = target.schema -%}{%- if target.name == 'prod' -%}{%- if custom_schema_name isnone -%}{{ default_schema }}{%- else -%}{{ custom_schema_name | trim }}{%- endif -%}{%- else -%}{%- if custom_schema_name isnone -%}{{ default_schema }}{%- else -%}{{ default_schema }}_{{ custom_schema_name | trim }}{%- endif -%}{%- endif -%}{%- endmacro %}
{% macro generate_schema_name(custom_schema_name, node) -%}{%- set default_schema = target.schema -%}{%- if target.name == 'prod' -%}{%- if custom_schema_name isnone -%}{{ default_schema }}{%- else -%}{{ custom_schema_name | trim }}{%- endif -%}{%- else -%}{%- if custom_schema_name isnone -%}{{ default_schema }}{%- else -%}{{ default_schema }}_{{ custom_schema_name | trim }}{%- endif -%}{%- endif -%}{%- endmacro %}
{% macro generate_schema_name(custom_schema_name, node) -%}{%- set default_schema = target.schema -%}{%- if target.name == 'prod' -%}{%- if custom_schema_name isnone -%}{{ default_schema }}{%- else -%}{{ custom_schema_name | trim }}{%- endif -%}{%- else -%}{%- if custom_schema_name isnone -%}{{ default_schema }}{%- else -%}{{ default_schema }}_{{ custom_schema_name | trim }}{%- endif -%}{%- endif -%}{%- endmacro %}
Works great in production. Catastrophic in shared dev/CI environments.
Here's what happens with a model called my_model and schema='marketing':
Environment
Target Schema
Result
Production
analytics
prod.marketing.my_model ✅
Developer - John
dbt_john
dev.marketing.my_model ❌
Developer - Jane
dbt_jane
dev.marketing.my_model ❌
CI PR 123
dbt_pr_123
ci.marketing.my_model ❌
CI PR 234
dbt_pr_234
ci.marketing.my_model ❌
See the problem? Everyone writes to the same schema. John and Jane overwrite each other's work. PR builds collide. Nobody knows whose version is currently deployed.
Fix: Only drop concatenation in production.
{% macro generate_schema_name(custom_schema_name, node) -%}{%- set default_schema = target.schema -%}{%- if target.name == 'prod' -%}{{ custom_schema_name | trim }} # Clean namesin prod
{%- else -%}{{ default_schema }}_{{ custom_schema_name | trim }} # Isolation everywhere else{%- endif -%}{%- endmacro %}
{% macro generate_schema_name(custom_schema_name, node) -%}{%- set default_schema = target.schema -%}{%- if target.name == 'prod' -%}{{ custom_schema_name | trim }} # Clean namesin prod
{%- else -%}{{ default_schema }}_{{ custom_schema_name | trim }} # Isolation everywhere else{%- endif -%}{%- endmacro %}
{% macro generate_schema_name(custom_schema_name, node) -%}{%- set default_schema = target.schema -%}{%- if target.name == 'prod' -%}{{ custom_schema_name | trim }} # Clean namesin prod
{%- else -%}{{ default_schema }}_{{ custom_schema_name | trim }} # Isolation everywhere else{%- endif -%}{%- endmacro %}
Production gets clean schemas. Everyone else gets their own namespace. Problem solved.
Key Takeaways
Default behavior: dbt™ concatenates your target schema with custom schemas. That's for dev isolation.
Override generate_schema_name to control schema behavior by environment.
Production gets clean schema names. Dev gets concatenated names. Everyone stays in their lane.
Configure at model, directory, or project level. Most specific wins.
Test with dbt compile before deploying.
Interested to Learn More? Try Out the Free 14-Days Trial
*dbt® and dbt Core® are federally registered trademarks of dbt Labs, Inc. in the United States and various jurisdictions around the world. Paradime is not a partner of dbt Labs. All rights therein are reserved to dbt Labs. Paradime is not a product or service of or endorsed by dbt Labs, Inc.
*dbt® and dbt Core® are federally registered trademarks of dbt Labs, Inc. in the United States and various jurisdictions around the world. Paradime is not a partner of dbt Labs. All rights therein are reserved to dbt Labs. Paradime is not a product or service of or endorsed by dbt Labs, Inc.
*dbt® and dbt Core® are federally registered trademarks of dbt Labs, Inc. in the United States and various jurisdictions around the world. Paradime is not a partner of dbt Labs. All rights therein are reserved to dbt Labs. Paradime is not a product or service of or endorsed by dbt Labs, Inc.