Totales acumulados y medias móviles en SQL con SUM() OVER

«¿Cuánto llevamos vendido en el año?» es un total acumulado: la suma de este mes y de todos los anteriores. Con las funciones de ventana, los totales acumulados, las medias móviles y los porcentajes del total se calculan en la misma consulta, sin perder el detalle de cada mes.

Todas las consultas usan la base de ejemplo «tienda» del curso (clientes, productos, pedidos y sus líneas). Las que funcionan en SQLite tienen un enlace para abrirlas en el editor de SQL, que la trae cargada.

Las ventas de cada mes

SELECT strftime('%Y-%m', pe.fecha) AS mes,
       SUM(d.cantidad * d.precio_unitario) AS ventas
FROM detalle d
JOIN pedidos pe ON pe.id = d.pedido_id
WHERE pe.estado NOT IN ('cancelado', 'devuelto')
GROUP BY mes
ORDER BY mes;

Ábrelo en el editor y cámbialo →

resultado · SQLitemes     | ventas
--------+-------------------
2026-01 | 2367.7599999999998
2026-02 |             2197.8
2026-03 |            3967.58
2026-04 |              305.9
2026-05 |             826.88
2026-06 |              498.6
2026-07 |            1503.48
2026-08 |             2297.8
2026-09 |              798.5
2026-10 |             308.29

Enero sale como 2367.7599999999998. SQLite guarda los precios como números de coma flotante, y la suma arrastra un error minúsculo; por eso, en las consultas siguientes, cada importe pasa por ROUND(…, 2). PostgreSQL y MySQL usan NUMERIC/DECIMAL, que son exactos.

Acumulado, media móvil y porcentaje

Las tres columnas nuevas son funciones de ventana: se calculan para cada fila mirando otras filas, pero sin agruparlas.

