Módulo 3: Star Vs Snowflake Vs One Big Table

Cuándo el snowflake todavía gana

Descripción

Las lecciones 4 y 5 construyeron un argumento sólido a favor de la tabla ancha: menos JOIN, consultas más simples, y —a esta escala de juguete— ni siquiera un costo de espacio medible. Sería fácil, después de esas dos lecciones, concluir que normalizar nunca vale la pena. Esta lección existe para corregir esa conclusión con la misma disciplina de evidencia: toma el ejemplo más simple posible —renombrar una categoría— y mide, con un UPDATE real ejecutado sobre las tres formas, exactamente cuántas filas hay que tocar en cada una. El resultado no es una opinión; es un conteo.

Conexión con el módulo. Esta lección usa las tres estructuras que ya construiste —dim_category (lección 2), dim_product star (módulo 2), mart_daily_sales_obt (lección 5)— para medir, no suponer, el costo de mantenimiento de cada forma ante el mismo cambio de negocio.

Una analogía: corregir una dirección en un documento versus en cien recibos

Imagina que una calle de tu ciudad cambia de nombre oficial —pasa de llamarse "Calle 5" a "Avenida Los Robles"—. Si tu documento de identidad guarda tu dirección como una referencia a un registro central de calles (un "código de calle", con el nombre real viviendo en un solo lugar, el registro de la municipalidad), corregir el cambio toma exactamente una actualización: la municipalidad actualiza el nombre en su registro, y automáticamente, cualquier documento que consulte ese registro ve el nombre correcto de inmediato. Pero si, en cambio, tienes cien recibos viejos de servicios donde la dirección se escribió a mano, letra por letra, en cada recibo —sin ninguna referencia a un registro central—, corregir el cambio de nombre de calle en esos cien recibos significa, literalmente, reescribir cien veces el mismo texto.

Esa es, con precisión, la diferencia que esta lección va a medir. dim_category es el registro central de la municipalidad: un solo lugar, una sola actualización. mart_daily_sales_obt son los cien recibos: el mismo texto, escrito una y otra vez, en cada fila que lo necesita.

Ejemplo trabajado: el costo real de renombrar una categoría

El equipo de marketing de Kiosko decide renombrar la categoría "beverages" a "drinks" —un cambio de vocabulario, sin ningún impacto en el negocio real, del tipo que ocurre con regularidad en cualquier catálogo vivo—. Aplica ese mismo cambio, con un UPDATE, en las tres formas que construiste en este módulo, y cuenta cuántas filas toca cada una.

# category_rename_cost.py
from datetime import datetime

import duckdb

from kiosko import DIM_PRODUCT, DIM_STORE, Order, transform_fact_orders
from raw_orders import RAW_ORDERS

orders = [
    Order(order_id=r[0], store_id=r[1], product_id=r[2], quantity=r[3],
          unit_price=r[4], order_ts=datetime.fromisoformat(r[5]))
    for r in RAW_ORDERS
]
fact_orders = transform_fact_orders(orders, DIM_STORE, DIM_PRODUCT)

con = duckdb.connect()
con.execute("""
    CREATE TABLE fact_orders (
        order_id VARCHAR, store_id VARCHAR, product_id VARCHAR,
        quantity INTEGER, unit_price DOUBLE, revenue DOUBLE, order_ts TIMESTAMP
    )
""")
con.executemany(
    "INSERT INTO fact_orders VALUES (?, ?, ?, ?, ?, ?, ?)",
    [(r["order_id"], r["store_id"], r["product_id"], r["quantity"],
      r["unit_price"], r["revenue"], r["order_ts"]) for r in fact_orders],
)

con.execute("CREATE TABLE dim_store_natural (store_id VARCHAR, store_name VARCHAR, city VARCHAR)")
con.executemany("INSERT INTO dim_store_natural VALUES (?, ?, ?)",
                 [(s["store_id"], s["store_name"], s["city"]) for s in DIM_STORE])
con.execute("""
    CREATE TABLE dim_store AS
    SELECT ROW_NUMBER() OVER (ORDER BY store_id) AS store_key, store_id, store_name, city
    FROM dim_store_natural
""")

con.execute("CREATE TABLE dim_product_natural (product_id VARCHAR, product_name VARCHAR, category VARCHAR, unit_cost DOUBLE)")
con.executemany("INSERT INTO dim_product_natural VALUES (?, ?, ?, ?)",
                 [(p["product_id"], p["product_name"], p["category"], p["unit_cost"]) for p in DIM_PRODUCT])
