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, ROLLUP y CUBE.
  • ⚡ 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í

  1. 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.
  2. ROLLUP y CUBE son subestimados. Una consulta reemplaza docenas de agregaciones.
  3. No siempre necesitas un clúster. Para millones de filas, un motor embebido como DuckDB es suficiente y elimina toda la operación.
  4. Prueba propiedades, no solo código. "El total por año es igual al total por mes" detecta errores reales.
  5. La automatización es parte del producto. Un git push basta 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ú. 🦆