Las bases de datos de nuestras aplicaciones son excelentes guardando ventas una por una. Pero cuando alguien pregunta "¿cuánto vendimos por país y canal en cada trimestre, y qué categoría creció más?", esa misma base de datos empieza a sufrir.
Ese es el problema que resuelve OLAP (Online Analytical Processing). En este artículo te muestro cómo construí una solución OLAP completa con DuckDB, la convertí en un dashboard interactivo y la desplegué automáticamente en una nube pública con GitHub Actions + Hugging Face Spaces, sin usar AWS.
🔗 Demo en vivo: https://huggingface.co/spaces/TU_USUARIO_HF/olap-duckdb-analytics
💻 Repositorio: https://github.com/IkerASierraR/olap-duckdb-analytics
OLTP vs OLAP en 30 segundos
| OLTP | OLAP | |
|---|---|---|
| Objetivo | Registrar transacciones | Analizar grandes volúmenes |
| Consultas |
INSERT/UPDATE de pocas filas |
GROUP BY sobre millones de filas |
| Almacenamiento | Por filas | Por columnas |
| Modelo | Normalizado | Dimensional (estrella) |
Un sistema OLAP organiza los datos como un cubo: las medidas (ingresos, utilidad, unidades) se analizan desde varias dimensiones (tiempo, producto, geografía, canal). Sobre ese cubo se aplican cinco operaciones clásicas: slice, dice, roll-up, drill-down y pivot.
¿Por qué DuckDB?
Evalué ClickHouse, Apache Druid y Apache Pinot. Son excelentes, pero piden servidores, clústeres o infraestructura gestionada. DuckDB es "el SQLite del análisis":
- 🗂️ Motor columnar y vectorizado, pensado para agregaciones.
- 📦 Embebido: es un
pip install, sin servidor. - 🧊 Soporta
GROUPING SETS,ROLLUPyCUBE. - ⚡ Agrega 1 millón de filas en milisegundos.
Para un proyecto que debía ser reproducible, barato y fácil de desplegar, era la mejor opción.
Arquitectura
git push ─► GitHub Actions ─┬─ test: pytest · build warehouse · docker build · smoke test
└─ deploy: huggingface_hub ─► Hugging Face Space (Docker)
│
docker build: python -m olap.warehouse
│
Streamlit ◄── DuckDB (sales.duckdb, solo lectura)
Paso 1: El modelo dimensional (esquema estrella)
Generé un dataset determinístico de 1.000.000 de ventas de una cadena de retail en Perú, Chile, Colombia y México, entre 2023 y 2025. La tabla de hechos guarda las medidas; las dimensiones guardan las jerarquías:
-
dim_date: año › trimestre › mes › día -
dim_product: categoría › subcategoría › producto -
dim_store: país › región › ciudad › tienda -
dim_channel: tienda física / online / marketplace
📸 [Captura: pestaña "Modelo" con el diagrama estrella]
Para que cada build produzca exactamente los mismos datos, usé hash() en lugar de random(). Así las pruebas del pipeline son reproducibles:
CREATE TABLE fact_sales AS
WITH base AS (
SELECT i AS sale_id,
(hash(i) % 1000000) / 1000000.0 AS u_date,
(hash(i * 7) % 1000000) / 1000000.0 AS u_prod
-- ...
FROM range(1, 1000001) t(i)
)
...
El warehouse completo se construye en ~2 segundos.
Paso 2: El cubo OLAP con ROLLUP + CUBE
Esta es la parte central. En vez de calcular cada combinación de subtotales por separado, DuckDB las calcula todas en una sola consulta:
CREATE TABLE cube_sales AS
SELECT
year, quarter, category, country, channel,
GROUPING(year, quarter, category, country, channel) AS grouping_id,
sum(revenue) AS revenue,
sum(profit) AS profit,
sum(quantity) AS units,
count(*) AS orders
FROM v_sales
GROUP BY ROLLUP (year, quarter), CUBE (category, country, channel);
-
ROLLUP(year, quarter)respeta la jerarquía del tiempo: total → año → trimestre. No tiene sentido "trimestre sin año". -
CUBE(category, country, channel)genera todas las combinaciones (2³ = 8). -
GROUPING()indica qué columnas son subtotales.
Resultado: 1 millón de filas se resumen en 1.600 celdas pre-calculadas. En el dashboard, consultar el cubo resulta ~7x más rápido que agregar la tabla de hechos.
📸 [Captura: pestaña "Cubo pre-agregado" con la comparación de tiempos]
Paso 3: Las 5 operaciones OLAP
Toda la lógica está en olap/queries.py. Las dimensiones y medidas se validan contra una lista blanca, así la entrada del usuario nunca se concatena directamente al SQL:
HIERARCHIES = {
"Tiempo": ["year", "quarter_label", "year_month", "full_date"],
"Producto": ["category", "subcategory", "product"],
"Geografía": ["country", "region", "city", "store"],
}
def drill(con, hierarchy, level, measure, filters=None):
"""Drill-down (level+1) / roll-up (level-1) a lo largo de una jerarquía."""
levels = HIERARCHIES[hierarchy]
return aggregate(con, levels[: level + 1], [measure], filters)
| Operación | Qué hace | En el dashboard |
|---|---|---|
| Slice | Fija una dimensión (ej. solo 2025) | Filtro de la barra lateral |
| Dice | Sub-cubo con varios filtros (Perú + Online) | Varios filtros combinados |
| Roll-up | Sube de nivel (mes → año) | Slider hacia la izquierda |
| Drill-down | Baja de nivel (país → ciudad → tienda) | Slider hacia la derecha |
| Pivot | Rota el cubo (filas × columnas) | Pestaña Pivot con mapa de calor |
📸 [Captura: drill-down de Geografía hasta nivel Tienda]
📸 [Captura: Pivot Categoría × País]
También agregué una consola SQL para consultas ad-hoc. Es segura porque la conexión es de solo lectura y no tiene acceso a archivos ni a la red:
duckdb.connect(DB_PATH, read_only=True,
config={"enable_external_access": False})
Paso 4: Pruebas que validan la lógica OLAP
Las pruebas no se limitan a "que corra". Verifican propiedades de un cubo:
def test_rollup_is_consistent_with_drilldown(con):
total = q.kpis(con)["revenue"]
by_year, _ = q.drill(con, "Tiempo", 0, "revenue")
by_month, _ = q.drill(con, "Tiempo", 2, "revenue")
assert by_year["revenue"].sum() == pytest.approx(total)
assert by_month["revenue"].sum() == pytest.approx(total)
def test_cube_matches_fact_table(con):
cube, _ = q.from_cube(con, ["country"], "revenue")
facts, _ = q.aggregate(con, ["country"], ["revenue"])
assert cube... == pytest.approx(facts...)
Si el cubo y la tabla de hechos no coinciden, el despliegue se detiene.
Paso 5: Despliegue automatizado con GitHub Actions
Cada git push a main dispara este pipeline:
jobs:
test:
steps:
- run: pytest -v
- run: python -m olap.warehouse # construye 1M filas + cubo
- run: docker build -t olap-duckdb .
- name: Smoke test container
run: |
docker run -d -p 7860:7860 olap-duckdb
curl -sf http://localhost:7860/_stcore/health
deploy:
needs: test
steps:
- run: python deploy/deploy_space.py
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_SPACE_ID: ${{ vars.HF_SPACE_ID }}
El script de despliegue usa huggingface_hub. Crea el Space si no existe y sube el código:
api.create_repo(space_id, repo_type="space", space_sdk="docker", exist_ok=True)
api.upload_folder(folder_path=stage, repo_id=space_id, repo_type="space")
Paso 6: Publicado en la nube pública
Elegí Hugging Face Spaces porque es un SaaS público y gratuito que ejecuta contenedores Docker. El warehouse se construye dentro del docker build, así la app arranca con los datos listos:
FROM python:3.12-slim
RUN useradd -m -u 1000 user
USER user
WORKDIR /home/user/app
COPY --chown=user requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=user . .
RUN python -m olap.warehouse --rows 1000000
CMD ["streamlit", "run", "app.py", "--server.port=7860", "--server.address=0.0.0.0"]
📸 [Captura: pipeline en verde en GitHub Actions]
📸 [Captura: la app corriendo en Hugging Face]
Resultados
| Métrica | Valor |
|---|---|
| Filas en la tabla de hechos | 1.000.000 |
| Tiempo de construcción del warehouse | ~2,3 s |
| Celdas del cubo pre-agregado | 1.600 |
| Agregación sobre 1M de filas | ~13 ms |
| Misma consulta desde el cubo | ~2 ms |
| Costo de infraestructura | $0 |
Lo que aprendí
-
El modelado importa más que la herramienta. Un buen esquema estrella con jerarquías claras hace que el drill-down sea simplemente agregar una columna al
GROUP BY. -
ROLLUPyCUBEson subestimados. Una consulta reemplaza docenas de agregaciones. - No siempre necesitas un clúster. Para millones de filas, un motor embebido como DuckDB es suficiente y elimina toda la operación.
- Prueba propiedades, no solo código. "El total por año es igual al total por mes" detecta errores reales.
-
La automatización es parte del producto. Un
git pushbasta para probar, construir y publicar.
¿Qué sigue?
- Cargar datos reales desde Parquet en almacenamiento de objetos.
- Comparar con ClickHouse o Apache Pinot cuando el volumen pase de cientos de millones de filas.
- Agregar actualizaciones incrementales del cubo.
Si te sirvió, deja una ⭐ en el repositorio y cuéntame en los comentarios qué motor OLAP usas tú. 🦆