con.execute("""
    CREATE TABLE dim_product AS
    SELECT ROW_NUMBER() OVER (ORDER BY product_id) AS product_key, product_id, product_name, category, unit_cost
    FROM dim_product_natural
""")
con.execute("""
    CREATE TABLE dim_category AS
    SELECT ROW_NUMBER() OVER (ORDER BY category) AS category_id, category AS category_name
    FROM (SELECT DISTINCT category FROM dim_product_natural) t
""")

con.execute("""
    CREATE TABLE mart_daily_sales_obt AS
    SELECT
        CAST(f.order_ts AS DATE) AS sale_date, f.store_id, s.store_name, s.city,
        f.product_id, p.product_name, p.category, p.unit_cost,
        SUM(f.quantity) AS quantity, ROUND(SUM(f.revenue), 2) AS revenue
    FROM fact_orders f
    JOIN dim_store s ON f.store_id = s.store_id
    JOIN dim_product p ON f.product_id = p.product_id
    GROUP BY 1, 2, 3, 4, 5, 6, 7, 8
""")

print("=== Marketing decide renombrar la categoria 'beverages' a 'drinks' ===\n")

print("--- Camino 1: dim_category (normalizada) ---")
print(con.sql("SELECT * FROM dim_category ORDER BY category_id"))
con.execute("UPDATE dim_category SET category_name = 'drinks' WHERE category_name = 'beverages'")
print(con.sql("SELECT * FROM dim_category ORDER BY category_id"))
count1 = con.sql("SELECT COUNT(*) FROM dim_category WHERE category_name = 'drinks'").fetchone()[0]
print(f"Filas actualizadas: {count1}\n")

print("--- Camino 2: dim_product (star, category como columna de texto) ---")
count2 = con.sql("SELECT COUNT(*) FROM dim_product WHERE category = 'beverages'").fetchone()[0]
print(f"Filas de dim_product con category = 'beverages' antes del UPDATE: {count2}")
con.execute("UPDATE dim_product SET category = 'drinks' WHERE category = 'beverages'")
print(con.sql("SELECT product_id, product_name, category FROM dim_product ORDER BY product_id"))
print(f"Filas actualizadas: {count2}\n")

print("--- Camino 3: mart_daily_sales_obt (OBT, category repetida en cada fila) ---")
count3 = con.sql("SELECT COUNT(*) FROM mart_daily_sales_obt WHERE category = 'beverages'").fetchone()[0]
print(f"Filas de mart_daily_sales_obt con category = 'beverages' antes del UPDATE: {count3}")
con.execute("UPDATE mart_daily_sales_obt SET category = 'drinks' WHERE category = 'beverages'")
print(f"Filas actualizadas: {count3}\n")

print("=== Resumen: mismo cambio de negocio, tres costos distintos ===")
print(f"dim_category (normalizada):        {count1} fila  actualizada")
print(f"dim_product (star, texto):         {count2} filas actualizadas")
print(f"mart_daily_sales_obt (OBT, texto): {count3} filas actualizadas")

Qué esperar. Al correr python3 category_rename_cost.py, la salida es exactamente esta:

=== Marketing decide renombrar la categoria 'beverages' a 'drinks' ===

--- Camino 1: dim_category (normalizada) ---
┌─────────────┬───────────────┐
│ category_id │ category_name │
│    int64    │    varchar    │
├─────────────┼───────────────┤
│           1 │ beverages     │
│           2 │ electronics   │
│           3 │ snacks        │
└─────────────┴───────────────┘

┌─────────────┬───────────────┐
│ category_id │ category_name │
│    int64    │    varchar    │
├─────────────┼───────────────┤
│           1 │ drinks        │
│           2 │ electronics   │
│           3 │ snacks        │
└─────────────┴───────────────┘

Filas actualizadas: 1

--- Camino 2: dim_product (star, category como columna de texto) ---
Filas de dim_product con category = 'beverages' antes del UPDATE: 2
┌────────────┬───────────────────────┬─────────────┐
│ product_id │     product_name      │  category   │
│  varchar   │        varchar        │   varchar   │
├────────────┼───────────────────────┼─────────────┤
│ P001       │ Bottled Water 600ml   │ drinks      │
│ P002       │ Energy Bar            │ snacks      │
│ P003       │ Instant Coffee Sachet │ drinks      │
│ P004       │ Phone Charger Cable   │ electronics │
└────────────┴───────────────────────┴─────────────┘

