Saltar a contenido

Changelog:

  • Nueva creación — Septiembre 2026

Apuntes en creación

Esta sesión está en construcción. La versión final se publicará durante Octubre de 2026.

Transformación con dbt

En sesiones anteriores hemos hecho varias cosas por separado. En la sesión de ingesta de datos con dlt resolvimos la E y la L: extrajimos retail_db de MySQL y lo cargamos tal cual en DuckDB, dentro del esquema retail_db. Y en la sesión de modelado dimensional diseñamos y construimos a mano el esquema en estrella sobre esos datos: la tabla de hechos fact_ventas, las dimensiones dim_producto, dim_cliente y dim_fecha, con sus claves subrogadas y su miembro desconocido.

Aquella estrella la montamos con scripts sueltos de CREATE TABLE AS: funcionaban, pero eran un conjunto de sentencias que había que ejecutar en el orden correcto, a mano, sin versionar y sin forma de saber qué depende de qué.

En esta sesión damos el salto que anunciamos entonces: nos vamos a centrar en la T e industrializar esas mismas transformaciones con dbt. La lógica SQL es prácticamente la misma; lo que cambia es que pasa a ser un proyecto de ingeniería —versionado, con dependencias explícitas, materialización configurable y documentación— en lugar de un puñado de consultas.

Dónde encaja dbt

dbt no extrae ni carga datos: se apoya en el motor que ya los tiene (en nuestro caso DuckDB) y le manda SELECT. Es la pieza de transformación de un stack ELT moderno. La lógica sigue siendo SQL; lo que aporta dbt es ingeniería de software alrededor de ese SQL: dependencias, entornos, control de versiones, documentación y pruebas.

Analytics engineering

Durante años hubo dos perfiles muy separados. El ingeniero de datos (data engineer) montaba infaestructura y movía datos, pero no conocía el negocio. El analista de datos (data analyst) conocía el negocio y escribía SQL —justo lo que hicimos en la sesión de modelado— pero trabajaba con consultas sueltas, sin control de versiones, que nadie más podía reutilizar ni verificar.

El ingeniero analítico (analytics engineer) es el puente: aplica prácticas de desarrollo de software (control de versiones, modularidad, pruebas, revisión de código, integraciones continuas, documentación) al trabajo analítico en SQL. En lugar de los scripts de CREATE TABLE que ejecutábamos a mano, produce un repositorio de modelos versionado, documentado y reproducible.

Linaje de los datos

El linaje de los datos es la información de qué modelo depende de qué otro modelo o fuente. dbt lo construye automáticamente y lo dibuja en un grafo navegable.

De dicha necesidad surgió dbt, la herramienta que popularizó ese rol. Un modelo dbt es, simplemente, un SELECT guardado en un fichero .sql. dbt se encarga de convertir ese SELECT en una vista o una tabla dentro del almacén, de resolver en qué orden ejecutar los modelos y de mantener el linaje entre ellos. No hay que aprender un lenguaje nuevo: el 95 % es SQL, el mismo que ya hemos utilizado hasta ahora.

dbt Core y dbt Cloud

Existen dos formas de usar dbt:

  • dbt Core es la herramienta de línea de comandos, de código abierto (licencia Apache 2.0). Es lo que usaremos: gratis, se ejecuta en local o en cualquier orquestador como Airflow o Prefect, y no depende de ningún servicio.
  • dbt Cloud es el producto SaaS comercial de dbt Labs: añade IDE web, planificador, control de accesos y observabilidad sobre dbt Core.

Todo lo que veremos funciona con dbt Core. Para conectarnos con DuckDB usaremos el adaptador dbt-duckdb, que es un plugin instalable aparte.

¿Y dbt Fusion?

Desde 2025 dbt Labs desarrolla Fusion, un motor nuevo escrito en Rust que sustituye al motor histórico en Python. No es un dbt distinto: usa el mismo lenguaje de proyecto (modelos, ref(), YAML), pero al comprender el SQL de forma nativa aporta compilación local, detección de errores en el editor y linaje a nivel de columna, además de ser mucho más rápido en proyectos grandes.

Instalación

Si quieres instalar dbt Core y el adaptador dbt-duckdb en tu máquina, la forma más sencilla es con pip:

pip install dbt-duckdb
dbt --version

Al instalar dbt-duckdb se arrastran como dependencias tanto dbt-core como duckdb, así que no hay que instalarlos por separado.


En nuestro caso, como ya está instalado en el contenedor iabd-lab (recuerda que te puedes conectar mediante docker exec -it iabd-lab bash), podemos comprobar directamente la instalación mediante:

dbt --version
# Core:
#   - installed: 1.12.3
#   - latest:    1.12.3 - Up to date!

