Módulo 4: Slowly Changing Dimensions
El problema: dim_product no es estática
Descripción
dim_product, tal como la dejaron los módulos 2 y 3, es una fotografía: cuatro productos, cada uno con un product_id, un product_name, una category y un unit_cost, capturados en un instante y congelados ahí desde entonces. Esa fotografía ha sido suficiente para todo lo que esta guía construyó hasta ahora —el star schema, el snowflake, la tabla ancha—, porque ninguna de esas lecciones necesitaba preguntar "¿y esto era así también la semana pasada?". Esta lección rompe esa comodidad con un caso concreto: el 15 de agosto de 2026, Kiosko recibe una actualización real de su proveedor de P002 (Energy Bar) — el costo mayorista sube de 0.60 a 0.68, y, en el mismo cambio, Kiosko decide reclasificar el producto de snacks a una categoría nueva y más específica, health-snacks, como parte de un reposicionamiento de su línea de barras energéticas.
Conexión con el módulo. Esta lección no historiza nada todavía —eso empieza en la lección 3—. Lo que hace es declarar, con evidencia ejecutada, exactamente qué cambió: dos instantáneas fijas del catálogo de productos de Kiosko, products_v1 (el estado hasta el 14 de agosto) y products_v2 (el estado desde el 15 de agosto), comparadas columna por columna con una consulta SQL que aísla la diferencia exacta, sin suponerla. Las lecciones 3, 4 y 5 de este módulo aplican tres soluciones distintas sobre este mismo cambio, exactamente como lo dejaste declarado aquí.
Una analogía: la página de catálogo que alguien edita en silencio
Imagina un catálogo impreso, de esos que una tienda envía por correo cada temporada. Alguien, en algún momento, decide corregir una página: cambia el precio de un producto, lo mueve a otra sección. Si esa persona simplemente reimprime la página y la reemplaza en el catálogo maestro —sin guardar la versión anterior en ningún archivo—, cualquier pregunta futura sobre "¿cuánto costaba este producto en la edición de julio?" se vuelve imposible de responder. La página vieja ya no existe en ningún lado; fue reemplazada, no archivada.
Eso es, exactamente, lo que le pasaría a dim_product si Kiosko simplemente sobrescribiera la fila de P002 el 15 de agosto: el costo 0.60 y la categoría snacks desaparecerían sin dejar rastro, reemplazados por 0.68 y health-snacks, como si esos valores nunca hubieran existido. Esta lección no resuelve todavía cómo evitar esa pérdida —eso es trabajo de las lecciones 3 y 4—; primero necesita dejar completamente claro, con una comparación ejecutada, qué se perdería si no se hiciera nada al respecto.
Ejemplo trabajado: dos instantáneas, una diferencia real
Primero, el catálogo tal como lo conoces desde el módulo 1 —products_v1, el estado vigente hasta el 14 de agosto de 2026, idéntico a DIM_PRODUCT de kiosko.py—, y products_v2, el estado vigente desde el 15 de agosto, con el cambio real de P002 ya aplicado:
# products_snapshots.py
import duckdb
from kiosko import DIM_PRODUCT
# products_v1: el catalogo de Kiosko tal como lo conoces desde el modulo 1,
# vigente hasta el 2026-08-14 inclusive.
PRODUCTS_V1 = [(p["product_id"], p["product_name"], p["category"], p["unit_cost"]) for p in DIM_PRODUCT]
# products_v2: el mismo catalogo, vigente desde el 2026-08-15. Un solo cambio real:
# P002 (Energy Bar) sube de costo (0.60 -> 0.68) y cambia de categoria (snacks -> health-snacks),
# el mismo dia, por la misma razon -- Kiosko reposiciona su linea de barras energeticas
# y renegocia el costo mayorista con el proveedor en la misma actualizacion de catalogo.
PRODUCTS_V2 = [
("P001", "Bottled Water 600ml", "beverages", 0.40),
("P002", "Energy Bar", "health-snacks", 0.68),
("P003", "Instant Coffee Sachet", "beverages", 0.35),
("P004", "Phone Charger Cable", "electronics", 2.10),
]
con = duckdb.connect()
con.execute("CREATE TABLE products_v1 (product_id VARCHAR, product_name VARCHAR, category VARCHAR, unit_cost DOUBLE)")
con.executemany("INSERT INTO products_v1 VALUES (?, ?, ?, ?)", PRODUCTS_V1)
con.execute("CREATE TABLE products_v2 (product_id VARCHAR, product_name VARCHAR, category VARCHAR, unit_cost DOUBLE)")
con.executemany("INSERT INTO products_v2 VALUES (?, ?, ?, ?)", PRODUCTS_V2)
print("=== products_v1: catalogo de Kiosko hasta el 2026-08-14 ===")
print(con.sql("SELECT * FROM products_v1 ORDER BY product_id"))
print("=== products_v2: catalogo de Kiosko desde el 2026-08-15 ===")
print(con.sql("SELECT * FROM products_v2 ORDER BY product_id"))
print("=== Diferencia exacta: que cambio, y en que producto ===")
print(con.sql("""
SELECT
v1.product_id,
v1.category AS category_before,
v2.category AS category_after,
v1.unit_cost AS unit_cost_before,
v2.unit_cost AS unit_cost_after
FROM products_v1 v1
JOIN products_v2 v2 ON v1.product_id = v2.product_id
WHERE v1.category <> v2.category OR v1.unit_cost <> v2.unit_cost
"""))
Qué esperar. Al correr python3 products_snapshots.py, la salida es exactamente esta:
=== products_v1: catalogo de Kiosko hasta el 2026-08-14 ===
┌────────────┬───────────────────────┬─────────────┬───────────┐
│ product_id │ product_name │ category │ unit_cost │
│ varchar │ varchar │ varchar │ double │
├────────────┼───────────────────────┼─────────────┼───────────┤
│ P001 │ Bottled Water 600ml │ beverages │ 0.4 │
│ P002 │ Energy Bar │ snacks │ 0.6 │
│ P003 │ Instant Coffee Sachet │ beverages │ 0.35 │
│ P004 │ Phone Charger Cable │ electronics │ 2.1 │
└────────────┴───────────────────────┴─────────────┴───────────┘
=== products_v2: catalogo de Kiosko desde el 2026-08-15 ===
┌────────────┬───────────────────────┬───────────────┬───────────┐
│ product_id │ product_name │ category │ unit_cost │
│ varchar │ varchar │ varchar │ double │
├────────────┼───────────────────────┼───────────────┼───────────┤
│ P001 │ Bottled Water 600ml │ beverages │ 0.4 │
│ P002 │ Energy Bar │ health-snacks │ 0.68 │
│ P003 │ Instant Coffee Sachet │ beverages │ 0.35 │
│ P004 │ Phone Charger Cable │ electronics │ 2.1 │
└────────────┴───────────────────────┴───────────────┴───────────┘
=== Diferencia exacta: que cambio, y en que producto ===
┌────────────┬─────────────────┬────────────────┬──────────────────┬─────────────────┐
│ product_id │ category_before │ category_after │ unit_cost_before │ unit_cost_after │
│ varchar │ varchar │ varchar │ double │ double │
├────────────┼─────────────────┼────────────────┼──────────────────┼─────────────────┤
│ P002 │ snacks │ health-snacks │ 0.6 │ 0.68 │
└────────────┴─────────────────┴────────────────┴──────────────────┴─────────────────┘
Detente en la última consulta, porque es el corazón de esta lección. products_v1 y products_v2 tienen cuatro filas cada una — el mismo número, los mismos cuatro product_id. La diferencia no está en cuántas filas hay, sino en el contenido de una sola de ellas: P001, P003 y P004 no aparecen en el resultado del JOIN filtrado, porque no cambiaron nada; P002 sí aparece, con sus dos columnas cambiadas —category y unit_cost— en la misma actualización. Esta consulta es exactamente el tipo de verificación que vas a usar, en la lección 5, dentro de la cláusula WHEN MATCHED AND (...) de MERGE INTO: la condición que decide si una fila necesita historizarse es, literalmente, "¿cambió alguno de estos valores?" — la misma pregunta, resuelta con <> en un WHERE aquí y en un WHEN MATCHED AND más adelante.
Diagrama: la fotografía fija, y la pregunta que no puede responder
┌──────────────────────────────────────────────────────────────────┐
│ dim_product HOY (modulos 2-3, una fila por producto) │
│ │
│ product_key │ product_id │ category │ unit_cost │
│ 2 │ P002 │ snacks │ 0.60 │
│ │
│ Pregunta que SI puede responder: │
│ "Cual es la categoria y el costo de P002 HOY?" -> snacks, 0.60 │
│ │
│ Pregunta que NO puede responder (todavia): │
│ "Cual era la categoria y el costo de P002 el 2026-08-10?" │
│ -> depende de si ya cambio o no, y esta tabla no guarda cuando │
└──────────────────────────────────────────────────────────────────┘
│
│ el 2026-08-15, P002 cambia:
│ category: snacks -> health-snacks
│ unit_cost: 0.60 -> 0.68
v
┌──────────────────────────────────────────────────────────────────┐
│ Si se SOBRESCRIBE (lo que la leccion 3 va a construir y criticar) │
│ product_key │ product_id │ category │ unit_cost │
│ 2 │ P002 │ health-snacks │ 0.68 │
│ "snacks" y "0.60" desaparecen -- ningun rastro de que existieron │
└──────────────────────────────────────────────────────────────────┘
│
│ o si se HISTORIZA (leccion 4 en adelante)
v
┌──────────────────────────────────────────────────────────────────┐
│ product_key │ product_id │ category │ unit_cost │ is_current│
│ 2 │ P002 │ snacks │ 0.60 │ false │
│ 5 │ P002 │ health-snacks│ 0.68 │ true │
│ Ambas versiones existen. La pregunta del 2026-08-10 SI se puede │
│ responder: cae dentro del rango de vigencia de la primera fila. │
└──────────────────────────────────────────────────────────────────┘
Profundización: por qué el cambio ocurre después de la semana de órdenes de Kiosko, y por qué eso importa
Fíjate en la fecha exacta del cambio: 15 de agosto de 2026. No es arbitraria. La semana completa de órdenes de Kiosko que conoces desde el módulo 1 —los cuarenta pedidos de raw_orders.py, de ORD-1001 a ORD-7003— ocurre entre el 3 y el 9 de agosto, es decir, antes de que P002 cambie de precio y de categoría. Esto no es una coincidencia de conveniencia: es una decisión deliberada que separa dos preguntas distintas, para que este módulo pueda responder la primera sin mezclarla con la segunda.
La primera pregunta —la que este módulo responde— es puramente estructural: "¿cómo se ve una dimensión que preserva su historia, en vez de perderla?". Para responderla, no hace falta que ninguna orden ya registrada dependa del cambio; basta con que la dimensión, por sí sola, guarde ambas versiones correctamente. La segunda pregunta —que el módulo 5 va a responder— es sobre las consecuencias del cambio: "si una orden se hubiera registrado después del 15 de agosto, ¿qué le pasaría a un reporte que une esa orden contra la dimensión, si el JOIN está mal escrito?". Esa pregunta necesita, como ingrediente, exactamente la dimensión historizada que este módulo va a dejar construida —con las dos versiones de P002, cada una con su rango de vigencia exacto—, y un hecho que pueda caer, según su fecha, a un lado u otro del cambio. Construir la dimensión primero, sin mezclarla todavía con el hecho, es lo que te permite entender cada pieza por separado antes de verlas interactuar mal (o bien) en el módulo siguiente.
Errores comunes
Asumir que "cambiar de categoría" y "cambiar de costo" son dos eventos separados que hay que historizar por separado. Qué pasa: alguien, al ver que P002 cambia dos columnas a la vez, diseña dos filas nuevas distintas —una para el cambio de categoría, otra para el cambio de costo—, en vez de una sola fila que capture ambos cambios juntos. Por qué pasa: parece más "limpio" separar cada cambio en su propio evento, especialmente si vienen de fuentes de datos distintas en un sistema real. Cómo detectarlo: si tu dim_product_scd termina con tres versiones de P002 en vez de dos, separaste algo que ocurrió en un solo instante. Cómo corregirlo: en este módulo, ambos cambios de P002 ocurren en la misma actualización de catálogo, el mismo día — se comparan y se aplican como una sola diferencia entre products_v1 y products_v2, exactamente como lo hizo la consulta de esta lección. Si un sistema real recibiera los dos cambios en momentos distintos, sí generaría dos versiones separadas — pero ese no es el caso de Kiosko en este módulo.
Comparar los catálogos "a ojo" en vez de con una consulta. Qué pasa: alguien mira las dos listas de productos —PRODUCTS_V1 y PRODUCTS_V2— línea por línea, y concluye de memoria cuál cambió, sin ejecutar el JOIN con la condición <>. Por qué pasa: con solo cuatro productos, parece innecesario escribir una consulta para algo "obvio a simple vista". Cómo detectarlo: si tu respuesta sobre qué cambió no viene de una consulta ejecutada, sino de una lectura manual, no tienes evidencia reproducible — exactamente el mismo error que el módulo 1 ya advirtió sobre declarar el grano por intuición en vez de verificarlo. Cómo corregirlo: con cuatro productos el riesgo de error humano es bajo, pero el patrón que estás construyendo —comparar dos instantáneas con un JOIN y una condición de desigualdad— es el mismo patrón que vas a necesitar cuando el catálogo tenga cuatro mil productos, no cuatro, y "a simple vista" deje de ser una opción.
Olvidar que product_name no cambió, y tratarlo como si también necesitara historizarse. Qué pasa: alguien, al preparar la comparación, incluye product_name en la condición del WHERE (v1.product_name <> v2.product_name OR ...), sin verificar primero si ese nombre realmente cambió. Por qué pasa: parece más "completo" comparar todas las columnas de negocio, no solo las dos que cambiaron. Cómo detectarlo: si tu consulta de diferencia incluye product_name en la condición, pero el resultado sigue mostrando exactamente una fila (P002) con las mismas dos columnas cambiadas, no rompiste nada —product_name simplemente nunca difiere entre v1 y v2—, pero exageraste la condición sin necesidad. Cómo corregirlo: en esta lección, la consulta compara explícitamente category y unit_cost, las dos columnas que sí cambian. La pregunta de qué hacer si product_name cambiara —y si debería tratarse igual que category/unit_cost— es exactamente el tema de la lección 6 de este módulo.
Ejercicios
Ejercicio 1 — Verifica que P001, P003 y P004 no aparecen en la diferencia. Sin modificar la consulta de la lección, escribe una consulta que confirme explícitamente que esos tres productos son idénticos entre products_v1 y products_v2.
Ver solución
print(con.sql("""
SELECT v1.product_id, 'sin cambios' AS estado
FROM products_v1 v1
JOIN products_v2 v2 ON v1.product_id = v2.product_id
WHERE v1.category = v2.category AND v1.unit_cost = v2.unit_cost
ORDER BY v1.product_id
"""))
Salida esperada:
┌────────────┬─────────────┐
│ product_id │ estado │
│ varchar │ varchar │
├────────────┼─────────────┤
│ P001 │ sin cambios │
│ P003 │ sin cambios │
│ P004 │ sin cambios │
└────────────┴─────────────┘
Exactamente los tres productos que no aparecieron en la consulta de diferencia de la lección — la condición inversa (= en vez de <>) confirma, con la misma evidencia, el lado complementario: tres productos sin cambios, uno con dos columnas cambiadas, cuatro en total, ni uno de más ni uno de menos.
Ejercicio 2 — Calcula el porcentaje de cambio en el costo de P002. Usando products_v1 y products_v2, escribe una consulta que calcule cuánto subió, en porcentaje, el unit_cost de P002.
Ver solución
print(con.sql("""
SELECT
v1.product_id,
v1.unit_cost AS cost_before,
v2.unit_cost AS cost_after,
ROUND((v2.unit_cost - v1.unit_cost) / v1.unit_cost * 100, 1) AS pct_increase
FROM products_v1 v1
JOIN products_v2 v2 ON v1.product_id = v2.product_id
WHERE v1.product_id = 'P002'
"""))
Salida esperada:
┌────────────┬─────────────┬────────────┬──────────────┐
│ product_id │ cost_before │ cost_after │ pct_increase │
│ varchar │ double │ double │ double │
├────────────┼─────────────┼────────────┼──────────────┤
│ P002 │ 0.6 │ 0.68 │ 13.3 │
└────────────┴─────────────┴────────────┴──────────────┘
El costo mayorista de P002 subió 13.3% — un número que solo es calculable porque products_v1 y products_v2 existen como dos instantáneas separadas, comparables directamente. Si Kiosko hubiera sobrescrito el catálogo sin conservar products_v1, esta pregunta —"¿cuánto subió el costo?"— dejaría de tener respuesta, exactamente el mismo problema estructural que las lecciones 3 y 4 exploran con la dimensión completa.
Ejercicio 3 — Explica, en tus propias palabras, por qué esta lección no escribe todavía ninguna tabla llamada dim_product_scd. En 2-3 frases, explica por qué esta lección se detiene en "declarar la diferencia" y deja la construcción de la dimensión historizada para la lección siguiente.
Ver solución
Esta lección tiene un solo trabajo: demostrar, con evidencia ejecutada, que dim_product no es estática — que existe un cambio real, en una fecha real, sobre un producto real de Kiosko. Mezclar esa demostración con la construcción de la solución (sobrescribir o historizar) habría oscurecido cuál es el problema y cuál es la respuesta. Separarlas —el problema aquí, las dos soluciones (tipo 1 en la lección 3, tipo 2 en la lección 4) después— sigue la misma disciplina que el módulo 1 ya usó al declarar el grano antes de construir el star schema: entender el problema con precisión antes de resolverlo.
Resumen y siguiente paso
Esta lección declaró, con una consulta ejecutada, exactamente qué cambia en el catálogo de Kiosko: P002 (Energy Bar), categoría de snacks a health-snacks, costo de 0.60 a 0.68, el 15 de agosto de 2026 — un cambio real, aislado con JOIN y una condición de desigualdad, no supuesto de memoria. products_v1 y products_v2 quedan como las dos instantáneas fijas que el resto de este módulo va a usar, una y otra vez, para demostrar tres soluciones distintas sobre el mismo cambio.
Antes de avanzar deberías poder: nombrar el producto exacto que cambia (P002), sus dos columnas afectadas (category, unit_cost) y la fecha del cambio (2026-08-15); explicar por qué el cambio ocurre después de la semana de órdenes de Kiosko (3-9 de agosto), no durante; y escribir de memoria el patrón de JOIN con <> que aísla una diferencia entre dos instantáneas.
La lección 3 aplica la primera solución sobre este mismo cambio — la más simple, y la que pierde la historia: SCD tipo 1.
Recursos
- Kimball Group — "Star Schema / OLAP Cube" — el vocabulario dimensional que enmarca por qué una dimensión, a diferencia de un hecho, puede necesitar historizarse. kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/star-schema-olap-cube. En inglés.
- DuckDB — documentación del cliente Python, la interfaz usada para construir y comparar
products_v1yproducts_v2en esta lección. duckdb.org/docs/current/clients/python/overview. En inglés.