16  dbt sobre el lakehouse

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:

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:

academia:
  target: dev
  outputs:
    dev:
      type: duckdb
      path: "ducklake:sqlite:{{ env_var('LAGO_CATALOGO') }}"
      extensions:
        - ducklake
      threads: 1

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.

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.sql

select
    {{ 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_source
from {{ source('staging', 'alumnos') }}
where id_alumno is not null

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:

# models/prep/_sources.yml
version: 2

sources:
  - name: staging
    schema: staging
    tables:
      - name: alumnos
      - name: asignaturas
      - name: cursa

16.3 Ejecutarlo

Preparamos el lago con lo que dejó la ingesta y lanzamos dbt.

import utilidades

utilidades.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:

salida = utilidades.dbt("build")
print(utilidades.resumen(salida))
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ás
dbt 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:

models:
  academia:
    prep:
      +materialized: view
    raw_vault:
      +materialized: incremental
      +incremental_strategy: append
    business_vault:
      +materialized: table
    marts:
      +materialized: table

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:

# models/raw_vault/_raw_vault.yml
models:
  - name: hub_alumno
    columns:
      - name: hk_alumno
        data_tests:
          - unique
          - not_null

  - name: link_matricula
    columns:
      - name: hk_alumno
        data_tests:
          - relationships:
              arguments:
                to: ref('hub_alumno')
                field: hk_alumno

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 repeticiones
from {{ model }}
group by {{ columnas | join(', ') }}
having count(*) > 1

{% endtest %}

Y se usa así, que es la comprobación que de verdad importa en un satélite:

  - name: sat_alumno
    data_tests:
      - unique_combination:
          arguments:
            columnas: ["hk_alumno", "load_date"]

Veámoslos correr:

print(utilidades.resumen(utilidades.dbt("test"), utilidades.TESTS))
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

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:

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

{%- set source_model = "stg_alumnos" -%}
{%- set src_pk = "hk_alumno" -%}
{%- set src_nk = "id_alumno" -%}
{%- set src_ldts = "load_date" -%}
{%- set src_source = "record_source" -%}

{{ automate_dv.hub(src_pk=src_pk, src_nk=src_nk, src_ldts=src_ldts,
                   src_source=src_source, source_model=source_model) }}
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.