# Plugins:
#   - duckdb: 1.11.0 - Up to date!

Un comando, muchos subcomandos

dbt funciona con subcomandos, al estilo de git: dbt run, dbt debug, dbt docs, dbt ls... Los iremos viendo a lo largo de la sesión.

Estructura de un proyecto

El primer paso es crear un proyecto dbt, que es un repositorio de modelos versionado y documentado. Para ello, ejecutaremos el comando dbt init:

dbt init iabd_retail

El comando pregunta el adaptador y genera un esqueleto de carpetas:

iabd/
├── profiles.yml             # cómo conectar con el almacén
iabd_retail/
├── dbt_project.yml          # configuración del proyecto

Como puedes observar, tenemos dos carpetas por separado, una para el proyecto dbt (iabd_retail) y otra para el fichero profiles.yml que contiene la conexión con DuckDB, y que dbt mantiene a propósito fuera del proyecto, porque ahí van las credenciales y no deben acabar versionadas en git.

Así pues, la carpeta iabd almacena la conexión. En una instalación por defecto, normalmente, la ruta sería ~/.dbt/profiles.yml, pero en nuestro caso la hemos configurado previamente mediante la variable de entorno DBT_PROFILES_DIR del docker-compose.yml.

Las tres zonas, ahora como código

La estructura recomendada de un proyecto dbt no es una convención arbitraria: es la misma división en zonas que ya conocemos, expresada en carpetas.

models/
├── staging/        ← una vista por cada tabla del origen, sin lógica de negocio
├── intermediate/   ← limpieza, combinaciones y cálculos reutilizables
└── marts/          ← hechos, dimensiones y tablas de negocio

La correspondencia entre los tres vocabularios que hemos ido usando:

Zona Bucket en MinIO Carpeta en dbt Qué contiene Materialización habitual
Aterrizaje raw-data staging los datos del origen, renombrados y tipados, sin más vista
Procesado processed intermediate lógica intermedia reutilizable entre varios marts vista o tabla
Presentación warehouse marts fact_ventas, dim_producto, dim_cliente, dim_fecha y los marts de negocio tabla

La razón de que staging se materialice como vista y los marts como tabla encaja con el propósito de cada zona. Una vista de staging no ocupa espacio y siempre refleja el estado actual del origen; un mart se consulta muchas veces al día y compensa pagar el coste de materializarlo una vez.

Una regla de oro del staging

En staging no se hace lógica de negocio: solo renombrar columnas, ajustar tipos y poco más. Un modelo de staging debe poder leerse al lado de la tabla de origen y reconocerse sin esfuerzo. Toda decisión de negocio vive aguas abajo, en intermediate o en marts, donde se puede documentar y probar.

Dos salas o tres zonas

La división original de Kimball es de dos: la trastienda, donde ocurre todo el proceso de ETL, y la sala, el área de presentación que consume el negocio. Partir la trastienda en aterrizaje y procesado es la lectura moderna de esa misma propuesta, nacida de que el almacenamiento barato permite conservar indefinidamente una copia intacta del origen, algo impensable cuando Kimball escribió su libro.

Conviene además no tomarse la frontera de forma dogmática. Kimball insistía en que el comensal no entra en la cocina, y con razón para los informes corporativos; pero en la práctica actual es normal que un analista consulte directamente una tabla de staging para investigar una anomalía. La regla sigue viva para lo que se publica; para explorar, la puerta está más abierta de lo que estaba en los noventa.

Configuración: dbt_project.yml

El fichero dbt_project.yml es el corazón del proyecto. Ahí se declara el nombre del proyecto, qué profile utiliza y la configuración por defecto de cada carpeta de modelos. En nuestro caso, queremos que todo lo que yazca bajo staging/ se materialice como vista y lo que esté bajo marts/ como tabla:

dbt_project.yml
name: 'iabd_retail'
version: '1.0.0'
profile: 'iabd_retail'

model-paths: ["models"]

# analysis-paths: ["analyses"] test-paths: ["tests"] ... rutas para otros tipos de artefactos que no usaremos en esta sesión

models:
  iabd_retail:
    staging:
      +materialized: view      # todo lo de staging/ será una vista
    marts:
      +materialized: table     # todo lo de marts/ será una tabla

Así pues, el resultado al que llegaremos una vez hayamos creado el modelo dimensional en estrella será similar al siguiente:

---
config:
    treeView-beta:
        showIcons: false
