SQL Avanzado en BigQuery: CTEs y Window Functions (OVER, LAG, RANK)
Las consultas analíticas de nivel enterprise no se resuelven únicamente con SELECT, JOIN y GROUP BY. Cuando necesitas comparar eventos, construir rankings, calcular variaciones temporales o mantener el detalle de cada fila mientras analizas su contexto, entran en juego los CTEs y las Window Functions de BigQuery.
En esta guía de nivel Senior / Staff / Architect vamos a diseñar consultas mantenibles utilizando OVER, PARTITION BY, ORDER BY, LAG, LEAD, RANK y ROW_NUMBER, prestando especial atención al rendimiento, al coste y a los trade-offs que aparecen en producción.
Lo que Aprenderás
1. El modelo mental correcto: GROUP BY vs Window Functions
El error conceptual más frecuente consiste en pensar que una Window Function
es una alternativa sintáctica a GROUP BY. No lo es.
GROUP BY reduce cardinalidad: varias filas se convierten en una fila por grupo. Una Window Function, en cambio, calcula un valor utilizando un conjunto de filas relacionadas pero mantiene la granularidad original.
| Patrón | Qué ocurre con las filas | Uso principal | Ejemplo |
|---|---|---|---|
| GROUP BY | Reduce el número de filas. | Agregaciones por dimensión. | Ventas totales por cliente. |
| Window Function | Mantiene las filas originales. | Contexto analítico entre filas. | Venta anterior del mismo cliente. |
| CTE | No define por sí mismo una agregación. | Descomponer y organizar lógica SQL. | Separar staging, métricas y ranking. |
2. OVER, PARTITION BY y ORDER BY en BigQuery
La cláusula OVER() define el contexto en el que una función
analítica debe evaluar cada fila. Sus dos componentes conceptualmente más
importantes son PARTITION BY y ORDER BY.
PARTITION BY determina los grupos lógicos independientes.
ORDER BY determina la secuencia dentro de cada grupo.
SELECT
customer_id,
order_id,
order_date,
amount,
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY order_date, order_id
) AS cumulative_amount
FROM `project.analytics.orders`;
El resultado conserva cada pedido, pero añade el importe acumulado de cada cliente siguiendo el orden temporal definido.
Desde una perspectiva arquitectónica, esto es importante: la ventana define el contexto de cálculo, no una nueva tabla física. Por tanto, una consulta con muchas ventanas puede introducir operaciones de ordenación y redistribución que deben vigilarse cuando trabajamos con grandes volúmenes.
3. LAG y LEAD: análisis temporal sin self-joins
LAG() permite acceder a una fila anterior dentro de una ventana,
mientras que LEAD() permite acceder a una fila posterior.
Este patrón es especialmente útil para series temporales, evolución de
métricas y detección de cambios.
WITH daily_sales AS (
SELECT
customer_id,
DATE(order_timestamp) AS order_date,
SUM(amount) AS daily_amount
FROM `project.analytics.orders`
GROUP BY
customer_id,
order_date
)
SELECT
customer_id,
order_date,
daily_amount,
LAG(daily_amount) OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS previous_day_amount,
LEAD(daily_amount) OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS next_day_amount
FROM daily_sales;
El CTE primero establece la granularidad correcta: una fila por
customer_id + order_date. Solo después se ejecutan las ventanas.
Esta separación evita comparar eventos individuales cuando realmente queremos
comparar métricas diarias.
El patrón puede extenderse para calcular variaciones:
daily_amount
- LAG(daily_amount) OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS absolute_change
4. RANK, DENSE_RANK y ROW_NUMBER: no son intercambiables
Los rankings son uno de los usos más habituales de Window Functions, pero elegir la función incorrecta puede introducir errores silenciosos.
| Función | Empates | ¿Deja huecos? | Uso recomendado |
|---|---|---|---|
| ROW_NUMBER() | No asigna la misma posición. | No aplica. | Seleccionar exactamente una fila por grupo cuando existe un criterio determinista. |
| RANK() | Sí. | Sí. | Clasificaciones donde los empates deben compartir posición. |
| DENSE_RANK() | Sí. | No. | Clasificaciones por niveles sin saltos numéricos. |
SELECT
category,
product_id,
revenue,
RANK() OVER (
PARTITION BY category
ORDER BY revenue DESC
) AS revenue_rank,
DENSE_RANK() OVER (
PARTITION BY category
ORDER BY revenue DESC
) AS dense_revenue_rank,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY revenue DESC, product_id
) AS deterministic_row_number
FROM `project.analytics.product_revenue`;
Observa un detalle de arquitectura: cuando utilizamos
ROW_NUMBER() para seleccionar un único registro por grupo,
conviene incluir un desempate explícito. Por ejemplo,
ORDER BY revenue DESC, product_id hace que la selección sea
mucho más determinista que ordenar únicamente por revenue.
5. Matriz de Decisión Arquitectónica
| Necesidad | Patrón recomendado | Latencia conceptual | Consistencia del cálculo | Coste / riesgo | Cuándo utilizarlo |
|---|---|---|---|---|---|
| Agregado por dimensión | GROUP BY | Baja a media | Alta | Generalmente eficiente | KPIs, métricas y agregaciones. |
| Comparación con fila anterior | LAG() | Media | Alta | Puede requerir ordenación | MoM, cambios diarios, churn y series temporales. |
| Top-N por grupo | RANK / ROW_NUMBER | Media | Alta | Depende de cardinalidad y ventana | Top productos, clientes o eventos por dimensión. |
| Transformación SQL compleja | CTEs | Variable | Alta | El coste depende del plan final | Separación lógica de etapas. |
| Transacción OLTP | Base relacional transaccional | Muy baja | Transaccional | No es el objetivo de BigQuery | Operaciones OLTP, locking y transacciones de aplicación. |
| Analítica OLAP masiva | BigQuery | Segundos a minutos según consulta | Analítica | Optimizable con particionado y clustering | Data warehouse, BI, analytics y procesamiento masivo. |
6. FinOps: el coste comienza antes de la Window Function
Una Window Function puede ser correcta desde el punto de vista funcional y, aun así, formar parte de una consulta arquitectónicamente cara.
Un antipatrón habitual es seleccionar millones o miles de millones de filas,
mantener decenas de columnas y ejecutar posteriormente varias ventanas.
El problema no es exclusivamente LAG() o RANK():
el problema comienza con la cantidad de datos que se obliga a procesar.
No optimices únicamente la función analítica. Optimiza primero el conjunto de datos sobre el que opera.
-- ❌ Antipatrón
SELECT
*,
RANK() OVER (
PARTITION BY customer_id
ORDER BY amount DESC
) AS ranking
FROM `project.raw.events`;
Una estrategia más sólida es filtrar y proyectar antes:
-- ✅ Patrón recomendado
WITH filtered_orders AS (
SELECT
customer_id,
order_id,
order_date,
amount
FROM `project.analytics.orders`
WHERE order_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)
AND customer_id IS NOT NULL
)
SELECT
customer_id,
order_id,
order_date,
amount,
RANK() OVER (
PARTITION BY customer_id
ORDER BY amount DESC
) AS ranking
FROM filtered_orders;
En una arquitectura de BigQuery, el objetivo es que filtros sobre columnas particionadas puedan reducir el volumen leído cuando el diseño de la tabla lo permita. Además, seleccionar solamente las columnas necesarias evita arrastrar datos irrelevantes a etapas posteriores.
7. Implementación Práctica de Producción
El siguiente patrón combina CTEs, agregación, LAG y ranking para producir una métrica analítica lista para consumo downstream. La clave es separar explícitamente las etapas: primero normalizamos la granularidad, después calculamos contexto temporal y finalmente clasificamos.
WITH daily_customer_revenue AS (
-- 1. Reducimos los eventos a la granularidad analítica necesaria.
SELECT
customer_id,
DATE(order_timestamp) AS order_date,
SUM(amount) AS revenue
FROM `project.analytics.orders`
WHERE order_timestamp >= TIMESTAMP_SUB(
CURRENT_TIMESTAMP(),
INTERVAL 180 DAY
)
AND status = 'COMPLETED'
GROUP BY
customer_id,
order_date
),
customer_evolution AS (
-- 2. Añadimos contexto temporal sin perder cada día.
SELECT
customer_id,
order_date,
revenue,
LAG(revenue) OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS previous_revenue
FROM daily_customer_revenue
),
customer_metrics AS (
-- 3. Calculamos el delta y el ranking por cliente.
SELECT
customer_id,
order_date,
revenue,
previous_revenue,
revenue - COALESCE(previous_revenue, 0) AS revenue_delta,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY revenue DESC, order_date DESC
) AS revenue_position
FROM customer_evolution
)
SELECT
customer_id,
order_date,
revenue,
previous_revenue,
revenue_delta,
revenue_position,
SAFE_DIVIDE(
revenue - previous_revenue,
previous_revenue
) AS revenue_growth_rate
FROM customer_metrics
ORDER BY
customer_id,
order_date;
Hay dos decisiones importantes aquí. La primera es utilizar
SAFE_DIVIDE para evitar que un denominador cero convierta una
transformación completa en un error. La segunda es utilizar
COALESCE únicamente donde el significado de negocio lo permite:
una primera observación no necesariamente significa que su revenue anterior
sea realmente cero.
8. Patrones de Diseño y Mejores Prácticas
SELECT * en pipelines productivos. Las columnas que no
participan en el cálculo no deberían viajar por todas las etapas.
9. CTEs: legibilidad frente a materialización
Un CTE mejora enormemente la estructura de una consulta compleja porque permite expresar una transformación como una secuencia lógica.
Pero existe una distinción fundamental para un arquitecto de datos: un CTE es una construcción lógica de la consulta, no una garantía de materialización física.
Por tanto, no debe asumirse automáticamente que cada CTE equivale a una tabla temporal persistida o que reutilizar el mismo CTE implica necesariamente que su resultado se calcule una única vez de una determinada manera.
Cuando una etapa intermedia es extremadamente costosa y se reutiliza en múltiples procesos, puede ser más apropiado evaluar una estrategia de materialización, tabla intermedia, tabla incremental o modelo de datos derivado. La decisión debe basarse en frecuencia, volumen, SLA, concurrencia y coste operativo.
10. Framework de Implementación Paso a Paso
11. Errores Críticos que Debes Evitar
Preguntas Frecuentes sobre Window Functions en BigQuery
Utilízala cuando necesites calcular un valor utilizando un conjunto de filas relacionadas sin colapsar el nivel de detalle de cada registro. Son especialmente útiles para rankings, acumulados, comparación con filas anteriores o siguientes y métricas por partición.
ROW_NUMBER asigna una posición única a cada fila. RANK permite empates y deja huecos después de ellos. DENSE_RANK permite empates pero continúa la numeración sin dejar huecos.
Filtra temprano las filas que realmente necesitas, selecciona únicamente las columnas necesarias, utiliza correctamente las capacidades de particionado y clustering de tus tablas y evita ventanas innecesariamente amplias. La optimización debe comenzar reduciendo el volumen de datos que llega a las operaciones analíticas.
Conclusión: SQL Analítico con Mentalidad de Arquitecto
Dominar window functions BigQuery SQL no consiste en memorizar funciones como LAG, LEAD o RANK. El verdadero salto profesional está en entender qué conjunto de filas debe observar cada cálculo, cuál es la granularidad correcta y qué impacto tiene la consulta sobre rendimiento, coste y mantenibilidad.
El patrón CTE → granularidad correcta → ventana → validación → optimización proporciona una base sólida para construir SQL analítico mantenible.
Cuando combinas OVER PARTITION BY BigQuery con una correcta modelización de datos, filtros eficientes y decisiones conscientes de FinOps, puedes resolver problemas analíticos complejos sin convertir cada consulta en un pipeline inmanejable.
