dbt (data build tool) does one job: it takes SQL SELECT statements you write and turns them into tables or views in your warehouse, in dependency order, with tests and documentation attached. It does not extract or load data — that is a job for Fivetran, Airbyte, or a custom pipeline — dbt owns the "T" in ELT.
A model is just a SELECT statement
Every dbt model is a .sql file containing one SELECT statement, no CREATE TABLE boilerplate. dbt wraps it in the DDL needed for whatever materialization you configure. The dependency graph is built entirely from ref() and source() calls inside the SQL — there is no separate config file listing dependencies, which is the detail that makes dbt projects easy to refactor.
-- models/staging/stg_orders.sql
{{ config(materialized='view') }}
select
order_id,
customer_id,
cast(order_date as date) as order_date,
amount_cents / 100.0 as amount
from {{ source('raw', 'orders') }}
where order_id is not null
source('raw', 'orders') points at a raw table declared in schema.yml, and ref('stg_orders') in any downstream model points back at this one. Run dbt run and dbt compiles the Jinja, resolves the dependency order via a topological sort of the ref()/source() graph, and executes each model against the warehouse.
Materializations — choosing how a model gets built
A view materialization creates a SQL view — cheap to build, always fresh, but re-executes the underlying query on every read. A table materialization runs the query once and stores the result — fast to query, stale until the next dbt run. incremental only processes new or changed rows on subsequent runs, essential once a table is too large to rebuild fully each time. ephemeral models are not built as objects in the warehouse at all — they get inlined as a CTE into whatever references them, useful for a small reusable snippet you do not want cluttering the schema.
Start every staging model as a view. Only promote to table or incremental once query latency or warehouse compute cost actually justifies the added complexity of managing state. Premature materialization is the most common way dbt projects end up with stale data nobody trusts.
schema.yml — tests and documentation live next to the model
Alongside each model's SQL file, a schema.yml declares sources, documents columns, and attaches generic tests without writing SQL by hand.
sources:
- name: raw
tables:
- name: orders
models:
- name: stg_orders
description: "One row per order, cents converted to dollars."
columns:
- name: order_id
tests: [unique, not_null]
- name: customer_id
tests:
- relationships:
to: ref('stg_customers')
field: customer_id
The project layout that scales
Most dbt projects converge on three layers: staging models that clean and rename raw source columns one-to-one, intermediate models that join and reshape staging models, and marts models that produce the final, business-facing tables analysts and BI tools query. Staging models should never be queried directly by a dashboard — that direct dependency is what makes later refactors painful, because you can no longer change a staging model without checking every dashboard in the company.
| Concept | What it does |
|---|---|
ref() | References another dbt model, builds the dependency graph |
source() | References a raw table outside dbt's control |
{{ config(...) }} | Sets materialization and other per-model settings |
schema.yml | Declares sources, tests, and documentation |
Wrapping up
Everything else in dbt — macros, snapshots, exposures, incremental strategies — builds on this same core: SQL models linked by ref(), tested and documented in schema.yml, compiled and run in dependency order. Get the staging/intermediate/marts layering right early, and the rest of the tooling stays easy to reason about as the project grows.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.