Learn

Learn

The Complete Guide to dbt™ Schema Configuration

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:

my_project:
  target: dev
  outputs:
    dev:
      type: snowflake
      database: analytics_dev
      schema: dbt_john
      # ... connection details

    prod:
      type: snowflake
      database: analytics_prod
      schema

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

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 schema

select * 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.

Understanding Schema Concatenation

Add a custom schema to your model:

{{ config(schema='staging') }}

select * from {{ source('f1', 'circuits') }}

You expect: analytics_dev.staging.my_model

You get: analytics_dev.dbt_john_staging.my_model

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 is none -%}
        {{ 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 is none -%}
            {{ default_schema }}
        {%- else -%}
            {{ custom_schema_name | trim }}
        {%- endif -%}
    {%- else -%}
        {%- if custom_schema_name is none -%}
            {{ default_schema }}
        {%- else -%}
            {{ default_schema }}_{{ custom_schema_name | trim }}
        {%- endif -%}
    {%- endif -%}
{%- endmacro %}

Now:

  • Production: schema='staging'staging

  • Dev: schema='staging'dbt_john_staging

Done.

Schema Configuration Priority

Four places to set schemas. Most specific wins.

1. Model Config Block

{{ config(schema='staging') }}

Highest priority. Overrides everything.

2. dbt_project.yml

models:
  my_project:
    staging:
      +schema: staging
      +materialized: view

    marts:
      +schema: marts
      +materialized: table

      finance:
        +schema

Directory-level configs. Applies to all models in the tree.

3. schema.yml

models:
  - name: stg_customers
    config:
      schema

Model properties. Lower priority than config blocks.

4. Target Schema

From profiles.yml. Your base schema. Used when nothing else is set.

Common Schema Patterns

Layered Architecture

models:
  my_project:
    staging:
      +schema: staging

    intermediate:
      +schema: intermediate
      +materialized: ephemeral

    marts:
      +schema: marts

      core:
        +schema: core

      finance:
        +schema

Multiple Production Targets

{% macro generate_schema_name(custom_schema_name, node) -%}
    {%- if target.name in ['prod', 'production'] -%}
        {{ custom_schema_name | trim }}
    {%- else -%}
        {{ target.schema }}_{{ custom_schema_name | trim }}
    {%- endif -%}
{%- endmacro %}

No Concatenation Ever

{% macro generate_schema_name(custom_schema_name, node) -%}
    {%- if custom_schema_name is none -%}
        {{ target.schema }}
    {%- else -%}
        {{ custom_schema_name | trim }}
    {%- endif -%}
{%- endmacro %}

Solo developer? Use this.

Debugging Schema Issues

See where models compile

dbt compile --select my_model
# Check target/compiled/my_project/...

Full schema.database.table reference is right there in the compiled SQL.

List models with locations

dbt ls --select my_model --output

Debug the macro

{% macro generate_schema_name(custom_schema_name, node) -%}
    {{ log("Target: " ~ target.name, info=True) }}
    {{ log("Schema: " ~ target.schema, info=True) }}
    {{ log("Custom: " ~ custom_schema_name, info=True) }}

    -- your logic
{%- endmacro %}

Run dbt compile and watch what values get passed in.

Best Practices

Override generate_schema_name for production. The default concatenation is for dev isolation only.

Pick a schema naming pattern and stick with it. Staging/intermediate/marts or bronze/silver/gold - doesn't matter which. Consistency matters.

Set schemas at directory level in dbt_project.yml. Less config duplication.

Test in dev first. Run dbt compile and verify models land where you expect before deploying.

Document your pattern. Drop a note in the project README for new developers.

What Not to Do

Don't Remove Concatenation Everywhere

Some people hate the concatenation and do this:

{% macro generate_schema_name(custom_schema_name, node) -%}
    {%- set default_schema = target.schema -%}
    {%- if custom_schema_name is none -%}
        {{ default_schema }}
    {%- else -%}
        {{ custom_schema_name | trim }}  # ❌ Missing target.schema prefix
    {%- 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 names in 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

More Articles

Experience Analytics for the AI-Era

Start your 14-day trial today - it's free and no credit card needed

Experience Analytics for the AI-Era

Start your 14-day trial today - it's free and no credit card needed

Experience Analytics for the AI-Era

Start your 14-day trial today - it's free and no credit card needed

Copyright © 2025 Paradime Labs, Inc.

Made with ❤️ in San Francisco ・ London

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

Copyright © 2025 Paradime Labs, Inc.

Made with ❤️ in San Francisco ・ London

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

Copyright © 2025 Paradime Labs, Inc.

Made with ❤️ in San Francisco ・ London

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