Módulo 3: Star Vs Snowflake Vs One Big Table
Construyendo la OBT de ventas de Kiosko
Descripción
Esta es la lección central del módulo — el equivalente, para la tabla ancha, a lo que fue la lección 7 del módulo 2 para el star. Construyes mart_daily_sales_obt: una sola tabla, con quince columnas, donde cada fila ya trae la fecha, la tienda, el producto y la categoría —todo lo que antes vivía repartido en fact_orders + dim_store + dim_product + dim_date— junto, lista para que el equipo de BI de Kiosko consulte sin escribir un solo JOIN. Y, con la misma disciplina de evidencia que ya conoces, mides el costo real de esa comodidad: cuántas veces se repite cada valor de dimensión, cuántos bytes de texto lógico duplica, y cuánto pesa en disco comparado contra las cuatro tablas normalizadas del star.
Conexión con el módulo. Esta lección resuelve el tercer resultado ejecutable del módulo, el que el diseño de esta guía nombra explícitamente: CREATE TABLE mart_daily_sales_obt AS SELECT ... totalmente denormalizada, con conteo de columnas repetidas y tamaño aproximado de cada tabla como evidencia literal de la disyuntiva espacio-vs-velocidad.
Una analogía: sirviendo el plato completo, una vez por comensal y por día
Retoma la mesa ya servida de la lección 1. Esta lección decide, con precisión, cómo se sirve esa mesa: no un plato por cada ingrediente que alguien pida en el momento —eso sería, otra vez, el star, donde JOIN ensambla el plato en tiempo real—, sino un plato ya completo, preparado con antelación, para cada combinación de "quién come, qué pidió, y qué día". Si dos personas piden exactamente lo mismo el mismo día, el restaurante no prepara dos platos idénticos por separado — los junta en una sola porción más grande y la sirve una vez. Eso es, exactamente, lo que vas a ver en la fila que colapsa dos órdenes en una sola de mart_daily_sales_obt: cuando la misma tienda vende el mismo producto más de una vez el mismo día, la OBT no guarda una fila por cada venta individual — agrupa, sirve un plato con la cantidad y el revenue ya sumados, y ahí se queda, listo para quien llegue a consultarlo.
Ejemplo trabajado: mart_daily_sales_obt, construida y verificada
Reconstruye el star completo del módulo 2 —fact_orders, dim_store, dim_product, dim_date— y construye mart_daily_sales_obt con un CREATE TABLE ... AS SELECT que une las cuatro tablas y agrupa por día, tienda y producto.
# build_obt_mart.py
from datetime import date, timedelta, datetime
import duckdb
from kiosko import DIM_PRODUCT, DIM_STORE, Order, transform_fact_orders
from raw_orders import RAW_ORDERS
DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
def generate_date_dim(start_date: str, end_date: str) -> list[dict]:
start = date.fromisoformat(start_date)
end = date.fromisoformat(end_date)
rows = []
current = start
while current <= end:
weekday_index = current.weekday()
rows.append({
"date_key": int(current.strftime("%Y%m%d")),
"calendar_date": current,
"day_of_week": DAY_NAMES[weekday_index],
"month": current.month,
"quarter": (current.month - 1) // 3 + 1,
"year": current.year,
"is_weekend": weekday_index >= 5,
})
current += timedelta(days=1)
return rows
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
""")
dim_date_rows = generate_date_dim("2026-08-01", "2026-08-31")
con.execute("""
CREATE TABLE dim_date (
date_key INTEGER, calendar_date DATE, day_of_week VARCHAR,
month INTEGER, quarter INTEGER, year INTEGER, is_weekend BOOLEAN
)
""")
con.executemany(
"INSERT INTO dim_date VALUES (?, ?, ?, ?, ?, ?, ?)",
[(r["date_key"], r["calendar_date"], r["day_of_week"], r["month"],
r["quarter"], r["year"], r["is_weekend"]) for r in dim_date_rows],
)
print("=== Construyendo mart_daily_sales_obt: todo flattened, sin JOIN pendiente para el consumidor ===")
con.execute("""
CREATE TABLE mart_daily_sales_obt AS
SELECT
CAST(f.order_ts AS DATE) AS sale_date,
d.day_of_week,
d.is_weekend,
d.month,
d.quarter,
d.year,
s.store_id,
s.store_name,
s.city,
p.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
JOIN dim_date d ON CAST(strftime(f.order_ts, '%Y%m%d') AS INTEGER) = d.date_key
GROUP BY 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13
ORDER BY sale_date, store_id, product_id
""")
print(con.sql("SELECT COUNT(*) AS obt_rows FROM mart_daily_sales_obt"))
print("=== Primeras filas (columnas representativas) ===")
print(con.sql("""
SELECT sale_date, store_name, product_name, category, quantity, revenue
FROM mart_daily_sales_obt
ORDER BY sale_date, store_name, product_name
LIMIT 5
"""))
print("=== La fila que colapso dos ordenes: ORD-1001 + ORD-1008 (S01, P001, 2026-08-03) ===")
print(con.sql("""
SELECT sale_date, store_name, product_name, quantity, revenue
FROM mart_daily_sales_obt
WHERE sale_date = '2026-08-03' AND store_id = 'S01' AND product_id = 'P001'
"""))
print("=== Verificacion: el revenue total no cambio al pasar por la OBT ===")
obt_total = con.sql("SELECT ROUND(SUM(revenue), 2) FROM mart_daily_sales_obt").fetchone()[0]
fact_total = con.sql("SELECT ROUND(SUM(revenue), 2) FROM fact_orders").fetchone()[0]
print(f"fact_orders (7 columnas, 3 tablas de dimension aparte): {fact_total}")
print(f"mart_daily_sales_obt (15 columnas, sin JOIN pendiente): {obt_total}")
assert obt_total == fact_total, "la OBT perdio o inflo revenue"
print("Verificacion: el revenue coincide -- OK")
Qué esperar. Al correr python3 build_obt_mart.py, la salida es exactamente esta:
=== Construyendo mart_daily_sales_obt: todo flattened, sin JOIN pendiente para el consumidor ===
┌──────────┐
│ obt_rows │
│ int64 │
├──────────┤
│ 39 │
└──────────┘
=== Primeras filas (columnas representativas) ===
┌────────────┬───────────────┬───────────────────────┬─────────────┬──────────┬─────────┐
│ sale_date │ store_name │ product_name │ category │ quantity │ revenue │
│ date │ varchar │ varchar │ varchar │ int128 │ double │
├────────────┼───────────────┼───────────────────────┼─────────────┼──────────┼─────────┤
│ 2026-08-03 │ Kiosko Centro │ Bottled Water 600ml │ beverages │ 5 │ 2.75 │
│ 2026-08-03 │ Kiosko Centro │ Energy Bar │ snacks │ 1 │ 1.2 │
│ 2026-08-03 │ Kiosko Centro │ Phone Charger Cable │ electronics │ 1 │ 4.5 │
│ 2026-08-03 │ Kiosko Norte │ Energy Bar │ snacks │ 2 │ 2.4 │
│ 2026-08-03 │ Kiosko Norte │ Instant Coffee Sachet │ beverages │ 2 │ 1.5 │
└────────────┴───────────────┴───────────────────────┴─────────────┴──────────┴─────────┘
=== La fila que colapso dos ordenes: ORD-1001 + ORD-1008 (S01, P001, 2026-08-03) ===
┌────────────┬───────────────┬─────────────────────┬──────────┬─────────┐
│ sale_date │ store_name │ product_name │ quantity │ revenue │
│ date │ varchar │ varchar │ int128 │ double │
├────────────┼───────────────┼─────────────────────┼──────────┼─────────┤
│ 2026-08-03 │ Kiosko Centro │ Bottled Water 600ml │ 5 │ 2.75 │
└────────────┴───────────────┴─────────────────────┴──────────┴─────────┘
=== Verificacion: el revenue total no cambio al pasar por la OBT ===
fact_orders (7 columnas, 3 tablas de dimension aparte): 106.15
mart_daily_sales_obt (15 columnas, sin JOIN pendiente): 106.15
Verificacion: el revenue coincide -- OK
Detente en dos números. Primero, mart_daily_sales_obt tiene 39 filas, no 40 — una menos que fact_orders. Eso no es un error: ORD-1001 (Kiosko Centro, P001, tres unidades, 2026-08-03T08:14:00) y ORD-1008 (Kiosko Centro, P001, dos unidades, 2026-08-03T10:22:00) son dos órdenes distintas, en momentos distintos del mismo lunes, pero venden el mismo producto en la misma tienda el mismo día — exactamente la combinación que define una fila de mart_daily_sales_obt. El GROUP BY de la consulta las junta en una sola fila: 5 unidades (3 + 2), 2.75 de revenue (1.65 + 1.10). El grano de esta OBT no es "una línea de orden" —el grano de fact_orders—; es "un producto vendido en una tienda en un día", el grano que un tablero diario de ventas realmente necesita.
Segundo, el revenue total —106.15— sigue siendo exactamente el mismo, a pesar de que el número de filas cambió. Esto confirma algo importante sobre agregar (SUM) contra un grano más grueso: mientras la agregación sea consistente —sumar cantidad y revenue dentro de cada grupo, sin perder ninguna fila de fact_orders en el proceso—, el total de negocio no se altera, aunque la forma de la tabla sí. Perder una fila (39 en vez de 40) no es lo mismo que perder revenue — y la verificación final de este ejemplo confirma, con evidencia, que aquí ocurrió lo primero, no lo segundo.
Diagrama: de cuatro tablas a una, y el grano que cambia en el camino
flowchart TD
subgraph Star["Star (modulo 2): grano = una linea de orden"]
FO["fact_orders\n40 filas"]
DS["dim_store\n3 filas"]
DP["dim_product\n4 filas"]
DD["dim_date\n31 filas"]
end
FO -->|"JOIN store_id"| DS
FO -->|"JOIN product_id"| DP
FO -->|"JOIN date_key"| DD
DS --> OBT
DP --> OBT
DD --> OBT
FO -->|"GROUP BY dia+tienda+producto"| OBT["mart_daily_sales_obt\n39 filas -- grano mas grueso\n15 columnas, 0 JOIN pendientes"]
Profundización: la evidencia de espacio-vs-velocidad, medida y honesta
El diseño de esta guía pide, explícitamente, "conteo de columnas repetidas y tamaño aproximado de cada tabla como evidencia literal". Vale la pena reunir esa evidencia con el mismo rigor que el resto de esta guía —y ser honesto sobre lo que sí muestra y lo que no, a esta escala—.
Primero, el conteo de columnas repetidas — cuántas veces aparece el mismo valor de dimensión en la OBT:
print("\n=== Evidencia 1: columnas por tabla ===")
for t in ["fact_orders", "dim_store", "dim_product", "dim_date", "mart_daily_sales_obt"]:
ncols = con.sql(f"SELECT COUNT(*) FROM pragma_table_info('{t}')").fetchone()[0]
print(f" {t:22} {ncols:2} columnas")
print("\n=== Evidencia 2: cuantas veces se repite cada store_name en la OBT ===")
print(con.sql("SELECT store_name, COUNT(*) AS times_repeated FROM mart_daily_sales_obt GROUP BY store_name ORDER BY store_name"))
print("=== Evidencia 3: cuantas veces se repite cada product_name/category en la OBT ===")
print(con.sql("SELECT product_name, category, COUNT(*) AS times_repeated FROM mart_daily_sales_obt GROUP BY product_name, category ORDER BY product_name"))
=== Evidencia 1: columnas por tabla ===
fact_orders 7 columnas
dim_store 4 columnas
dim_product 5 columnas
dim_date 7 columnas
mart_daily_sales_obt 15 columnas
=== Evidencia 2: cuantas veces se repite cada store_name en la OBT ===
┌───────────────┬────────────────┐
│ store_name │ times_repeated │
│ varchar │ int64 │
├───────────────┼────────────────┤
│ Kiosko Centro │ 15 │
│ Kiosko Norte │ 13 │
│ Kiosko Sur │ 11 │
└───────────────┴────────────────┘
=== Evidencia 3: cuantas veces se repite cada product_name/category en la OBT ===
┌───────────────────────┬─────────────┬────────────────┐
│ product_name │ category │ times_repeated │
│ varchar │ varchar │ int64 │
├───────────────────────┼─────────────┼────────────────┤
│ Bottled Water 600ml │ beverages │ 15 │
│ Energy Bar │ snacks │ 10 │
│ Instant Coffee Sachet │ beverages │ 7 │
│ Phone Charger Cable │ electronics │ 7 │
└───────────────────────┴─────────────┴────────────────┘
"Kiosko Centro" vive una vez en dim_store — y se repite quince veces dentro de mart_daily_sales_obt. "Bottled Water 600ml" y "beverages" viven una vez cada uno en dim_product — y se repiten quince veces en la OBT. Esta es, literalmente, la duplicación que el argumento clásico contra la denormalización nombra: cuantificable, medible con una consulta, no una suposición.
Ahora, lo más interesante — y lo más honesto de reportar. A esta escala de juguete, ¿ese conteo de repeticiones se traduce en más bytes en disco?
import os
os.makedirs("kiosko_parquet", exist_ok=True)
for table in ["fact_orders", "dim_store", "dim_product", "dim_date", "mart_daily_sales_obt"]:
con.execute(f"COPY {table} TO 'kiosko_parquet/{table}.parquet' (FORMAT PARQUET)")
star_total = 0
print("=== Tamano en disco, Parquet, cada tabla del star ===")
for table in ["fact_orders", "dim_store", "dim_product", "dim_date"]:
size = os.path.getsize(f"kiosko_parquet/{table}.parquet")
star_total += size
print(f" {table:22} {size:5} bytes")
print(f" {'total star (4 tablas)':22} {star_total:5} bytes")
obt_size = os.path.getsize("kiosko_parquet/mart_daily_sales_obt.parquet")
print(f"\n {'mart_daily_sales_obt':22} {obt_size:5} bytes")
=== Tamano en disco, Parquet, cada tabla del star ===
fact_orders 2029 bytes
dim_store 688 bytes
dim_product 967 bytes
dim_date 1435 bytes
total star (4 tablas) 5119 bytes
mart_daily_sales_obt 3572 bytes
Sorpresa: a esta escala, mart_daily_sales_obt (3572 bytes) pesa menos que las cuatro tablas del star sumadas (5119 bytes), a pesar de repetir texto en cada fila. Esto no contradice el argumento clásico — lo pone en su contexto correcto. Tres factores explican esta inversión, y vale la pena nombrarlos con precisión, no dejarlos como un misterio:
Primero, cada archivo Parquet tiene un costo fijo de metadatos —encabezados, esquema, estadísticas por columna— que no depende del número de filas. Con solo entre 3 y 39 filas por tabla, ese costo fijo pesa proporcionalmente mucho más que el dato en sí; a escala de producción, con millones de filas, ese costo fijo se vuelve insignificante frente al dato real.
Segundo, dim_date —31 filas, la tabla de calendario completa de agosto— no tiene ninguna relación directa con el tamaño de mart_daily_sales_obt, que solo usa 7 de esos 31 días (los que realmente tuvieron ventas). El star paga el costo completo de una dimensión de calendario generada de antemano; la OBT, al construirse a partir de las ventas reales, nunca almacena las fechas sin actividad.
Tercero, y el más importante: el dictionary encoding de Parquet comprime exactamente el tipo de redundancia que esta lección acaba de medir. Cuando una columna tiene pocos valores distintos repetidos muchas veces —store_name con solo tres valores posibles en 39 filas—, Parquet guarda cada valor único una sola vez en un diccionario, y reemplaza cada aparición por una referencia corta. A la escala de Kiosko, con solo tres tiendas y cuatro productos, ese diccionario es minúsculo y la compresión es casi perfecta — la duplicación lógica que mediste en la Evidencia 2 y 3 casi desaparece en el archivo comprimido.
Ninguno de estos tres factores invalida el benchmark de Fivetran citado en la lección anterior —25% a 50% más rápido, 2-3x más almacenamiento, medido sobre datos reales en Redshift, Snowflake y BigQuery—: a escala de producción, con millones de filas y decenas de columnas descriptivas por dimensión, el costo fijo de metadatos se diluye, y la compresión por diccionario deja de ser "casi perfecta" porque el número de combinaciones únicas de valores repetidos crece con el volumen. El dataset de Kiosko es de juguete a propósito —cientos de filas, no millones—, y esta lección midió su tamaño con total honestidad: a esta escala específica, la OBT no cuesta más espacio. El patrón se revierte a escala real, y el benchmark de Fivetran es la evidencia de esa reversión, no esta medición de Kiosko.
Errores comunes
Concluir, a partir del tamaño en bytes de Kiosko, que "la OBT nunca cuesta espacio". Qué pasa: alguien ve que mart_daily_sales_obt pesa menos que las cuatro tablas del star sumadas, y generaliza esa observación —válida solo a esta escala de juguete— como una propiedad universal de las tablas anchas. Por qué pasa: el número está justo ahí, medido y literal — es tentador tomarlo como la conclusión final del módulo, sin leer la explicación de por qué ocurre a esta escala específica. Cómo detectarlo: si tu argumento para justificar una OBT en un proyecto real cita "en Kiosko, la OBT pesaba menos", sin mencionar el benchmark de Fivetran a escala de producción, tienes esta confusión. Cómo corregirlo: la profundización de esta lección explica, con tres factores concretos, por qué la inversión ocurre a esta escala y por qué se revierte con volumen real — repásala antes de generalizar cualquier conclusión sobre espacio.
Confundir el grano de mart_daily_sales_obt con el grano de fact_orders. Qué pasa: alguien, acostumbrado a que fact_orders tenga cuarenta filas —una por línea de orden—, espera que mart_daily_sales_obt también tenga cuarenta, y se alarma al ver 39. Por qué pasa: todas las tablas construidas hasta ahora en esta guía preservaron el mismo número de filas que fact_orders; esta es la primera vez que una tabla nueva tiene, deliberadamente, un grano distinto. Cómo detectarlo: si esperas assert before == after con before = 40 para mart_daily_sales_obt, como en las verificaciones de joins de módulos anteriores, vas a fallar esa comparación por una razón que no es un bug. Cómo corregirlo: recuerda que mart_daily_sales_obt agrupa por día + tienda + producto — un grano más grueso que "una línea de orden" a propósito, porque es el grano que un tablero de ventas diarias realmente necesita. La verificación correcta para esta tabla no es "mismo número de filas", es "mismo revenue total" — exactamente lo que el ejemplo trabajado de esta lección verificó.
Olvidar el GROUP BY y terminar con una OBT al grano equivocado. Qué pasa: alguien copia la estructura de SELECT de esta lección pero olvida agregar el GROUP BY con la lista completa de columnas no agregadas, y DuckDB lanza un error de sintaxis (o, en un motor menos estricto, produce un resultado ambiguo). Por qué pasa: con quince columnas en el SELECT, es fácil perder la cuenta de cuáles están agregadas (SUM) y cuáles necesitan aparecer en el GROUP BY. Cómo detectarlo: DuckDB, como la mayoría de motores SQL modernos, exige que toda columna no agregada en el SELECT aparezca también en el GROUP BY — si te falta una, vas a recibir un error explícito antes de que la tabla se construya, no un resultado silenciosamente incorrecto. Cómo corregirlo: cuenta las columnas de tu SELECT que no llevan SUM() u otra función de agregación, y confirma que el GROUP BY liste exactamente esas mismas posiciones — trece en este caso, las columnas 1 a 13 de la consulta de esta lección.
Ejercicios
Ejercicio 1 — Confirma que 39 es exactamente 40 - 1, no una coincidencia. Usando fact_orders, escribe una consulta que confirme cuántas combinaciones de (día, tienda, producto) tienen más de una orden — y verifica que ese número explica exactamente la diferencia entre 40 filas de fact_orders y 39 filas de mart_daily_sales_obt.
Ver solución
print(con.sql("""
SELECT CAST(order_ts AS DATE) AS d, store_id, product_id, COUNT(*) AS n
FROM fact_orders
GROUP BY 1, 2, 3
HAVING COUNT(*) > 1
ORDER BY 1, 2, 3
"""))
Salida esperada:
┌────────────┬──────────┬────────────┬───────┐
│ d │ store_id │ product_id │ n │
│ date │ varchar │ varchar │ int64 │
├────────────┼──────────┼────────────┼───────┤
│ 2026-08-03 │ S01 │ P001 │ 2 │
└────────────┴──────────┴────────────┴───────┘
Existe exactamente una combinación de (día, tienda, producto) con más de una orden: 2026-08-03, S01, P001, con n = 2 — las dos órdenes, ORD-1001 y ORD-1008, que la OBT colapsó en una sola fila. Cada combinación adicional con n > 1 reduce el conteo de filas de la OBT en n - 1 respecto a fact_orders — aquí, una sola combinación con n = 2 reduce el conteo en 2 - 1 = 1, exactamente la diferencia entre 40 y 39 que viste en el ejemplo trabajado.
Ejercicio 2 — Calcula el revenue promedio por fila, star vs OBT, y explica la diferencia. Usando fact_orders y mart_daily_sales_obt, calcula SUM(revenue) / COUNT(*) en cada tabla y compara los dos resultados.
Ver solución
print(con.sql("""
SELECT
(SELECT ROUND(SUM(revenue) / COUNT(*), 4) FROM fact_orders) AS avg_per_fact_row,
(SELECT ROUND(SUM(revenue) / COUNT(*), 4) FROM mart_daily_sales_obt) AS avg_per_obt_row
"""))
Salida esperada:
┌──────────────────┬──────────────────┐
│ avg_per_fact_row │ avg_per_obt_row │
│ double │ double │
├──────────────────┼──────────────────┤
│ 2.6537 │ 2.7218 │
└──────────────────┴──────────────────┘
El promedio por fila es ligeramente mayor en la OBT (2.7218 contra 2.6537), porque el mismo revenue total (106.15) se reparte entre menos filas (39 en vez de 40) — la fila colapsada suma el revenue de dos órdenes en una sola. Esto no significa que el revenue "aumentó" en ningún sentido real: el total sigue siendo idéntico; lo que cambia es cuántas filas dividen ese total al calcular un promedio. Es exactamente el tipo de trampa a la que hay que prestar atención cuando se trabaja con tablas de grano distinto: un promedio calculado sobre fact_orders y un promedio calculado sobre mart_daily_sales_obt no son comparables directamente, porque el denominador (COUNT(*)) mide cosas distintas en cada tabla.
Ejercicio 3 — Explica, sin código, por qué dim_date "pesa de más" en el star a esta escala. En 2-3 frases, explica por qué dim_date —con 31 filas, una por cada día de agosto— contribuye al tamaño total del star aunque solo 7 de esos días tuvieran ventas reales de Kiosko, y por qué esa característica no aplica de la misma forma a mart_daily_sales_obt.
Ver solución
dim_date se genera de antemano, para un rango de fechas completo —el mes entero de agosto, como aprendiste en el módulo 2—, sin mirar si hubo o no ventas en cada día; esa es, precisamente, la propiedad de independencia que la hace útil como dimensión conformada, reutilizable para cualquier hecho futuro. El costo de esa independencia es que dim_date almacena filas para los 24 días sin ninguna venta de Kiosko, junto con los 7 días que sí tuvieron actividad — ese "peso extra" contribuye al tamaño total del star sin aportar ningún dato a las consultas actuales. mart_daily_sales_obt, en cambio, se construye a partir de las ventas reales agrupadas —nunca contiene una fila para un día sin ventas, porque no hay ningún fact_orders que agrupar en esa fecha—, así que no paga ese mismo costo de "cobertura completa de calendario" que sí paga el star.
Resumen y siguiente paso
En esta lección construiste mart_daily_sales_obt: quince columnas, treinta y nueve filas —una menos que fact_orders, porque dos órdenes del mismo producto en la misma tienda el mismo día se agruparon en una sola—, cero JOIN pendientes para quien la consulte, y el mismo revenue total de siempre, 106.15, verificado. Mediste, con evidencia literal, cuántas veces se repite cada valor de dimensión (quince, trece, once para las tres tiendas), y descubriste —honestamente, no a pesar de la sorpresa— que a esta escala de juguete el tamaño en disco no confirma el patrón clásico, y entendiste con precisión por qué.
Antes de avanzar deberías poder: explicar de memoria por qué mart_daily_sales_obt tiene 39 filas y no 40; nombrar los tres factores que explican por qué su tamaño en Parquet no refleja el patrón de producción a esta escala; y describir la diferencia entre "perder una fila por agregación" y "perder revenue por un error".
Las lecciones 6 y 7 cierran el argumento del módulo con evidencia concreta de cuándo cada forma gana: la lección 6 mide el costo real de mantener un valor repetido en las tres formas; la lección 7 resuelve la misma pregunta de negocio por los tres caminos y confirma que dan el mismo resultado, con costos de consulta muy distintos.
Recursos
- DuckDB — documentación oficial de
COPY ... TO ... (FORMAT PARQUET), el comando usado en esta lección para exportar cada tabla y medir su tamaño real en disco. duckdb.org/docs/current/sql/statements/copy. En inglés. - Apache Parquet — documentación oficial, incluida la explicación de dictionary encoding que sostiene por qué la OBT de esta lección comprime tan bien a escala de juguete. parquet.apache.org/docs. En inglés.
- Fivetran — "Star Schema vs. OBT for Data Warehouse Performance" — el benchmark a escala de producción que contextualiza —y contrasta— el resultado de tamaño medido en esta lección. fivetran.com/blog/star-schema-vs-obt. En inglés.
- DuckDB — documentación de funciones de agregación (
SUM,GROUP BY), la base de la construcción demart_daily_sales_obten esta lección. duckdb.org/docs/current/sql/functions/aggregates. En inglés.