Módulo 4: Slowly Changing Dimensions

Eligiendo tipo 1 vs tipo 2 por columna

Descripción

Las lecciones 3, 4 y 5 trataron category y unit_cost como si fueran las únicas columnas de dim_product_scd — y, para el cambio real de P002 que vienes siguiendo desde la lección 2, lo son. Pero dim_product_scd tiene una cuarta columna de negocio, product_name, que ninguna lección anterior tocó todavía. Esta lección hace la pregunta que Kimball responde a nivel de columna, no de tabla completa: si Kiosko decide agregar el tamaño al nombre de P002 —de "Energy Bar" a "Energy Bar 40g", una mejora de catálogo puramente descriptiva, sin ninguna implicación de negocio—, ¿ese cambio merece una fila nueva, como category y unit_cost? La respuesta, con evidencia ejecutada, es no — y esta lección construye exactamente esa mezcla: dos columnas con SCD tipo 2, una columna con SCD tipo 1, conviviendo en la misma tabla historizada.

Conexión con el módulo. Esta lección no agrega ninguna fila nueva a dim_product_scd —el número de versiones de P002 sigue siendo dos, exactamente como lo dejó la lección 5—. Lo que hace es aplicar una corrección de product_name que se propaga a ambas versiones de P002 por igual, demostrando en código que una tabla historizada con SCD tipo 2 no obliga a que todas sus columnas se traten con SCD tipo 2 — el tipo se elige columna por columna, según el mismo criterio que la lección 3 ya adelantó.

Una analogía: el expediente médico, con dos tipos de anotación

Piensa otra vez en un expediente médico, la misma clase de sistema que ya usaste para pensar en llaves sustitutas. Un expediente bien diseñado distingue, con mucho cuidado, entre dos tipos de anotación. Está el diagnóstico —una presión arterial registrada en cada visita, un resultado de laboratorio con su fecha exacta—: cada valor nuevo se agrega como una entrada separada, con su propia fecha, porque el historial completo importa para entender la evolución del paciente. Y está la información de contacto —el número de teléfono, la dirección de correo—: si el paciente corrige un error de tipeo en su número de teléfono, nadie espera que el expediente conserve "el número de teléfono mal escrito" como una entrada histórica separada; simplemente se corrige, en el lugar, y el registro sigue mostrando el valor correcto en todas las visitas anteriores y futuras.

dim_product_scd es ese mismo expediente. category y unit_cost son el diagnóstico: cada cambio real es un hecho de negocio que vale la pena preservar, versión por versión. product_name, cuando el cambio es una mejora descriptiva del catálogo —no un relanzamiento de marca que alguien vaya a querer auditar—, es la información de contacto: se corrige donde está, sin crear una versión nueva, y la corrección se aplica retroactivamente a cualquier fila que ya existiera.

Ejemplo trabajado: una política de columnas, declarada y aplicada

Primero, declara explícitamente qué tipo de SCD le corresponde a cada columna de negocio de dim_product_scd — no como una decisión implícita, sino como una estructura que cualquiera en el equipo puede consultar:

# scd_column_policy.py
import duckdb

from kiosko import DIM_PRODUCT

con = duckdb.connect()
con.execute("CREATE SEQUENCE product_key_seq START 1")
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 (nextval('product_key_seq'), ?, ?, ?, ?, DATE '2026-08-01', NULL, true)",
    [(p["product_id"], p["product_name"], p["category"], p["unit_cost"]) for p in DIM_PRODUCT],
)
# El cambio de la leccion 5, ya aplicado: P002 tiene dos versiones.
con.execute("UPDATE dim_product_scd SET valid_to = DATE '2026-08-14', is_current = false WHERE product_id = 'P002' AND is_current = true")
con.execute("""
    INSERT INTO dim_product_scd VALUES
        (nextval('product_key_seq'), 'P002', 'Energy Bar', 'health-snacks', 0.68, DATE '2026-08-15', NULL, true)
""")