---
treeView-beta
    iabd_retail/
        dbt_project.yml          :::highlight   ## configuración del proyecto
        models/
            staging/             :::highlight   ## capa de limpieza (1 modelo por tabla origen)
                _retail_sources.yml
                stg_customers.sql
                stg_orders.sql
                stg_order_items.sql
                stg_products.sql
                stg_categories.sql
                stg_departments.sql
            marts/               :::highlight   ## la estrella (tablas de negocio)
                dim_producto.sql
                dim_cliente.sql
                dim_fecha.sql
                fact_ventas.sql
                ingresos_por_departamento.sql

Conexión: profiles.yml

dbt separa qué transformar (los modelos, versionados en el repo) de dónde hacerlo (la conexión, que cambia entre tu portátil y producción). Esa conexión vive en profiles.yml.

En un entorno real este fichero va en ~/.dbt/profiles.yml para no meter las credenciales en el repositorio; en clase lo dejamos junto al proyecto para tenerlo todo a la vista.

profiles.yml
iabd_retail:              # debe coincidir con 'profile:' de dbt_project.yml
  target: dev
  outputs:
    dev:
      type: duckdb
      path: /workspace/dbt/iabd/retail_el.duckdb
      schema: main
      threads: 4

Para que nos funcione con duckdb, vamos a copiar el fichero retail.duckdb que generó dlt en la sesión de ingesta a la carpeta del proyecto dbt:

cp /workspace/retail_el.duckdb /workspace/dbt/iabd/retail_el.duckdb

Antes de ejecutar nada, conviene comprobar que la conexión funciona mediante dbt debug:

16:45:59  Running with dbt=1.12.3
16:45:59  dbt version: 1.12.3
16:45:59  python version: 3.12.14
16:45:59  python path: /usr/local/bin/python3.12
16:45:59  os info: Linux-5.15.153.1-microsoft-standard-WSL2-x86_64-with-glibc2.41
16:45:59  Using profiles dir at /workspace/dbt/iabd
16:45:59  Using profiles.yml file at /workspace/dbt/iabd/profiles.yml
16:45:59  Using dbt_project.yml file at /workspace/dbt/iabd_retail/dbt_project.yml
16:45:59  adapter type: duckdb
16:45:59  adapter version: 1.11.0
16:45:59  Configuration:
16:45:59    profiles.yml file [OK found and valid]
16:45:59    dbt_project.yml file [OK found and valid]
16:45:59  Required dependencies:
16:45:59   - git [OK found]

16:45:59  Connection:
16:45:59    database: retail_el
16:45:59    schema: main
16:45:59    path: retail_el.duckdb
...
16:45:59    Connection test: [OK connection ok]

Bloqueo de escritura en DuckDB

Recuerda que DuckDB admite un único proceso escritor sobre el mismo fichero. Si tienes abierta una consola de DuckDB apuntando a retail_el.duckdb, dbt run fallará con un error de bloqueo. Cierra cualquier otra conexión antes de ejecutar dbt.

Caso 1. Cargando un modelo

Una vez tenemos la configuración inicial, vamos a configurar los elementos del proyecto.

Fuentes de datos

Los datos crudos que cargó dlt viven en el esquema retail_db que reside en el fichero retail_el.duckdb. Antes de transformarlos, debemos informarlos a dbt declarándolos como sources en un fichero YAML, el cual colocaremos en la carpeta models/staging/ y lo llamaremos _retail_sources.yml.

Esto no crea nada, solo le dice a dbt que dichas tablas ya existen y que serán la fuente de nuestros modelos:

models/staging/_retail_sources.yml
version: 2

sources:
  - name: retail_db
    description: "Tablas crudas cargadas por dlt desde MySQL en la sesión de ingesta."
    database: retail_el
    schema: raw
    tables:
      - name: customers
      - name: orders
      - name: order_items
      - name: products
      - name: categories
      - name: departments

Al configurar aquí la fuente, dbt puede generar documentación y pruebas de integridad sobre ella, y además nos permite referenciarla en los modelos sin escribir el nombre real de la tabla origen. De esta manera, en el SQL de los modelos nunca escribimos el nombre real de la tabla origen, sino que en su lugar usamos la función source():

select * from {{ source('retail_db', 'customers') }}

¿Te suena la sintaxis {{ ... }}? Sí, es Jinja, un motor de plantillas que dbt intercala en el SQL.

En tiempo de compilación, dbt sustituye source('retail_db', 'customers') por "retail"."retail_db"."customers". Esto nos ofrece una mayor flexibilidad y mantenimiento, ya que si mañana el origen cambia de esquema, sólo debemos modificar una línea en el YAML y no cada consulta. a, se toca una línea en el YAML y no cada consulta.

El primer modelo

Un modelo de staging hace un trabajo modesto pero imprescindible: leer una tabla cruda, seleccionar las columnas útiles, renombrarlas a un vocabulario consistente y poco más. El resultado es una tabla con un formato y nombres de columnas consistentes, listos para ser utilizados en los modelos de análisis.

