Módulo 6: Incremental Models And Idempotency
La macro `is_incremental()`
Descripción
is_incremental() es la pieza que hace posible que un único archivo .sql se comporte de dos formas distintas, según el momento en que lo corras: la primera vez que un modelo incremental se construye —cuando su tabla de destino todavía no existe—, compila a un SELECT completo, sin ningún filtro; cualquier corrida posterior —cuando la tabla ya existe—, compila a una versión filtrada del mismo SELECT. No es magia ni un comando separado: es una función de Jinja que dbt evalúa en tiempo de compilación, consultando el estado real del warehouse antes de generar el SQL final. Esta lección explica exactamente qué condiciones evalúa, con evidencia real —el SQL compilado, dos veces, en dos estados distintos del proyecto—.
Conexión con el módulo. La lección 2 dejó claro por qué haría falta un mecanismo así —el costo de reconstruir todo, siempre—. Esta lección presenta el mecanismo en sí, todavía sin aplicarlo de forma permanente a fact_orders (eso empieza en la lección 5): vas a experimentar con is_incremental() sobre una copia de trabajo, para entender su comportamiento antes de comprometerte a cambiar el modelo real del proyecto.
Qué evalúa is_incremental(), exactamente
La documentación oficial de dbt es precisa sobre esto: is_incremental() devuelve True únicamente cuando se cumplen las cuatro condiciones siguientes, todas a la vez:
- La relación de destino ya existe en el warehouse (la tabla
fact_orders, en este caso, ya fue creada por una corrida anterior). - Esa relación es una tabla real (no, por ejemplo, una vista dejada por un cambio de materialización previo sin limpiar).
- La configuración del modelo dice
materialized='incremental'— si el modelo fueratableoview,is_incremental()sería siempreFalse, sin importar qué más exista en el warehouse. - La corrida no incluye la bandera
--full-refresh— esa bandera, como vas a ver en la lección 7, fuerza a dbt a tratar la corrida como si la tabla no existiera, sin importar que sí exista.
Si cualquiera de las cuatro no se cumple, is_incremental() devuelve False, y el bloque {% if is_incremental() %} ... {% endif %} de tu modelo simplemente no se incluye en el SQL compilado — como si esas líneas no existieran.
Una analogía: el guardia que solo pide identificación la segunda vez
Piensa en un evento con entrada libre para el primer ingreso, pero que exige identificación para cualquier reingreso posterior. La primera vez que alguien llega, el guardia no pide nada —lo deja pasar directo, porque no hay ningún registro previo contra el cual comparar—. Pero si esa misma persona sale y vuelve a entrar, el guardia sí pide identificación esta vez, porque ahora existe un registro anterior con el cual contrastar: ¿ya estaba adentro? ¿es la misma persona que salió hace un rato? is_incremental() es exactamente esa pregunta que el guardia se hace antes de decidir su comportamiento: "¿ya existe un registro de esto?" — la respuesta cambia el procedimiento completo, sin que el evento en sí (el modelo .sql) tenga que ser dos archivos distintos.
Ejemplo trabajado: el mismo archivo, dos SQL compilados distintos
Trabaja sobre una copia temporal de fact_orders.sql para experimentar sin comprometer el modelo real del proyecto todavía (la lección 5 hace el cambio permanente). Agrega, al final del archivo, un bloque condicional con la macro:
-- models/marts/fact_orders.sql (version de experimentacion de esta leccion)
select
o.order_id,
o.store_id,
o.product_id,
o.quantity,
o.unit_price,
o.quantity * o.unit_price as revenue,
o.order_ts
from {{ ref('stg_orders') }} as o
inner join {{ ref('dim_store') }} as ds
on o.store_id = ds.store_id
inner join {{ ref('dim_date') }} as dd
on cast(o.order_ts as date) = dd.calendar_date
{% if is_incremental() %}
where cast(o.order_ts as date) = cast('{{ var("run_date") }}' as date)
{% endif %}
Con esto solo, sin agregar {{ config(materialized='incremental') }} todavía, compílalo:
dbt compile --select fact_orders
cat target/compiled/kiosko_analytics/models/marts/fact_orders.sql
Qué esperar.
select
o.order_id,
o.store_id,
o.product_id,
o.quantity,
o.unit_price,
o.quantity * o.unit_price as revenue,
o.order_ts
from "kiosko"."main"."stg_orders" as o
inner join "kiosko"."main"."dim_store" as ds
on o.store_id = ds.store_id
inner join "kiosko"."main"."dim_date" as dd
on cast(o.order_ts as date) = dd.calendar_date
Sin ningún WHERE. Esto confirma la condición 3 de la lista de arriba: aunque el bloque {% if is_incremental() %} ya está escrito en el archivo, mientras fact_orders siga configurado como materialized='table' (heredado de dbt_project.yml), is_incremental() es siempre False — el bloque nunca se activa, sin importar qué exista en el warehouse.
Ejemplo trabajado (continuación): activando la condición 3
Ahora agrega el bloque de configuración que falta, con la estrategia y la clave que vas a usar de forma permanente desde la lección 5 (por ahora, sigue siendo solo un experimento):
-- models/marts/fact_orders.sql (con materialized='incremental' agregado)
{{
config(
materialized='incremental',
incremental_strategy='delete+insert',
unique_key='order_id'
)
}}
select
o.order_id,
o.store_id,
o.product_id,
o.quantity,
o.unit_price,
o.quantity * o.unit_price as revenue,
o.order_ts
from {{ ref('stg_orders') }} as o
inner join {{ ref('dim_store') }} as ds
on o.store_id = ds.store_id
inner join {{ ref('dim_date') }} as dd
on cast(o.order_ts as date) = dd.calendar_date
{% if is_incremental() %}
where cast(o.order_ts as date) = cast('{{ var("run_date") }}' as date)
{% endif %}
Compílalo de nuevo, sin haber corrido dbt run todavía —la tabla fact_orders sigue existiendo en el warehouse, con 40 filas, desde el módulo 5—:
dbt compile --select fact_orders --vars '{"run_date": "2026-08-03"}'
cat target/compiled/kiosko_analytics/models/marts/fact_orders.sql
Qué esperar.
select
o.order_id,
o.store_id,
o.product_id,
o.quantity,
o.unit_price,
o.quantity * o.unit_price as revenue,
o.order_ts
from "kiosko"."main"."stg_orders" as o
inner join "kiosko"."main"."dim_store" as ds
on o.store_id = ds.store_id
inner join "kiosko"."main"."dim_date" as dd
on cast(o.order_ts as date) = dd.calendar_date
where cast(o.order_ts as date) = cast('2026-08-03' as date)
Ahora sí aparece el WHERE. Nada cambió en el archivo .sql entre este compilado y el anterior salvo el bloque config(...) — pero como fact_orders ya existía como tabla en el warehouse (condición 1), es una tabla real (condición 2), y ahora está configurado como materialized='incremental' (condición 3), y no pasaste --full-refresh (condición 4), las cuatro condiciones se cumplieron a la vez, y is_incremental() devolvió True.
Corriendo el modelo: de compilar a ejecutar
Compilar solo te muestra el SQL que se generaría; correrlo confirma qué hace dbt-duckdb con ese SQL de verdad:
dbt run --select fact_orders --vars '{"run_date": "2026-08-03"}'
Qué esperar.
Running with dbt=1.12.2
Registered adapter: duckdb=1.11.0
Found 7 models, 15 data tests, 1 snapshot, 4 sources, 501 macros
Concurrency: 4 threads (target='dev')
1 of 1 START sql incremental model main.fact_orders ............................ [RUN]
1 of 1 OK created sql incremental model main.fact_orders ....................... [OK in 0.11s]
Finished running 1 incremental model in 0 hours 0 minutes and 0.19 seconds (0.19s).
Completed successfully
Done. PASS=1 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=1
Fíjate en sql incremental model — un tercer tipo de modelo en el log de dbt, distinto de sql view model y sql table model que ya conocías. Inspecciona el SQL que dbt ejecutó de verdad contra DuckDB:
cat target/run/kiosko_analytics/models/marts/fact_orders.sql
Qué esperar (el sufijo numérico de la tabla temporal es una marca de tiempo — va a ser distinto en tu máquina, sin que eso cambie el comportamiento).
delete from "kiosko"."main"."fact_orders"
where (
order_id) in (
select (order_id)
from "fact_orders__dbt_tmp20260812192626453363"
);
insert into "kiosko"."main"."fact_orders" ("order_id", "store_id", "product_id", "quantity", "unit_price", "revenue", "order_ts")
(
select "order_id", "store_id", "product_id", "quantity", "unit_price", "revenue", "order_ts"
from "fact_orders__dbt_tmp20260812192626453363"
)
Dos sentencias, no una: un DELETE que borra, de la tabla real fact_orders, cualquier fila cuyo order_id coincida con lo que trajo la consulta filtrada por run_date (materializada primero en una tabla temporal, fact_orders__dbt_tmp...), seguido de un INSERT que agrega esas mismas filas frescas. Esto es exactamente la estrategia delete+insert que vas a elegir formalmente en la lección 4 — y es, con precisión, el mismo patrón overwrite-partition que ya conociste en la lección 2: borrar la partición antes de insertarla de nuevo, nunca solo agregar.
Confirma que el conteo total no cambió —fact_orders sigue con sus 40 filas de siempre, porque el run_date que usaste (2026-08-03) ya estaba representado en la tabla desde antes—:
dbt show --inline "select count(*) as n_rows, sum(revenue) as total_revenue from {{ ref('fact_orders') }}"
Qué esperar.
Previewing inline node:
| n_rows | total_revenue |
| ------ | -------------- |
| 40 | 106.15 |
Diagrama: las cuatro condiciones, una a una
flowchart TD
A["is_incremental()"] --> B{"Existe la relacion\nde destino?"}
B -- No --> F["False -> SELECT completo,\nsin WHERE"]
B -- Si --> C{"Es una tabla real?"}
C -- No --> F
C -- Si --> D{"materialized='incremental'\nen la config?"}
D -- No --> F
D -- Si --> E{"Se paso --full-refresh?"}
E -- Si --> F
E -- No --> G["True -> se activa el bloque\n{% if is_incremental() %}"]
Profundización: por qué is_incremental() es una función, no una variable
Vale la pena notar algo sobre la sintaxis: is_incremental() se escribe con paréntesis, como una llamada a función —{% if is_incremental() %}—, no como una variable simple ({% if is_incremental %}, sin paréntesis, fallaría). Eso es intencional: cada vez que dbt compila un modelo, necesita volver a evaluar el estado real del warehouse en ese momento exacto —no puede cachear la respuesta de una corrida a la siguiente, porque el estado de la tabla de destino puede haber cambiado (por ejemplo, si alguien la borró a mano, o si es la primera vez que el proyecto se clona en una máquina nueva). Una función que se ejecuta en cada compilación, en vez de un valor fijo, es la única forma correcta de modelar una pregunta cuya respuesta depende del momento en que se hace.
Errores comunes
Escribir is_incremental sin paréntesis. Qué pasa: alguien, acostumbrado a variables de Jinja como {{ target.name }}, escribe {% if is_incremental %} sin los paréntesis de función. Por qué pasa: Jinja no distingue visualmente, a primera vista, entre una variable y una función sin argumentos — ambas se ven como una sola palabra. Cómo detectarlo: dbt falla al compilar con un error indicando que is_incremental no es un valor booleano válido, o el bloque simplemente nunca se activa (según la versión exacta del error de Jinja). Cómo corregirlo: is_incremental() es siempre una llamada a función, con paréntesis vacíos — cópiala exactamente como aparece en el ejemplo trabajado de esta lección.
Correr dbt compile después de borrar kiosko.duckdb, y sorprenderse de que no aparece ningún WHERE. Qué pasa: alguien borra la base de datos para "empezar de cero", corre dbt compile --select fact_orders, y ve el SELECT sin filtro — igual que antes de agregar materialized='incremental' — y piensa que el cambio de configuración no funcionó. Por qué pasa: es fácil olvidar que is_incremental() depende del estado del warehouse, no solo de la configuración del archivo. Cómo detectarlo: revisa si kiosko.duckdb existe y si la tabla fact_orders está en él —si la base de datos se borró, la condición 1 (la relación de destino ya existe) es falsa, así que is_incremental() devuelve False sin importar la configuración del modelo. Cómo corregirlo: esto no es un error, es el comportamiento correcto — la primera vez que un modelo incremental se construye (tabla de destino inexistente), siempre compila completo, sin filtro; recién la segunda corrida en adelante, con la tabla ya creada, activa el bloque condicional.
Pensar que is_incremental() "sabe" qué filas son nuevas, automáticamente. Qué pasa: alguien escribe {% if is_incremental() %} where order_id not in (select order_id from {{ this }}) {% endif %} o algo similar, esperando que dbt detecte automáticamente qué órdenes ya están en la tabla. Por qué pasa: el nombre de la macro —"es incremental"— suena a que trae consigo alguna lógica de detección de filas nuevas. Cómo detectarlo: is_incremental() solo responde una pregunta booleana —¿debería activarse el modo incremental?—, no filtra nada por sí sola; el filtro real (WHERE cast(o.order_ts as date) = cast('...' as date), en este módulo) lo escribes tú, dentro del bloque condicional. Cómo corregirlo: piensa en is_incremental() como un interruptor, no como un filtro — enciende o apaga un bloque de código que tú mismo escribes; la lógica de "qué es nuevo" siempre la defines explícitamente, típicamente comparando contra una variable como run_date (este módulo) o, en proyectos reales, contra una marca de tiempo de la última corrida.
Ejercicios
Ejercicio 1 — Predice el resultado sin ejecutar nada. Sin correr ningún comando, responde: si borraras kiosko.duckdb por completo y corrieras dbt compile --select fact_orders --vars '{"run_date": "2026-08-03"}' sobre el modelo con materialized='incremental' ya configurado, ¿el SQL compilado tendría el WHERE o no? Justifica con las cuatro condiciones de esta lección.
Ver solución
No tendría el WHERE. Aunque las condiciones 3 (materialized='incremental' en la config) y 4 (sin --full-refresh) se cumplen, la condición 1 —que la relación de destino ya exista— sería falsa: sin kiosko.duckdb, no hay ninguna tabla fact_orders previa contra la cual comparar. Con cualquiera de las cuatro condiciones sin cumplirse, is_incremental() devuelve False, y el bloque completo se omite del SQL compilado — el resultado sería idéntico al primer compilado de esta lección, sin filtro, procesando las 40 filas completas.
Ejercicio 2 — Verifica la condición 3 directamente. Cambia temporalmente materialized='incremental' a materialized='table' en fact_orders.sql (sin tocar el resto del archivo, incluido el bloque {% if is_incremental() %}), y corre dbt compile --select fact_orders --vars '{"run_date": "2026-08-03"}'. ¿El WHERE aparece?
Ver solución
No aparece, aunque la tabla fact_orders sí exista en el warehouse (condición 1 cumplida) y no hayas pasado --full-refresh (condición 4 cumplida). La condición 3 —materialized='incremental' en la configuración— es una condición necesaria, no opcional: sin ella, is_incremental() devuelve False sin importar el resto del estado del proyecto, exactamente el comportamiento que ya viste en el primer ejemplo trabajado de esta lección, antes de agregar el bloque config(...). Vuelve a materialized='incremental' antes de continuar con el resto del módulo.
Ejercicio 3 — Explica, con tus propias palabras, la analogía del guardia aplicada a fact_orders. En 2-3 frases, usando la analogía de esta lección, explica qué pregunta concreta responde is_incremental() en el contexto de fact_orders, y por qué esa pregunta necesita reevaluarse en cada corrida en vez de responderse una sola vez.
Ver solución
is_incremental() responde, cada vez que se compila fact_orders.sql, la misma pregunta que el guardia del ejemplo: "¿ya existe un registro previo de esto?" — en este caso, "¿la tabla fact_orders ya fue construida antes?". Esa pregunta no se puede responder una sola vez y guardar la respuesta, porque el estado del warehouse puede cambiar entre una corrida y la siguiente —alguien podría borrar la tabla, clonar el proyecto en una máquina nueva sin ninguna base de datos todavía, o pasar --full-refresh para forzar una reconstrucción completa—; por eso is_incremental() es una función que se evalúa de nuevo en cada compilación, consultando el estado real en ese momento exacto, en vez de una constante fija en el archivo.
Resumen y siguiente paso
Esta lección diseccionó is_incremental(): las cuatro condiciones que evalúa (relación existente, es tabla, materialized='incremental', sin --full-refresh), y la evidencia real de que un mismo archivo .sql compila a dos SQL distintos según el estado del warehouse — sin filtro la primera vez, con un WHERE sobre run_date cualquier corrida posterior. Viste, con el SQL ejecutado real, que dbt-duckdb traduce esto a un DELETE seguido de un INSERT — el mismo patrón overwrite-partition de la lección 2, ahora generado automáticamente por dbt a partir de configuración declarativa.
Antes de avanzar deberías poder: nombrar las cuatro condiciones de is_incremental() de memoria; y explicar por qué se escribe con paréntesis, como una función, y no como una variable simple.
La lección 4 da el siguiente paso: con el mecanismo ya entendido, toca decidir qué hacer dentro del bloque {% if is_incremental() %} — la elección entre append, delete+insert y merge, con un experimento real que muestra por qué no cualquier estrategia sirve para el caso de fact_orders.
Recursos
- dbt Developer Hub — "About incremental models", sección "How do incremental models work?", la referencia oficial de las cuatro condiciones que
is_incremental()evalúa. docs.getdbt.com/docs/build/incremental-models. En inglés. - dbt Developer Hub — "
is_incremental", la referencia específica de la función Jinja usada en esta lección. docs.getdbt.com/reference/dbt-jinja-functions/is_incremental. En inglés. - dbt Developer Hub — "
this", la variable Jinja que representa la relación de destino del modelo actual — mencionada en el ejercicio de esta lección como una forma alternativa (no usada aquí) de referenciar la tabla que se está construyendo. docs.getdbt.com/reference/dbt-jinja-functions/this. En inglés.