COLUMN_SCD_POLICY = {
    "product_name": {"scd_type": 1, "reason": "dato descriptivo/catalogo -- correcciones no ameritan historia"},
    "category":     {"scd_type": 2, "reason": "cambia el agrupamiento de reportes historicos"},
    "unit_cost":    {"scd_type": 2, "reason": "afecta margen -- un reporte de rentabilidad necesita el costo real de cada momento"},
}

print("=== Politica de historizacion por columna, dim_product_scd ===")
for col, policy in COLUMN_SCD_POLICY.items():
    print(f"{col:14} -> SCD tipo {policy['scd_type']}  ({policy['reason']})")

Qué esperar (política). Al correr esta primera parte, la salida es exactamente esta:

=== Politica de historizacion por columna, dim_product_scd ===
product_name   -> SCD tipo 1  (dato descriptivo/catalogo -- correcciones no ameritan historia)
category       -> SCD tipo 2  (cambia el agrupamiento de reportes historicos)
unit_cost      -> SCD tipo 2  (afecta margen -- un reporte de rentabilidad necesita el costo real de cada momento)

Ahora, la parte que hace la política real: Kiosko decide agregar el tamaño al nombre de P002 en su catálogo —"Energy Bar" pasa a "Energy Bar 40g"—, una corrección puramente descriptiva. Como product_name está declarado como SCD tipo 1 en la política, se aplica con un UPDATE directo, sin el filtro is_current = true que sí usarías para cerrar una versión — precisamente porque no se trata de cerrar nada, sino de corregir el mismo dato en todas las filas que existan de ese producto:

print("\n=== P002 ANTES de la correccion de product_name (2 versiones, mismo nombre) ===")
print(con.sql("SELECT product_key, product_id, product_name, category, unit_cost, valid_from, valid_to, is_current FROM dim_product_scd WHERE product_id = 'P002' ORDER BY product_key"))

# SCD tipo 1 sobre UNA columna especifica: sin filtro is_current, se aplica a TODAS las versiones.
con.execute("""
    UPDATE dim_product_scd
    SET product_name = 'Energy Bar 40g'
    WHERE product_id = 'P002'
""")

print("\n=== P002 DESPUES de la correccion de product_name (2 versiones, nombre corregido en ambas) ===")
print(con.sql("SELECT product_key, product_id, product_name, category, unit_cost, valid_from, valid_to, is_current FROM dim_product_scd WHERE product_id = 'P002' ORDER BY product_key"))

Qué esperar (corrección de product_name). Al correr esta segunda parte, la salida es exactamente esta:

=== P002 ANTES de la correccion de product_name (2 versiones, mismo nombre) ===
┌─────────────┬────────────┬──────────────┬───────────────┬───────────┬────────────┬────────────┬────────────┐
│ product_key │ product_id │ product_name │   category    │ unit_cost │ valid_from │  valid_to  │ is_current │
│    int32    │  varchar   │   varchar    │    varchar    │  double   │    date    │    date    │  boolean   │
├─────────────┼────────────┼──────────────┼───────────────┼───────────┼────────────┼────────────┼────────────┤
│           2 │ P002       │ Energy Bar   │ snacks        │       0.6 │ 2026-08-01 │ 2026-08-14 │ false      │
│           5 │ P002       │ Energy Bar   │ health-snacks │      0.68 │ 2026-08-15 │ NULL       │ true       │
└─────────────┴────────────┴──────────────┴───────────────┴───────────┴────────────┴────────────┴────────────┘

