Funciones de ventana en SQL y consultas desde Python y Node

Lección 8 de 8. Las funciones de ventana calculan rankings, totales acumulados y medias móviles sin perder ninguna fila, y son lo que más distingue el SQL de hoy del de hace veinte años. Y para terminar, cómo se usa SQL desde un programa en Python y Node, con la regla de seguridad que no admite excepciones: los parámetros, demostrados con una inyección SQL de verdad.

GROUP BY junta filas; OVER no

Con GROUP BY, varias filas se convierten en una: si agrupas los productos por categoría, pierdes cada producto y te quedas con la media. A veces quieres las dos cosas a la vez: cada producto y la media de su categoría al lado. Para eso están las funciones de ventana: una función de agregado seguida de OVER se calcula sobre un grupo de filas relacionadas, la ventana, pero cada fila sigue en el resultado.

SELECT nombre,
       categoria_id,
       precio,
       ROUND(AVG(precio) OVER (PARTITION BY categoria_id), 2) AS media_categoria
FROM productos
WHERE categoria_id IN (2, 3)
ORDER BY categoria_id, precio DESC;

Ábrela en el editor y cámbiala →

resultado+---------------------------+--------------+--------+-----------------+
|          nombre           | categoria_id | precio | media_categoria |
+---------------------------+--------------+--------+-----------------+
| Monitor 27" 4K            | 2            | 429.9  | 294.45          |
| Monitor 24" FHD           | 2            | 159    | 294.45          |
| Webcam 4K                 | 3            | 139    | 78.12           |
| Teclado mecánico          | 3            | 89.5   | 78.12           |
| Auriculares con micrófono | 3            | 59     | 78.12           |
| Ratón inalámbrico         | 3            | 24.99  | 78.12           |
+---------------------------+--------------+--------+-----------------+

PARTITION BY hace el papel del GROUP BY: dice qué filas forman cada ventana. Sin él, la ventana es la tabla entera. Esto es lo que habría costado una subconsulta correlacionada en la lección 5, en una sola línea.

ROW_NUMBER, RANK y DENSE_RANK

Hay funciones que solo existen como funciones de ventana. Las más usadas numeran las filas dentro de cada ventana, en el orden que diga el ORDER BY de dentro del OVER. Con ellas se resuelve un problema clásico: el último pedido de cada cliente.

WITH numerados AS (
  SELECT cliente_id, id AS pedido, fecha,
         ROW_NUMBER() OVER (PARTITION BY cliente_id ORDER BY fecha DESC) AS n
  FROM pedidos
)
SELECT c.nombre, nu.pedido, nu.fecha
FROM numerados AS nu
JOIN clientes AS c ON c.id = nu.cliente_id
WHERE nu.n = 1
ORDER BY nu.fecha DESC
LIMIT 5;

Ábrela en el editor y cámbiala →

resultado+----------------+--------+------------+
|     nombre     | pedido |   fecha    |
+----------------+--------+------------+
| Ana Torres     | 22     | 2026-10-12 |
| Lucía Gómez    | 21     | 2026-10-02 |
| Andrés Navarro | 20     | 2026-09-23 |
| Jorge Ramírez  | 19     | 2026-09-09 |
| Luis Fernández | 18     | 2026-08-21 |
+----------------+--------+------------+

Cambiando n = 1 por n <= 3 salen los tres últimos de cada uno: es el patrón del ejemplo de top N por grupo. Las tres funciones de numerar solo se distinguen cuando hay empates. Con las unidades vendidas de cada producto, donde hay varios empates:

WITH vendidos AS (
  SELECT producto_id, SUM(cantidad) AS unidades
  FROM detalle
  GROUP BY producto_id
)
SELECT producto_id, unidades,
       ROW_NUMBER() OVER (ORDER BY unidades DESC, producto_id) AS row_number,
       RANK()       OVER (ORDER BY unidades DESC)              AS rank,
       DENSE_RANK() OVER (ORDER BY unidades DESC)              AS dense_rank
FROM vendidos
ORDER BY unidades DESC, producto_id
LIMIT 7;

Ábrela en el editor y cámbiala →

resultado+-------------+----------+------------+------+------------+
| producto_id | unidades | row_number | rank | dense_rank |
+-------------+----------+------------+------+------------+
| 10          | 12       | 1          | 1    | 1          |
| 6           | 7        | 2          | 2    | 2          |
| 8           | 7        | 3          | 2    | 2          |
| 3           | 6        | 4          | 4    | 3          |
| 4           | 6        | 5          | 4    | 3          |
| 5           | 5        | 6          | 6    | 4          |
| 12          | 5        | 7          | 6    | 4          |
+-------------+----------+------------+------+------------+

