dbt · Data Build Tool

Estudo Aprofundado
dbt

Do SELECT mais simples aos data contracts e ao Semantic Layer: como o dbt transformou SQL em engenharia de software — modelos versionados, testados, documentados e orquestrados — e por que virou a ferramenta de transformação mais demandada do mercado de engenharia de dados. Cada tópico traz teoria, exemplo prático, um caso de uso real e link para a documentação oficial.

23 tópicos
SQL + Jinja
Foco: ELT / Modern Data Stack
Recursos gerais
01dbt Core é grátis e open-source — instale com pip install dbt-core dbt-<adapter> (ex.: dbt-snowflake, dbt-databricks, dbt-postgres, dbt-duckdb)
02Todo projeto novo nasce com dbt init, que gera a estrutura de pastas e o profiles.yml de conexão
03Rode dbt compile para ver o SQL puro gerado a partir do Jinja antes de rodar de verdade
04Para praticar sem custo de warehouse, combine dbt-duckdb com arquivos locais — o mesmo raciocínio de produção, em escala de notebook
01

Fundamentos

O que o dbt resolve e como um projeto é organizado

O que é dbt e por que existeBásico
O "T" do ELT como engenharia de software

dbt (data build tool) não extrai nem carrega dados — ele assume que o dado bruto já está no warehouse (ELT) e cuida só da transformação: SQL organizado em arquivos .sql, versionados no Git, com dependências resolvidas automaticamente, testes de dados e documentação gerada. Antes do dbt, transformação em SQL geralmente vivia em scripts soltos, procedures ou notebooks sem controle de versão nem teste — dbt trouxe para o SQL analítico práticas que engenharia de software já tinha havia décadas.

TeoriaUm model dbt é, na essência, um arquivo SELECT — o dbt não escreve o dado, ele compila o Jinja em SQL puro e executa um CREATE TABLE AS ou CREATE VIEW AS no warehouse. Isso significa que o dbt nunca move dados entre sistemas: tudo acontece dentro do mesmo warehouse/lakehouse onde os dados já residem — daí ele ser a peça "T" clássica de uma stack ELT (Airbyte/Fivetran fazem o EL, dbt faz o T).
Caso de usoUma equipe recebe dados brutos de vendas via Fivetran direto no Snowflake. Em vez de uma procedure SQL gigante e não testada, o time escreve modelos dbt encadeados — cada um documentado, testado e versionado no Git, com histórico de mudanças em pull requests.
Estrutura de um projeto dbtBásico
dbt_project.yml, profiles.yml e a pasta models/

dbt_project.yml declara o nome do projeto e as configurações padrão de cada pasta de models. profiles.yml guarda as credenciais de conexão com o warehouse — fica fora do projeto (normalmente em ~/.dbt/) e nunca é versionado, porque tem segredos.

TeoriaEssa separação entre dbt_project.yml (o quê) e profiles.yml (onde/como conectar) é o que permite o mesmo projeto rodar em dev, CI e produção sem alterar uma linha de código — só o target ativo muda. Cada profile pode ter múltiplos targets (ex.: dev e prod), cada um apontando para um schema/warehouse diferente.
yaml
# dbt_project.yml
name: 'loja_analytics'
version: '1.0.0'
profile: 'loja_analytics'

model-paths: ["models"]
seed-paths: ["seeds"]
snapshot-paths: ["snapshots"]

models:
  loja_analytics:
    staging:
      +materialized: view
    marts:
      +materialized: table
dbt init gera a estrutura inicial de pastas do zero
dbt debug testa a conexão do profiles.yml com o warehouse
Seu primeiro modelBásico
Um arquivo .sql é um model

Cada arquivo .sql dentro de models/ é um model: o nome do arquivo vira o nome da view/tabela, e o conteúdo é apenas um SELECT — sem CREATE TABLE, sem INSERT. O dbt cuida da DDL/DML por trás.

💡Onde usar: dbt run executa todos os models do projeto na ordem certa; dbt run --select stg_pedidos roda só um model específico.
sql
-- models/staging/stg_pedidos.sql
select
    id as pedido_id,
    cliente_id,
    valor_total,
    criado_em as data_pedido
from {{ source('vendas', 'pedidos') }}
Materializations: view, table, ephemeralBásico
Como o SELECT vira objeto no warehouse

