Skip to content

Funciones de ventana ​

¿Cómo sacas "el top 3 de productos por categoría", "las ventas acumuladas día a día" o "cuánto cambió cada pedido respecto al anterior"? Con GROUP BY pierdes el detalle de cada fila. Las funciones de ventana calculan sobre un grupo de filas sin colapsarlas. Son una de las herramientas más poderosas de SQL moderno y aparecen en casi toda entrevista técnica de datos.

Concepto ​

Imagina una hoja con todas las ventas. GROUP BY es como resumir la hoja en una tabla pequeña de totales: el detalle desaparece. Una función de ventana es como agregar una columna nueva a la hoja original donde, en cada fila, escribes un cálculo que mira a sus "vecinas" (su ventana).

txt
GROUP BY categoria_id                 Función de ventana (PARTITION BY categoria_id)
┌──────────┬──────────┐               ┌─────────────────────┬─────┬────────┬───────────────┐
│ categoria│ promedio │               │ producto            │ cat │ precio │ promedio_cat  │
├──────────┼──────────┤               ├─────────────────────┼─────┼────────┼───────────────┤
│ 1        │ 1882.67  │               │ Audífonos Bluetooth │ 1   │ 899    │ 1882.67       │
│ 2        │ 319.00   │               │ Teclado mecánico    │ 1   │ 1250   │ 1882.67       │
│ ...      │ ...      │               │ Monitor 24 pulgadas │ 1   │ 3499   │ 1882.67       │
└──────────┴──────────┘               │ Libro Aprende SQL   │ 2   │ 349    │ 319.00        │
   (3-4 filas)                        │ ...                 │     │        │               │
                                      └─────────────────────┴─────┴────────┴───────────────┘
                                         (las 8 filas se conservan)

Sintaxis:

sql
funcion() OVER (
  PARTITION BY columna   -- opcional: divide en grupos ("ventanas")
  ORDER BY columna       -- opcional: orden dentro de cada ventana
  ROWS BETWEEN ...       -- opcional: marco de filas
)

Disponibles en PostgreSQL, MySQL 8+, MariaDB 10.2+, SQLite 3.25+ y SQL Server.

Agregados como ventana ​

Cualquier SUM, AVG, COUNT, MIN, MAX se convierte en función de ventana con OVER:

sql
SELECT
  nombre,
  categoria_id,
  precio,
  ROUND(AVG(precio) OVER (PARTITION BY categoria_id), 2) AS promedio_categoria,
  ROUND(precio - AVG(precio) OVER (PARTITION BY categoria_id), 2) AS diferencia
FROM productos
WHERE categoria_id IS NOT NULL
ORDER BY categoria_id, precio DESC;
nombrecategoria_idpreciopromedio_categoriadiferencia
Monitor 24 pulgadas13499.001882.671616.33
Teclado mecánico11250.001882.67-632.67
Audífonos Bluetooth1899.001882.67-983.67
Libro Aprende SQL2349.00319.0030.00
Cien años de soledad2289.00319.00-30.00
Lámpara de escritorio3459.00329.00130.00
Taza térmica3199.00329.00-130.00

Con OVER () vacío, la ventana es toda la tabla. Ideal para porcentajes:

sql
-- Participación de cada pedido en las ventas totales
WITH totales AS (
  SELECT pedido_id, SUM(cantidad * precio_unitario) AS total
  FROM detalle_pedido
  GROUP BY pedido_id
)
SELECT
  pedido_id,
  total,
  ROUND(100.0 * total / SUM(total) OVER (), 1) AS porcentaje
FROM totales
ORDER BY total DESC;
pedido_idtotalporcentaje
33499.0038.9
21847.0020.5
11597.0017.8
41146.0012.8
5899.0010.0

Funciones de ranking ​

FunciónEmpatesEjemplo con valores 5, 2, 2, 1
ROW_NUMBER()Numera sin repetir (desempate arbitrario)1, 2, 3, 4
RANK()Mismo número, luego salta1, 2, 2, 4
DENSE_RANK()Mismo número, sin saltos1, 2, 2, 3
NTILE(n)Divide en n grupos igualescuartiles, deciles
sql
-- Productos más vendidos (piezas)
WITH vendidos AS (
  SELECT producto_id, SUM(cantidad) AS piezas
  FROM detalle_pedido
  GROUP BY producto_id
)
SELECT
  producto_id,
  piezas,
  ROW_NUMBER() OVER (ORDER BY piezas DESC, producto_id) AS fila,
  RANK()       OVER (ORDER BY piezas DESC)              AS rank,
  DENSE_RANK() OVER (ORDER BY piezas DESC)              AS dense_rank
FROM vendidos
ORDER BY fila;
producto_idpiezasfilarankdense_rank
75111
12222
42322
21443
31543
51643
61743

Ranking por grupo con PARTITION BY ​

sql
SELECT
  nombre,
  categoria_id,
  precio,
  RANK() OVER (PARTITION BY categoria_id ORDER BY precio DESC) AS lugar_en_categoria
