DBTStructure & Production

Environments, CI/CD, and Slim Runs

How analytics engineers turn raw warehouse tables into trusted models — pick a topic on the left and its full breakdown loads here: the mental model, the compiled SQL dbt actually issues, live runs you can drive yourself, and the failure modes that quietly ship wrong numbers.

18 min readTopics chapter readerLevel · Hard
01 · Chapter 13 hands over 23 / 20 / 15 / 24 — and this chapter adds none of it

Where A Model Actually Lands

new code, production data. The selector draws a line through the DAG. Right of the line, your code builds. Left of the line, production’s tables answer. A slim CI is only as correct as where that line falls.

4 min · Topic 1 of 9

Say it once, out loud: CI is a process, not a node. This chapter opens on the exact census Chapter 13 closed on — 23 graph nodes, 20 non-exposure, 15 buildable, 24 manifest nodes — and closes on the same four numbers. Nothing here adds a model, a seed or a test.

What changes is where a model lands, and which of your fifteen buildable nodes a pull request actually has to rebuild before anyone can trust it. Chapter 12 handed +schema and generate_schema_name to this chapter on purpose — start there.

Where a model actually lands
target
fct_listens' +schema config
generate_schema_name implementation
the trace
  • target.schema = 'analytics' (target='prod')
  • custom_schema_name = 'marts'
  • generate_schema_name_for_env: target.name == 'prod' and custom_schema_name is not none → return custom_schema_name, bare
analytics.marts.fct_listensThis is the address Chapter 13 and Chapter 16 have both been printing — analytics.marts.fct_listens. This widget is the macro that makes it true.
both macro bodies, verbatim (dbt-adapters, get_custom_schema.sql)
macros/get_custom_schema.sql
{% macro generate_schema_name(custom_schema_name=none, node=none) -%}
    {{ return(adapter.dispatch('generate_schema_name', 'dbt')(custom_schema_name, node)) }}
{% endmacro %}

{% macro default__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 %}

{% macro generate_schema_name_for_env(custom_schema_name, node) -%}
    {%- set default_schema = target.schema -%}
    {%- if target.name == 'prod' and custom_schema_name is not none -%}
        {{ custom_schema_name | trim }}
    {%- else -%}
        {{ default_schema }}
    {%- endif -%}
{%- endmacro %}
generate_schema_name_for_env ships WITH dbt but is not used by default — a project opts in by overriding generate_schema_name in its own macros/ to call it, which is exactly what Wavelength's dbt_project.yml does from this chapter forward: models: wavelength: marts: +schema: marts, nothing else customised. generate_database_name and generate_alias_name exist too (alias handles versioned models as {{ node.name }}_v{{ version }}) — Chapter 18 owns them as reusable package code.
Core mental model

CI is the only environment where the code and the data deliberately disagree: new code, production data. The selector draws a line through the DAG. Right of the line, your code builds. Left of the line, production’s tables answer. A slim CI is only as correct as where that line falls.

Why it matters

Every "why is my table in the wrong schema" incident and every "why did CI just rebuild the whole warehouse" incident traces back to the same two mechanisms: which schema a target resolves a model into, and which nodes a selector decides belong to a given run. Get the first wrong and two developers silently overwrite each other, or a badly-written override sends a laptop straight at production. Get the second wrong and slim CI either misses a real change or burns twenty minutes proving nothing new.

target
A named connection profile — dev, ci, prod — each with its own schema, and nothing else. The SQL never changes; only the destination does.
generate_schema_name
The macro dbt calls to turn a model’s +schema config into a real schema. dbt’s own default CONCATENATES target.schema and the custom name; it does not use the custom name bare.
this chapter’s schema decision
From here forward, only models/marts/ carries +schema: marts (config in dbt_project.yml), using generate_schema_name_for_env. Staging, intermediate, the snapshot and both seeds keep no custom schema at all — Chapter 12 explicitly deferred this decision to this chapter.
PR schema
analytics.dbt_ci_pr_412 — Chapter 2’s own worked example, reused verbatim here. A throwaway schema, one per pull request, that a plain dbt build in CI writes into.
One model, one config, three real addresses
Who is runningtargetschemafct_listens resolves to
Vijay, locallydevdbt_vijayanalytics.dbt_vijay.fct_listens
A pull request in CIcidbt_ci_pr_412analytics.dbt_ci_pr_412.fct_listens
The production jobprodanalyticsanalytics.marts.fct_listens

Every one of these three addresses has already appeared in this KB — Chapter 2 published the first two, Chapter 13 and Chapter 16 both print the third. This widget is the macro that makes all three true at once.

Common mistake

Assuming a model configured with +schema: marts lands in a schema literally called marts. Under dbt’s unoverridden default macro it does not — it concatenates onto your target schema, landing in dbt_vijay_marts in dev. This is exactly the surprise Chapter 2 flagged and handed forward here.

Writing a custom generate_schema_name override that returns the custom schema name unconditionally, with no target check. Every environment, including a developer’s own laptop, now resolves +schema: marts to the same bare marts schema production uses. A routine local dbt build can silently overwrite a production mart.

Better habit

Before trusting a printed table address, name which macro produced it — dbt’s default concatenates; generate_schema_name_for_env only branches on target.name == 'prod'.

Scope +schema configuration to the layer that needs it (marts, here) rather than sprinkling it project-wide — every additional custom schema is one more combination to reason about per target.

Treat a schema-name override as production code: it runs on every single build, in every environment, and a mistake in it is invisible until two things collide.

The clock this chapter runs on

This chapter runs at 2026-08-04 09:00 UTC, four days after Chapter 13’s 2026-07-31 09:00 UTC handoff. The graph opens at 23 nodes, 20 non-exposure, 15 buildable, 24 manifest, and closes at exactly the same four numbers.

"Why did my model build into a schema I did not expect?"

The weak answer blames the config. The strong answer names the macro: dbt’s default generate_schema_name concatenates target.schema and the custom name — it never uses the custom name alone unless something overrides that macro. Ask which macro is in play before touching a single YAML file.

Remember this

Every table address in this KB is the output of one small macro, running once per model, per build — know which implementation is in play before you trust the address it prints.