Los N primeros de cada grupo en SQL: ROW_NUMBER y PARTITION BY

«Los dos productos más vendidos de cada categoría» es una de las preguntas más comunes y una de las que más cuesta la primera vez: un LIMIT 2 devuelve dos filas en total, no dos por grupo. Para el top N por grupo, la herramienta es ROW_NUMBER() con PARTITION BY, que funciona igual en SQLite, PostgreSQL y MySQL 8.

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.

Primero, las ventas de cada producto

Unidades vendidas por producto y categoría, sin contar los pedidos cancelados ni devueltos:

SELECT c.nombre AS categoria, p.nombre AS producto, SUM(d.cantidad) AS unidades
FROM detalle d
JOIN pedidos pe   ON pe.id = d.pedido_id
JOIN productos p  ON p.id = d.producto_id
JOIN categorias c ON c.id = p.categoria_id
WHERE pe.estado NOT IN ('cancelado', 'devuelto')
GROUP BY c.nombre, p.nombre
ORDER BY categoria, unidades DESC, producto;

Ábrelo en el editor y cámbialo →

resultado · SQLitecategoria      | producto                  | unidades
---------------+---------------------------+---------
Almacenamiento | Memoria USB 128 GB        |       12
Almacenamiento | SSD 1 TB                  |        7
Almacenamiento | Disco externo 4 TB        |        2
Monitores      | Monitor 24" FHD           |        6
Monitores      | Monitor 27" 4K            |        6
Periféricos    | Ratón inalámbrico         |        7
Periféricos    | Teclado mecánico          |        5
Periféricos    | Auriculares con micrófono |        3
Periféricos    | Webcam 4K                 |        1
Portátiles     | Portátil Aero 14          |        4
Portátiles     | Portátil Nova 16          |        2
Redes          | Switch 8 puertos          |        5
Redes          | Router Wi-Fi 7            |        2

Numerar dentro de cada categoría

ROW_NUMBER() numera las filas en el orden que digas, y PARTITION BY categoria hace que la cuenta vuelva a empezar en 1 en cada categoría. Como una función de ventana no se puede usar en el WHERE de la misma consulta, se calcula en una CTE (WITH) y se filtra fuera:

WITH ventas AS (
  SELECT c.nombre AS categoria, p.nombre AS producto, SUM(d.cantidad) AS unidades
  FROM detalle d
  JOIN pedidos pe   ON pe.id = d.pedido_id
  JOIN productos p  ON p.id = d.producto_id
  JOIN categorias c ON c.id = p.categoria_id
  WHERE pe.estado NOT IN ('cancelado', 'devuelto')
  GROUP BY c.nombre, p.nombre
),
numeradas AS (
  SELECT categoria, producto, unidades,
         ROW_NUMBER() OVER (PARTITION BY categoria ORDER BY unidades DESC, producto) AS puesto
  FROM ventas
)
SELECT categoria, puesto, producto, unidades
FROM numeradas
WHERE puesto <= 2
ORDER BY categoria, puesto;

Ábrelo en el editor y cámbialo →

resultado · SQLitecategoria      | puesto | producto           | unidades
---------------+--------+--------------------+---------
Almacenamiento |      1 | Memoria USB 128 GB |       12
Almacenamiento |      2 | SSD 1 TB           |        7
Monitores      |      1 | Monitor 24" FHD    |        6
Monitores      |      2 | Monitor 27" 4K     |        6
Periféricos    |      1 | Ratón inalámbrico  |        7
Periféricos    |      2 | Teclado mecánico   |        5
Portátiles     |      1 | Portátil Aero 14   |        4
Portátiles     |      2 | Portátil Nova 16   |        2
Redes          |      1 | Switch 8 puertos   |        5
Redes          |      2 | Router Wi-Fi 7     |        2

ROW_NUMBER con PARTITION BY numera los productos de cada categoría por separadoPARTITION BY categoria: la numeración vuelve a 1 en cada grupoAlmacenamiento1Memoria USB 128 GB122SSD 1 TB73Disco externo 4 TB2puesto > 2: fueraPeriféricos1Ratón inalámbrico72Teclado mecánico53Auriculares con micrófono3puesto > 2: fuera4Webcam 4K1puesto > 2: fueraRedes1Switch 8 puertos52Router Wi-Fi 72puestoproductounidades

Con PARTITION BY categoria, cada categoría tiene su propio 1 y su propio 2; el filtro puesto <= 2 deja fuera el resto. Esquema: decodigo.com.