Filas actualizadas: 2

--- Camino 3: mart_daily_sales_obt (OBT, category repetida en cada fila) ---
Filas de mart_daily_sales_obt con category = 'beverages' antes del UPDATE: 22
Filas actualizadas: 22

=== Resumen: mismo cambio de negocio, tres costos distintos ===
dim_category (normalizada):        1 fila  actualizada
dim_product (star, texto):         2 filas actualizadas
mart_daily_sales_obt (OBT, texto): 22 filas actualizadas

Un solo cambio de negocio —"beverages" ahora se llama "drinks"—, medido con el mismo UPDATE ... WHERE category = 'beverages' (o category_name, según la tabla), toca 1 fila en dim_category, 2 filas en dim_product, y 22 filas en mart_daily_sales_obt. La proporción no es casualidad: dim_category toca una fila porque cada categoría existe una sola vez, sin importar a cuántos productos pertenezca. dim_product toca dos porque dos productos (P001 y P003) pertenecen a "beverages". Y mart_daily_sales_obt toca veintidós porque, a diferencia de las dos dimensiones, cada fila de la OBT representa una combinación de día + tienda + producto — y "beverages" aparece repetida en cada una de las veintidós combinaciones donde se vendió un producto de esa categoría, a lo largo de toda la semana y en las tres tiendas.

Diagrama: el mismo cambio, propagado con costos distintos

flowchart TD
    Change["'beverages' -> 'drinks'\n(un cambio de negocio)"]

    Change --> A["dim_category\n1 fila actualizada"]
    Change --> B["dim_product (star)\n2 filas actualizadas"]
    Change --> C["mart_daily_sales_obt\n22 filas actualizadas"]

    A -.->|"cualquier consulta que haga\nJOIN contra dim_category\nve 'drinks' de inmediato"| D["Sin trabajo adicional"]
    B -.->|"cualquier consulta que haga\nJOIN contra dim_product\nve 'drinks' de inmediato"| D
    C -.->|"la OBT necesita re-materializarse\ncompleta, o el UPDATE manual\nqueda desincronizado del origen"| E["Riesgo de inconsistencia"]

Profundización: por qué el costo real no es solo "número de filas tocadas"

Contar filas actualizadas es la parte fácil de medir, y ya es evidencia suficiente para el argumento central de esta lección. Pero vale la pena nombrar una segunda dimensión del costo, más sutil, que el conteo de filas no captura por completo: ¿de dónde viene el dato que se está actualizando?

dim_category y dim_product son tablas de dimensión, construidas directamente a partir del catálogo de origen de Kiosko (DIM_PRODUCT en kiosko.py). Cuando renombras una categoría en dim_category, estás corrigiendo la fuente de verdad misma — cualquier tabla derivada que dependa de ella (incluyendo, si la reconstruyeras, la propia mart_daily_sales_obt) heredaría el cambio automáticamente la próxima vez que se regenere. mart_daily_sales_obt, en cambio, es una tabla derivada: no es la fuente de verdad de qué categoría tiene cada producto, es una materialización de esa verdad, congelada en el momento en que se construyó. El UPDATE de veintidós filas que ejecutaste en esta lección corrige el síntoma —el texto que aparece en la OBT—, pero no corrige la causa: si mañana alguien reconstruye mart_daily_sales_obt desde cero con el mismo CREATE TABLE ... AS SELECT de la lección 5, y dim_product todavía dice "beverages" en vez de "drinks" porque nadie lo actualizó ahí también, la OBT recién reconstruida vuelve a tener el nombre viejo — deshaciendo, en silencio, el UPDATE manual que acabas de aplicar.

Esta es la razón real, más allá del conteo de veintidós filas, por la que un warehouse de producción normaliza sus dimensiones de referencia y trata cualquier tabla ancha como una vista materializada que se reconstruye a partir de esas dimensiones, no como una tabla que se edita directamente. El UPDATE de esta lección es un ejercicio pedagógico válido —te deja ver el costo con tus propios ojos—, pero en un pipeline real, la forma correcta de propagar el cambio de "beverages" a "drinks" sería: actualizar dim_product (la fuente de verdad, dos filas), y reconstruir mart_daily_sales_obt desde cero con la consulta de la lección 5 — no editarla fila por fila. El costo de mantenimiento real de la OBT no es solo "veintidós UPDATE"; es "la disciplina de nunca editarla directamente, y siempre regenerarla desde una fuente que sí se mantiene consistente".