Para cada una de las tablas, definiremos una consulta SQL en un fichero .sql dentro de models/staging/. Por ejemplo, el modelo stg_orders.sql lee la tabla orders y renombra las columnas a un vocabulario consistente:

models/staging/stg_orders.sql
with fuente as (
    select * from {{ source('retail_db', 'orders') }}
)
select
    order_id as pedido_id,
    cast(order_date as date) as fecha,      -- dlt cargó la fecha como timestamp, aquí la convertimos a DATE
    order_customer_id as cliente_id,
    order_status as estado
from fuente

Aquí ya podemos ver el valor de la capa de staging, donde la columna order_date llegó como timestamp desde la ingesta, y la convertimos a DATE de una vez para todo el proyecto. En la sesión de modelado ese CAST lo repetíamos en cada consulta; ahora se hace una sola vez y todo lo que consuma stg_orders ya recibe la fecha con su tipo.

Para poder probar el modelo, podemos ejecutar:

dbt run --select stg_orders
# 17:48:51  Running with dbt=1.12.3
# 17:48:51  Registered adapter: duckdb=1.11.0
# 17:48:52  [WARNING]: Configuration paths exist in your dbt_project.yml file which do not apply to any resources.
# There are 1 unused configuration paths:
# - models.iabd_retail.marts
# 17:48:52  Found 1 model, 6 sources, 500 macros
# 17:48:52  
# 17:48:52  Concurrency: 4 threads (target='dev')
# 17:48:52  
# 17:48:52  1 of 1 START sql view model main.stg_orders ..................................... [RUN]
# 17:48:52  1 of 1 OK created sql view model main.stg_orders ................................ [OK in 0.09s]
# 17:48:52  
# 17:48:52  Finished running 1 view model in 0 hours 0 minutes and 0.18 seconds (0.18s).
# 17:48:52  
# 17:48:52  Completed successfully
# 17:48:52  
# 17:48:52  Done. PASS=1 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=1

Y si queremos comprobar que el modelo se ha creado correctamente, podemos abrir una consola de DuckDB y ejecutar un SELECT sobre la vista stg_orders:

duckdb /workspace/dbt/iabd/retail_el.duckdb

y comprobar que se ha creado la vista stg_orders en el esquema main de DuckDB, con los nombres de las columnas y tipos de datos que hemos definido en el modelo:

select * from main.stg_orders limit 5;
-- ┌───────────┬────────────┬────────────┬────────────┐
-- │ pedido_id │   fecha    │ cliente_id │   estado   │
-- │   int64   │    date    │   int64    │  varchar   │
-- ├───────────┼────────────┼────────────┼────────────┤
-- │     25885 │ 2014-01-01 │       7253 │ PENDING    │
-- │     25894 │ 2014-01-01 │       7839 │ PROCESSING │
-- │     25915 │ 2014-01-01 │       2963 │ COMPLETE   │
-- │     25942 │ 2014-01-01 │       1117 │ PROCESSING │
-- │     25954 │ 2014-01-01 │       2582 │ PROCESSING │
-- └───────────┴────────────┴────────────┴────────────┘

El resto de los modelos de staging

Los cinco siguen el mismo patrón que stg_orders: leen una fuente con source(), seleccionan las columnas útiles y las renombran al vocabulario del proyecto. Ni joins ni agregaciones: una tabla origen por modelo.

models/staging/stg_customers.sql
with fuente as (
    select * from {{ source('retail_db', 'customers') }}
)
select
    customer_id as cliente_id,
    customer_fname as nombre,
    customer_lname as apellido,
    customer_email as email,
    customer_city as ciudad,
    customer_state as provincia
from fuente
models/staging/stg_order_items.sql
with fuente as (
    select * from {{ source('retail_db', 'order_items') }}
)
select
    order_item_id as linea_id,
    order_item_order_id as pedido_id,
    order_item_product_id as producto_id,
    order_item_quantity as cantidad,
    order_item_subtotal as subtotal,
    order_item_product_price as precio_unitario
from fuente
models/staging/stg_products.sql
with fuente as (
    select * from {{ source('retail_db', 'products') }}
)
select
    product_id as producto_id,
    product_category_id as categoria_id,
    product_name as nombre,
    product_price as precio
from fuente
models/staging/stg_categories.sql
with fuente as (
    select * from {{ source('retail_db', 'categories') }}
)
select
    category_id as categoria_id,
    category_department_id as departamento_id,
    category_name as nombre
from fuente
models/staging/stg_departments.sql
with fuente as (
    select * from {{ source('retail_db', 'departments') }}
)
select
    department_id as departamento_id,
    department_name  as nombre