PostgreSQL 17 y MySQL 8.4 devuelven exactamente las mismas filas. El producto que va detrás de unidades DESC en el ORDER BY sirve de desempate: Monitores tiene dos productos con 6 unidades, y sin él no estaría definido cuál es el primero.

Empates: ROW_NUMBER, RANK y DENSE_RANK

Las tres numeran, pero tratan distinto los empates. Con todos los productos juntos, del más al menos vendido:

WITH ventas AS (
  SELECT p.nombre AS producto, SUM(d.cantidad) AS unidades
  FROM detalle d
  JOIN pedidos pe  ON pe.id = d.pedido_id
  JOIN productos p ON p.id = d.producto_id
  WHERE pe.estado NOT IN ('cancelado', 'devuelto')
  GROUP BY p.nombre
)
SELECT producto, unidades,
       ROW_NUMBER() OVER (ORDER BY unidades DESC, producto) AS fila,
       RANK()       OVER (ORDER BY unidades DESC) AS rango,
       DENSE_RANK() OVER (ORDER BY unidades DESC) AS rango_denso
FROM ventas
ORDER BY unidades DESC, producto
LIMIT 8;

Ábrelo en el editor y cámbialo →

resultado · SQLiteproducto           | unidades | fila | rango | rango_denso
-------------------+----------+------+-------+------------
Memoria USB 128 GB |       12 |    1 |     1 |           1
Ratón inalámbrico  |        7 |    2 |     2 |           2
SSD 1 TB           |        7 |    3 |     2 |           2
Monitor 24" FHD    |        6 |    4 |     4 |           3
Monitor 27" 4K     |        6 |    5 |     4 |           3
Switch 8 puertos   |        5 |    6 |     6 |           4
Teclado mecánico   |        5 |    7 |     6 |           4
Portátil Aero 14   |        4 |    8 |     8 |           5
FunciónCon empatePara qué
ROW_NUMBER()Números distintos (2 y 3), en el orden del desempateExactamente N filas por grupo
RANK()El mismo número (2 y 2) y luego salta al 4Puestos «de competición»: dos segundos y nadie tercero
DENSE_RANK()El mismo número y sigue sin huecos (2, 2, 3)Los N valores más altos, con todos sus empatados

Si pides «los 2 primeros» con RANK() <= 2, salen tres productos (los dos empatados en segundo lugar). Con ROW_NUMBER(), siempre dos.

Sin funciones de ventana

En motores antiguos (MySQL 5.7, SQLite anterior a la 3.25) no hay ROW_NUMBER. La alternativa clásica es una subconsulta correlacionada: un producto está entre los dos primeros si menos de dos productos de su categoría vendieron más que él:

-- Sin funciones de ventana: un producto está entre los 2 primeros de su categoría
-- si menos de 2 productos de esa categoría vendieron más unidades que él.
WITH ventas AS (
  SELECT p.categoria_id, p.nombre AS producto, SUM(d.cantidad) AS unidades
  FROM detalle d
  JOIN pedidos pe  ON pe.id = d.pedido_id
  JOIN productos p ON p.id = d.producto_id
  WHERE pe.estado NOT IN ('cancelado', 'devuelto')
  GROUP BY p.categoria_id, p.nombre
)
SELECT c.nombre AS categoria, v.producto, v.unidades
FROM ventas v
JOIN categorias c ON c.id = v.categoria_id
WHERE (SELECT COUNT(*) FROM ventas otra
       WHERE otra.categoria_id = v.categoria_id AND otra.unidades > v.unidades) < 2
ORDER BY categoria, v.unidades DESC, v.producto;

Ábrelo en el editor y cámbialo →

resultado · SQLitecategoria      | producto           | unidades
---------------+--------------------+---------
Almacenamiento | Memoria USB 128 GB |       12
Almacenamiento | SSD 1 TB           |        7
Monitores      | Monitor 24" FHD    |        6
Monitores      | Monitor 27" 4K     |        6
Periféricos    | Ratón inalámbrico  |        7
Periféricos    | Teclado mecánico   |        5
Portátiles     | Portátil Aero 14   |        4
Portátiles     | Portátil Nova 16   |        2
Redes          | Switch 8 puertos   |        5
Redes          | Router Wi-Fi 7     |        2

Da lo mismo en este caso, pero recorre la tabla una vez por fila, y con empates se comporta como RANK, no como ROW_NUMBER. Si tu motor tiene funciones de ventana, úsalas.

Ojo con el JOIN a categorías: la «Base para portátil» no tiene categoría (categoria_id es NULL) y desaparece con un JOIN normal. Si quieres verla, usa LEFT JOIN y COALESCE(c.nombre, 'Sin categoría'), como en la tabla dinámica.

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 SQLite.