=== P002 DESPUES de la correccion de product_name (2 versiones, nombre corregido en ambas) ===
┌─────────────┬────────────┬────────────────┬───────────────┬───────────┬────────────┬────────────┬────────────┐
│ product_key │ product_id │  product_name  │   category    │ unit_cost │ valid_from │  valid_to  │ is_current │
│    int32    │  varchar   │    varchar     │    varchar    │  double   │    date    │    date    │  boolean   │
├─────────────┼────────────┼────────────────┼───────────────┼───────────┼────────────┼────────────┼────────────┤
│           2 │ P002       │ Energy Bar 40g │ snacks        │       0.6 │ 2026-08-01 │ 2026-08-14 │ false      │
│           5 │ P002       │ Energy Bar 40g │ health-snacks │      0.68 │ 2026-08-15 │ NULL       │ true       │
└─────────────┴────────────┴────────────────┴───────────────┴───────────┴────────────┴────────────┴────────────┘

Detente en las dos filas del resultado final. product_name cambió de "Energy Bar" a "Energy Bar 40g" en ambas filas —product_key = 2 (la versión histórica, cerrada) y product_key = 5 (la versión vigente)—, mientras que category y unit_cost conservan, cada fila, sus valores propios de esa versión: snacks/0.6 en la fila cerrada, health-snacks/0.68 en la vigente. Ninguna fila nueva se creó, y el número de versiones de P002 sigue siendo dos. Esta es, en código, la diferencia completa entre las dos técnicas conviviendo en la misma tabla: category/unit_cost respetan el rango de vigencia de cada fila; product_name se corrige de forma retroactiva y uniforme, como si el nombre "correcto" siempre hubiera sido "Energy Bar 40g".

Diagrama: la misma tabla, dos disciplinas distintas

dim_product_scd -- P002, despues de la correccion de product_name
─────────────────────────────────────────────────────────────────────
product_key │ product_name    │ category      │ unit_cost │ vigencia
─────────────────────────────────────────────────────────────────────
     2      │ Energy Bar 40g  │ snacks        │   0.60    │ hasta 08-14
     5      │ Energy Bar 40g  │ health-snacks │   0.68    │ desde 08-15
─────────────────────────────────────────────────────────────────────
              ↑ SCD tipo 1:          ↑ SCD tipo 2 en ambas:
              mismo valor en          cada fila conserva SU
              TODAS las filas         propio valor historico
flowchart TB
    A["dim_product_scd, P002"] --> B["product_name\nSCD tipo 1\nUPDATE sin filtro is_current\naplica a TODAS las filas"]
    A --> C["category, unit_cost\nSCD tipo 2\nUPDATE + INSERT con filtro is_current\nsolo la fila vigente se cierra"]

Profundización: el criterio de Kimball, en la práctica de Kiosko

La lección 3 ya adelantó el criterio en abstracto: SCD tipo 1 es correcto cuando el cambio es una corrección de un dato descriptivo, no un hecho de negocio auditable. Esta lección lo convierte en una pregunta operativa, columna por columna, que puedes hacerte sobre cualquier dimensión: si alguien preguntara "¿cuál era el valor de esta columna en tal fecha pasada?", ¿esa pregunta tiene sentido de negocio, o es simplemente "¿cuál es el valor correcto hoy?" disfrazada de pregunta histórica?

Para unit_cost, la pregunta "¿cuánto costaba P002 el 10 de agosto?" tiene sentido de negocio real: un analista de márgenes la necesita para calcular la rentabilidad de las ventas de esa semana con el costo correcto de ese momento, no con el costo de hoy. Para category, la pregunta "¿en qué categoría estaba P002 el 10 de agosto?" también importa: un reporte de "revenue por categoría, agosto completo" necesita clasificar las ventas de antes del 15 de agosto bajo snacks, no bajo health-snacks retroactivamente —exactamente el error que el módulo 5 va a mostrar en números cuando el JOIN está mal escrito—. Para product_name, en cambio, la pregunta "¿cómo se llamaba P002 el 10 de agosto?" casi nunca tiene una respuesta de negocio distinta a "como se llama ahora, corregido" — a menos que el cambio de nombre sea, en sí mismo, un evento de negocio (el relanzamiento de marca del ejercicio 3 de la lección 3), el nombre es, para efectos prácticos, información de catálogo que se mantiene correcta, no un hecho que se audita.

