Módulo 3: Star Vs Snowflake Vs One Big Table
Cuándo la OBT todavía gana
Descripción
La lección anterior le puso número al costo de mantener una tabla ancha: veintidós filas para renombrar una categoría, contra una sola en la dimensión normalizada. Sería igual de fácil, después de esa lección, concluir que la OBT nunca vale la pena. Esta lección cierra el argumento en la dirección contraria, con la misma disciplina: toma la pregunta de negocio que la lección 4 usó como ejemplo conceptual —revenue por categoría, por ciudad, separado por fin de semana— y la resuelve, de verdad, por los tres caminos. Vas a confirmar, con una comparación programática, que las tres formas dan exactamente el mismo resultado — y vas a ver, con tus propios ojos, cuál de las tres consultas fue más simple de escribir.
Conexión con el módulo. Esta lección ejecuta, con datos reales, la comparación que la lección 4 solo contó en abstracto (3 JOIN, 4 JOIN, 0 JOIN). Cierra el argumento espacio-vs-velocidad con la mitad que faltaba: la velocidad, no solo el espacio.
Una analogía: la misma pregunta, hecha tres veces, a tres empleados distintos
Imagina que le haces la misma pregunta a tres empleados de Kiosko, cada uno organizado de una forma distinta. Al primero —que trabaja con el star— le preguntas "¿cuánto vendimos de bebidas, en Bogotá, los fines de semana?", y tiene que ir al archivador de ventas, cruzarlo con el archivador de productos, cruzarlo con el archivador de tiendas, y cruzarlo con el calendario — cuatro pasos, pero cada archivador es pequeño y fácil de consultar. Al segundo —que trabaja con el snowflake— le haces la misma pregunta, pero su archivador de productos está, a su vez, dividido en dos: primero tiene que ir al archivador de productos, y de ahí saltar a un archivador de categorías aparte — un paso más que el primer empleado. Al tercero —que trabaja con la OBT— le haces la misma pregunta, y la responde mirando un solo reporte diario ya armado, sin ir a ningún archivador — la respuesta está ahí, lista, con solo sumar las filas correctas.
Los tres van a darte el mismo número. Esta lección lo confirma, no lo supone. Pero el tercer empleado, con el reporte ya armado, es el que responde más rápido — y esa es, precisamente, la ganancia de velocidad que justifica el costo de mantenimiento que mediste en la lección anterior.
Ejemplo trabajado: la misma pregunta, tres caminos, un resultado
Reconstruye el star, el snowflake y la OBT —las tres formas completas de este módulo— y responde la misma pregunta de negocio por cada camino: revenue por categoría, por ciudad de tienda, separado en fin de semana contra entre semana.
# same_question_three_shapes.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")
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 dim_product_normalized AS
SELECT ROW_NUMBER() OVER (ORDER BY n.product_id) AS product_key, n.product_id, n.product_name, c.category_id, n.unit_cost
FROM dim_product_natural n JOIN dim_category c ON n.category = c.category_name
""")
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])
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
""")
# la misma pregunta de negocio, resuelta por los tres caminos
star_q = """
SELECT p.category, s.city, d.is_weekend, ROUND(SUM(f.revenue), 2) AS revenue
FROM fact_orders f
JOIN dim_product p ON f.product_id = p.product_id
JOIN dim_store s ON f.store_id = s.store_id
JOIN dim_date d ON CAST(strftime(f.order_ts, '%Y%m%d') AS INTEGER) = d.date_key
GROUP BY p.category, s.city, d.is_weekend
ORDER BY p.category, s.city, d.is_weekend
"""
snowflake_q = """
SELECT c.category_name AS category, s.city, d.is_weekend, ROUND(SUM(f.revenue), 2) AS revenue
FROM fact_orders f
JOIN dim_product_normalized p ON f.product_id = p.product_id
JOIN dim_category c ON p.category_id = c.category_id
JOIN dim_store s ON f.store_id = s.store_id
JOIN dim_date d ON CAST(strftime(f.order_ts, '%Y%m%d') AS INTEGER) = d.date_key
GROUP BY c.category_name, s.city, d.is_weekend
ORDER BY c.category_name, s.city, d.is_weekend
"""
obt_q = """
SELECT category, city, is_weekend, ROUND(SUM(revenue), 2) AS revenue
FROM mart_daily_sales_obt
GROUP BY category, city, is_weekend
ORDER BY category, city, is_weekend
"""
print("Pregunta: revenue por categoria, por ciudad de tienda, separado fin de semana / entre semana\n")
print("=== star: 3 JOIN ===")
print(con.sql(star_q))
print("=== snowflake: 4 JOIN ===")
print(con.sql(snowflake_q))
print("=== OBT: 0 JOIN ===")
print(con.sql(obt_q))
star_rows = con.sql(star_q).fetchall()
sf_rows = con.sql(snowflake_q).fetchall()
obt_rows = con.sql(obt_q).fetchall()
print(f"Filas: star={len(star_rows)}, snowflake={len(sf_rows)}, obt={len(obt_rows)}")
print(f"star == snowflake: {star_rows == sf_rows}")
print(f"star == obt: {star_rows == obt_rows}")
Qué esperar. Al correr python3 same_question_three_shapes.py, la salida es exactamente esta:
Pregunta: revenue por categoria, por ciudad de tienda, separado fin de semana / entre semana
=== star: 3 JOIN ===
┌─────────────┬──────────┬────────────┬─────────┐
│ category │ city │ is_weekend │ revenue │
│ varchar │ varchar │ boolean │ double │
├─────────────┼──────────┼────────────┼─────────┤
│ beverages │ Bogota │ false │ 10.35 │
│ beverages │ Bogota │ true │ 5.15 │
│ beverages │ Lima │ false │ 9.6 │
│ beverages │ Lima │ true │ 6.1 │
│ beverages │ Santiago │ false │ 9.0 │
│ beverages │ Santiago │ true │ 3.85 │
│ electronics │ Bogota │ false │ 9.0 │
│ electronics │ Bogota │ true │ 9.0 │
│ electronics │ Lima │ false │ 13.5 │
│ electronics │ Santiago │ false │ 4.5 │
│ electronics │ Santiago │ true │ 4.5 │
│ snacks │ Bogota │ false │ 4.8 │
│ snacks │ Lima │ false │ 4.8 │
│ snacks │ Lima │ true │ 4.8 │
│ snacks │ Santiago │ false │ 4.8 │
│ snacks │ Santiago │ true │ 2.4 │
└─────────────┴──────────┴────────────┴─────────┘
16 rows 4 columns
=== snowflake: 4 JOIN ===
┌─────────────┬──────────┬────────────┬─────────┐
│ category │ city │ is_weekend │ revenue │
│ varchar │ varchar │ boolean │ double │
├─────────────┼──────────┼────────────┼─────────┤
│ beverages │ Bogota │ false │ 10.35 │
│ beverages │ Bogota │ true │ 5.15 │
│ beverages │ Lima │ false │ 9.6 │
│ beverages │ Lima │ true │ 6.1 │
│ beverages │ Santiago │ false │ 9.0 │
│ beverages │ Santiago │ true │ 3.85 │
│ electronics │ Bogota │ false │ 9.0 │
│ electronics │ Bogota │ true │ 9.0 │
│ electronics │ Lima │ false │ 13.5 │
│ electronics │ Santiago │ false │ 4.5 │
│ electronics │ Santiago │ true │ 4.5 │
│ snacks │ Bogota │ false │ 4.8 │
│ snacks │ Lima │ false │ 4.8 │
│ snacks │ Lima │ true │ 4.8 │
│ snacks │ Santiago │ false │ 4.8 │
│ snacks │ Santiago │ true │ 2.4 │
└─────────────┴──────────┴────────────┴─────────┘
16 rows 4 columns
=== OBT: 0 JOIN ===
┌─────────────┬──────────┬────────────┬─────────┐
│ category │ city │ is_weekend │ revenue │
│ varchar │ varchar │ boolean │ double │
├─────────────┼──────────┼────────────┼─────────┤
│ beverages │ Bogota │ false │ 10.35 │
│ beverages │ Bogota │ true │ 5.15 │
│ beverages │ Lima │ false │ 9.6 │
│ beverages │ Lima │ true │ 6.1 │
│ beverages │ Santiago │ false │ 9.0 │
│ beverages │ Santiago │ true │ 3.85 │
│ electronics │ Bogota │ false │ 9.0 │
│ electronics │ Bogota │ true │ 9.0 │
│ electronics │ Lima │ false │ 13.5 │
│ electronics │ Santiago │ false │ 4.5 │
│ electronics │ Santiago │ true │ 4.5 │
│ snacks │ Bogota │ false │ 4.8 │
│ snacks │ Lima │ false │ 4.8 │
│ snacks │ Lima │ true │ 4.8 │
│ snacks │ Santiago │ false │ 4.8 │
│ snacks │ Santiago │ true │ 2.4 │
└─────────────┴──────────┴────────────┴─────────┘
16 rows 4 columns
Filas: star=16, snowflake=16, obt=16
star == snowflake: True
star == obt: True
Dieciséis filas, en las tres formas, con exactamente los mismos valores de revenue en el mismo orden — la comparación programática star_rows == sf_rows y star_rows == obt_rows lo confirma con un True literal, no con una inspección visual. Esto es la mitad de la evidencia que esta lección necesita: los tres caminos son intercambiables en cuanto al resultado. La otra mitad está en el código fuente que acabas de leer: cuenta las líneas de la consulta obt_q contra las líneas de star_q y snowflake_q. obt_q es un SELECT ... GROUP BY ... ORDER BY de cinco líneas, sin un solo JOIN. star_q necesita tres JOIN explícitos; snowflake_q, cuatro. Para alguien del equipo de BI de Kiosko que no conoce de memoria el modelo dimensional completo —qué tabla tiene qué columna, cómo se llama la llave de unión—, la consulta contra la OBT es, objetivamente, más simple de escribir correctamente a la primera.
Diagrama: la misma respuesta, tres esfuerzos de consulta
flowchart LR
Q["Revenue por categoria,\npor ciudad, por fin de semana"]
Q --> S["star\n3 JOIN explicitos\n16 filas"]
Q --> SF["snowflake\n4 JOIN explicitos\n16 filas"]
Q --> O["OBT\n0 JOIN, solo GROUP BY\n16 filas"]
S --> R["Mismo resultado,\nverificado == True"]
SF --> R
O --> R
Profundización: la OBT como capa de servicio, no como reemplazo del modelo
Vale la pena cerrar esta lección regresando al argumento de dataarchitect.studio que la lección 4 citó: el star sigue siendo el modelo central, y la OBT es una capa de servicio construida encima. Esta lección acaba de mostrar, con evidencia ejecutada, por qué esa capa de servicio tiene valor real: no porque el star sea "malo" —sigue siendo perfectamente capaz de responder la misma pregunta, con el mismo resultado correcto—, sino porque quién consulta la tabla ancha, en un caso de uso real, casi nunca es la misma persona que diseñó el modelo dimensional.
Piensa en el equipo de BI de Kiosko —analistas de negocio, no ingenieros de datos— construyendo un tablero de ventas diarias que un gerente de tienda va a revisar cada mañana. Ese analista no necesita saber que category vive en dim_product, ni que dim_date requiere una conversión de tipo para unirse contra order_ts — información que sí es indispensable para alguien que mantiene el modelo dimensional, pero que es puro ruido para alguien que solo quiere responder "¿cuánto vendimos ayer, por categoría?". mart_daily_sales_obt traduce el modelo dimensional completo —con toda su disciplina de llaves sustitutas, dimensiones conformadas y normalización cuando corresponde— a una forma que ese analista puede consultar sin tener que aprenderlo primero. El costo de mantenimiento que mediste en la lección 6 lo paga el equipo que mantiene el pipeline de datos, una vez, cuando regenera la tabla; la ganancia de simplicidad la cobra cada analista, cada vez que abre su herramienta de BI y escribe una consulta sin JOIN. Esa asimetría —un costo pagado pocas veces, un beneficio cobrado muchas veces— es, en una frase, todo el argumento de este módulo.
Errores comunes
Pensar que "0 JOIN" significa que la OBT no tiene ningún costo de complejidad. Qué pasa: alguien, impresionado por la simplicidad de obt_q, concluye que consultar la OBT no requiere ningún conocimiento del negocio, solo saber escribir un GROUP BY. Por qué pasa: la ausencia de JOIN en el código SQL se siente como ausencia total de complejidad. Cómo detectarlo: si alguien consulta mart_daily_sales_obt sin entender que su grano es "un producto vendido en una tienda en un día" —no "una línea de orden"—, puede escribir un AVG(revenue) esperando el promedio por venta individual, y obtener, sin ningún error, un número que en realidad promedia por combinación día-tienda-producto (el mismo problema que viste en el ejercicio 2 de la lección 5). Cómo corregirlo: la OBT simplifica la sintaxis de la consulta —el "cómo"—, pero no elimina la necesidad de entender el grano y el significado de cada columna — el "qué". Documentar el grano de una tabla ancha, con la misma disciplina que esta guía aplicó a fact_orders desde el módulo 1, sigue siendo obligatorio.
Usar la comparación == de Python entre resultados de tipos numéricos distintos y confiar en que siempre funciona. Qué pasa: alguien copia el patrón de verificación star_rows == obt_rows de esta lección para comparar resultados donde una columna numérica viene como int en una consulta y como float o Decimal en otra, y la comparación falla por una diferencia de tipo, no de valor real. Por qué pasa: Python compara tuplas elemento por elemento, y 1 == 1.0 es True, pero Decimal('1.00') == 1.0 puede comportarse de forma menos obvia dependiendo del contexto. Cómo detectarlo: si tu verificación == da False aunque los números "se vean iguales" al imprimirlos, sospecha de una diferencia de tipo subyacente antes de asumir que el dato realmente difiere. Cómo corregirlo: en esta lección, las tres consultas usan ROUND(SUM(revenue), 2) de forma consistente en los tres caminos, garantizando que el tipo y la precisión de revenue sean idénticos en las tres tuplas comparadas — esa consistencia deliberada es la que hace que == sea una verificación confiable aquí.
Concluir que, porque la OBT ganó esta comparación de simplicidad, siempre debería ser la forma por defecto para nuevas tablas. Qué pasa: alguien, convencido por esta lección, decide que todo modelo nuevo de Kiosko debería construirse directamente como tabla ancha, saltándose el star schema por completo. Por qué pasa: haber visto la ganancia de simplicidad de cerca hace fácil olvidar el costo de mantenimiento que la lección 6 ya midió, y la condición —patrón de consulta conocido y repetido— que la lección 4 estableció como requisito. Cómo detectarlo: si tu plan para un hecho de negocio completamente nuevo, sin ningún consumidor de BI todavía identificado, es construir directamente una tabla ancha sin pasar primero por un star schema, te falta la disciplina de secuencia de esta guía. Cómo corregirlo: recuerda el argumento en capas de dataarchitect.studio — el star se construye primero, como modelo flexible y central; la OBT se construye después, encima del star, cuando ya existe un consumidor concreto con un patrón de consulta conocido que la justifique. Esta guía nunca construyó la OBT antes que el star, y esa secuencia no fue casualidad.
Ejercicios
Ejercicio 1 — Cuenta las líneas de código SQL de cada consulta. Sin ejecutar nada nuevo, cuenta cuántas líneas de SQL (sin contar líneas vacías) tiene cada una de las tres consultas del ejemplo trabajado (star_q, snowflake_q, obt_q), y confirma que el orden de complejidad coincide con el número de JOIN de cada una.
Ver solución
star_q tiene 7 líneas de SQL (SELECT, tres JOIN, GROUP BY, ORDER BY, más la línea de columnas). snowflake_q tiene 8 líneas (una línea más, por el JOIN adicional contra dim_category). obt_q tiene 4 líneas (SELECT, FROM, GROUP BY, ORDER BY, sin ningún JOIN). El orden —obt_q más corta, star_q en el medio, snowflake_q la más larga— coincide exactamente con el número de JOIN de cada una (0, 3, 4). Más líneas de código no es automáticamente "peor" —el star y el snowflake ganan en otras dimensiones, como flexibilidad y costo de mantenimiento, medidas en lecciones anteriores—, pero para el criterio específico de "qué tan simple es de escribir correctamente a la primera", el conteo de líneas es una señal razonable, y esta lección la confirma con números, no con intuición.
Ejercicio 2 — Resuelve una cuarta pregunta por los tres caminos y verifica que coincide. Escribe las tres versiones (star, snowflake, OBT) de la pregunta "revenue total por día de la semana" (sin desglose de categoría, ciudad o fin de semana), y confirma con == que las tres dan el mismo resultado.
Ver solución
star_dow = con.sql("""
SELECT d.day_of_week, ROUND(SUM(f.revenue), 2) AS revenue
FROM fact_orders f
JOIN dim_date d ON CAST(strftime(f.order_ts, '%Y%m%d') AS INTEGER) = d.date_key
GROUP BY d.day_of_week
ORDER BY MIN(d.calendar_date)
""").fetchall()
obt_dow = con.sql("""
SELECT day_of_week, ROUND(SUM(revenue), 2) AS revenue
FROM mart_daily_sales_obt
GROUP BY day_of_week
ORDER BY MIN(sale_date)
""").fetchall()
print(f"star == obt: {star_dow == obt_dow}")
print(star_dow)
Salida esperada:
star == obt: True
[('Monday', 15.85), ('Tuesday', 15.85), ('Wednesday', 9.55), ('Thursday', 11.05), ('Friday', 18.05), ('Saturday', 31.85), ('Sunday', 3.95)]
Los mismos siete valores de revenue por día de la semana que ya viste en el ejercicio 1 del proyecto del módulo 2 (15.85, 15.85, 9.55, 11.05, 18.05, 31.85, 3.95), ahora confirmados como idénticos entre el camino star (con JOIN contra dim_date) y el camino OBT (sin ningún JOIN, day_of_week ya es una columna propia de mart_daily_sales_obt). Esta pregunta necesita solo un JOIN bajo el star —más simple que el ejemplo trabajado de esta lección—, así que la ventaja relativa de la OBT es menor aquí que en preguntas con más desgloses simultáneos, consistente con el ejercicio 1 de la lección 4.
Ejercicio 3 — Explica, sin código, por qué la comparación == de esta lección es más fuerte que revisar las tablas "a simple vista". En 2-3 frases, explica qué tipo de error podría pasar desapercibido si solo compararas los tres resultados imprimidos, mirándolos uno al lado del otro, en vez de usar star_rows == obt_rows en código.
Ver solución
Comparar visualmente tres tablas de dieciséis filas cada una es propenso a error humano — es fácil que el ojo pase por alto una diferencia pequeña, como un 9.0 contra un 9.00 (aunque sean el mismo valor numérico), o una fila en un orden ligeramente distinto entre las tres tablas, especialmente si las miras en pantallas o momentos distintos, no una junto a la otra. La comparación == en código, en cambio, es exhaustiva y precisa por construcción: compara cada tupla, en cada posición, sin fatiga ni distracción, y devuelve False ante cualquier diferencia real, sin importar qué tan sutil sea. Esta es la misma razón, ya establecida desde el módulo 1 de esta guía, por la que cada "Qué esperar" de esta guía se verifica con una consulta o una comparación de código —assert, ==—, nunca solo con una inspección visual de la salida.
Resumen y siguiente paso
En esta lección resolviste la misma pregunta de negocio por los tres caminos de este módulo —star, snowflake, OBT— y confirmaste, con una comparación programática (==, no inspección visual), que los tres dan exactamente el mismo resultado: dieciséis filas, mismo revenue. La diferencia entre ellos no está en la corrección —los tres son correctos—, está en el esfuerzo de consulta: cero JOIN en la OBT contra tres en el star y cuatro en el snowflake. Entendiste, además, por qué esa ganancia de simplicidad tiene más valor cuando quien consulta no es la misma persona que mantiene el modelo dimensional.
Antes de avanzar deberías poder: explicar por qué la comparación == de esta lección es más confiable que revisar tablas visualmente; contar de memoria el número de JOIN de cada una de las tres consultas del ejemplo trabajado; y recitar la asimetría central de este módulo — un costo de mantenimiento pagado pocas veces, contra un beneficio de consulta cobrado muchas veces.
La lección 8 —el mini-proyecto de cierre— reúne las tres formas en una sola entrega formal: construidas a la vez, verificadas contra el mismo revenue de siempre, y documentadas en una declaración que compara, número por número, cuándo cada una gana.
Recursos
- dataarchitect.studio — "One Big Table vs the Star Schema: The Real Trade-off" — la fuente de la arquitectura en capas (star como base, OBT como servicio) que esta lección confirma con evidencia ejecutada. dataarchitect.studio/essays/one-big-table-vs-star-schema. En inglés.
- Fivetran — "Star Schema vs. OBT for Data Warehouse Performance" — el benchmark de producción que sostiene, a escala real, la ganancia de velocidad que esta lección confirma en la estructura de las consultas de Kiosko. fivetran.com/blog/star-schema-vs-obt. En inglés.
- Microsoft Learn — "Understand star schema and the importance for Power BI" — la sección sobre snowflake dimensions documenta, desde la perspectiva de una herramienta de BI real, por qué "menos tablas" suele preferirse para el consumidor final. learn.microsoft.com/en-us/power-bi/guidance/star-schema. En inglés.
- DuckDB — documentación oficial del cliente Python, la herramienta que ejecutó cada verificación de esta lección. duckdb.org/docs/current/clients/python/overview. En inglés.