from fuente

Creando una dimensión con ref()

Cuando un modelo depende de otro modelo (y no de una fuente), debemos emplear ref() en lugar de source(). Este es el mecanismo más importante de dbt. Fíjate en dim_producto, la misma dimensión de la sesión de modelado —clave subrogada con row_number(), LEFT JOIN hacia categoría y departamento, y la fila del miembro desconocido— pero ahora leyendo de modelos de staging con ref():

models/marts/dim_producto.sql
with productos as (
    select * from {{ ref('stg_products') }}
),
categorias as (
    select * from {{ ref('stg_categories') }}
),
departamentos as (
    select * from {{ ref('stg_departments') }}
),
base as (
    select
        row_number() over (order by p.producto_id) as producto_sk,
        p.producto_id,
        p.nombre as nombre,
        coalesce(c.nombre, '(sin asignar)') as categoria,
        coalesce(d.nombre, '(sin asignar)') as departamento,
        p.precio as precio_catalogo
    from productos p
    left join categorias c on p.categoria_id = c.categoria_id
    left join departamentos d on c.departamento_id = d.departamento_id
)
select * from base
union all
select -1, -1, '(desconocido)', '(desconocido)', '(desconocido)', 0   -- miembro desconocido

ref() construye el grafo por ti

Cada vez que un modelo referencia a otro con ref(), dbt anota esa dependencia. Con el conjunto de todas las dependencias construye un DAG (grafo dirigido acíclico) y deduce el orden de ejecución: primero staging, después las dimensiones, después el hecho que las referencia, y por último el informe. Nunca ordenamos nosotros las ejecuciones a mano; ese trabajo —el que en la sesión de modelado hacíamos vigilando el orden de los CREATE TABLE— es justo el que dbt nos quita.

Capas: staging y marts

No metemos todos los modelos en un saco. Un proyecto dbt se organiza en capas, y la convención más extendida distingue al menos dos:

  • staging — una vista por cada tabla origen. Limpieza ligera: renombrar columnas, castear tipos, estandarizar. No se hacen joins ni agregaciones. Es la "aduana" por donde entra todo dato crudo. Prefijo stg_.
  • marts — las tablas de negocio ya listas para consumo. Aquí vive nuestra estrella: las dimensiones (dim_), el hecho (fact_ventas) y los informes que se apoyan en ellos.

Nombres consistentes con la sesión de modelado

Mantenemos los nombres que ya fijamos a mano: fact_ventas, dim_producto, dim_cliente, dim_fecha. Parte de la documentación de dbt usa el prefijo fct_ para los hechos; aquí priorizamos la coherencia con lo que el alumnado ya construyó frente a la convención externa.

Capas del proyecto dbt: fuentes crudas de dlt, staging como vistas y la estrella como tablas, con source() y ref() enlazando las capas
Las fuentes crudas (dlt) entran por source(); los modelos se enlazan con ref(). fact_ventas resuelve sus claves subrogadas contra las dimensiones, también con ref().

La tabla de hechos conserva el grano de línea de pedido que decidimos en modelado (una fila por producto dentro de un pedido) y resuelve las claves subrogadas contra las dimensiones con ref(), redirigiendo al miembro desconocido con coalesce(..., -1):

models/marts/fact_ventas.sql
with lineas as (
    select * from {{ ref('stg_order_items') }}
),
pedidos as (
    select * from {{ ref('stg_orders') }}
),
producto as (
    select * from {{ ref('dim_producto') }}
),
cliente as (
    select * from {{ ref('dim_cliente') }}
)
select
    l.linea_id,
    p.pedido_id                      as pedido_id,       -- dimensión degenerada
    coalesce(dc.cliente_sk,  -1)     as cliente_sk,
    coalesce(dp.producto_sk, -1)     as producto_sk,
    p.fecha                          as fecha_id,
    p.estado,
    l.cantidad,                                          -- aditiva
    l.subtotal                       as importe,         -- aditiva
    l.precio_unitario                                    -- NO aditiva
from lineas l
join pedidos p on l.pedido_id = p.pedido_id
left join producto dp on dp.producto_id = l.producto_id
left join cliente  dc on dc.cliente_id  = p.cliente_id

Para ver qué genera realmente ref(), podemos mirar el SQL compilado (dbt lo deja en target/compiled/). Las llamadas Jinja han desaparecido y en su lugar hay nombres de tabla reales; observa cómo las dimensiones se resuelven a tablas del esquema main:

with lineas as (
    select * from "retail"."main"."stg_order_items"
),
pedidos as (
    select * from "retail"."main"."stg_orders"
),
producto as (
    select * from "retail"."main"."dim_producto"
),
cliente as (
    select * from "retail"."main"."dim_cliente"
)
...

Y el último mart, ingresos_por_departamento, cierra el bucle con la pregunta que abría la sesión de modelado. Allí, responder ¿cuánto ingresamos por departamento? costaba cuatro tablas y tres combinaciones; sobre la estrella es un SELECT con un solo join:

models/marts/ingresos_por_departamento.sql
with ventas as (
    select * from {{ ref('fact_ventas') }}
),
producto as (
    select * from {{ ref('dim_producto') }}
)
select
    dp.departamento,
    count(distinct v.pedido_id)   as pedidos,
    sum(v.cantidad)               as unidades,
    round(sum(v.importe), 2)      as ingresos
from ventas v
join producto dp on dp.producto_sk = v.producto_sk
where v.estado in ('COMPLETE', 'CLOSED')
group by dp.departamento
order by ingresos desc

Las otras dos dimensiones: dim_cliente y dim_fecha

dim_cliente es idéntica en estructura a dim_producto: clave subrogada con row_number() y su fila para el miembro desconocido.

models/marts/dim_cliente.sql
with clientes as (
    select * from {{ ref('stg_customers') }}
),
base as (
    select
        row_number() over (order by cliente_id)   as cliente_sk,
        cliente_id,
        nombre || ' ' || apellido                 as nombre,
        ciudad,
        provincia
    from clientes
)
select * from base
union all
select -1, -1, '(desconocido)', '(desconocido)', '(desconocido)'   -- miembro desconocido

dim_fecha no sale de ninguna tabla origen: se genera a partir del rango de fechas de los pedidos (con range() de DuckDB) y deriva los atributos de calendario que luego facilitan agrupar por mes, trimestre o día de la semana.

models/marts/dim_fecha.sql
with limites as (
    select min(fecha) as desde, max(fecha) as hasta
    from {{ ref('stg_orders') }}
),
dias as (
    select unnest(range(
        (select desde from limites),
        (select hasta from limites) + interval 1 day,
        interval 1 day
    ))::date as fecha_id
)
select
    fecha_id,
    year(fecha_id)     as anio,
    quarter(fecha_id)  as trimestre,
    month(fecha_id)    as mes,
    ['enero','febrero','marzo','abril','mayo','junio','julio',
        'agosto','septiembre','octubre','noviembre','diciembre'][month(fecha_id)] as mes_nombre,
    isodow(fecha_id)   as dia_semana,
    ['lunes','martes','miércoles','jueves','viernes','sábado',
        'domingo'][isodow(fecha_id)] as dia_nombre,
    isodow(fecha_id) >= 6 as es_finde
from dias

Materializaciones: view y table

La materialización decide cómo persiste dbt el resultado de un modelo en el almacén. En esta sesión usamos las dos básicas:

  • view — dbt crea una vista (CREATE VIEW). No ocupa espacio y siempre refleja los datos más recientes, pero se recalcula en cada consulta. Ideal para staging, donde la lógica es ligera.
  • table — dbt crea una tabla física (CREATE TABLE AS SELECT). Ocupa espacio y hay que reconstruirla para actualizarla, pero se consulta rápido porque el cálculo ya está hecho. Es exactamente lo que hacíamos a mano con los CREATE TABLE AS de la estrella; ahora lo declara la carpeta.

La materialización se puede fijar por carpeta en dbt_project.yml (como hicimos) o modelo a modelo con un bloque config() al principio del .sql:

{{ config(materialized='table') }}

select ...

Lo que aún hacemos a mano y dbt hará por nosotros

En la sesión de modelado generamos las claves subrogadas con row_number() y versionamos la dimensión de cliente como SCD tipo 2 escribiendo el histórico paso a paso. Esas dos piezas —claves subrogadas estables y versionado SCD— son precisamente las que dbt industrializa con sus snapshots y con macros como generate_surrogate_key. Junto con las materializaciones incrementales para cargar el hecho sin reconstruirlo entero, las veremos en la sesión de cierre del bloque, ya sobre el pipeline completo dlt → DuckDB → dbt.

Ejecutar el proyecto

El comando estrella es dbt run. Compila todos los modelos, resuelve el orden a partir del DAG y los ejecuta contra DuckDB:

Concurrency: 4 threads (target='dev')

1 of 11 OK created sql view model main.stg_categories .............. [OK in 0.21s]
...
6 of 11 OK created sql view model main.stg_products ................ [OK in 0.08s]
7 of 11 OK created sql table model main.dim_cliente ............... [OK in 0.13s]
8 of 11 OK created sql table model main.dim_producto ............. [OK in 0.10s]
9 of 11 OK created sql table model main.dim_fecha ................ [OK in 0.07s]
10 of 11 OK created sql table model main.fact_ventas ............. [OK in 0.04s]
11 of 11 OK created sql table model main.ingresos_por_departamento [OK in 0.07s]

