Data Warehouses y Data Lakes
"Si tu warehouse es un montón de tablas crudas igual que producción, no es un warehouse — es un Frankenstein."
Qué vas a aprender en este capítulo
Un data warehouse no es solo "una BD grande". Tiene una estructura específica diseñada para análisis: tablas de hechos, dimensiones, modelado dimensional. Este capítulo cubre cómo diseñarlo, llenarlo (ETL/ELT) y consultarlo eficientemente.
3.1 Modelado dimensional — Kimball
💡 Intuición
Para análisis, no querés normalizar a 3FN como en OLTP. Querés desnormalizar para que las queries sean rápidas y comprensibles para analistas no técnicos.
Ralph Kimball (años 90) propuso el modelado dimensional: organizá los datos en torno a "hechos" (lo que pasó) y "dimensiones" (el contexto del hecho).
📐 Fundamento
Star schema:
[dim_tiempo]
│
│
[dim_cliente] ───── [fact_ventas] ───── [dim_platillo]
│
│
[dim_local]
- Tabla de hechos (fact): mediciones numéricas (ventas, clicks, eventos). Una fila por evento.
- Tablas de dimensión: descripciones (cliente, producto, tiempo, ubicación). Una fila por entidad.
Ejemplo La Esquina:
-- DIMENSIONES
CREATE TABLE dim_tiempo (
tiempo_id INT PRIMARY KEY,
fecha DATE,
dia_semana VARCHAR,
mes INT,
trimestre INT,
año INT,
es_feriado BOOLEAN
);
CREATE TABLE dim_platillo (
platillo_id INT PRIMARY KEY,
nombre VARCHAR,
categoria VARCHAR,
precio_actual DECIMAL,
vegano BOOLEAN
);
CREATE TABLE dim_cliente (
cliente_id INT PRIMARY KEY,
nombre VARCHAR,
ciudad VARCHAR,
segmento VARCHAR, -- 'frecuente', 'ocasional', 'nuevo'
fecha_registro DATE
);
CREATE TABLE dim_local (
local_id INT PRIMARY KEY,
nombre VARCHAR,
ciudad VARCHAR,
region VARCHAR
);
-- TABLA DE HECHOS
CREATE TABLE fact_ventas (
venta_id BIGINT PRIMARY KEY,
tiempo_id INT REFERENCES dim_tiempo,
platillo_id INT REFERENCES dim_platillo,
cliente_id INT REFERENCES dim_cliente,
local_id INT REFERENCES dim_local,
-- Medidas (lo que se agrega)
cantidad INT,
precio_unitario DECIMAL,
total DECIMAL,
descuento DECIMAL
);
Query analítico clásico (ventas por categoría y mes):
SELECT
t.año, t.mes,
p.categoria,
SUM(f.total) AS ingresos,
COUNT(*) AS num_ventas
FROM fact_ventas f
JOIN dim_tiempo t ON f.tiempo_id = t.tiempo_id
JOIN dim_platillo p ON f.platillo_id = p.platillo_id
WHERE t.año = 2026
GROUP BY t.año, t.mes, p.categoria
ORDER BY t.mes, ingresos DESC;
Snowflake schema: dimensiones también normalizadas (dim_platillo → dim_categoria). Más normalizado, menos denormalizado, más joins. Generalmente star schema es preferible para warehouses.
Slowly Changing Dimensions (SCD):
Las dimensiones cambian con el tiempo (un cliente cambia de ciudad, un platillo cambia de categoría). ¿Cómo manejarlo?
| Tipo | Descripción |
|---|---|
| SCD Type 1 | Sobrescribir (perdés historia) |
| SCD Type 2 | Agregar fila nueva con valid_from / valid_to (mantenés historia) |
| SCD Type 3 | Agregar columna valor_anterior (historia limitada) |
-- SCD Type 2 ejemplo
CREATE TABLE dim_cliente (
surrogate_key BIGSERIAL PRIMARY KEY, -- nuevo id por cada versión
cliente_id INT, -- id de negocio (persistente)
nombre VARCHAR,
ciudad VARCHAR,
segmento VARCHAR,
valid_from DATE,
valid_to DATE, -- NULL si es la versión actual
is_current BOOLEAN
);
-- Cuando Ana cambia de ciudad:
UPDATE dim_cliente SET valid_to = '2026-05-01', is_current = false
WHERE cliente_id = 1 AND is_current = true;
INSERT INTO dim_cliente (cliente_id, nombre, ciudad, valid_from, is_current)
VALUES (1, 'Ana García', 'San Salvador', '2026-05-01', true);
3.2 ETL vs ELT
📐 Fundamento
ETL (Extract, Transform, Load):
[Source DB] → Extract → Transform (en servidor ETL) → Load → [Warehouse]
↑
código complejo aquí
Modelo tradicional. Las transformaciones se hacen ANTES de cargar al warehouse.
ELT (Extract, Load, Transform) — moderno:
[Source DB] → Extract → Load (raw) → [Warehouse] → Transform (con SQL)
↑
el warehouse tiene poder
de cómputo gigante
Las transformaciones se hacen DENTRO del warehouse usando SQL. Aprovecha los warehouses modernos columnar (BigQuery, Snowflake) que son extremadamente rápidos.
| ETL | ELT | |
|---|---|---|
| Transformaciones | Antes de cargar | Después de cargar |
| Lenguaje | Java, Python | SQL |
| Herramienta | Informatica, Talend, Spark | dbt |
| Escalabilidad | Limitada por servidor ETL | Limitada por warehouse |
| Flexibilidad | Cambios requieren reprocesar | Datos crudos siempre disponibles |
| Recomendado para | Legacy, datos sensibles que no pueden ir crudos | Stack moderno |
dbt — el estándar actual de transformaciones:
-- models/staging/stg_pedidos.sql
SELECT
id AS pedido_id,
cliente_id,
LOWER(estado) AS estado,
total::DECIMAL AS total,
fecha::TIMESTAMP AS creado_en
FROM {{ source('app_db', 'pedidos') }}
WHERE estado != 'TEST'
-- models/marts/fact_ventas.sql
SELECT
p.pedido_id,
p.cliente_id,
ip.platillo_id,
DATE_TRUNC('day', p.creado_en)::DATE AS fecha,
SUM(ip.cantidad * ip.precio_unit) AS total
FROM {{ ref('stg_pedidos') }} p
JOIN {{ ref('stg_items_pedido') }} ip USING (pedido_id)
WHERE p.estado = 'CERRADO'
GROUP BY 1, 2, 3, 4
dbt run # ejecuta todas las transformaciones
dbt test # corre tests de calidad
dbt docs generate && dbt docs serve # documentación auto-generada
dbt aporta:
- Modularidad y reutilización (
{{ ref(...) }}). - Tests de calidad de datos.
- Documentación auto-generada.
- Lineage (qué tabla viene de qué).
- Versionado en Git.
3.3 Plataformas de Data Warehouse
📐 Fundamento
Comparación de los 3 grandes:
| BigQuery | Snowflake | Redshift | |
|---|---|---|---|
| Cloud | GCP (multi via Omni) | Multi-cloud | AWS |
| Pricing | Por bytes scaneados ($5/TB) | Por créditos de cómputo + storage | Por nodo (cluster) |
| Separación storage/compute | Sí | Sí | Limitada |
| SQL flavor | Standard SQL | Snowflake SQL | PostgreSQL-like |
| Serverless | Sí | Sí (con auto-suspend) | Serverless option |
| Time travel | 7 días | 1-90 días | Snapshots |
BigQuery — ejemplo de query a escala:
-- Esto consulta el dataset público de Wikipedia (TBs de datos) en segundos
SELECT
title,
SUM(views) AS total_views
FROM `bigquery-public-data.wikipedia.pageviews_2025`
WHERE DATE(datehour) BETWEEN '2025-01-01' AND '2025-12-31'
GROUP BY title
ORDER BY total_views DESC
LIMIT 100;
-- BigQuery escanea solo las particiones necesarias y procesa en paralelo
-- Costo: $5/TB scaneado. Tiempo: ~10 segundos para terabytes.
Optimizaciones específicas:
-- Particionamiento (por fecha)
CREATE TABLE pedidos
PARTITION BY DATE(fecha)
CLUSTER BY cliente_id, local_id -- ordena dentro de la partición
AS
SELECT * FROM raw_pedidos;
-- Query: BigQuery lee SOLO las particiones de mayo 2026
SELECT COUNT(*) FROM pedidos
WHERE DATE(fecha) BETWEEN '2026-05-01' AND '2026-05-31';
Costos:
- Sin particionamiento: query escanea toda la tabla = caro.
- Con particionamiento: escanea solo lo necesario = barato.
- Materializar resultados frecuentes en tablas separadas.
- Usar slot reservations para cargas constantes (vs on-demand).
3.4 Data Lakes y Lakehouse
📐 Fundamento
Data Lake con S3:
s3://la-esquina-lake/
├── bronze/ # raw, como llega
│ ├── pedidos/
│ │ └── año=2026/mes=05/dia=05/file_001.json
│ └── eventos_app/
├── silver/ # limpio, deduplicado, tipado
│ └── pedidos/
│ └── año=2026/mes=05/parte-001.parquet
└── gold/ # agregado, listo para BI
├── ventas_diarias.parquet
└── ltv_clientes.parquet
Esta arquitectura "medallón" (bronze → silver → gold) es el patrón estándar.
Delta Lake / Iceberg / Hudi — formatos transaccionales:
Convierten S3 (que solo soporta archivos) en algo más parecido a una BD: ACID, time travel, schema evolution.
# Delta Lake con PySpark
df.write.format("delta").save("s3://lake/silver/pedidos/")
# Update (no soportado en Parquet plano, sí en Delta)
deltaTable.update(
condition = "estado = 'PENDIENTE' AND creado_en < current_timestamp() - interval 24 hours",
set = { "estado": "'EXPIRADO'" }
)
# Time travel
df_ayer = spark.read.format("delta") \
.option("timestampAsOf", "2026-05-04") \
.load("s3://lake/silver/pedidos/")
Lakehouse (Databricks):
Combina lake (storage barato S3) + warehouse (queries rápidas, transacciones). Una sola plataforma para BI, ML, streaming.
Cuándo Lake vs Warehouse:
| Necesidad | Mejor en |
|---|---|
| Análisis SQL ad-hoc rápido | Warehouse (BigQuery, Snowflake) |
| ML training (datos crudos, mucha variedad) | Lake |
| Reportes BI de baja latencia | Warehouse |
| Logs históricos de bajo costo | Lake |
| Compliance de datos crudos | Lake |
🛠️ En la práctica
La Esquina Cloud — pipeline completo de datos:
Day 0:
[App] → escribe en PostgreSQL (operativo)
Day 0 (continuo):
[Debezium CDC] captura cambios → publica en Kafka
[Spark Streaming] consume Kafka → escribe en bronze (S3 + Delta Lake)
Cada hora:
[Airflow DAG] dispara Spark job:
- bronze → silver (limpieza, tipado, dedupe)
- silver → gold (agregaciones para BI)
Cada hora:
[dbt] transforma datos en BigQuery:
- Carga incremental desde Cloud Storage
- Aplica modelado dimensional (fact + dim tables)
- Corre tests de calidad
Diario:
[Dashboards Metabase/Looker] consultan BigQuery
[Notebooks ML] consumen gold layer en S3 para entrenar modelos
3.5 Ejercicios
✏️ Ejercicio 3.1 — Diseñar star schema
Diseñá un star schema para analizar el desempeño de delivery de La Esquina Cloud:
Métricas a analizar:
- Tiempo promedio de entrega por zona/repartidor/día.
- Tasa de cancelación por causa.
- Ingresos por delivery por hora del día.
Definí: tabla(s) de hechos, dimensiones, y al menos una query analítica.
Solución
-- DIMENSIONES
CREATE TABLE dim_tiempo (
tiempo_id INT PRIMARY KEY,
fecha DATE,
hora_dia INT,
dia_semana VARCHAR,
es_fin_semana BOOLEAN
);
CREATE TABLE dim_repartidor (
repartidor_id INT PRIMARY KEY,
nombre VARCHAR,
vehiculo VARCHAR,
veterano BOOLEAN -- > 6 meses
);
CREATE TABLE dim_zona (
zona_id INT PRIMARY KEY,
nombre VARCHAR,
ciudad VARCHAR,
distancia_km DECIMAL -- desde local
);
CREATE TABLE dim_estado (
estado_id INT PRIMARY KEY,
estado VARCHAR, -- ENTREGADO, CANCELADO_CLIENTE, CANCELADO_REPARTIDOR
es_exitoso BOOLEAN
);
-- HECHOS
CREATE TABLE fact_delivery (
delivery_id BIGINT PRIMARY KEY,
tiempo_inicio_id INT REFERENCES dim_tiempo,
tiempo_fin_id INT REFERENCES dim_tiempo,
repartidor_id INT REFERENCES dim_repartidor,
zona_id INT REFERENCES dim_zona,
estado_id INT REFERENCES dim_estado,
-- Medidas
duracion_minutos INT,
monto_pedido DECIMAL,
monto_propina DECIMAL,
distancia_real_km DECIMAL
);
-- QUERY ANALÍTICA: tiempo promedio por zona y hora
SELECT
z.nombre AS zona,
ti.hora_dia,
AVG(f.duracion_minutos) AS tiempo_promedio,
COUNT(*) AS num_entregas
FROM fact_delivery f
JOIN dim_zona z ON f.zona_id = z.zona_id
JOIN dim_tiempo ti ON f.tiempo_inicio_id = ti.tiempo_id
JOIN dim_estado e ON f.estado_id = e.estado_id
WHERE e.es_exitoso = TRUE
AND ti.fecha >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY z.nombre, ti.hora_dia
ORDER BY z.nombre, ti.hora_dia;
3.6 Para profundizar
- Kimball, The Data Warehouse Toolkit — la biblia del modelado dimensional.
- dbt docs — getdbt.com.
- BigQuery / Snowflake / Databricks docs.
- Siguiente: Streaming y Kafka.
Definiciones nuevas: modelado dimensional, star schema, snowflake schema, fact table, dimension table, SCD, ETL, ELT, dbt, BigQuery, Snowflake, Redshift, Delta Lake, Iceberg, Hudi, Lakehouse, arquitectura medallón (bronze/silver/gold), surrogate key.