Esta distinción tiene una consecuencia práctica importante para cualquier tabla dim_* real: no existe tal cosa como "esta dimensión es SCD tipo 1" o "esta dimensión es SCD tipo 2" como una etiqueta única para toda la tabla. dim_product_scd, en este módulo, es SCD tipo 2 para dos de sus columnas y SCD tipo 1 para una tercera — y esa mezcla es exactamente lo que Kimball documenta como la práctica común en un modelo dimensional real, no una excepción o un compromiso a medias.

Errores comunes

Aplicar el mismo WHERE (con o sin is_current) a todas las columnas, sin distinguir su política. Qué pasa: alguien, al corregir product_name, copia el patrón de UPDATE ... WHERE product_id = 'P002' AND is_current = true que usó para cerrar versiones en las lecciones 4 y 5, sin darse cuenta de que ese filtro deja la fila histórica (product_key = 2) con el nombre viejo. Por qué pasa: el patrón WHERE ... AND is_current = true se vuelve casi automático después de repetirlo varias veces en el módulo. Cómo detectarlo: si después de "corregir" product_name sigues viendo dos nombres distintos para P002 al consultar sus dos versiones, aplicaste el filtro equivocado — una corrección de catálogo debería verse igual en toda la historia del producto. Cómo corregirlo: antes de escribir cualquier UPDATE sobre dim_product_scd, pregúntate primero qué política le corresponde a esa columna específica —consulta COLUMN_SCD_POLICY— y solo entonces decide si el UPDATE necesita el filtro is_current = true (tipo 2, cerrar una versión) o no lo necesita en absoluto (tipo 1, corregir todas las versiones).

Decidir la política de una columna sin involucrar a quien realmente consume el dato. Qué pasa: un equipo de datos decide, unilateralmente, que category debería ser SCD tipo 1 —"para simplificar, total category casi nunca cambia"— sin preguntarle al equipo de finanzas si necesita revenue histórico correctamente clasificado por categoría. Por qué pasa: la decisión de tipo 1 vs tipo 2 parece una decisión puramente técnica, cuando en realidad depende por completo de qué preguntas de negocio alguien va a hacer más adelante. Cómo detectarlo: si tu política de columnas se decidió sin ninguna conversación con las personas que consumen los reportes construidos sobre esa dimensión, corres el riesgo de que, meses después, alguien descubra que necesitaba historia que nunca se preservó — un error irreversible, igual que el de la lección 3. Cómo corregirlo: la política de columnas de esta lección —COLUMN_SCD_POLICY— debería ser una conversación documentada con los consumidores del dato, no una decisión técnica aislada; la razón (reason) de cada entrada es, deliberadamente, tan importante como el tipo mismo.

Asumir que una columna con SCD tipo 1 nunca necesita revisarse. Qué pasa: un equipo declara product_name como tipo 1 hoy y nunca vuelve a cuestionar esa decisión, incluso cuando el contexto de negocio cambia —por ejemplo, si Kiosko empieza a hacer pruebas A/B de nombres de producto y sí necesita saber qué nombre estuvo activo durante cada prueba—. Por qué pasa: una política de columnas, una vez declarada, se siente como una decisión permanente. Cómo detectarlo: si el negocio empieza a hacer preguntas históricas sobre una columna marcada como tipo 1 —y la respuesta siempre es "no lo sabemos, se sobrescribió"—, la política quedó desactualizada. Cómo corregirlo: COLUMN_SCD_POLICY no es una decisión que se toma una sola vez — es una declaración que se revisa cuando el negocio cambia, exactamente como cualquier otra decisión de diseño de esta guía se justifica por contexto, no por dogma.

Ejercicios

Ejercicio 1 — Verifica que P001, P003 y P004 no fueron afectados por la corrección de product_name. Después de correr el ejemplo trabajado, confirma que los otros tres productos conservan su product_name original.

Ver solución
print(con.sql("""
    SELECT product_id, product_name
    FROM dim_product_scd
    WHERE product_id != 'P002'
    ORDER BY product_id
"""))