view recalcula a query a cada leitura (zero armazenamento extra, mais lento); table materializa fisicamente o resultado (mais rápido de ler, ocupa espaço e leva tempo de build); ephemeral nem chega a existir como objeto — é injetado como CTE dentro de quem o referencia.

TeoriaA materialização é só a estratégia de persistência do resultado do SELECT — o dbt gera SQL diferente por trás de cada uma: para table, um CREATE TABLE AS SELECT (ou DROP + recriação); para view, um CREATE VIEW AS; para ephemeral, nenhum DDL — o SQL é literalmente colado como CTE no model consumidor no momento da compilação. Trocar a materialização não muda o resultado lógico, só a característica de custo/performance de leitura vs. escrita.
⚠️Cuidado: ephemeral não pode ser consultado fora do dbt (não existe no warehouse) e, encadeado em excesso, gera CTEs profundas difíceis de debugar — use para models pequenos e intermediários, não para camadas inteiras.
Padrão da camada staging: view — barata, sempre atualizada
Padrão da camada marts: table — performance de leitura para BI
02

Modelagem e o DAG

ref(), source() e as camadas de um projeto maduro

ref() e source() — o DAG automáticoBásico
A ideia mais importante do dbt

source() aponta para uma tabela bruta já existente no warehouse (fora do controle do dbt); ref() aponta para outro model dbt. Nunca escreva o nome do schema/tabela na mão — sempre ref()/source().

TeoriaQuando você escreve {{ ref('stg_pedidos') }}, o dbt não insere o nome literal — ele resolve o nome completo e qualificado da tabela/view naquele ambiente (dev pode usar um schema com seu prefixo, prod usa o schema de produção). É justamente por parsear cada chamada de ref()/source() no projeto inteiro que o dbt constrói o DAG (grafo de dependências) automaticamente e decide sozinho a ordem de execução — nenhuma orquestração manual é necessária dentro do próprio dbt.
💡Onde usar: dbt docs generate + dbt docs serve mostra esse DAG visualmente — ótimo para revisar o impacto de uma mudança antes de fazer merge.
sql
-- models/marts/fct_pedidos.sql
select
    p.pedido_id,
    p.cliente_id,
    c.nome as nome_cliente,
    p.valor_total
from {{ ref('stg_pedidos') }} p
left join {{ ref('stg_clientes') }} c
    on p.cliente_id = c.cliente_id
Camadas: staging → intermediate → martsIntermediário
O medallion do mundo dbt

staging (stg_*) faz limpeza 1:1 com a fonte — renomeia colunas, converte tipos, nada de joins. intermediate (int_*) combina/agrega lógica reutilizável. marts (fct_*/dim_*) entrega o modelo final consumido pelo BI.

TeoriaEssa separação é o mesmo princípio da arquitetura Medallion (bronze → silver → gold) aplicado dentro do warehouse: cada camada isola um tipo de responsabilidade, então uma mudança na fonte (ex.: coluna renomeada) se conserta em um único lugar — o model de staging — sem quebrar toda a cadeia de marts. É também o que torna o projeto testável em partes: você testa a staging isoladamente antes de confiar nela como insumo dos marts.
Caso de usoO time de dados recebe 40 tabelas brutas de um ERP. Cada uma vira um model de staging simples; a lógica de negócio de "pedido cancelado não conta em receita" mora só nos marts — nunca duplicada em staging, o que evitaria divergência entre dashboards.
Jinja e config()Intermediário
SQL programável: if/for/set

Todo {{ ... }} ou {% ... %} num model é Jinja, o motor de templates Python que o dbt usa para gerar SQL dinamicamente — config() no topo de um arquivo sobrescreve as configurações do dbt_project.yml só para aquele model.

TeoriaO dbt roda em duas fases: primeiro compila (o Jinja executa em Python e produz SQL puro, sem chaves nem tags), depois executa esse SQL no warehouse. Isso significa que loops, condicionais e variáveis Jinja acontecem antes do banco ver qualquer coisa — o warehouse nunca sabe que Jinja existiu. É por isso que dbt compile é a ferramenta de debug nº1: mostra exatamente o SQL que será enviado.
sql
{{ config(materialized='table', tags=['diario']) }}

select
    regiao,
    {% for ano in [2024, 2025, 2026] %}
    sum(case when ano_pedido = {{ ano }} then valor end) as receita_{{ ano }}{% if not loop.last %},{% endif %}
    {% endfor %}
from {{ ref('fct_pedidos') }}
group by 1
Macros customizadosIntermediário
DRY para SQL repetido