WITH por_mes AS (
  SELECT strftime('%Y-%m', pe.fecha) AS mes,
         ROUND(SUM(d.cantidad * d.precio_unitario), 2) AS ventas
  FROM detalle d
  JOIN pedidos pe ON pe.id = d.pedido_id
  WHERE pe.estado NOT IN ('cancelado', 'devuelto')
  GROUP BY mes
)
SELECT mes, ventas,
       ROUND(SUM(ventas) OVER (ORDER BY mes), 2) AS acumulado,
       ROUND(AVG(ventas) OVER (ORDER BY mes ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS media_3_meses,
       ROUND(100.0 * ventas / SUM(ventas) OVER (), 1) AS porcentaje
FROM por_mes
ORDER BY mes;

Ábrelo en el editor y cámbialo →

resultado · SQLitemes     | ventas  | acumulado | media_3_meses | porcentaje
--------+---------+-----------+---------------+-----------
2026-01 | 2367.76 |   2367.76 |       2367.76 |       15.7
2026-02 |  2197.8 |   4565.56 |       2282.78 |       14.6
2026-03 | 3967.58 |   8533.14 |       2844.38 |       26.3
2026-04 |   305.9 |   8839.04 |       2157.09 |        2.0
2026-05 |  826.88 |   9665.92 |       1700.12 |        5.5
2026-06 |   498.6 |  10164.52 |        543.79 |        3.3
2026-07 | 1503.48 |   11668.0 |        942.99 |       10.0
2026-08 |  2297.8 |   13965.8 |       1433.29 |       15.2
2026-09 |   798.5 |   14764.3 |       1533.26 |        5.3
2026-10 |  308.29 |  15072.59 |       1134.86 |        2.0

La media móvil usa un marco de tres filas que se desliza; el acumulado suma desde la primeraDos ventanas sobre la misma columna de ventasventasacumul.ene2367.762367.76feb2197.804565.56mar3967.588533.14abr305.908839.04may826.889665.92jun498.6010164.52ROWS BETWEEN 2 PRECEDING AND CURRENT ROWmedia de mar, abr y may: 1700.12SUM(ventas) OVER (ORDER BY mes): de enero hasta la fila actual

La media móvil mira un marco de tres filas que se desliza; el acumulado, todas las filas desde la primera. Esquema: decodigo.com.
ColumnaVentanaQué hace
acumuladoSUM(ventas) OVER (ORDER BY mes)Suma desde el primer mes hasta el actual
media_3_mesesAVG(ventas) OVER (ORDER BY mes ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)Media del mes y los dos anteriores (en enero y febrero, de los que hay)
porcentajeSUM(ventas) OVER ()Con la ventana vacía, el total de todas las filas

La misma consulta en PostgreSQL y MySQL, cambiando solo cómo se saca el mes (to_char y DATE_FORMAT), da los mismos números, ya con dos decimales:

resultado · PostgreSQL   mes   | ventas  | acumulado | media_3_meses | porcentaje 
---------+---------+-----------+---------------+------------
 2026-01 | 2367.76 |   2367.76 |       2367.76 |       15.7
 2026-02 | 2197.80 |   4565.56 |       2282.78 |       14.6
 2026-03 | 3967.58 |   8533.14 |       2844.38 |       26.3
 2026-04 |  305.90 |   8839.04 |       2157.09 |        2.0
 2026-05 |  826.88 |   9665.92 |       1700.12 |        5.5
 2026-06 |  498.60 |  10164.52 |        543.79 |        3.3
 2026-07 | 1503.48 |  11668.00 |        942.99 |       10.0
 2026-08 | 2297.80 |  13965.80 |       1433.29 |       15.2
 2026-09 |  798.50 |  14764.30 |       1533.26 |        5.3
 2026-10 |  308.29 |  15072.59 |       1134.86 |        2.0

Un acumulado por cliente

Con PARTITION BY, la suma vuelve a empezar para cada cliente. Aquí se combina con GROUP BY: primero se suma el importe de cada pedido y luego se acumula esa suma (SUM(SUM(…)) OVER):

-- Acumulado de cada cliente por separado: PARTITION BY reinicia la suma
SELECT pe.cliente_id, pe.id AS pedido, pe.fecha,
       SUM(d.cantidad * d.precio_unitario) AS importe,
       SUM(SUM(d.cantidad * d.precio_unitario))
         OVER (PARTITION BY pe.cliente_id ORDER BY pe.fecha) AS acumulado_cliente
FROM pedidos pe
JOIN detalle d ON d.pedido_id = pe.id
WHERE pe.cliente_id IN (1, 2) AND pe.estado NOT IN ('cancelado', 'devuelto')
GROUP BY pe.cliente_id, pe.id, pe.fecha
ORDER BY pe.cliente_id, pe.fecha;

Ábrelo en el editor y cámbialo →

resultado · SQLitecliente_id | pedido | fecha      | importe | acumulado_cliente
-----------+--------+------------+---------+------------------
         1 |      1 | 2026-01-10 | 1324.98 |           1324.98
         1 |      4 | 2026-02-04 |   189.8 |           1514.78
         1 |     12 | 2026-05-19 |   429.9 |           1944.68
         1 |     22 | 2026-10-12 |  154.39 |           2099.07
         2 |      2 | 2026-01-15 |   859.8 |             859.8
         2 |     18 | 2026-08-21 |  2228.9 |            3088.7

El pedido 7 de Luis no aparece porque se devolvió.

Sin funciones de ventana

Antes de las ventanas, el acumulado se calculaba con una subconsulta que suma todos los meses anteriores a cada fila. Funciona, pero repite la suma una vez por fila:

-- Antes de las funciones de ventana: una subconsulta por fila
WITH por_mes AS (
  SELECT strftime('%Y-%m', pe.fecha) AS mes,
         ROUND(SUM(d.cantidad * d.precio_unitario), 2) AS ventas
  FROM detalle d
  JOIN pedidos pe ON pe.id = d.pedido_id
  WHERE pe.estado NOT IN ('cancelado', 'devuelto')
  GROUP BY mes
)
SELECT a.mes, a.ventas,
       ROUND((SELECT SUM(b.ventas) FROM por_mes b WHERE b.mes <= a.mes), 2) AS acumulado
FROM por_mes a
ORDER BY a.mes
LIMIT 3;

Ábrelo en el editor y cámbialo →

resultado · SQLitemes     | ventas  | acumulado
--------+---------+----------
2026-01 | 2367.76 |   2367.76
2026-02 |  2197.8 |   4565.56
2026-03 | 3967.58 |   8533.14
ROWS frente a RANGE. Sin marco explícito, SUM() OVER (ORDER BY mes) usa RANGE: si dos filas tuvieran el mismo mes, las sumaría juntas en las dos. Para «exactamente las dos filas anteriores», escribe ROWS, como en la media móvil.

Todas las consultas de esta página se ejecutaron antes de publicarla sobre la base de ejemplo «tienda» del curso, en contenedores limpios: SQLite 3.46.1 (el de Python 3.13), PostgreSQL 17 y MySQL 8.4. Los resultados y los mensajes de error están copiados de esa ejecución. Documentación oficial: funciones de ventana en PostgreSQL.