Finished running 5 table models, 6 view models in 0.79 seconds.
Done. PASS=11 WARN=0 ERROR=0 SKIP=0 TOTAL=11

Observa el orden que eligió dbt: primero las 6 vistas de staging, después las 3 dimensiones, luego fact_ventas (que depende de ellas) y por último el informe. Nadie se lo dijo; lo dedujo del grafo de ref(). Y ejecuta en paralelo lo que puede (4 hilos), respetando siempre las dependencias.

No siempre queremos reconstruir todo. Con --select se ejecuta un subconjunto. El operador + a la derecha del nombre incluye el modelo y todo lo que depende de él (aguas abajo):

dbt run --select stg_products+
1 of 4 OK created sql view model main.stg_products ................ [OK in 0.10s]
2 of 4 OK created sql table model main.dim_producto ............. [OK in 0.10s]
3 of 4 OK created sql table model main.fact_ventas ............. [OK in 0.06s]
4 of 4 OK created sql table model main.ingresos_por_departamento [OK in 0.09s]
Done. PASS=4 ... TOTAL=4

Cambiar stg_products disparó la reconstrucción de dim_producto, del hecho y del informe, que dependen de él en cadena, y solo de esos. Para listar los modelos sin ejecutarlos está dbt ls:

iabd_retail.marts.dim_cliente
iabd_retail.marts.dim_fecha
iabd_retail.marts.dim_producto
iabd_retail.marts.fact_ventas
iabd_retail.marts.ingresos_por_departamento
iabd_retail.staging.stg_customers
...

Comprobar el resultado

Igual que en la sesión de modelado, toda carga de un hecho termina con un control de calidad. La misma consulta de entonces, ahora sobre el fact_ventas que ha construido dbt:

┌───────┬──────────────┬─────────────┬─────────┬───────────────┐
│ filas │ sin_producto │ sin_cliente │ pedidos │ importe_total │
├───────┼──────────────┼─────────────┼─────────┼───────────────┤
│   508 │            0 │           0 │     200 │      169034.4 │
└───────┴──────────────┴─────────────┴─────────┴───────────────┘

508 filas (el grano de línea coincide con order_items), ninguna redirigida al miembro desconocido y 200 pedidos distintos. La dimensión de producto conserva su miembro desconocido:

┌───────┬─────────────────────┐
│ filas │ miembro_desconocido │
├───────┼─────────────────────┤
│    55 │                   1 │
└───────┴─────────────────────┘

Y el informe final, ya consultable por cualquier herramienta de BI, responde a la pregunta con la que empezó todo:

┌──────────────┬─────────┬──────────┬──────────┐
│ departamento │ pedidos │ unidades │ ingresos │
├──────────────┼─────────┼──────────┼──────────┤
│ Apparel      │      47 │      172 │ 25618.28 │
│ Outdoors     │      38 │      148 │ 20268.52 │
│ Fan Shop     │      51 │      171 │ 19278.29 │
│ Golf         │      36 │      146 │ 12578.54 │
│ Fitness      │      44 │      178 │ 10828.22 │
│ Footwear     │      37 │      114 │  6348.86 │
└──────────────┴─────────┴──────────┴──────────┘

Documentación y linaje

dbt genera documentación del proyecto a partir del propio código y los ficheros YAML. Con dos comandos se construye y se sirve un sitio web navegable:

dbt docs generate
dbt docs serve
Building catalog
Catalog written to target/catalog.json

Ese sitio incluye el catálogo de modelos, sus columnas y descripciones, y —lo más útil— el grafo de linaje interactivo: de un vistazo se ve qué source alimenta a qué modelo y qué depende de cuál, incluida la resolución de fact_ventas contra sus dimensiones. Es la misma información que usa dbt para ordenar dbt run, pero dibujada.

Puesta en práctica de extremo a extremo

Recapitulando el hilo completo de las tres sesiones:

  1. dlt cargó retail_db desde MySQL a DuckDB (esquema retail_db) — la E y la L.
  2. En modelado dimensional diseñamos la estrella y la construimos a mano con CREATE TABLE AS.
  3. Ahora dbt industrializa esa misma estrella: sources para el raw, staging para la limpieza, y marts para dimensiones, hecho e informe, todo enlazado con ref().
  4. dbt run resolvió el orden por el grafo y lo ejecutó; dbt docs documentó el resultado.