FROM productos
WHERE categoria_id IS NOT NULL
ORDER BY categoria_id, lugar_en_categoria;
nombrecategoria_idpreciolugar_en_categoria
Monitor 24 pulgadas13499.001
Teclado mecánico11250.002
Audífonos Bluetooth1899.003
Libro Aprende SQL2349.001
Cien años de soledad2289.002
Lámpara de escritorio3459.001
Taza térmica3199.002

El patrón "top N por grupo" ​

Uno de los problemas más comunes en el trabajo real. No puedes usar una función de ventana en el WHERE (se calcula después), así que la envuelves en una CTE:

sql
-- Los 2 productos más caros de cada categoría
WITH ranking AS (
  SELECT
    p.nombre,
    c.nombre AS categoria,
    p.precio,
    ROW_NUMBER() OVER (PARTITION BY p.categoria_id ORDER BY p.precio DESC) AS rn
  FROM productos p
  JOIN categorias c ON c.id = p.categoria_id
)
SELECT categoria, nombre, precio
FROM ranking
WHERE rn <= 2
ORDER BY categoria, precio DESC;
categorianombreprecio
ElectrónicaMonitor 24 pulgadas3499.00
ElectrónicaTeclado mecánico1250.00
HogarLámpara de escritorio459.00
HogarTaza térmica199.00
LibrosLibro Aprende SQL349.00
LibrosCien años de soledad289.00

Otro clásico: el último pedido de cada cliente con todos sus datos:

sql
WITH ordenados AS (
  SELECT pe.*,
         ROW_NUMBER() OVER (PARTITION BY cliente_id ORDER BY fecha DESC) AS rn
  FROM pedidos pe
)
SELECT id, cliente_id, fecha, estado
FROM ordenados
WHERE rn = 1
ORDER BY cliente_id;
idcliente_idfechaestado
312024-06-01enviado
222024-05-10entregado
432024-06-15pendiente
542024-06-20cancelado

Acumulados: SUM con ORDER BY ​

Si agregas ORDER BY dentro de OVER, el SUM se vuelve acumulado (suma desde la primera fila hasta la actual):

sql
WITH totales AS (
  SELECT pe.id, pe.fecha, SUM(d.cantidad * d.precio_unitario) AS total
  FROM pedidos pe
  JOIN detalle_pedido d ON d.pedido_id = pe.id
  GROUP BY pe.id, pe.fecha
)
SELECT
  id,
  fecha,
  total,
  SUM(total) OVER (ORDER BY fecha) AS acumulado
FROM totales
ORDER BY fecha;
idfechatotalacumulado
12024-05-021597.001597.00
22024-05-101847.003444.00
32024-06-013499.006943.00
42024-06-151146.008089.00
52024-06-20899.008988.00

Perfecto para gráficas de "ventas acumuladas del mes" o el saldo de una cuenta.

LAG y LEAD: mirar la fila anterior o siguiente ​

FunciónDevuelve
LAG(col, n, defecto)El valor de n filas antes (por defecto 1)
LEAD(col, n, defecto)El valor de n filas después
FIRST_VALUE(col)El primer valor de la ventana
LAST_VALUE(col)El último (¡cuidado con el marco!)
sql
WITH totales AS (
  SELECT pe.id, pe.fecha, SUM(d.cantidad * d.precio_unitario) AS total
  FROM pedidos pe
  JOIN detalle_pedido d ON d.pedido_id = pe.id
  GROUP BY pe.id, pe.fecha
)
SELECT
  id,
  fecha,
  total,
  LAG(total) OVER (ORDER BY fecha)          AS total_anterior,
  total - LAG(total) OVER (ORDER BY fecha)  AS cambio
FROM totales
ORDER BY fecha;
idfechatotaltotal_anteriorcambio
12024-05-021597.00NULLNULL
22024-05-101847.001597.00250.00
32024-06-013499.001847.001652.00
42024-06-151146.003499.00-2353.00
52024-06-20899.001146.00-247.00

Caso real: días entre compras de un mismo cliente (indicador de recurrencia).

sql
-- PostgreSQL: restar fechas da días. MySQL: DATEDIFF(fecha, LAG(...))
SELECT
  cliente_id,
  fecha,
  fecha - LAG(fecha) OVER (PARTITION BY cliente_id ORDER BY fecha) AS dias_desde_anterior
FROM pedidos
WHERE cliente_id = 1;
cliente_idfechadias_desde_anterior
12024-05-02NULL
12024-06-0130

Marcos de ventana: ROWS BETWEEN ​

El marco define exactamente qué filas entran en el cálculo de cada fila:

txt
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW   → desde el inicio hasta aquí (acumulado)
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW           → esta fila y las 2 anteriores (media móvil de 3)
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING           → anterior, actual y siguiente
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING → toda la partición
sql
-- Media móvil de los últimos 3 pedidos
WITH totales AS (
  SELECT pe.id, pe.fecha, SUM(d.cantidad * d.precio_unitario) AS total
  FROM pedidos pe
  JOIN detalle_pedido d ON d.pedido_id = pe.id
  GROUP BY pe.id, pe.fecha
)
SELECT
  id, fecha, total,
  ROUND(AVG(total) OVER (ORDER BY fecha ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS media_movil_3
FROM totales
ORDER BY fecha;
idfechatotalmedia_movil_3
12024-05-021597.001597.00
22024-05-101847.001722.00
32024-06-013499.002314.33
42024-06-151146.002164.00
52024-06-20899.001848.00

Error común: LAST_VALUE "no funciona"

Con ORDER BY en OVER, el marco por defecto es "desde el inicio hasta la fila actual". Por eso LAST_VALUE(x) OVER (ORDER BY fecha) devuelve el valor de la fila actual, no el último. Solución: ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.

Ventanas con nombre (WINDOW) ​

Si repites la misma ventana, dale nombre (PostgreSQL, MySQL 8, SQLite):

sql
SELECT nombre, precio,
       RANK()     OVER w AS lugar,
       AVG(precio) OVER w AS promedio_hasta_aqui
FROM productos
WINDOW w AS (ORDER BY precio DESC);

Dónde se pueden usar ​

Las funciones de ventana se calculan después de WHERE, GROUP BY y HAVING, justo con el SELECT. Por eso:

  • Puedes usarlas en SELECT y ORDER BY.
  • No puedes usarlas en WHERE, GROUP BY ni HAVING → envuélvelas en una CTE o subconsulta.
  • Sí puedes aplicar una ventana sobre un resultado agrupado: SUM(SUM(x)) OVER ().

Buena práctica

Cuando te pidan "el primero/último/mejor de cada grupo", piensa de inmediato en ROW_NUMBER() OVER (PARTITION BY grupo ORDER BY criterio) + filtro rn = 1. Resuelve el 90 % de esos problemas.

Ejercicios ​

  1. Numera los pedidos de cada cliente por fecha (columna num_pedido: 1 para su primer pedido, 2 para el segundo...).
  2. Muestra cada cliente con su fecha de registro y el lugar en que se registró (1 = el primero) usando ROW_NUMBER.
  3. Para cada producto, muestra el porcentaje que representa su precio dentro del total de precios de su categoría.
  4. Obtén el producto más barato de cada categoría con el patrón top-N.
  5. Calcula el acumulado de clientes registrados por fecha de registro.
Ver solución
sql
-- 1
SELECT id, cliente_id, fecha,
       ROW_NUMBER() OVER (PARTITION BY cliente_id ORDER BY fecha) AS num_pedido
FROM pedidos
ORDER BY cliente_id, fecha;
idcliente_idfechanum_pedido
112024-05-021
312024-06-012
222024-05-101
432024-06-151
542024-06-201
sql
-- 2
SELECT nombre, fecha_registro,
       ROW_NUMBER() OVER (ORDER BY fecha_registro) AS lugar
FROM clientes;

-- 3
SELECT nombre, categoria_id, precio,
       ROUND(100.0 * precio / SUM(precio) OVER (PARTITION BY categoria_id), 1) AS pct
FROM productos
WHERE categoria_id IS NOT NULL
ORDER BY categoria_id, pct DESC;
nombrecategoria_idpreciopct
Monitor 24 pulgadas13499.0062.0
Teclado mecánico11250.0022.1
Audífonos Bluetooth1899.0015.9
Libro Aprende SQL2349.0054.7
Cien años de soledad2289.0045.3
Lámpara de escritorio3459.0069.8
Taza térmica3199.0030.2
sql
-- 4
WITH r AS (
  SELECT nombre, categoria_id, precio,
         ROW_NUMBER() OVER (PARTITION BY categoria_id ORDER BY precio) AS rn
  FROM productos
  WHERE categoria_id IS NOT NULL
)
SELECT categoria_id, nombre, precio FROM r WHERE rn = 1;
-- 1: Audífonos Bluetooth 899 · 2: Cien años de soledad 289 · 3: Taza térmica 199

-- 5
SELECT nombre, fecha_registro,
       COUNT(*) OVER (ORDER BY fecha_registro) AS clientes_acumulados
FROM clientes;
-- 1, 2, 3, 4, 5

Resumen ​

  • Las funciones de ventana calculan sobre grupos de filas sin colapsarlas: funcion() OVER (...).
  • PARTITION BY define los grupos; ORDER BY el orden dentro de cada uno.
  • ROW_NUMBER, RANK, DENSE_RANK para rankings; el patrón top-N usa una CTE y WHERE rn <= N.
  • SUM(...) OVER (ORDER BY ...) da acumulados; LAG/LEAD comparan con filas vecinas.
  • ROWS BETWEEN controla el marco (medias móviles). No se pueden usar en WHERE.

Academia Web — aprende, copia y construye.