ROW_NUMBER numera seguido y desempata como pueda (aquí, por producto_id). RANK da el mismo puesto a los empatados y se salta los siguientes, como en una carrera: dos segundos y ningún tercero. DENSE_RANK no deja huecos.

Totales acumulados y LAG

Con ORDER BY dentro del OVER, una suma se vuelve acumulada: en cada fila, la suma desde el principio hasta esa fila. Y LAG trae el valor de la fila anterior, para comparar con ella. Las dos sobre las ventas de cada mes:

WITH meses AS (
  SELECT strftime('%Y-%m', p.fecha) AS mes,
         ROUND(SUM(d.cantidad * d.precio_unitario), 2) AS ventas
  FROM pedidos AS p
  JOIN detalle AS d ON d.pedido_id = p.id
  WHERE p.estado <> 'cancelado'
  GROUP BY mes
)

SELECT mes,
       ventas,
       ROUND(SUM(ventas) OVER (ORDER BY mes), 2) AS acumulado,
       ROUND(ventas - LAG(ventas) OVER (ORDER BY mes), 2) AS cambio
FROM meses
ORDER BY mes;

Ábrela en el editor y cámbiala →

resultado+---------+---------+-----------+----------+
|   mes   | ventas  | acumulado |  cambio  |
+---------+---------+-----------+----------+
| 2026-01 | 2367.76 | 2367.76   | NULL     |
| 2026-02 | 2197.8  | 4565.56   | -169.96  |
| 2026-03 | 4106.58 | 8672.14   | 1908.78  |
| 2026-04 | 305.9   | 8978.04   | -3800.68 |
| 2026-05 | 826.88  | 9804.92   | 520.98   |
| 2026-06 | 498.6   | 10303.52  | -328.28  |
| 2026-07 | 1503.48 | 11807.0   | 1004.88  |
| 2026-08 | 2297.8  | 14104.8   | 794.32   |
| 2026-09 | 798.5   | 14903.3   | -1499.3  |
| 2026-10 | 308.29  | 15211.59  | -490.21  |
+---------+---------+-----------+----------+

En enero, cambio es NULL, porque no hay mes anterior. Su pareja, LEAD, mira a la fila siguiente.

El marco: medias móviles

Por omisión, con ORDER BY, la ventana de cada fila va desde el principio hasta ella. El marco permite acotarla, por ejemplo a la fila actual y las dos anteriores, para una media móvil de tres meses, que suaviza los altibajos:

WITH meses AS (
  SELECT strftime('%Y-%m', p.fecha) AS mes,
         ROUND(SUM(d.cantidad * d.precio_unitario), 2) AS ventas
  FROM pedidos AS p
  JOIN detalle AS d ON d.pedido_id = p.id
  WHERE p.estado <> 'cancelado'
  GROUP BY mes
)

SELECT mes,
       ventas,
       ROUND(AVG(ventas) OVER (
         ORDER BY mes
         ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
       ), 2) AS media_3_meses
FROM meses
ORDER BY mes;

Ábrela en el editor y cámbiala →

resultado+---------+---------+---------------+
|   mes   | ventas  | media_3_meses |
+---------+---------+---------------+
| 2026-01 | 2367.76 | 2367.76       |
| 2026-02 | 2197.8  | 2282.78       |
| 2026-03 | 4106.58 | 2890.71       |
| 2026-04 | 305.9   | 2203.43       |
| 2026-05 | 826.88  | 1746.45       |
| 2026-06 | 498.6   | 543.79        |
| 2026-07 | 1503.48 | 942.99        |
| 2026-08 | 2297.8  | 1433.29       |
| 2026-09 | 798.5   | 1533.26       |
| 2026-10 | 308.29  | 1134.86       |
+---------+---------+---------------+

Seis meses de ventas; para la fila de julio, la ventana abarca mayo, junio y julio, y la función calcula su media sin agrupar las filasOVER: cada fila mira a sus vecinas sin dejar de ser una filaventas por mesmesventas2026-034106.582026-04305.92026-05826.882026-06498.62026-071503.482026-082297.8AVG(ventas) OVER (ORDER BY mes ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)En la fila de julio, la media de mayo, junioy julio: 942.99, sin juntar las filas.La ventana se desliza: en la fila de agosto abarca junio, julio y agosto. GROUP BY, en cambio, juntaría las filas.