Errores comunes

Editar la OBT directamente en producción, en vez de regenerarla desde la fuente. Qué pasa: alguien, apurado por corregir un dato incorrecto que un usuario de BI reportó, escribe un UPDATE directo sobre la tabla ancha de producción, sin tocar la dimensión de origen. Por qué pasa: el UPDATE directo se siente más rápido y más simple que rehacer todo el pipeline de construcción de la OBT. Cómo detectarlo: si la próxima vez que el pipeline corre, el dato "corregido" vuelve a aparecer con el valor viejo, es evidencia de que el UPDATE corrigió el síntoma sin corregir la causa — exactamente lo que la profundización de esta lección advirtió. Cómo corregirlo: cualquier corrección de dato debe aplicarse en la dimensión de origen (dim_product, en este caso) y propagarse regenerando la tabla derivada, nunca al revés.

Concluir que "22 filas actualizadas" significa que la OBT es 22 veces más cara de mantener que la dimensión. Qué pasa: alguien toma el conteo literal —1 contra 22— y lo interpreta como un factor de costo fijo, universal, aplicable a cualquier cambio futuro. Por qué pasa: el número es concreto y fácil de recordar, y es tentador tratarlo como una constante en vez de una medición específica de este cambio, sobre este dataset. Cómo detectarlo: si tu argumento en otro contexto cita "22x más caro" como una regla general, sin mencionar que depende de cuántas filas de la OBT contienen esa categoría específica, generalizaste de más. Cómo corregirlo: el factor real depende de la cardinalidad del cambio —cuántas filas de la tabla ancha tocan el valor que cambió—; en Kiosko, con solo cuatro productos y tres categorías, el factor es pequeño; en un catálogo de producción con miles de productos y millones de filas de ventas históricas, el mismo tipo de cambio podría tocar millones de filas, no veintidós.

Pensar que esta lección invalida el argumento de la lección 4. Qué pasa: alguien, después de ver el costo del UPDATE, concluye que la OBT fue un error y que la lección 4 estaba equivocada al defenderla. Por qué pasa: es fácil tratar cada lección como si compitiera con la anterior, en vez de sumar matices a la misma decisión. Cómo detectarlo: si tu conclusión de este módulo es "la OBT nunca vale la pena", perdiste el punto central de la lección 4 —el argumento depende del patrón de consulta y de qué tan seguido cambia el dato subyacente—. Cómo corregirlo: las categorías de un catálogo cambian con poca frecuencia —"beverages" a "drinks" es, en la práctica, un evento raro—; la ganancia de velocidad de consulta de la OBT (medida conceptualmente en la lección 4, y que la lección 7 va a confirmar con evidencia) ocurre en cada consulta, muchas veces al día. Un costo de mantenimiento raro, comparado contra una ganancia de consulta frecuente, sigue favoreciendo a la OBT para el caso de uso correcto — esta lección solo hace ese costo visible, no lo declara descalificante.

Ejercicios

Ejercicio 1 — Calcula el costo de renombrar "snacks" en vez de "beverages". Repite el experimento de esta lección, pero con la categoría "snacks" en vez de "beverages", y compara los tres conteos resultantes contra los de esta lección.

Ver solución
count1_snacks = con.sql("SELECT COUNT(*) FROM dim_category WHERE category_name = 'snacks'").fetchone()[0]
count2_snacks = con.sql("SELECT COUNT(*) FROM dim_product WHERE category = 'snacks'").fetchone()[0]
count3_snacks = con.sql("SELECT COUNT(*) FROM mart_daily_sales_obt WHERE category = 'snacks'").fetchone()[0]
print(f"dim_category: {count1_snacks}, dim_product: {count2_snacks}, mart_daily_sales_obt: {count3_snacks}")

Salida esperada:

dim_category: 1, dim_product: 1, mart_daily_sales_obt: 10

dim_category sigue tocando 1 fila (cada categoría, sin importar cuál, existe una sola vez). dim_product toca 1 fila —no 2, como con "beverages"—, porque solo P002 (Energy Bar) pertenece a "snacks". mart_daily_sales_obt toca 10 filas —no 22—, el conteo exacto de combinaciones día+tienda+producto donde se vendió P002 durante la semana. La proporción cambia con cada categoría, confirmando exactamente el punto del segundo error común de esta lección: el factor de costo no es una constante universal, depende de cuántos productos y cuántas ventas tiene la categoría específica que cambia.

