Cuando los sistemas permiten una carga de datos flexible y disponen de capacidad de computo, podemos hacernos valer de la propia sintaxis para que realice todas las transformaciones necesarias con mínimo movimiento de datos.
Data Build Tool o dbt se ha convertido en uno de los estándares a la hora de orquestar todas estas transformaciones. Y conviene entender qué es exactamente, porque su fama va acompañada de bastante confusión: dbt no es un motor de cálculo, no mueve datos y no tiene un planificador. Todo el trabajo lo hace la base de datos.
Lo que aporta dbt cabe en cuatro puntos:
Convierte cada SELECT en un modelo, un fichero .sql con nombre propio, y se encarga de envolverlo en el CREATE TABLE o CREATE VIEW que corresponda.
Deduce el orden de ejecución a partir de las dependencias entre modelos, con lo que nadie tiene que mantener a mano una lista de qué va antes que qué.
Permite plantillas con Jinja, de modo que lo que se repite se escribe una vez.
Añade pruebas y documentación al mismo repositorio que el código, con lo que dejan de ser un anexo que nadie actualiza.
16.1 Los conceptos
Cuatro nombres bastan para empezar:
Modelo: un fichero con un SELECT. dbt lo materializa como vista, tabla o carga incremental según se le indique.
ref(): la forma de referirse a otro modelo. Es la pieza clave, porque además de resolver el nombre real de la tabla es lo que dibuja el grafo de dependencias.
source(): la forma de referirse a una tabla que dbt no construye, como nuestra capa de aterrizaje. Marca la frontera del proyecto.
Test: una consulta que no debe devolver ninguna fila. Si devuelve alguna, hay un problema.
Esa cuarta definición merece detenerse un segundo, porque es de una simplicidad desarmante y explica por qué en dbt probar sale casi gratis. Un test de unicidad no es más que un GROUP BY ... HAVING count(*) > 1.
16.2 El proyecto de ejemplo
Vamos a construir el almacén de la secretaría académica sobre el lago que dejó la parte de ingesta. El proyecto vive en el repositorio del libro, en content/transform/academia/, y tiene la estructura habitual:
academia/
├── dbt_project.yml # configuración del proyecto
├── profiles.yml # cómo conectarse al lakehouse
├── macros/
│ ├── data_vault.sql # macros de claves hash y hashdiff
│ └── tests.sql # test genérico propio
└── models/
├── prep/ # preparación, vistas sobre staging
├── raw_vault/ # hubs, enlaces y satélites
├── business_vault/ # reglas de negocio
└── marts/ # capa de consumo
16.2.1 Conectar dbt a DuckLake
La conexión se declara en profiles.yml. El adaptador dbt-duckdb reconoce el esquema ducklake: y se encarga del resto:
Tres detalles que valen para cualquier proyecto real. La ruta llega por variable de entorno, para no fijar rutas absolutas ni credenciales en el repositorio. El catálogo es SQLite en lugar de un fichero DuckDB, porque así el cuaderno que consulta y el proceso de dbt que escribe pueden convivir. Y threads: 1 porque, con un catálogo local, el paralelismo solo trae problemas de bloqueo.
NotaUn catálogo, dos procesos
Merece la pena entender el motivo. Un fichero DuckDB solo admite un proceso escribiendo a la vez. Como dbt se ejecuta en su propio proceso, si el cuaderno mantuviera abierta una conexión al catálogo, dbt no podría abrirlo.
Se resuelve de dos formas y aquí usamos las dos: catálogo en SQLite, que tolera mejor la concurrencia, y no dejar nunca una conexión abierta mientras dbt trabaja. En un despliegue real el catálogo sería un PostgreSQL y el problema desaparece.
16.2.2 La capa de preparación
El primer modelo no transforma nada de negocio, solo prepara: calcula las claves que necesitará el vault y traduce los metadatos de carga al vocabulario de Data Vault. Reglas duras, todas.
-- models/prep/stg_alumnos.sqlselect {{ hash_key('id_alumno') }} as hk_alumno, {{ hashdiff(['nombre', 'apellido', 'email']) }} as hd_alumno, id_alumno, nombre, apellido, email, _cargado_en as load_date, _origen as record_sourcefrom {{ source('staging', 'alumnos') }}where id_alumno isnotnull
Ahí se ven las tres piezas a la vez: source() apuntando al aterrizaje, y dos macros propias, hash_key y hashdiff, que veremos en detalle en el capítulo siguiente. El source() se declara en un fichero YAML:
Preparamos el lago con lo que dejó la ingesta y lanzamos dbt.
import utilidadesutilidades.usar("dbt")utilidades.crear_lago()utilidades.consultar(""" select id_alumno, nombre, email, _cargado_en from staging.alumnos order by id_alumno""")
id_alumno
nombre
email
_cargado_en
0
1
Iraitz
iraitz@ejemplo.eus
2026-01-10 03:00:00
1
2
Javier
javier@ejemplo.eus
2026-01-10 03:00:00
2
3
Miguel
miguel@ejemplo.eus
2026-01-10 03:00:00
Con el aterrizaje en su sitio, dbt build construye los modelos y ejecuta las pruebas en el orden correcto:
1 of 27 OK created sql view model main_prep.stg_alumnos ........................ [OK in 0.13s]
2 of 27 OK created sql view model main_prep.stg_asignaturas .................... [OK in 0.07s]
3 of 27 OK created sql view model main_prep.stg_matriculas ..................... [OK in 0.07s]
4 of 27 OK created sql incremental model main_raw_vault.hub_alumno ............. [OK in 0.14s]
5 of 27 OK created sql incremental model main_raw_vault.sat_alumno ............. [OK in 0.08s]
6 of 27 OK created sql incremental model main_raw_vault.hub_asignatura ......... [OK in 0.09s]
7 of 27 OK created sql incremental model main_raw_vault.link_matricula ......... [OK in 0.08s]
23 of 27 OK created sql table model main_business_vault.pit_alumno ............. [OK in 0.11s]
24 of 27 OK created sql table model main_business_vault.sat_alumno_bv .......... [OK in 0.09s]
25 of 27 OK created sql table model main_marts.dim_asignatura .................. [OK in 0.10s]
26 of 27 OK created sql table model main_marts.fct_matriculas .................. [OK in 0.10s]
27 of 27 OK created sql table model main_marts.dim_alumno ...................... [OK in 0.10s]
Completed successfully
Done. PASS=27 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=27
Veintisiete nodos, que son los modelos más las pruebas de cada uno. Nadie ha indicado en qué orden ejecutarlos: dbt lo dedujo de los ref().
16.4 El grafo
Esa deducción es el corazón de la herramienta. Cada ref() es una arista, y el conjunto forma un grafo dirigido:
flowchart LR
src[("staging<br/>(source)")]
stg["stg_alumnos"]
hub["hub_alumno"]
sat["sat_alumno"]
bv["sat_alumno_bv"]
dim["dim_alumno"]
src --> stg
stg --> hub
stg --> sat
sat --> bv
hub --> dim
bv --> dim
%% Paleta por bloque del libro
classDef carga fill:#fae3c8,stroke:#bf7f28,stroke-width:1.5px,color:#573809
classDef transformacion fill:#d7eddc,stroke:#4a9463,stroke-width:1.5px,color:#1c4a2e
classDef explotacion fill:#e6ddf5,stroke:#7457bd,stroke-width:1.5px,color:#332757
class src carga
class stg,hub,sat,bv transformacion
class dim explotacion
De ahí salen dos cosas muy prácticas. La primera es que se puede ejecutar un trozo del grafo con --select, incluyendo lo que depende de un modelo:
dbt run --select sat_alumno+ # sat_alumno y todo lo que viene detrásdbt run --select +dim_alumno # dim_alumno y todo lo que necesita
La segunda es el análisis de impacto: antes de tocar un modelo se sabe exactamente qué se va a romper. Es la misma información que un catálogo de gobierno publica como linaje, y de hecho es de aquí de donde suele extraerla.
16.5 Materializaciones
Un mismo SELECT puede acabar en el almacén de varias formas, y se elige con una línea de configuración:
Materializaciones de dbt
Materialización
Qué hace
Cuándo
view
Crea una vista, no almacena datos
Capas de preparación, lógica ligera
table
Reconstruye la tabla entera cada vez
Capas de consumo, si el volumen lo permite
incremental
Añade solo lo nuevo a la tabla existente
Tablas grandes, y todo el raw vault
ephemeral
No se materializa, se inserta como CTE
Lógica intermedia que no interesa exponer
En nuestro proyecto se declaran por carpeta, en dbt_project.yml:
La elección no es caprichosa y anticipa la arquitectura entera. El raw vault es incremental con estrategia append porque en Data Vault nunca se actualiza ni se borra, solo se añade. El business vault y los marts son table porque contienen reglas blandas y queremos poder reconstruirlos enteros cuando el criterio cambie.
TipReconstruir es una función de la arquitectura
Que el business vault sea table significa que un dbt build --full-refresh lo rehace desde cero a partir del raw vault. Esa capacidad no es un detalle de implementación, es la razón de ser de la separación. La capa de aterrizaje versionada que construimos con DuckLake permite además rehacer el raw vault si hiciera falta.
16.6 Probar
Los tests se declaran junto a los modelos, en YAML, y los más comunes vienen de serie:
Ese último es la integridad referencial que en un sistema transaccional haría cumplir una clave foránea. En un almacén analítico no hay claves foráneas, así que se comprueba a posteriori.
Cuando hace falta algo que no viene de serie, se escribe. Un test genérico no es más que una macro que devuelve una consulta:
-- macros/tests.sql{% test unique_combination(model, columnas) %}select {{ columnas | join(', ') }},count(*) as repeticionesfrom {{ model }}groupby {{ columnas | join(', ') }}havingcount(*) >1{% endtest %}
Y se usa así, que es la comprobación que de verdad importa en un satélite:
1 of 15 PASS not_null_hub_alumno_hk_alumno ..................................... [PASS in 0.06s]
2 of 15 PASS not_null_hub_alumno_id_alumno ..................................... [PASS in 0.02s]
3 of 15 PASS not_null_hub_asignatura_hk_asignatura ............................. [PASS in 0.02s]
4 of 15 PASS not_null_link_matricula_hk_alumno ................................. [PASS in 0.02s]
5 of 15 PASS not_null_link_matricula_hk_matricula .............................. [PASS in 0.02s]
6 of 15 PASS not_null_sat_alumno_hd_alumno ..................................... [PASS in 0.02s]
7 of 15 PASS not_null_sat_alumno_hk_alumno ..................................... [PASS in 0.02s]
8 of 15 PASS relationships_link_matricula_hk_alumno__hk_alumno__ref_hub_alumno_ [PASS in 0.08s]
9 of 15 PASS relationships_link_matricula_hk_asignatura__hk_asignatura__ref_hub_asignatura_ [PASS in 0.06s]
10 of 15 PASS relationships_sat_alumno_hk_alumno__hk_alumno__ref_hub_alumno_ ... [PASS in 0.06s]
11 of 15 PASS unique_combination_sat_alumno_hk_alumno__load_date ............... [PASS in 0.03s]
12 of 15 PASS unique_hub_alumno_hk_alumno ...................................... [PASS in 0.03s]
13 of 15 PASS unique_hub_alumno_id_alumno ...................................... [PASS in 0.02s]
14 of 15 PASS unique_hub_asignatura_hk_asignatura .............................. [PASS in 0.03s]
15 of 15 PASS unique_link_matricula_hk_matricula ............................... [PASS in 0.03s]
Completed successfully
Done. PASS=15 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=15
NotaPor qué no usamos dbt_utils
Existe un paquete, dbt_utils, con un test equivalente al que acabamos de escribir y muchas otras utilidades. En un proyecto real se usa sin dudarlo.
Aquí lo hemos escrito a mano por dos razones: instalar paquetes requiere dbt deps, que descarga de internet y haría que compilar el libro dependiera de la red; y porque ver el test por dentro deja claro que no hay magia, solo una consulta que no debe devolver filas.
16.7 Los paquetes de Data Vault
Ya que hablamos de paquetes, conviene mencionar los dos que automatizan buena parte de lo que vamos a construir a mano en los próximos capítulos:
Ambos ofrecen macros que generan un hub, un enlace o un satélite completo a partir de unos pocos parámetros, y en un proyecto de tamaño empresarial ahorran una cantidad enorme de código repetitivo. Con AutomateDV, el hub que escribiremos en el capítulo siguiente se reduce a esto:
AdvertenciaNinguno de los dos soporta DuckDB oficialmente
AutomateDV declara compatibilidad con Snowflake, BigQuery, Databricks, Postgres y SQL Server. datavault4dbt cubre una lista parecida. DuckDB no está en ninguna de las dos, y por tanto tampoco DuckLake. Existe un fork no oficial de datavault4dbt que lo soporta, sin planes de integrarse en el paquete oficial.
Es la razón por la que en este libro construimos el vault a mano. No es una decisión estética: es lo que funciona hoy sobre este stack. Y tiene una ventaja pedagógica nada menor, que es entender qué genera el macro antes de dejar que lo genere.
Con la herramienta en su sitio, toca darle forma al almacén. Empezamos por el raw vault.