Módulo 5: Point In Time Joins And Deduplication
Dimensiones de llegada tardía
Descripción
La lección 3 asumió algo que vale la pena hacer explícito: que dim_product_scd ya tenía, en el momento del JOIN, todas las versiones que un reporte histórico podría necesitar. Esa suposición es correcta para las cuarenta órdenes de la semana de Kiosko —todas anteriores al cambio del 15 de agosto—, pero deja de serlo en un caso concreto y real: cuando una venta ocurre después de un cambio de precio o categoría, pero el proceso que historiza ese cambio en dim_product_scd —el MERGE INTO del módulo 4— todavía no ha corrido. Esta lección construye ese escenario con dos órdenes fijas de Kiosko, fechadas después del 15 de agosto, y muestra qué le pasa al join punto-en-el-tiempo cuando la dimensión va más lenta que los hechos que describe.
Conexión con el módulo. Kimball nombra esta situación con precisión —"late arriving dimension"— y describe dos variantes: la principal (un hecho llega referenciando una entidad que todavía no existe en la dimensión) y una secundaria, menos citada pero igual de relevante aquí, que aplica directamente a una dimensión SCD-2: un cambio retroactivo requiere insertar una fila nueva y volver a procesar los hechos que ya se unieron contra la versión equivocada mientras el cambio no estaba historizado. Esta lección construye esa segunda variante con datos reales de Kiosko.
Una analogía: el correo que llega antes que el aviso de mudanza
Imagina que alguien te envía una carta certificada a tu dirección anterior, el mismo día que te mudaste — antes de que la oficina de correos haya procesado tu aviso de cambio de dirección. La carta existe, el evento (el envío) ya ocurrió, pero el sistema que debería redirigirla correctamente todavía no tiene la información actualizada. Dos cosas pueden pasar: la carta llega a la dirección vieja (silenciosamente mal, porque el sistema "cree" que esa sigue siendo tu dirección vigente), o el sistema espera a que el aviso de mudanza se procese antes de intentar entregarla (correcto, pero requiere que alguien la retenga y la reintente).
Eso es, exactamente, lo que le pasa a una venta de P002 fechada el 16 de agosto si el MERGE que historiza el cambio de precio del 15 de agosto todavía no corrió: el join punto-en-el-tiempo, ejecutado en ese momento, encuentra la versión vieja de dim_product_scd —la única que existe todavía— y la usa, sin ningún error, porque para el motor de base de datos esa sigue siendo, en ese instante, la única versión "vigente y sin fecha de cierre". El problema no es la lógica del JOIN —sigue siendo la correcta—; es que uno de sus dos insumos, la dimensión, todavía no refleja la realidad.
Ejemplo trabajado: dos órdenes fechadas después del cambio de P002
Estas dos órdenes no forman parte de las cuarenta de la semana canónica de Kiosko (3 al 9 de agosto) — son un lote de demostración, declarado explícitamente para esta lección, fechado después del cambio real de P002 (15 de agosto de 2026):
# late_orders_demo.py
import duckdb
from dim_product_scd import DIM_PRODUCT_SCD_ROWS
con = duckdb.connect()
con.execute("""
CREATE TABLE dim_product_scd (
product_key INTEGER PRIMARY KEY,
product_id VARCHAR NOT NULL,
product_name VARCHAR,
category VARCHAR,
unit_cost DOUBLE,
valid_from DATE NOT NULL,
valid_to DATE,
is_current BOOLEAN NOT NULL DEFAULT true
)
""")
con.executemany("INSERT INTO dim_product_scd VALUES (?, ?, ?, ?, ?, ?, ?, ?)", DIM_PRODUCT_SCD_ROWS)
con.execute("""
CREATE TABLE late_orders (
order_id VARCHAR, store_id VARCHAR, product_id VARCHAR,
quantity INTEGER, unit_price DOUBLE, revenue DOUBLE, order_ts TIMESTAMP
)
""")
con.executemany(
"INSERT INTO late_orders VALUES (?, ?, ?, ?, ?, ?, ?)",
[
("ORD-8001", "S01", "P002", 2, 1.20, 2.40, "2026-08-16T09:00:00"),
("ORD-8002", "S03", "P002", 1, 1.20, 1.20, "2026-08-20T10:30:00"),
],
)
print(con.sql("SELECT * FROM late_orders ORDER BY order_ts"))
┌──────────┬──────────┬────────────┬──────────┬────────────┬─────────┬─────────────────────┐
│ order_id │ store_id │ product_id │ quantity │ unit_price │ revenue │ order_ts │
│ varchar │ varchar │ varchar │ int32 │ double │ double │ timestamp │
├──────────┼──────────┼────────────┼──────────┼────────────┼─────────┼─────────────────────┤
│ ORD-8001 │ S01 │ P002 │ 2 │ 1.2 │ 2.4 │ 2026-08-16 09:00:00 │
│ ORD-8002 │ S03 │ P002 │ 1 │ 1.2 │ 1.2 │ 2026-08-20 10:30:00 │
└──────────┴──────────┴────────────┴──────────┴────────────┴─────────┴─────────────────────┘
Primero, el caso feliz: unirlas contra dim_product_scd tal como el módulo 4 la dejó —completa, con el MERGE del 15 de agosto ya aplicado—.
print("\n=== Ordenes tardias contra dim_product_scd COMPLETA (el MERGE ya corrio) ===")
print(con.sql("""
SELECT o.order_id, o.order_ts, d.product_key, d.category, d.unit_cost
FROM late_orders o
JOIN dim_product_scd d
ON o.product_id = d.product_id
AND o.order_ts BETWEEN d.valid_from AND COALESCE(d.valid_to, DATE '9999-12-31')
ORDER BY o.order_ts
"""))
=== Ordenes tardias contra dim_product_scd COMPLETA (el MERGE ya corrio) ===
┌──────────┬─────────────────────┬─────────────┬───────────────┬───────────┐
│ order_id │ order_ts │ product_key │ category │ unit_cost │
│ varchar │ timestamp │ int32 │ varchar │ double │
├──────────┼─────────────────────┼─────────────┼───────────────┼───────────┤
│ ORD-8001 │ 2026-08-16 09:00:00 │ 5 │ health-snacks │ 0.68 │
│ ORD-8002 │ 2026-08-20 10:30:00 │ 5 │ health-snacks │ 0.68 │
└──────────┴─────────────────────┴─────────────┴───────────────┴───────────┘
Correcto: las dos órdenes, fechadas después del 15 de agosto, caen en product_key = 5 — la versión health-snacks/0.68, la que realmente regía en esas fechas. El join punto-en-el-tiempo funciona exactamente como se espera cuando la dimensión ya tiene la versión que el hecho necesita.
Ahora, el escenario del retraso: dim_product_scd_delayed, una instantánea de la dimensión como si el MERGE del 15 de agosto todavía no hubiera corrido — P002 sigue con una sola versión, snacks/0.60, valid_to = NULL (todavía no cerrada, porque nadie la cerró):
print("\n=== Escenario del retraso: el MERGE del 15-ago TODAVIA no corrio ===")
con.execute("""
CREATE TABLE dim_product_scd_delayed (
product_key INTEGER PRIMARY KEY,
product_id VARCHAR NOT NULL,
product_name VARCHAR,
category VARCHAR,
unit_cost DOUBLE,
valid_from DATE NOT NULL,
valid_to DATE,
is_current BOOLEAN NOT NULL DEFAULT true
)
""")
con.executemany(
"INSERT INTO dim_product_scd_delayed VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[
(1, "P001", "Bottled Water 600ml", "beverages", 0.40, "2026-08-01", None, True),
(2, "P002", "Energy Bar", "snacks", 0.60, "2026-08-01", None, True), # todavia sin cerrar
(3, "P003", "Instant Coffee Sachet", "beverages", 0.35, "2026-08-01", None, True),
(4, "P004", "Phone Charger Cable", "electronics", 2.10, "2026-08-01", None, True),
],
)
print("\n=== Mismo JOIN punto-en-el-tiempo, contra la dimension RETRASADA ===")
print(con.sql("""
SELECT o.order_id, o.order_ts, d.product_key, d.category, d.unit_cost
FROM late_orders o
JOIN dim_product_scd_delayed d
ON o.product_id = d.product_id
AND o.order_ts BETWEEN d.valid_from AND COALESCE(d.valid_to, DATE '9999-12-31')
ORDER BY o.order_ts
"""))
=== Mismo JOIN punto-en-el-tiempo, contra la dimension RETRASADA ===
┌──────────┬─────────────────────┬─────────────┬──────────┬───────────┐
│ order_id │ order_ts │ product_key │ category │ unit_cost │
│ varchar │ timestamp │ int32 │ varchar │ double │
├──────────┼─────────────────────┼─────────────┼──────────┼───────────┤
│ ORD-8001 │ 2026-08-16 09:00:00 │ 2 │ snacks │ 0.6 │
│ ORD-8002 │ 2026-08-20 10:30:00 │ 2 │ snacks │ 0.6 │
└──────────┴─────────────────────┴─────────────┴──────────┴───────────┘
La consulta SQL es idéntica, palabra por palabra, a la que dio el resultado correcto un párrafo antes. La única diferencia es cuál instantánea de la dimensión estaba disponible en el momento de correrla. Contra la dimensión retrasada, las dos órdenes caen en product_key = 2 —snacks/0.60, la versión vieja—, porque para esa versión de dim_product_scd_delayed, valid_to sigue siendo NULL: sin que nadie haya cerrado esa fila todavía, COALESCE(d.valid_to, '9999-12-31') la trata como "vigente hasta el infinito", y cualquier fecha —incluyendo el 16 y el 20 de agosto— cae dentro de ese rango sin ambigüedad.
Diagrama: el mismo JOIN, dos resultados, según qué tan al día está la dimensión
flowchart TD
A["ORD-8001, ORD-8002\norder_ts: 16 y 20 de agosto"] --> B{"Mismo JOIN punto-en-el-tiempo\nBETWEEN valid_from AND COALESCE(valid_to, 9999-12-31)"}
B --> C["dim_product_scd COMPLETA\n(el MERGE del 15-ago ya corrio)\nP002 tiene 2 versiones"]
B --> D["dim_product_scd_delayed\n(el MERGE del 15-ago NO corrio)\nP002 sigue con 1 version, valid_to=NULL"]
C --> E["MATCH correcto:\nhealth-snacks / 0.68"]
D --> F["MATCH silenciosamente incorrecto:\nsnacks / 0.60\n(la unica version que existe todavia)"]
Nota algo importante en este diagrama: ninguna de las dos ramas produce un error o una fila NULL. Ambas encuentran un MATCH válido, con el mismo tipo de dato y la misma forma de resultado. Verifica esto directamente:
print("\n=== Ninguna fila se pierde -- el bug no truena, solo miente ===")
print(con.sql("""
SELECT COUNT(*) AS matched_rows
FROM late_orders o
LEFT JOIN dim_product_scd_delayed d
ON o.product_id = d.product_id
AND o.order_ts BETWEEN d.valid_from AND COALESCE(d.valid_to, DATE '9999-12-31')
WHERE d.product_key IS NOT NULL
"""))
=== Ninguna fila se pierde -- el bug no truena, solo miente ===
┌──────────────┐
│ matched_rows │
│ int64 │
├──────────────┤
│ 2 │
└──────────────┘
Las dos filas encuentran MATCH. No hay ningún LEFT JOIN con resultado NULL que delate el problema —eso es exactamente lo que la lección 7 de este módulo va a usar para encontrar filas genuinamente huérfanas, un problema distinto al de esta lección—. Aquí el problema es más sutil: la consulta encuentra una respuesta, con total confianza, y esa respuesta es la equivocada.
Profundización: el vocabulario de Kimball, y la solución de "restatement"
Kimball Group documenta esta situación con el nombre "late arriving dimension" — literalmente, cuando "los hechos de un proceso de negocio operacional llegan minutos, horas, días o semanas antes que el contexto de dimensión asociado". El ejemplo que ellos dan es distinto al de Kiosko en la superficie —una fila de inventario que llega referenciando un cliente o producto cuya llave natural todavía no existe en ningún lado de la dimensión—, pero el mecanismo es el mismo: un hecho que necesita un contexto dimensional que todavía no está disponible.
Para ese caso principal —una llave natural que no existe en absoluto todavía—, la técnica de Kimball es crear una fila de dimensión con "valores genéricos desconocidos" para la mayoría de las columnas descriptivas, con la llave natural que sí se conoce; cuando el contexto real llega después, esa fila se actualiza con un sobrescribe tipo 1 (los mismos conceptos del módulo 4, lección 3). Kiosko no necesita construir esto en esta lección —el catálogo de cuatro productos existe, completo, desde el primer día de esta guía; ningún product_id llega nunca sin dimensión alguna—, así que se nombra el patrón sin implementarlo, exactamente con la misma disciplina que el módulo 4 nombró WHEN NOT MATCHED BY SOURCE/WHEN NOT MATCHED BY TARGET sin implementarlos.
Lo que Kiosko sí necesita, y lo que esta lección demuestra con datos reales, es la segunda variante que Kimball documenta para esta misma técnica: un cambio retroactivo en una dimensión tipo 2 —exactamente el caso de P002— requiere que "se inserte una fila nueva en la tabla de dimensión, y luego las filas de hecho asociadas deban ser restauradas [restated]". Traducido al escenario de esta lección: mientras dim_product_scd_delayed no tenga la versión nueva de P002, cualquier venta fechada después del cambio real que ya se haya unido contra esa dimensión desactualizada quedó con la atribución equivocada — y no se corrige sola. La corrección exige dos pasos: primero, que el MERGE del módulo 4 corra y agregue la fila nueva (dim_product_scd, completa, ya lo tiene); segundo, volver a ejecutar el JOIN —restaurar (restate) los hechos afectados— contra la dimensión ya actualizada. Eso es, exactamente, lo que el segundo bloque de código de esta lección hizo: la misma consulta, corrida dos veces, contra dos instantáneas distintas de la misma dimensión.
Errores comunes
Pensar que un MATCH encontrado siempre significa que el JOIN está bien. Qué pasa: alguien corre el join punto-en-el-tiempo, ve que cada orden encuentra exactamente una fila de dimensión, sin NULL ni fan-out, y da el resultado por correcto sin preguntarse si la dimensión estaba completa en el momento de la consulta. Por qué pasa: las lecciones 2 y 3 de este módulo enseñaron a verificar "¿encontró match?" y "¿encontró exactamente uno?" — pero ninguna de esas dos preguntas puede detectar que la dimensión misma está desactualizada. Cómo detectarlo: si tu pipeline corre el JOIN en algún momento cercano a cuando ocurrieron las ventas, pregúntate explícitamente si el proceso que actualiza la dimensión (el MERGE) ya corrió para el período que estás reportando — un MATCH encontrado no es evidencia de que la dimensión esté al día. Cómo corregirlo: en un pipeline de producción real, el orden de ejecución importa: el MERGE que historiza cambios de dimensión debe correr, y confirmarse completo, antes de que cualquier reporte histórico dependa de esa dimensión para el período afectado.
Confundir "llegada tardía de la dimensión" con "orden huérfana" (sin ningún match). Qué pasa: alguien lee "late arriving dimension" y espera que el síntoma sea una fila sin MATCH, similar al caso de la lección 7 de este módulo (una orden fechada antes de que exista cualquier versión del producto). Por qué pasa: ambos problemas están relacionados —una dimensión incompleta—, y es fácil asumir que se manifiestan igual. Cómo detectarlo: compara los dos escenarios de esta lección — cuando el problema es que el MERGE todavía no capturó un cambio reciente, sí hay MATCH (contra la versión vieja, que sigue "abierta"); cuando el problema es que una fecha cae antes de cualquier versión conocida de un producto, no hay MATCH en absoluto. Cómo corregirlo: distingue los dos síntomas — sin MATCH es un problema de cobertura estructural (la lección 7 lo detecta con ANTI JOIN); con MATCH pero contra la versión equivocada es un problema de sincronización entre el pipeline de hechos y el de dimensiones, el que esta lección demuestra.
Reprocesar todos los hechos históricos cada vez que corre el MERGE, "por si acaso". Qué pasa: alguien, después de entender el riesgo de esta lección, decide que la solución más segura es volver a ejecutar el JOIN sobre toda la historia de fact_orders cada vez que dim_product_scd cambia, sin acotar el reprocesamiento a las filas realmente afectadas. Por qué pasa: parece la opción más segura —"si no sé exactamente qué se vio afectado, reproceso todo"—, y con un dataset de juguete como el de Kiosko, el costo de hacerlo es invisible. Cómo detectarlo: en un warehouse de producción, con millones de filas de hechos, reprocesar todo cada vez que cualquier dimensión cambia es una estrategia que no escala — el costo crece con cada MERGE, sin importar cuántas filas realmente necesitaban corregirse. Cómo corregirlo: acota el "restatement" a los hechos cuyo order_ts cae después de valid_from de la versión nueva y antes del momento en que corrió el MERGE que la creó — exactamente el rango que este ejemplo demuestra con ORD-8001 y ORD-8002. Identificar ese rango con precisión, en vez de reprocesar todo, es una decisión de diseño de pipeline que queda fuera del alcance de esta guía (es terreno de airflow-and-declarative-orchestration-guide), pero la lógica del rango afectado sí es parte de lo que esta lección enseña.
Ejercicios
Ejercicio 1 — Agrega una tercera orden tardía, fechada el 2026-08-14 (un día antes del cambio), y predice a qué versión cae. Sin ejecutar nada todavía, razona: 2026-08-14 es la fecha exacta de valid_to de la primera versión de P002. ¿Cae en la versión vieja o en la nueva, contra dim_product_scd completa?
Ver solución
con.execute("INSERT INTO late_orders VALUES ('ORD-8003', 'S02', 'P002', 1, 1.20, 1.20, '2026-08-14T23:00:00')")
print(con.sql("""
SELECT o.order_id, o.order_ts, d.product_key, d.category
FROM late_orders o
JOIN dim_product_scd d
ON o.product_id = d.product_id
AND o.order_ts BETWEEN d.valid_from AND COALESCE(d.valid_to, DATE '9999-12-31')
WHERE o.order_id = 'ORD-8003'
"""))
Salida esperada:
┌──────────┬─────────────────────┬─────────────┬──────────┐
│ order_id │ order_ts │ product_key │ category │
│ varchar │ timestamp │ int32 │ varchar │
├──────────┼─────────────────────┼─────────────┼──────────┤
│ ORD-8003 │ 2026-08-14 23:00:00 │ 2 │ snacks │
└──────────┴─────────────────────┴─────────────┴──────────┘
Cae en la versión vieja (product_key = 2, snacks), porque valid_to = 2026-08-14 es una fecha (sin hora), y BETWEEN la trata como el final del rango — cualquier order_ts con fecha 2026-08-14, sin importar la hora, sigue dentro del rango [2026-08-01, 2026-08-14]. El MERGE del módulo 4 cerró la primera versión exactamente un día antes de que abriera la segunda (DATE '{change_date}' - INTERVAL 1 DAY), así que el 14 de agosto completo —las 24 horas— pertenece todavía a la versión vieja, y el 15 de agosto completo pertenece a la nueva. No hay ningún instante del calendario que quede sin cubrir por ninguna de las dos versiones.
Ejercicio 2 — Confirma que las cuarenta órdenes originales de Kiosko no sufren este problema. Usando fact_orders (las cuarenta órdenes reales, no las de demostración de esta lección) y dim_product_scd_delayed, corre el mismo join punto-en-el-tiempo y confirma que el resultado es idéntico al que ya viste en la lección 3.
Ver solución
print(con.sql("""
SELECT d.category, ROUND(SUM(f.revenue), 2) AS revenue
FROM fact_orders f
JOIN dim_product_scd_delayed d
ON f.product_id = d.product_id
AND f.order_ts BETWEEN d.valid_from AND COALESCE(d.valid_to, DATE '9999-12-31')
GROUP BY d.category
ORDER BY d.category
"""))
Salida esperada:
┌─────────────┬─────────┐
│ category │ revenue │
│ varchar │ double │
├─────────────┼─────────┤
│ beverages │ 44.05 │
│ electronics │ 40.5 │
│ snacks │ 21.6 │
└─────────────┴─────────┘
Idéntico al resultado correcto de la lección 3 — porque las cuarenta órdenes reales de Kiosko son, todas, anteriores al 15 de agosto, así que nunca necesitan la versión nueva de P002 para empezar. dim_product_scd_delayed —que solo le falta la versión posterior al cambio— sigue teniendo toda la información que esas cuarenta órdenes necesitan. Esto confirma algo importante: el problema de la llegada tardía de la dimensión solo se manifiesta para hechos fechados después del cambio real, nunca para hechos anteriores a él.
Ejercicio 3 — Explica, en tus propias palabras, la diferencia entre "restatement" (Kimball) y simplemente "correr el JOIN de nuevo". En 2-3 frases, explica por qué la solución de esta lección —correr la misma consulta dos veces, contra dos instantáneas distintas de dim_product_scd— es exactamente lo que Kimball llama "restatement", y no una casualidad de cómo está construido el ejemplo.
Ver solución
"Restatement" no significa reescribir la consulta —la consulta de esta lección es idéntica en ambas corridas—; significa volver a ejecutarla una vez que su insumo (la dimensión) cambió, para que las filas de hecho que ya se habían unido contra la versión desactualizada queden reemplazadas por el resultado correcto. En un pipeline real, esto normalmente implica reescribir físicamente las filas de una tabla gold que ya se había publicado con la atribución equivocada —no solo volver a consultar—, pero el mecanismo central es el mismo que demuestra esta lección: el JOIN no cambia, lo que cambia es que se ejecuta de nuevo después de que la dimensión se puso al día, y el resultado de esa segunda ejecución reemplaza al de la primera.
Resumen y siguiente paso
Esta lección demostró, con dos órdenes reales de Kiosko fechadas después del cambio de P002, que el join punto-en-el-tiempo de la lección 3 solo es tan correcto como la dimensión contra la que se ejecuta: contra dim_product_scd completa, las dos órdenes caen correctamente en health-snacks/0.68; contra una instantánea retrasada —como si el MERGE del 15 de agosto todavía no hubiera corrido—, la misma consulta, palabra por palabra, cae silenciosamente en snacks/0.60. Nombraste el vocabulario formal de Kimball para este problema —"late arriving dimension"— y su solución para el caso de una SCD-2: insertar la versión nueva y restaurar (restate) los hechos afectados, ejecutando de nuevo el JOIN una vez que la dimensión se puso al día.
Antes de avanzar deberías poder: explicar la diferencia entre un JOIN sin MATCH (huérfano) y un JOIN con MATCH contra la versión equivocada (llegada tardía); nombrar las dos variantes de "late arriving dimension" que Kimball documenta, y cuál de las dos aplica a dim_product_scd; y explicar qué significa "restatement" en el contexto de una dimensión SCD-2.
Las lecciones 5 y 6 cambian de tema: de "cuál versión de la dimensión" a "cuántas veces aparece cada fila" — el problema de las filas duplicadas, que puede convivir con un join punto-en-el-tiempo perfectamente correcto.
Recursos
- Kimball Group — "Late Arriving Dimension" — la fuente exacta de la técnica que esta lección implementa: el caso principal (llave natural sin dimensión) y el caso secundario (cambio retroactivo en SCD-2 con restatement de hechos). kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/late-arriving-dimension. En inglés.
- Kimball Group — "Slowly Changing Dimension Type 2" — el mecanismo de
valid_from/valid_to/is_currentsobre el que opera el retraso demostrado en esta lección. kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/type-2. En inglés. - DuckDB — documentación oficial del statement
MERGE INTO— el proceso cuyo momento exacto de ejecución determina si una dimensión está "al día" o "retrasada" en esta lección. duckdb.org/docs/lts/sql/statements/merge_into. En inglés.