Um macro é uma função Jinja reutilizável escrita em macros/*.sql — encapsula lógica SQL repetida (uma fórmula de negócio, uma conversão de fuso horário) num único lugar chamado de vários models.

💡Onde usar: qualquer expressão SQL que você copia e cola em mais de dois models é candidata a virar macro — centraliza a manutenção quando a regra de negócio mudar.
sql
-- macros/centavos_para_reais.sql
{% macro centavos_para_reais(coluna) %}
    ( {{ coluna }} / 100.0 )
{% endmacro %}

-- uso em um model
select {{ centavos_para_reais('valor_em_centavos') }} as valor_reais
from {{ ref('stg_pedidos') }}
03

Testes e Documentação

Confiança versionada junto com o código

Testes genéricosBásico
unique, not_null, relationships, accepted_values

Testes vivem em arquivos .yml ao lado dos models, não em código Python separado. Os 4 testes nativos cobrem a maioria dos casos: chave única, coluna obrigatória, integridade referencial e domínio de valores.

TeoriaCada teste genérico é, por baixo, um macro que compila para uma query SELECT que deve retornar zero linhas para passar — unique vira um GROUP BY ... HAVING COUNT(*) > 1, por exemplo. É por isso que testes dbt rodam inteiramente dentro do warehouse (dbt test só dispara SQL) e escalam junto com o volume de dados, sem precisar extrair nada para fora.
yaml
# models/marts/_marts.yml
models:
  - name: fct_pedidos
    columns:
      - name: pedido_id
        tests: [unique, not_null]
      - name: cliente_id
        tests:
          - relationships:
              to: ref('dim_clientes')
              field: cliente_id
      - name: status
        tests:
          - accepted_values:
              values: ['pago', 'cancelado', 'pendente']
Testes customizados e dbt-utilsIntermediário
Singular tests + dbt_utils.expression_is_true

Um singular test é um arquivo .sql em tests/ com uma query livre que também precisa retornar zero linhas — para regras de negócio que não cabem nos 4 testes genéricos. Pacotes como dbt-utils e dbt_expectations adicionam dezenas de testes genéricos prontos (faixas de valor, distribuição, formato de string).

Caso de uso"Nenhum pedido pode ter data de entrega anterior à data do pedido" não é not_null nem unique — vira um singular test com uma condição livre.
sql
-- tests/assert_entrega_apos_pedido.sql
select *
from {{ ref('fct_pedidos') }}
where data_entrega < data_pedido
Documentação e linhagemBásico
dbt docs generate + o DAG navegável

Descrições de models e colunas ficam no mesmo .yml dos testes. dbt docs generate lê o projeto inteiro e monta um site estático navegável, com o DAG completo — quem depende de quem, até a fonte bruta.

💡Onde usar: antes de renomear ou remover uma coluna, abra o DAG e veja quem consome aquele model — evita quebrar um dashboard sem saber.
Data contractsAvançado
Congelando schema e tipos de um model

Um contract declara explicitamente nome, tipo e constraints (not null, primary key) de cada coluna. Se o SELECT do model produzir algo diferente do declarado, o build falha antes de gravar no warehouse.

TeoriaSem contract, um ALTER silencioso de tipo (um INT que virou BIGINT na fonte) só é percebido quando algo consumidor quebra rio abaixo — às vezes um dashboard de produção. O contract move essa verificação para o momento do dbt build, tratando a interface do model como um contrato de API entre times, não como um detalhe implícito de implementação.
yaml
models:
  - name: fct_pedidos
    config:
      contract:
        enforced: true
    columns:
      - name: pedido_id
        data_type: bigint
        constraints:
          - type: not_null
      - name: valor_total
        data_type: decimal(18,2)
04

Incrementalidade

Quando reprocessar tudo todo dia não escala mais

Incremental models e is_incremental()Avançado
Processar só o que mudou

Um model incremental só reprocessa linhas novas/alteradas depois do primeiro build completo. A macro is_incremental() retorna true apenas em runs subsequentes, permitindo um WHERE de filtro que não existe na primeira execução.

TeoriaA estratégia incremental (incremental_strategy) define como as linhas novas se combinam com a tabela existente: append só insere (risco de duplicar se reprocessar a mesma janela); merge faz upsert por chave (a mais usada, exige suporte a MERGE no warehouse); insert_overwrite substitui partições inteiras (rápido em warehouses particionados como BigQuery); microbatch (mais recente) processa a incremental em lotes de tempo previsíveis, com reprocessamento automático de lotes com falha.
⚠️Cuidado: incremental é uma otimização de performance, não uma correção de bug de qualidade — se a lógica do SELECT mudar, é preciso um dbt run --full-refresh para reconstruir a tabela do zero com a nova regra.
sql
{{ config(
    materialized='incremental',
    unique_key='pedido_id',
    incremental_strategy='merge'
) }}

select *
from {{ source('vendas', 'pedidos') }}

{% if is_incremental() %}
where atualizado_em > (select max(atualizado_em) from {{ this }})
{% endif %}
Snapshots — SCD Type 2Avançado
Capturando histórico de dimensões que mudam

A fonte só guarda o estado atual de uma linha (ex.: endereço do cliente hoje) — se ela mudar, o valor antigo se perde para sempre. snapshots resolvem isso: a cada run, comparam o estado atual da fonte com o snapshot anterior e inserem uma nova versão da linha quando algo muda, preservando a antiga com data de validade.

TeoriaIsso é a implementação clássica de Slowly Changing Dimension Type 2 do modelo dimensional (Kimball): cada versão de uma linha ganha colunas dbt_valid_from/dbt_valid_to, e a versão vigente tem dbt_valid_to IS NULL. A estratégia timestamp detecta mudança por uma coluna de updated_at; a check compara colunas específicas linha a linha quando a fonte não tem timestamp confiável.
Caso de uso"Em que região o cliente estava quando fez cada pedido histórico?" só é respondível se o endereço do cliente foi versionado por snapshot — sem isso, um relatório histórico usaria (incorretamente) o endereço atual para pedidos antigos.
sql
-- snapshots/clientes_snapshot.sql
{% snapshot clientes_snapshot %}
{{
    config(
      target_schema='snapshots',
      unique_key='cliente_id',
      strategy='timestamp',
      updated_at='atualizado_em'
    )
}}
select * from {{ source('vendas', 'clientes') }}
{% endsnapshot %}
SeedsBásico
Dados de referência versionados em CSV

Um seed é um CSV pequeno em seeds/ que o dbt carrega como tabela via dbt seed — útil para listas fixas (mapeamento de UF para região, categorias de produto) que não existem em nenhum sistema de origem.

⚠️Cuidado: seeds são para dados pequenos e estáveis (dezenas a milhares de linhas). Para volumes maiores ou dados que mudam com frequência, isso é responsabilidade de uma fonte real, não de um CSV no Git.
05

Recursos avançados

Packages, hooks, métricas e controle de ambiente

Packages — dbt-utils, codegenIntermediário
Reaproveitando macros e testes da comunidade

Um package dbt é um projeto dbt inteiro (macros, models, testes) publicado no dbt Hub, instalado via packages.yml e dbt deps. dbt-utils (funções genéricas), dbt_expectations (testes ao estilo Great Expectations) e codegen (gera YAML de sources/models automaticamente) são os mais usados do mercado.

yaml
# packages.yml
packages:
  - package: dbt-labs/dbt_utils
    version: [">=1.0.0", "<2.0.0"]
  - package: calogica/dbt_expectations
    version: [">=0.10.0", "<0.11.0"]
Hooks e run-operationAvançado
Código que roda antes/depois do build

pre-hook/post-hook rodam SQL antes/depois de um model específico ser materializado (ex.: conceder permissões numa tabela recém-criada). on-run-start/on-run-end rodam uma vez por invocação do dbt inteira. dbt run-operation executa um macro isoladamente, sem nenhum model — útil para scripts de manutenção.

Caso de usoTodo mart recém-criado precisa de GRANT SELECT automático para o role de BI — um post-hook genérico aplicado a toda a pasta marts resolve isso sem intervenção manual em cada model novo.
yaml
# dbt_project.yml
models:
  loja_analytics:
    marts:
      +post-hook: "grant select on {{ this }} to role bi_reader"
Semantic Layer & Metrics (MetricFlow)Avançado
Métricas definidas uma vez, consultadas em todo lugar

O Semantic Layer permite definir uma métrica (ex.: "receita líquida") uma única vez, em cima de um model, com o motor MetricFlow — e consultá-la de forma consistente a partir de SQL, Python, Power BI, Tableau ou qualquer ferramenta integrada, sem reescrever a fórmula em cada uma.

TeoriaIsso resolve o mesmo problema que o Looker (LookML) resolve, mas na camada dbt: em vez de cada ferramenta de BI calcular "receita" com uma lógica SQL levemente diferente (um inclui frete, outro não), a métrica vira uma definição declarativa versionada no Git — a fonte única de verdade das definições de negócio, não só dos dados.
yaml
# models/marts/_metrics.yml
metrics:
  - name: receita_liquida
    label: "Receita Líquida"
    type: simple
    type_params:
      measure: valor_total
    filter: "{{ Dimension('status') }} != 'cancelado'"
Ambientes, --vars e Slim CIAvançado
state:modified+ e defer

--vars injeta valores em tempo de execução (ex.: uma data de corte) sem editar código. --select state:modified+ compara o projeto atual com um manifest.json de produção salvo e roda só os models alterados (e seus downstream) — a base do "Slim CI".

Teoria--defer combinado com state:modified permite rodar um PR de CI construindo apenas os models tocados, enquanto ref() para models não tocados resolve para o objeto já existente em produção em vez de tentar reconstruí-lo num schema temporário vazio. Isso reduz o tempo e o custo de CI de "rodar o projeto inteiro" para "rodar só o que mudou", mesmo em projetos com milhares de models.
06

Produção & Orquestração

Onde o dbt entra numa stack real, e onde não entra

dbt Core vs dbt Cloud/FusionIntermediário
O motor é o mesmo; a operação, não

dbt Core é a CLI open-source: você orquestra e agenda por fora (cron, Airflow). dbt Cloud (evoluindo para o motor dbt Fusion) adiciona IDE web, agendamento nativo, ambientes gerenciados, Slim CI pronto e o Semantic Layer hospedado — sem infraestrutura própria para manter.

💡Onde usar: dbt Core quando já existe um orquestrador (Airflow/Dagster) e um time capaz de operar CI próprio; dbt Cloud quando o objetivo é produtividade imediata sem montar essa infraestrutura.
Orquestração: Airflow/Dagster + dbt no DatabricksAvançado
Quem dispara o dbt run, e onde ele roda

O dbt não é um agendador — algo precisa disparar dbt build periodicamente. Airflow tem o cosmos (Astronomer) e operadores nativos que expandem o DAG do dbt em tasks individuais dentro do próprio Airflow; Dagster trata cada model dbt como um asset de primeira classe, herdando o DAG do dbt automaticamente.

TeoriaNo Databricks especificamente, o dbt roda contra um SQL Warehouse (via dbt-databricks) e pode ser agendado direto como uma task de Databricks Workflows/Lakeflow Jobs — dispensando um orquestrador externo quando o resto do pipeline já vive na mesma plataforma. Fora do Databricks, expandir o dbt dentro de um DAG do Airflow (em vez de um único node "rodar dbt") dá granularidade de retry e alerta por model, não só por pipeline inteiro.
CI/CD e observabilidade em produçãoAvançado
Slim CI, elementary e alertas

Um pipeline maduro roda dbt build (models + testes juntos, na ordem certa) a cada PR usando Slim CI, e falha o merge se um teste quebrar. O pacote elementary lê os metadados de execução do próprio dbt (run_results.json) e monta dashboards de observabilidade — falhas de teste, freshness, anomalias — sem ferramenta paga.

Caso de usoUm PR altera a lógica de stg_pedidos. O CI roda automaticamente só esse model e seus downstream (via state:modified+), os testes de fct_pedidos quebram, e o merge é bloqueado antes que o erro chegue à produção.
Casos de uso reais e quando NÃO usar dbtIntermediário
Onde o dbt brilha, e onde é a ferramenta errada

O dbt é a escolha natural sempre que a transformação acontece dentro de um warehouse/lakehouse com SQL — que é o caso da maioria absoluta dos pipelines analíticos modernos.

Modern Data Stack completo: Fivetran/Airbyte → warehouse → dbt → BI
Lakehouse: Delta/Iceberg → dbt rodando via SQL Warehouse do Databricks ou Trino
Star schema (Kimball) na camada Gold, com snapshots resolvendo SCD Type 2
Data contracts entre o time de dados e squads de produto que consomem marts
⚠️Quando NÃO é a ferramenta certa: streaming em tempo real (dbt é batch por natureza), transformação de dados semi-estruturados complexos que exigem código imperativo (Spark/Python fazem isso melhor), ou ingestão de dados — dbt nunca extrai nem carrega, só transforma o que já está no warehouse.