«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.
También te puede interesar
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
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ón | Con empate | Para qué |
|---|---|---|
ROW_NUMBER() | Números distintos (2 y 3), en el orden del desempate | Exactamente N filas por grupo |
RANK() | El mismo número (2 y 2) y luego salta al 4 | Puestos «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.
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.