Ejercicio 2 — Verifica que el JOIN entre dim_product y mart_daily_sales_obt quedó desincronizado después del UPDATE. Después de correr el ejemplo trabajado de esta lección (donde ya actualizaste las tres tablas), confirma que el category de dim_product y el category de mart_daily_sales_obt siguen siendo consistentes entre sí para P001 — y explica por qué esta consistencia fue una decisión deliberada de este ejercicio, no una garantía automática del sistema.

Ver solución
print(con.sql("""
    SELECT dp.product_id, dp.category AS category_en_dim_product, obt.category AS category_en_obt
    FROM dim_product dp
    JOIN (SELECT DISTINCT product_id, category FROM mart_daily_sales_obt) obt
      ON dp.product_id = obt.product_id
    WHERE dp.product_id = 'P001'
"""))

Salida esperada:

┌────────────┬──────────────────────────┬──────────────────┐
│ product_id │ category_en_dim_product  │ category_en_obt  │
│  varchar   │         varchar          │      varchar     │
├────────────┼──────────────────────────┼──────────────────┤
│ P001       │ drinks                   │ drinks           │
└────────────┴──────────────────────────┴──────────────────┘

Ambas tablas muestran "drinks" para P001, consistentes entre sí — pero esto solo es cierto porque el ejemplo trabajado de esta lección ejecutó los tres UPDATE explícitamente, uno por cada tabla. Si solo hubieras actualizado dim_product y olvidado mart_daily_sales_obt (o viceversa), las dos tablas quedarían desincronizadas, sin que ningún mecanismo automático del sistema te avisara — nada en DuckDB propaga un UPDATE de una tabla independiente a otra. Esta es exactamente la razón, nombrada en la profundización de esta lección, por la que un pipeline real regenera la OBT desde la fuente en vez de mantenerla sincronizada a mano con UPDATE paralelos.

Ejercicio 3 — Explica, sin código, un escenario de Kiosko donde el costo de dim_category normalizada sería aún más bajo que 1 fila. En 2-3 frases, describe una situación hipotética —no necesariamente sobre renombrar una categoría— donde tener dim_category separada permitiría un cambio con cero filas tocadas en las tablas de hechos o en la OBT.

Ver solución

Agregar un atributo completamente nuevo a las categorías —por ejemplo, una columna requires_refrigeration BOOLEAN, útil para logística de las tiendas de Kiosko— es un caso donde dim_category normalizada permite el cambio sin tocar ninguna fila de fact_orders, dim_product ni mart_daily_sales_obt: basta con ALTER TABLE dim_category ADD COLUMN requires_refrigeration BOOLEAN y poblar esa columna para las tres categorías existentes. Bajo la versión denormalizada (star o OBT), agregar ese mismo atributo requeriría, como mínimo, una columna nueva en cada tabla que ya almacena category como texto, y decidir cómo poblarla para cada fila existente — un cambio de esquema, no solo de dato, con un costo de migración mucho mayor que el simple ALTER TABLE sobre la dimensión normalizada.

Resumen y siguiente paso

En esta lección mediste, con un UPDATE real ejecutado sobre las tres formas, el costo concreto de mantener un valor de dimensión repetido: 1 fila en dim_category normalizada, 2 en dim_product (star), 22 en mart_daily_sales_obt (OBT). Entendiste, además, por qué el costo real de una tabla ancha en producción no es solo el conteo de UPDATE —es la disciplina de tratarla como una vista derivada que se regenera desde la fuente, nunca como una tabla que se edita directamente.

Antes de avanzar deberías poder: recitar de memoria los tres números de esta lección (1, 2, 22) y a qué tabla corresponde cada uno; explicar la diferencia entre "corregir el síntoma" y "corregir la causa" al actualizar un valor de dimensión; y nombrar un escenario donde dim_category normalizada permite un cambio con cero filas tocadas en las tablas de hechos.

La lección 7 completa el argumento en la dirección contraria: la misma pregunta de negocio, resuelta por los tres caminos, confirmando con evidencia que la OBT sí entrega la ganancia de velocidad de consulta que la lección 4 prometió — el otro lado de la balanza que esta lección acaba de pesar.

Recursos