I once inherited a dbt project where the same "active customer" filter existed in four different models, each written slightly differently. Two of them excluded test accounts, one didn't, and nobody could say which number was correct. It's a structure problem, and it shows up in almost every dbt project once it grows past the demo stage, because the tool hands you a lot of flexibility and very little guidance on how to actually use it well.
dbt gives you very few rules. You can name models however you want and put your logic almost anywhere in the project. Early on, that freedom feels great, you move fast and nothing gets in your way. The problem shows up six or eight months later, when three different engineers have each solved the same problem in three different places, and now a "simple" column rename means hunting through a dozen models to catch every version of the logic.
A rigid framework isn't the fix. What works better is a handful of habits that keep the project's flexibility from working against you. Here are the five that actually move the needle on a real project, not the vague "keep your code clean" advice that doesn't tell you what to do on a Tuesday afternoon.
1. Build a Staging Layer and Actually Use It
Say you're working on an e-commerce warehouse with raw tables coming in from Postgres: orders, customers, and payments. Everything downstream, your revenue reporting model, your churn analysis, your customer lifetime value calculation, eventually touches these three tables.
The staging layer sits directly on top of those raw tables, one model per source table, doing nothing but the boring stuff: renaming cust_id to customer_id, casting created_at from text to a timestamp, cleaning up whatever type mismatches or inconsistent nulls the source system hands you. Nothing clever happens here, and that's exactly the point. Every downstream model references stg_orders instead of the raw table. When someone finds a problem at the source, you fix it in exactly one place instead of four, and staging becomes a consistent interface between your raw data and everything built on top of it.
One thing worth knowing before you lean on this too hard: not every filter belongs in staging. Excluding test accounts because they should never show up anywhere in the warehouse is a reasonable staging rule. Excluding cancelled orders because one particular finance report doesn't want to see them is different. That's business logic specific to a single use case, and it belongs in the model that needs it, not baked into the shared foundation everything else depends on.
2. CTEs Keep a Model Readable Once the Logic Piles Up
Once you're past staging, the temptation is to write one long SELECT that joins everything and computes everything in a single pass, but that's exactly the query someone else will struggle to trace six months from now. Bring your staging models in as CTEs at the top, build your business logic in named CTEs in the middle, and finish with a final CTE that selects only the columns you actually want to expose. A simplified orders revenue model looks something like this:
with orders as (
select * from {{ ref('stg_orders') }}
),
payments as (
select * from {{ ref('stg_payments') }}
),
final as (
select
orders.order_id,
orders.customer_id,
sum(payments.amount) as order_revenue
from orders
left join payments on orders.order_id = payments.order_id
group by 1, 2
)
select * from final
That select * on the staging CTEs isn't laziness. In 2018, dbt Labs' founder ran the same query directly against a raw table and again through ten passthrough CTEs, then compared the explain plans across Redshift, BigQuery, and Snowflake: they came back identical, meaning the extra columns never got touched. That result has held up well on most modern engines since, though Postgres was a real exception until version 12 introduced automatic CTE inlining, and a few engines hit real limits on very long CTE chains at scale.
For a staging CTE in a normal-sized model, the pattern is safe to use. The real payoff is readability: when something breaks, you change the final SELECT to pull from an earlier CTE and see exactly what the data looks like at that stage, instead of untangling a wall of joins to find where a number went wrong.
3. Your Directory Structure Can Do the Configuration Work For You
A directory structure isn't just for keeping files tidy. It lets you set configs once instead of repeating them everywhere. Say your project looks like this:
models/
├── staging/
│ ├── ecommerce/
│ │ ├── stg_orders.sql
│ │ ├── stg_customers.sql
│ │ └── stg_payments.sql
│
├── marts/
│ ├── finance/
│ │ └── revenue/
│ │ └── fct_revenue.sql
│ └── marketing/
│ └── customer_ltv.sql
In your dbt_project.yml, you can define a schema or materialization at the directory level. Everything under staging builds as a view and every model under finance gets its own schema, all inherited automatically without you setting a config() block in each file:
models:
project_name:
staging:
+materialized: view
marts:
finance:
+schema: finance
revenue:
+tags: ["critical"]
That revenue line under finance is the subdirectory override in action. It inherits the +schema: finance from its parent and adds a tag on top, without touching anything outside that one folder. You also get to run or select models by directory path, so testing just your finance marts before a release is one command instead of a manual list. A new engineer joining the team should be able to guess roughly where a model lives before they even open the folder.
4. A Style Guide Saves You From Arguments Nobody Wants to Have
Naming and formatting decisions feel minor right up until three people on the same team are writing customer_id, cust_id, and CustomerID in different models of the same project, and now every join between them needs a mental translation step. Decide early: snake_case or camelCase, plural or singular table names, whether staging models get a stg_ prefix and marts get nothing. Whether a boolean column is called is_active or active_flag matters just as little on its own, and just as much once fifteen models are using one or the other inconsistently. These choices aren't objectively correct or incorrect. The point is that everyone on the team makes the same one and stops relitigating it in every code review.
The easiest place to put this guide is your project's README, not a Slack thread from eight months ago that nobody can find, and not a Notion page that only half the team knows exists. dbt Labs publishes their own SQL style guide as a public reference, and borrowing the shape of it, even if you change half the specifics, is a faster start than writing one from scratch. A new hire reading it on day one should walk away knowing exactly how to name their first model without asking anyone.
5. Stop Optimizing for Fewer Lines
If I had to pick the one habit that separates a dbt project people actually enjoy working in from one everyone quietly dreads, it's this: engineers optimize for fewer lines instead of for readability, and it costs the team far more than it saves.
This usually shows up as someone chaining five joins and three window functions into a single dense SELECT because it "feels more efficient," or stripping out whitespace and line breaks to make a query look tighter. It rarely saves any real execution time. What it does is force the next person, who might be you in three months, to sit there mentally re-parsing a query just to figure out what it's doing before they can even start fixing the actual bug. I've spent a full afternoon untangling a teammate's revenue model that could have been three named CTEs instead of one clever subquery nested inside another subquery. The fix itself took an hour once I understood it. Understanding it was the expensive part.
The better approach is boring on purpose: break logic into named CTEs even when you technically could combine them, add a blank line between joins, and don't be afraid of a model that's 80 lines long if every section of it is obvious at a glance. New lines are cheap. Somebody's time spent reverse-engineering your query is not.
The Takeaway
Every one of these habits fits inside a single afternoon of cleanup, no process overhaul, no new tool. Start your next model by asking whether it references a raw table directly and whether someone unfamiliar with it could follow the logic top to bottom without asking you a single question. If the answer to either is no, that's the fix to make before you move on to anything else.
Top comments (0)