La ventana de tres filas avanza con cada mes; los dos primeros meses solo tienen una o dos filas en su ventana. Esquema: decodigo.com.

Las funciones de ventana existen en SQLite desde la versión 3.25 (2018), en PostgreSQL desde la 8.4 y en MySQL desde la 8.0, con la misma sintaxis. Más casos de totales y medias en el ejemplo de totales acumulados.

SQL desde un programa: Python

En la práctica, SQL casi nunca se escribe a mano en una consola: lo lanza un programa, una web o un script. Python trae sqlite3 en su biblioteca estándar. Este programa abre la base tienda.db que creamos en la lección 1 y busca los clientes de un país:

import sqlite3

con = sqlite3.connect("tienda.db")
con.execute("PRAGMA foreign_keys = ON")

pais = "Chile"
filas = con.execute(
    "SELECT nombre, ciudad FROM clientes WHERE pais = ? ORDER BY nombre",
    (pais,),
).fetchall()

for nombre, ciudad in filas:
    print(nombre, "-", ciudad)
con.close()
python consulta.pyMateo Herrera - None
Tomás Ortega - Santiago

Fíjate en dos cosas. La primera: el país no va pegado dentro del texto de la consulta, sino como un parámetro: un ? en el SQL y el valor aparte, en una tupla. La segunda: el NULL de la ciudad de Mateo llega a Python como None.

La inyección SQL

Lo de los parámetros no es una manía. Si el programa construye la consulta pegando el texto que escribe un usuario, ese usuario puede escribir SQL. Es la inyección SQL, uno de los fallos de seguridad más antiguos y que más daño siguen haciendo. Este programa tiene una búsqueda mal hecha, con el nombre pegado en el texto, y otra bien hecha, con parámetro:

import sqlite3

con = sqlite3.connect("tienda.db")

def buscar_mal(nombre):
    sql = f"SELECT nombre, email FROM clientes WHERE nombre = '{nombre}'"
    print("SQL:", sql)
    return con.execute(sql).fetchall()

def buscar_bien(nombre):
    return con.execute("SELECT nombre, email FROM clientes WHERE nombre = ?", (nombre,)).fetchall()

print(len(buscar_mal("Ana Torres")), "fila")
print(len(buscar_mal("x' OR '1'='1")), "filas")
print(len(buscar_bien("x' OR '1'='1")), "filas con parámetro")

try:
    buscar_mal("x'; DELETE FROM clientes; --")
except Exception as e:
    print(type(e).__name__ + ":", e)
python inyeccion.pySQL: SELECT nombre, email FROM clientes WHERE nombre = 'Ana Torres'
1 fila
SQL: SELECT nombre, email FROM clientes WHERE nombre = 'x' OR '1'='1'
12 filas
0 filas con parámetro
SQL: SELECT nombre, email FROM clientes WHERE nombre = 'x'; DELETE FROM clientes; --'
ProgrammingError: You can only execute one statement at a time.

Con un nombre normal, las dos funcionan igual. Pero con x' OR '1'='1, la comilla del usuario cierra la del programa y la condición pasa a ser «el nombre es x, o uno es igual a uno», que es verdad para todas las filas: la búsqueda devuelve los 12 clientes con su correo. Con el parámetro, ese texto se busca como un nombre más, y no hay nadie que se llame así. El último intento, que pretendía borrar la tabla de clientes, lo frena el propio sqlite3, que no ejecuta dos sentencias en una llamada; pero otros conectores sí, y la filtración de datos del segundo intento no la frena nadie.

La regla, sin excepciones: nunca construyas una consulta pegando valores que vienen de fuera (formularios, URL, archivos, otras APIs). Usa siempre parámetros. Cada conector tiene su marcador: ? en sqlite3 de Python y en node:sqlite, %s en psycopg (PostgreSQL desde Python) y $1, $2… en node-postgres. Lo que cambia es el símbolo; la idea es la misma.

SQL desde un programa: Node

Node.js también trae SQLite incorporado, en el módulo node:sqlite, sin instalar nada. El mismo programa:

import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync("tienda.db");
const consulta = db.prepare(
  "SELECT nombre, ciudad FROM clientes WHERE pais = ? ORDER BY nombre"
);