Todo el "movimiento" de datos lo hizo el motor (DuckDB). dbt solo orquestó SELECT. Esa es la esencia del ELT moderno: cargar primero, transformar dentro del almacén con SQL versionado.

FAQ

¿dbt mueve o copia los datos entre sistemas?

No. dbt no tiene motor de datos propio: envía sentencias SQL al almacén (DuckDB, en nuestro caso) y es ese motor el que hace el trabajo. Por eso dbt cubre solo la T del ELT; la extracción y la carga las hizo dlt.

¿Cuándo uso source() y cuándo ref()?

source() para las tablas crudas que entraron desde fuera (las que declaraste en el YAML de sources). ref() para referirte a otro modelo de tu propio proyecto. Regla práctica: si el nombre lleva prefijo stg_, dim_ o fact_, es un modelo tuyo y va con ref().

¿Por qué staging como vista y marts como tabla?

Staging hace poco trabajo y conviene que siempre refleje el dato más reciente sin ocupar espacio: encaja con view. Los marts se consultan mucho y su cálculo es más costoso, así que interesa dejarlos persistidos y rápidos de leer: encaja con table, igual que los CREATE TABLE de la estrella. No es una regla rígida, es el punto de partida razonable.

¿Qué pasa si escribo mal el nombre dentro de un ref()?

dbt falla en la fase de compilación, antes de tocar la base de datos, con un error de tipo depends on a node named ... which was not found. Es una de las ventajas frente al SQL suelto: los errores de dependencia se detectan al compilar, no en producción.

¿Tengo que decirle a dbt en qué orden ejecutar los modelos?

No, y de hecho no se puede definir el orden a mano. dbt lo deriva del grafo de dependencias que construye con las llamadas a ref(). Por eso fact_ventas, que referencia a las dimensiones, siempre se ejecuta después de ellas sin que lo indiquemos.

Referencias

Actividades

Para las actividades reutilizamos el proyecto iabd_retail y la estrella que estamos industrializando. Cada modelo nuevo se materializa según su carpeta y se comprueba con dbt run.

  1. (RABDA.1 / CEBDA.1b / 1p) Sources y staging. Comprueba que las seis tablas de retail_db están declaradas como sources en _retail__sources.yml y que existe un modelo de staging por cada una. Añade a stg_order_items la columna precio_unitario (a partir de order_item_product_price) y ejecuta dbt run --select stg_order_items confirmando que se materializa como vista.

    Tip

    Recuerda que un modelo de staging no hace joins: una source por modelo.

  2. (RABDA.5 / CEBDA.5b / 2p) Limpieza en staging. En stg_customers, además de renombrar, normaliza el email a minúsculas y crea una columna nombre_completo. Justifica en un comentario por qué esta limpieza va en staging y no en la dimensión, y comprueba que dim_cliente sigue construyéndose sin tocar su SQL.

  3. (RASBD.1 / CESBD.1c CESBD.1d / 2p) La dimensión de cliente. Revisa dim_cliente y comprueba que reproduce la de la sesión de modelado: clave subrogada con row_number() y miembro desconocido. Ejecuta dbt run --select dim_cliente y valida con una consulta que hay exactamente una fila con cliente_sk = -1 y que el resto de claves subrogadas son únicas.

  4. (RABDA.1 / CEBDA.1d / 2p) Materializaciones. Cambia la materialización de ingresos_por_departamento de table a view mediante un bloque config() en el propio modelo (sobrescribiendo la herencia de la carpeta). Ejecuta dbt run --select ingresos_por_departamento, consulta el information_schema y verifica que el objeto pasó de BASE TABLE a VIEW. Vuelve a dejarlo como table y razona en qué caso preferirías cada opción para un mart de informe.

  5. (RABDA.1 / CEBDA.1e / 1p) Documentación y linaje. Genera la documentación con dbt docs generate y sírvela con dbt docs serve. Localiza fact_ventas en el grafo de linaje y anota, aguas arriba, todos los modelos y sources de los que depende hasta llegar a retail_db. Comprueba que aparecen las dependencias hacia dim_producto y dim_cliente.

  6. (RASBD.3 / CESBD.3a / 3p) Un nuevo informe sobre la estrella. Aprovechando dim_fecha, crea el mart ventas_mensuales que devuelva, por año y mes, el número de pedidos completados y los ingresos totales, uniendo fact_ventas con dim_fecha por fecha_id. Decide y justifica su materialización, y captura la salida real de dbt run y de una consulta al modelo.

    Tip

    Solo se cuentan pedidos en estado COMPLETE o CLOSED, igual que en ingresos_por_departamento. La ventaja de unir con dim_fecha es que el nombre del mes y el trimestre ya vienen calculados.