These standards bind every dbt model in the Causeway monorepo. They are not recommendations. If a model needs to deviate, deviate in the model's yml file with a comment explaining why and a link to the RFD or decision.

1. Project layout

Three layers, no fourth.

models/
  staging/
  intermediate/
  marts/

Warning

Do not invent a fourth layer. "metrics/", "presentation/", "semantic/" always feel clarifying the day they are introduced and always become naming arguments within a quarter. The three layers compose everything.

2. Naming

ThingPatternExample
Stagingstg_<source>__<table>stg_salesforce__accounts
Intermediateint_<verb>__<noun>int_joined__orders_customers
Fact martfct_<grain>fct_orders
Dimension martdim_<entity>dim_customers
Aggregate martagg_<metric>agg_revenue_daily
Test filetest_<assertion>.sqltest_no_orphan_orders.sql
Columnsnake_case, no abbrev. except idcustomer_id, placed_at, revenue_usd
Booleanis_ / has_ prefixis_active, has_subscription
Timestamp_at suffixcreated_at, updated_at
Date_date suffixevent_date, placed_date
Ingestion timestamp_loaded_atreserved; never use for anything else

Currency columns carry the currency in the name: amount_usd, spend_eur.

3. File structure inside a model

Every non-trivial model follows this shape:

with <input1> as ( select * from {{ ref('…') }} ),
     <input2> as ( select * from {{ ref('…') }} ),

     <transform_step_1> as (
         ...
     ),

     <transform_step_2> as (
         ...
     ),

     final as (
         ...
     )

select * from final

Rules:

4. Jinja discipline

5. Materialization defaults

Baked into dbt_project.yml:

models:
  causeway:
    staging:
      +materialized: view
      +schema: staging
      +tags: [staging]
    intermediate:
      +materialized: ephemeral
      +tags: [intermediate]
    marts:
      +materialized: table
      +schema: gold
      +tags: [marts]

Override per-model only when the default is wrong. Justify the deviation in a comment next to config().

Note

"Justify" means one line pointing at a measurable reason: "full refresh takes 42m, incremental needed" or "consumed by four dashboards that poll every minute, needs to be a table". Opinions about "feels right" do not count.

6. Incremental models

If you write materialized='incremental', you commit to all of these:

See Build your first incremental model for the worked example.

Danger

on_schema_change: 'ignore' silently drops columns that disappear upstream. It is a data-loss path that hides until someone asks why a column is empty. Never set it. If you have a reason, write an RFD first.

7. Physical layer

{{ config(
    materialized='table',
    liquid_clustered_by=['customer_id', 'event_date'],
    tblproperties={'delta.autoOptimize.optimizeWrite': 'true'}
) }}

8. Tests

Tests fall into four categories. Do not conflate them.

CategoryWhere declaredRuns againstCatches
Generic data testsschema.ymlReal dataData quality issues
Singular tests.sql in tests/Real dataOne-off business rules
Unit testsschema.ymlStatic mocksLogic regressions
Model contractsschema.ymlSchema metadataBreaking changes to public interfaces

Required per model:

Model typeRequired tests
Stagingnot_null + unique on the primary key; not_null on the ingestion timestamp
Intermediatenot_null on any required join keys
Fact martnot_null + unique on the grain key; not_null on all required dims; relationships to referenced dims
Dim martnot_null + unique on the entity key; contract enforced
Incremental modelAll of the above plus not_null on the watermark

Run tests with dbt build, not dbt run + dbt test split. build interleaves so a failing test blocks downstream, which is almost always what you want.

9. Model contracts

Contracts are for public interfaces: marts that BI, other dbt projects, or services consume. Contracts are not for internal intermediates.

models:
  - name: dim_customers
    config:
      contract:
        enforced: true
    columns:
      - name: customer_id
        data_type: bigint
        constraints:
          - type: not_null
          - type: primary_key
      - name: email
        data_type: string
        constraints: [{type: not_null}]

When a contract breaks, create dim_customers_v2 alongside and version with versions: rather than breaking consumers in place. Consumers migrate on their own cadence.

10. Sources and freshness

Every external table enters the project through a declared source. Every source has freshness thresholds.

sources:
  - name: bronze
    database: prod
    schema: bronze
    freshness:
      warn_after: {count: 12, period: hour}
      error_after: {count: 24, period: hour}
    loaded_at_field: _loaded_at
    tables:
      - name: raw_orders

Source freshness checks run out-of-band via dbt source freshness, wired into orchestration (Airflow DAG, Cosmos task group), not into CI. A stale source should page someone, not fail a PR.

11. Packages

Prefer community packages over writing macros. Vendor-controlled and version-pinned in packages.yml.

Acceptable packages in Causeway projects:

Any package not on that list requires review in a PR with explicit justification.

12. Review checklist

PRs touching models/ must satisfy:

See also