for (const fila of consulta.all("Chile")) {
  console.log(fila.nombre, "-", fila.ciudad);
}
db.close();
node consulta.mjsMateo Herrera - null
Tomás Ortega - Santiago

Aquí el NULL llega como null. Ojo con el estado del módulo: en Node 24.22, la documentación oficial lo marca como release candidate (estabilidad 1.2), un paso antes de estable. Ya no hace falta ninguna opción para usarlo, pero su API todavía puede cambiar. Además trae su propia copia de SQLite, la 3.53.4, más nueva que la de Python 3.13. Para un proyecto en producción con Node, lo más habitual sigue siendo un paquete como better-sqlite3, o un servidor PostgreSQL con pg. Hay más en los ejemplos de SQL desde Python y SQL desde Node.

Resumen

ParaEscribeRecuerda
Un valor del grupo en cada filaAVG(x) OVER (PARTITION BY g)no junta filas, a diferencia de GROUP BY
Numerar dentro de cada grupoROW_NUMBER() OVER (PARTITION BY g ORDER BY f DESC)filtra con n = 1 en una CTE
Puestos con empatesRANK(), DENSE_RANK()RANK deja huecos; DENSE_RANK no
Total acumuladoSUM(x) OVER (ORDER BY f)el ORDER BY de dentro decide el orden
La fila anterior o la siguienteLAG(x), LEAD(x)NULL si no existe
Media móvil... ROWS BETWEEN 2 PRECEDING AND CURRENT ROWel marco acota la ventana
Valores en una consultaexecute("... WHERE pais = ?", (pais,))nunca pegarlos en el texto

Retos

Reto 1. ¿Cuál es el producto más caro de cada categoría? Resuélvelo con ROW_NUMBER en vez de con la subconsulta correlacionada de la lección 5.

Ver la solución
WITH ordenados AS (
  SELECT categoria_id, nombre, precio,
         ROW_NUMBER() OVER (PARTITION BY categoria_id ORDER BY precio DESC) AS n
  FROM productos
  WHERE categoria_id IS NOT NULL
)
SELECT categoria_id, nombre, precio
FROM ordenados
WHERE n = 1
ORDER BY categoria_id;

Ábrela en el editor y cámbiala →

resultado+--------------+--------------------+--------+
| categoria_id |       nombre       | precio |
+--------------+--------------------+--------+
| 1            | Portátil Nova 16   | 1849   |
| 2            | Monitor 27" 4K     | 429.9  |
| 3            | Webcam 4K          | 139    |
| 4            | Disco externo 4 TB | 119    |
| 5            | Router Wi-Fi 7     | 249    |
+--------------+--------------------+--------+

Reto 2. Para cada pedido de Ana Torres (cliente 1), muestra su fecha y cuántos días pasaron desde su pedido anterior.

Ver la solución
SELECT id, fecha,
       julianday(fecha) - julianday(LAG(fecha) OVER (ORDER BY fecha)) AS dias_desde_el_anterior
FROM pedidos
WHERE cliente_id = 1
ORDER BY fecha;

Ábrela en el editor y cámbiala →

resultado+----+------------+------------------------+
| id |   fecha    | dias_desde_el_anterior |
+----+------------+------------------------+
| 1  | 2026-01-10 | NULL                   |
| 4  | 2026-02-04 | 25.0                   |
| 12 | 2026-05-19 | 104.0                  |
| 22 | 2026-10-12 | 146.0                  |
+----+------------+------------------------+

julianday convierte una fecha de SQLite en un número de días, y la resta da la distancia. En PostgreSQL basta con restar dos fechas.

Reto 3. Este código de Python es vulnerable. ¿Cómo lo arreglarías? con.execute("DELETE FROM pedidos WHERE id = " + id_del_formulario)

Ver la solución
con.execute("DELETE FROM pedidos WHERE id = ?", (id_del_formulario,))

Con un parámetro. Tal como estaba, un id_del_formulario como 1 OR 1=1 borraría todos los pedidos de una vez, sin necesidad de una segunda sentencia.

Todas las consultas de esta lección se ejecutaron en SQLite 3.46.1 sobre la base de ejemplo «tienda» antes de publicarla, y los resultados están copiados de esa ejecución; las diferencias con PostgreSQL 17 y MySQL 8.4 se comprobaron en esos motores. ¿Quieres probar algo? Usa el editor de SQL, que ya trae la base cargada.