Logo GCP con Eduardo

GCP con Eduardo

Descarga el código de la lección

SQL Avanzado en BigQuery: CTEs y Window Functions (OVER, LAG, RANK)
✦ Guía Técnica & Arquitectura

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

Diseñar CTEs legibles y composables para pipelines analíticos.
Dominar OVER, PARTITION BY y ORDER BY en Window Functions.
Aplicar LAG, LEAD, RANK y ROW_NUMBER a problemas reales.
Evitar patrones que disparan bytes procesados y coste.
Google BigQuery Standard SQL CTE OVER() PARTITION BY LAG() LEAD() RANK() ROW_NUMBER()

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.
⚠ Antipatrón crítico: calcular ventanas sobre un dataset innecesariamente grande

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

01 · Define la granularidad Antes de escribir una ventana, determina exactamente qué representa una fila. Muchas consultas incorrectas nacen de aplicar LAG o RANK sobre eventos cuando el análisis requiere métricas agregadas.
02 · Filtra temprano Aplica filtros de negocio y de tiempo tan pronto como sea semánticamente seguro. Esto ayuda a evitar procesar datos que nunca participarán en el resultado.
03 · Proyecta lo necesario Evita SELECT * en pipelines productivos. Las columnas que no participan en el cálculo no deberían viajar por todas las etapas.
04 · Orden determinista Cuando el resultado depende de una posición concreta, proporciona criterios de desempate. Esto es especialmente importante con ROW_NUMBER.
05 · CTEs con intención Un CTE debe representar una transformación comprensible: staging, normalización de granularidad, métricas o ranking. Evita crear capas artificiales sin valor semántico.
06 · Diseña para observabilidad Las consultas críticas deben poder analizarse con las herramientas de BigQuery para entender bytes procesados, etapas, slots y comportamiento del plan.

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

Define el problema analítico. Especifica si necesitas ranking, comparación temporal, acumulado, deduplicación o una métrica contextual.
Fija la granularidad. Determina qué representa una fila antes de elegir PARTITION BY.
Construye un CTE de staging. Selecciona únicamente columnas relevantes y aplica filtros seguros.
Aplica la ventana. Define PARTITION BY y ORDER BY de forma explícita.
Valida los casos límite. Comprueba empates, valores NULL, primera/última fila y denominadores cero.
Evalúa el rendimiento. Revisa bytes procesados, etapas de ejecución y volumen de datos.
Evalúa FinOps. Comprueba si la consulta puede beneficiarse de particionado, clustering, filtros más selectivos o una tabla derivada.
Productiviza. Añade tests de calidad, documentación, monitorización y ownership claro antes de incorporar la consulta a un pipeline crítico.

11. Errores Críticos que Debes Evitar

Error 1 · PARTITION BY incorrecto Una partición demasiado amplia puede mezclar entidades que deberían analizarse independientemente. Una partición demasiado específica puede destruir el contexto que necesita el cálculo.
Error 2 · ORDER BY incompleto Si varias filas tienen el mismo valor de ordenación, el resultado puede no representar el desempate de negocio esperado. Añade una clave secundaria cuando la posición exacta importe.
Error 3 · RANK cuando necesitas una fila única Si el requisito es "el mejor registro", RANK puede devolver varios empates. En esos casos, ROW_NUMBER con un orden determinista suele ser más apropiado.
Error 4 · Ignorar NULL LAG de la primera fila produce NULL. Eso no significa automáticamente cero. La semántica del negocio debe decidir cómo tratarlo.
Error 5 · SELECT * Arrastrar columnas innecesarias dificulta mantenimiento y puede aumentar el volumen de datos procesado en consultas y transformaciones posteriores.
Error 6 · Optimizar solo el SQL El rendimiento también depende del modelo de almacenamiento: particionado, clustering, volumen, cardinalidad, frecuencia de ejecución y arquitectura del pipeline.

Preguntas Frecuentes sobre Window Functions en BigQuery

¿Cuándo debo utilizar una Window Function 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.

¿Cuál es la diferencia entre RANK, DENSE_RANK y ROW_NUMBER?

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.

¿Cómo puedo reducir el coste de una consulta con Window Functions en BigQuery?

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.

Sobre el Autor: Eduardo Martínez Agrelo

AI & Data Architect

Eduardo Martínez Agrelo es AI & Data Architect especializado en arquitectura de datos, inteligencia artificial y diseño de plataformas analíticas. Su enfoque combina profundidad técnica con decisiones arquitectónicas orientadas a escalabilidad, rendimiento, gobernanza y eficiencia operativa.

A través de sus contenidos técnicos comparte patrones de arquitectura, ingeniería de datos y prácticas aplicables a entornos profesionales de alta exigencia.

© 2026 Eduardo Martínez Agrelo · AI & Data Architect · SQL avanzado, BigQuery y arquitectura de datos.