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ú. 🦆