Salida esperada:

┌────────────┬───────────────────────┐
│ product_id │      product_name     │
│  varchar   │        varchar        │
├────────────┼───────────────────────┤
│ P001       │ Bottled Water 600ml   │
│ P003       │ Instant Coffee Sachet │
│ P004       │ Phone Charger Cable   │
└────────────┴───────────────────────┘

El UPDATE de la lección filtró explícitamente por WHERE product_id = 'P002' — los otros tres productos, con una sola versión cada uno, conservan su nombre original sin ningún cambio.

Ejercicio 2 — Explica por qué product_id y product_key no aparecen en COLUMN_SCD_POLICY. En 2-3 frases, usando lo que sabes sobre llaves naturales y sustitutas desde el módulo 2, explica por qué esas dos columnas no necesitan ninguna política de SCD.

Ver solución

product_id es la llave natural: identifica al producto de negocio de forma permanente, y por definición nunca cambia —si cambiara, ya no sería el mismo producto, sería uno distinto—. product_key es la llave sustituta: la genera el propio modelo, una vez por cada versión, y su valor nunca se actualiza después de asignado —es, literalmente, lo que hace posible que existan dos filas de P002 sin conflicto—. Ninguna de las dos es un "atributo descriptivo" que pueda cambiar de valor con el tiempo; son las columnas que hacen posible el resto de la historización, no columnas que se historizan a sí mismas. COLUMN_SCD_POLICY solo tiene sentido para columnas de atributo —product_name, category, unit_cost—, nunca para las llaves.

Ejercicio 3 — Decide la política de una columna nueva e hipotética. Supón que Kiosko agrega una columna supplier_name a dim_product_scd, registrando qué proveedor surte cada producto. En 2-3 frases, argumenta si supplier_name debería tratarse con SCD tipo 1 o tipo 2, usando el criterio de la profundización de esta lección.

Ver solución

Depende, otra vez, de si "¿quién era el proveedor de este producto en tal fecha pasada?" es una pregunta con sentido de negocio real. Si Kiosko negocia contratos con proveedores y necesita poder auditar, más adelante, con qué proveedor trabajó durante un período específico —por ejemplo, para investigar un problema de calidad que apareció en una fecha concreta—, supplier_name debería ser SCD tipo 2, igual que category y unit_cost: el proveedor vigente en el momento de cada venta es un hecho de negocio auditable. Si, en cambio, supplier_name es solo un dato de referencia rápida sin ninguna necesidad de trazabilidad histórica, SCD tipo 1 —sobrescribir con el proveedor actual— sería suficiente. La pregunta de esta lección —¿el valor histórico importa para alguna decisión de negocio real?— aplica exactamente igual a cualquier columna nueva que se agregue en el futuro.

Resumen y siguiente paso

Esta lección demostró, con código ejecutado, que dim_product_scd no necesita un solo tipo de SCD para toda la tabla: category y unit_cost siguen siendo SCD tipo 2 —cada versión conserva su propio valor, respetando valid_from/valid_to—, mientras que product_name se corrige con SCD tipo 1 —un UPDATE sin filtro de vigencia, que se propaga a todas las versiones existentes por igual—. COLUMN_SCD_POLICY formalizó esa mezcla como una decisión explícita y documentada, no implícita.

Antes de avanzar deberías poder: explicar la diferencia entre el WHERE de un UPDATE tipo 1 (sin is_current) y uno tipo 2 (con is_current = true); nombrar la pregunta central que decide la política de una columna ("¿el valor histórico tiene sentido de negocio?"); y argumentar, para una columna hipotética nueva, qué tipo de SCD le correspondería.

La lección 7 completa el vocabulario de SCD, brevemente: SCD tipo 3 —que preserva un valor anterior en una columna adicional, sin crecer indefinidamente como tipo 2— y los nombres de las variantes que existen más allá de tipo 1, 2 y 3, sin implementarlas a fondo.

Recursos