ROW_NUMBER
ROW_NUMBER asigna un número entero secuencial a cada fila dentro de su partición, empezando desde 1. A diferencia de RANK y DENSE_RANK, nunca produce empates ni huecos: cada fila recibe un número único incluso cuando varias filas tienen el mismo valor en la columna de orden. Es probablemente la función de ventana más utilizada en la práctica porque resuelve problemas cotidianos como paginar resultados, obtener el registro más reciente por grupo y eliminar duplicados.
Cuando dos filas tienen el mismo valor en la columna de orden, MySQL asigna los números de forma arbitraria pero determinista dentro de la misma ejecución. Si necesitas un orden completamente predecible en caso de empate, añade una segunda columna al ORDER BY, como la clave primaria.
Sintaxis
ROW_NUMBER() OVER (
[PARTITION BY columna1, columna2, ...]
ORDER BY columna1 [ASC|DESC], columna2 [ASC|DESC], ...
)ROW_NUMBER no acepta argumentos. Todo su comportamiento se define dentro de la cláusula OVER. El ORDER BY es esencial porque determina en qué secuencia se asignan los números. Sin ORDER BY, los números se asignan en un orden no garantizado, lo cual rara vez es útil.
Comportamiento básico
En su forma más simple, ROW_NUMBER numera todas las filas del resultado según un orden:
SELECT
ROW_NUMBER() OVER (ORDER BY salario DESC) AS num,
nombre,
departamento,
salario
FROM empleados;| num | nombre | departamento | salario |
|---|---|---|---|
| 1 | Roberto Fernández | Marketing | 72000.00 |
| 2 | Carlos López | Ventas | 65000.00 |
| 3 | Ana García | Ventas | 58000.00 |
| 4 | Marta Ruiz | Ventas | 55000.00 |
| 5 | Laura Díaz | Marketing | 52000.00 |
| 6 | David Moreno | Soporte | 48000.00 |
| 7 | Elena Torres | Soporte | 45000.00 |
| 8 | Pedro Sánchez | Ventas | 43000.00 |
Cada empleado recibe un número único del 1 al 8 según su salario de mayor a menor. Si dos empleados tuvieran exactamente el mismo salario, uno recibiría por ejemplo el 3 y el otro el 4, sin repetir ni saltar números.
Numerar por grupo con PARTITION BY
Al añadir PARTITION BY, la numeración se reinicia en cada grupo:
SELECT
ROW_NUMBER() OVER (
PARTITION BY departamento
ORDER BY salario DESC
) AS num_en_depto,
nombre,
departamento,
salario
FROM empleados;| num_en_depto | nombre | departamento | salario |
|---|---|---|---|
| 1 | Roberto Fernández | Marketing | 72000.00 |
| 2 | Laura Díaz | Marketing | 52000.00 |
| 1 | Carlos López | Ventas | 65000.00 |
| 2 | Ana García | Ventas | 58000.00 |
| 3 | Marta Ruiz | Ventas | 55000.00 |
| 4 | Pedro Sánchez | Ventas | 43000.00 |
| 1 | David Moreno | Soporte | 48000.00 |
| 2 | Elena Torres | Soporte | 45000.00 |
Cada departamento tiene su propia secuencia empezando en 1. Roberto es el número 1 en Marketing, Carlos es el número 1 en Ventas, y David es el número 1 en Soporte.
Caso práctico: paginación de resultados
ROW_NUMBER es la forma más limpia de implementar paginación en consultas complejas. Supón que necesitas la página 3 de un listado de productos con 10 elementos por página:
WITH productos_numerados AS (
SELECT
ROW_NUMBER() OVER (ORDER BY nombre) AS num_fila,
id_producto,
nombre,
precio,
categoria
FROM productos
WHERE activo = 1
)
SELECT id_producto, nombre, precio, categoria
FROM productos_numerados
WHERE num_fila BETWEEN 21 AND 30;| id_producto | nombre | precio | categoria |
|---|---|---|---|
| 78 | Impresora láser HP | 289.00 | Electrónica |
| 134 | Jersey lana merino | 45.90 | Ropa |
| 22 | Lámpara de escritorio LED | 34.50 | Hogar |
| 91 | Libro SQL avanzado | 29.95 | Libros |
| 45 | Mochila viaje 40L | 67.00 | Accesorios |
La ventaja sobre LIMIT ... OFFSET es que ROW_NUMBER te permite numerar los registros de forma predecible y luego filtrar con WHERE. Además, puedes añadir el total de filas con otra función de ventana en el mismo CTE.
Caso práctico: top N por grupo
Uno de los problemas más clásicos de SQL es obtener los N mejores registros de cada grupo. Con ROW_NUMBER se resuelve de forma elegante. Supón que quieres los 2 productos más vendidos de cada categoría:
WITH ranking_productos AS (
SELECT
p.nombre,
p.categoria,
SUM(dp.cantidad) AS unidades_vendidas,
SUM(dp.cantidad * dp.precio_unitario) AS ingresos,
ROW_NUMBER() OVER (
PARTITION BY p.categoria
ORDER BY SUM(dp.cantidad) DESC
) AS rn
FROM productos p
JOIN detalle_pedidos dp ON p.id_producto = dp.id_producto
GROUP BY p.id_producto, p.nombre, p.categoria
)
SELECT nombre, categoria, unidades_vendidas, ingresos
FROM ranking_productos
WHERE rn <= 2;| nombre | categoria | unidades_vendidas | ingresos |
|---|---|---|---|
| Monitor 27" 4K | Electrónica | 342 | 119700.00 |
| Teclado mecánico RGB | Electrónica | 289 | 28900.00 |
| Camiseta algodón orgánico | Ropa | 567 | 14175.00 |
| Pantalón chino slim | Ropa | 423 | 21150.00 |
| Aceite oliva virgen extra 1L | Alimentación | 890 | 10680.00 |
| Café molido premium 500g | Alimentación | 756 | 9072.00 |
El patrón es siempre el mismo: asignar ROW_NUMBER con PARTITION BY la columna de grupo, envolver en CTE y filtrar por rn <= N. Es tan frecuente que vale la pena memorizarlo.
Caso práctico: eliminar duplicados conservando el más reciente
Otro uso habitual es la deduplicación. Cuando una tabla tiene registros duplicados y necesitas quedarte solo con el más reciente de cada grupo, ROW_NUMBER te permite identificarlos sin ambigüedad:
WITH clientes_ordenados AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY fecha_registro DESC
) AS rn
FROM clientes
)
SELECT id_cliente, nombre, email, fecha_registro
FROM clientes_ordenados
WHERE rn = 1;| id_cliente | nombre | fecha_registro | |
|---|---|---|---|
| 1045 | Ana García Ruiz | ana.garcia@correo.com | 2024-08-15 |
| 892 | Carlos López Martín | carlos.lopez@correo.com | 2024-06-22 |
| 1203 | Marta Díaz Soto | marta.diaz@correo.com | 2024-11-03 |
Para cada email duplicado, la consulta conserva solo el registro con la fecha de registro más reciente. Si quisieras eliminar los duplicados de forma permanente, podrías usar esta misma lógica dentro de un DELETE:
DELETE c FROM clientes c
JOIN (
SELECT id_cliente,
ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY fecha_registro DESC
) AS rn
FROM clientes
) ranked ON c.id_cliente = ranked.id_cliente
WHERE ranked.rn > 1;Diferencia con RANK
La diferencia entre ROW_NUMBER y RANK se manifiesta cuando hay empates. Observa esta consulta donde dos empleados tienen el mismo salario:
SELECT
nombre,
salario,
ROW_NUMBER() OVER (ORDER BY salario DESC) AS row_num,
RANK() OVER (ORDER BY salario DESC) AS ranking
FROM empleados
WHERE departamento = 'Ventas';| nombre | salario | row_num | ranking |
|---|---|---|---|
| Carlos López | 65000.00 | 1 | 1 |
| Ana García | 55000.00 | 2 | 2 |
| Marta Ruiz | 55000.00 | 3 | 2 |
| Pedro Sánchez | 43000.00 | 4 | 4 |
Ana y Marta ganan lo mismo. RANK les asigna a ambas el rango 2 y salta al 4 para Pedro. ROW_NUMBER les da números distintos (2 y 3) de forma arbitraria. Usa ROW_NUMBER cuando necesitas un número único por fila, y RANK cuando los empates deben reflejarse en el resultado.
Manejo de NULL
ROW_NUMBER trata los valores NULL como cualquier otro valor a efectos de ordenación. En MySQL, los NULL aparecen primero en orden ascendente y al final en orden descendente:
SELECT
ROW_NUMBER() OVER (ORDER BY fecha_baja) AS num,
nombre,
fecha_baja
FROM empleados;| num | nombre | fecha_baja |
|---|---|---|
| 1 | Pedro Sánchez | NULL |
| 2 | Elena Torres | NULL |
| 3 | Laura Díaz | 2023-06-15 |
| 4 | Roberto Fernández | 2024-01-20 |
Los empleados sin fecha de baja (activos) reciben los primeros números porque NULL se ordena antes que cualquier fecha en orden ascendente. Si prefieres que los activos aparezcan al final, puedes usar ORDER BY fecha_baja DESC o ORDER BY ISNULL(fecha_baja), fecha_baja.
En cuanto a PARTITION BY, si la columna de partición contiene NULL, todas las filas con NULL se agrupan en una sola partición.
Combinación con otras funciones
ROW_NUMBER se combina frecuentemente con funciones de agregación de ventana para crear informes enriquecidos:
SELECT
ROW_NUMBER() OVER (
PARTITION BY categoria ORDER BY unidades_vendidas DESC
) AS posicion,
nombre,
categoria,
unidades_vendidas,
ROUND(
unidades_vendidas * 100.0 / SUM(unidades_vendidas) OVER (PARTITION BY categoria),
1
) AS pct_categoria
FROM productos
ORDER BY categoria, posicion;| posicion | nombre | categoria | unidades_vendidas | pct_categoria |
|---|---|---|---|---|
| 1 | Monitor 27" 4K | Electrónica | 342 | 38.6 |
| 2 | Teclado mecánico RGB | Electrónica | 289 | 32.6 |
| 3 | Ratón inalámbrico | Electrónica | 255 | 28.8 |
| 1 | Camiseta algodón orgánico | Ropa | 567 | 57.3 |
| 2 | Pantalón chino slim | Ropa | 423 | 42.7 |
Cada producto tiene su posición dentro de su categoría y el porcentaje que representa del total de unidades vendidas en esa categoría, todo en una sola consulta.
Practica con ROW_NUMBER
Usa el editor para numerar filas dentro de particiones:
En el siguiente artículo veremos RANK para asignar rangos que permiten empates.
Escrito por Eduardo Lázaro
