Analytics engineering and dbt¶
12-minute read. Assumes comfortable SQL and the idea of ELT. The hands-on follow-up is Build a dbt project.
The one-line answer¶
Analytics engineering is applying software engineering habits (version control, code review, tests, documentation, environments) to the SQL that turns raw warehouse tables into trusted models. dbt (data build tool) is the most widely used tool for it: you write SELECT statements, and dbt works out the order to build them in, runs them in the warehouse, and tests the results.
The problem it solves¶
Before analytics engineering, transformation logic lived in a few common places, all bad:
- Inside dashboards. Each BI report had its own SQL. Three reports defined "active customer" three ways.
- In scheduled stored procedures nobody could review, test or roll back.
- In a Python ETL codebase that only the data engineers could change, so analysts waited weeks for a new column.
Once the warehouse became fast and cheap enough to do the transforming (the shift from ETL to ELT), the transformation step became "a pile of SQL in the warehouse". Analytics engineering is the discipline that keeps that pile maintainable.
The role sits between data engineering (getting raw data in, running the platform) and analysis (asking business questions). See Data from Scratch phase 7 for how the roles differ.
How dbt works¶
A dbt project is a folder of SQL files. Each file is a model: one SELECT that defines one table or view.
-- models/staging/stg_orders.sql
select
id as order_id,
customer as customer_id,
cast(ordered_at as date) as order_date,
status,
amount_cents / 100.0 as amount
from {{ source('shop', 'raw_orders') }}
where _deleted is not true
-- models/marts/fct_orders.sql
select
o.order_id,
o.order_date,
c.customer_key,
o.amount
from {{ ref('stg_orders') }} o
join {{ ref('dim_customers') }} c using (customer_id)
where o.status <> 'cancelled'
The two pieces of templating do the heavy lifting:
{{ source(...) }}points at a raw table loaded by something else, declared once in YAML.{{ ref(...) }}points at another model. dbt reads everyrefto build a dependency graph (a DAG), runs models in the right order, and substitutes the correct schema for the environment (your dev schema or production).
You never write CREATE TABLE, and you never hand-maintain the run order.
flowchart LR
S1[(source: raw_orders)] --> STG1[stg_orders]
S2[(source: raw_customers)] --> STG2[stg_customers]
STG2 --> DIM[dim_customers]
STG1 --> FCT[fct_orders]
DIM --> FCT
FCT --> RPT[rpt_revenue_by_month]
T{{tests: unique, not_null,<br/>relationships}} -.-> DIM
T -.-> FCT The DAG dbt derives from ref and source calls. Staging models clean each raw table, marts combine them into facts and dimensions, and tests run against the built tables.
The standard layers¶
Most projects follow the same three-layer layout, which maps onto the bronze/silver/gold idea from Warehouses, lakes, and lakehouses:
| Layer | Prefix | Job | Rules |
|---|---|---|---|
| Staging | stg_ | One model per source table. Rename, cast, light cleanup | No joins, no business logic. A 1:1 cleaned mirror |
| Intermediate | int_ | Reusable building blocks between staging and marts | Not exposed to BI tools |
| Marts | fct_, dim_ | Facts and dimensions for a business area | What analysts and dashboards query |
The point of staging is that if a source renames a column, you fix it in exactly one place.
Materializations¶
Each model chooses how it exists in the warehouse:
| Materialization | What dbt builds | Use for |
|---|---|---|
| view | A view; computed on every query | Staging, light models |
| table | A full rebuild every run | Marts that are small enough to rebuild |
| incremental | Insert or merge only new rows each run | Large fact tables |
| ephemeral | Nothing; inlined as a CTE into models that use it | Small helper logic |
| materialized view | A warehouse-maintained materialized view | Where the platform supports it well |
Incremental models are where most of the cost savings and most of the bugs are:
{{ config(materialized='incremental', unique_key='order_id') }}
select * from {{ ref('stg_orders') }}
{% if is_incremental() %}
where updated_at > (select max(updated_at) from {{ this }})
{% endif %}
The trap: rows that arrive late with an older updated_at are skipped forever. Common fixes are a lookback window (> max(updated_at) - interval '3 days') combined with the unique_key merge, and a periodic --full-refresh.
Tests¶
dbt has two kinds of test, both of which compile to SQL that returns the rows that break the rule. Zero rows means pass.
Generic data tests are declared in YAML:
models:
- name: dim_customers
columns:
- name: customer_key
data_tests: [unique, not_null]
- name: fct_orders
columns:
- name: customer_key
data_tests:
- relationships:
to: ref('dim_customers')
field: customer_key
- name: amount
data_tests:
- dbt_utils.accepted_range:
min_value: 0
unique on every dimension key is the cheapest high-value test there is: it catches the join fan-out described in SQL joins explained before a dashboard double-counts.
Singular tests are any SQL file in tests/ that should return no rows, for rules too specific for a generic test:
-- tests/assert_revenue_matches_source.sql
select 1
from (select sum(amount) a from {{ ref('fct_orders') }}) f,
(select sum(amount_cents) / 100.0 b from {{ source('shop','raw_orders') }}
where status <> 'cancelled' and _deleted is not true) s
where abs(f.a - s.b) > 0.01
dbt also supports unit tests (fixed input rows, expected output rows) for complex logic, and model contracts that enforce column names and types at build time; see data contracts.
dbt build runs models and their tests together in DAG order, and stops downstream models from building on top of a model whose tests failed. That one behavior turns "a wrong number reached the CEO" into "the pipeline stopped and paged someone".
Snapshots, seeds, docs¶
- Snapshots record changes to a mutable source table over time, producing Type 2 history. See slowly changing dimensions.
- Seeds are small CSV files in the repo loaded as tables: country codes, mapping tables.
dbt docs generatebuilds a browsable site of every model, column description and the lineage graph. It is a lightweight catalog for the transformation layer.
The everyday workflow¶
- Branch in git. Change or add a model.
dbt build --select my_model+runs the model and everything downstream of it, with tests, in your personal dev schema.- Open a pull request. CI runs the changed models and their children against a CI schema (often with
state:modified+so only what changed is built). - Review, merge, and the production job picks it up on its next run.
This is the whole value: data transformations get the same safety net as application code.
dbt versions and products¶
- dbt Core is the open-source command-line tool (Apache 2.0 license). You run it anywhere, usually from an orchestrator.
- dbt v2 / Fusion is the newer engine, written in Rust, with faster parsing and SQL understanding built in. Current dbt Core 1.x releases announce that v2 is the default for new installations. Check its license and adapter support for your warehouse, and use the π upgrade guide for existing projects. The concepts on this page apply to both.
- dbt platform (formerly dbt Cloud) is the hosted product: IDE, scheduler, CI, docs hosting, semantic layer.
Alternatives with the same idea include SQLMesh, Dataform (built into BigQuery), and the declarative pipeline features in Databricks and Snowflake (Dynamic Tables). The habits transfer between them.
Common mistakes¶
- Business logic in staging. Keep staging boring, so source changes have one place to land.
- No tests, or only
not_null. Start withuniqueandnot_nullon every key,relationshipson every foreign key, and one reconciliation test per important mart. - Hard-coded table names instead of
refandsource. They break lineage and environment switching. - Incremental models without a late-data plan.
- Hundreds of marts nobody uses. Every model is maintenance. Delete what no dashboard or person reads.
Official docs¶
- π dbt: What is dbt? - the product overview and core concepts
- π dbt: Add data tests to your DAG - generic and singular tests
- π dbt: Incremental models - strategies,
unique_key, and full refreshes - π dbt: How we structure our dbt projects - the staging, intermediate and marts layout
What to look at next¶
- Build a dbt project - a tested star schema, step by step
- Data modeling for analytics - what the marts layer should look like
- Data quality and lineage - tests in the wider context of trusting data
- Data pipelines and orchestration - running
dbt buildon a schedule after the loads finish