FOUND_ROWS() y ROW_COUNT()

En el desarrollo de aplicaciones con MySQL, hay dos preguntas que surgen constantemente: "¿cuántos resultados totales tiene esta consulta aunque solo muestre una página?" y "¿cuántas filas modificó realmente esa operación?". La primera pregunta la respondía históricamente FOUND_ROWS() junto con SQL_CALC_FOUND_ROWS, y la segunda la responde ROW_COUNT(). Aunque cumplen propósitos distintos, ambas funciones comparten una característica: informan sobre el efecto de la última sentencia ejecutada en la sesión.

Es importante señalar desde el inicio que SQL_CALC_FOUND_ROWS y FOUND_ROWS() fueron declaradas obsoletas (deprecated) en MySQL 8.0.17. Aun así, merece la pena entender cómo funcionan porque aparecen en código legado con enorme frecuencia, y conocer las alternativas modernas te permitirá migrar ese código de forma efectiva.

FOUND_ROWS() y SQL_CALC_FOUND_ROWS

La función FOUND_ROWS() devuelve el número total de filas que habría devuelto la sentencia SELECT anterior si no hubiera tenido una cláusula LIMIT. Para que funcione, la consulta SELECT debe incluir el modificador SQL_CALC_FOUND_ROWS inmediatamente después de la palabra clave SELECT.

-- Paso 1: Ejecutar la consulta con SQL_CALC_FOUND_ROWS
SELECT SQL_CALC_FOUND_ROWS
  id, nombre, email, ciudad
FROM clientes
WHERE ciudad = 'Madrid'
ORDER BY nombre
LIMIT 10;
 
-- Paso 2: Obtener el total de filas sin LIMIT
SELECT FOUND_ROWS() AS total_resultados;

En este ejemplo, la primera consulta devuelve como máximo 10 clientes de Madrid, pero FOUND_ROWS() indica cuántos clientes de Madrid existen en total. Si hay 147 clientes en Madrid, la primera consulta devuelve 10 filas y FOUND_ROWS() devuelve 147.

El propósito original de este mecanismo era evitar ejecutar dos consultas separadas para implementar paginación: una con LIMIT para obtener los datos de la página y otra con COUNT(*) para obtener el total. Con SQL_CALC_FOUND_ROWS, MySQL calculaba ambos resultados en una sola pasada.

Ejemplo clásico de paginación con FOUND_ROWS()

El patrón de paginación con FOUND_ROWS() era extremadamente popular en aplicaciones PHP/MySQL de los años 2000 y 2010. Funcionaba así:

-- Parámetros de paginación
SET @pagina = 3;
SET @por_pagina = 20;
SET @offset = (@pagina - 1) * @por_pagina;
 
-- Consulta paginada con cálculo de total
SELECT SQL_CALC_FOUND_ROWS
  p.id,
  p.nombre,
  p.precio,
  c.nombre AS categoria
FROM productos p
JOIN categorias c ON p.categoria_id = c.id
WHERE p.precio BETWEEN 10.00 AND 100.00
  AND p.stock > 0
ORDER BY p.nombre
LIMIT 20 OFFSET 40;
 
-- Obtener total para calcular número de páginas
SELECT FOUND_ROWS() AS total_productos;
-- Ejemplo: 238
 
-- Con 238 productos y 20 por página: CEIL(238/20) = 12 páginas

La aplicación recibía los 20 productos de la página 3 y sabía que había 238 productos en total, permitiendo mostrar una barra de paginación con 12 páginas. Todo esto sin necesidad de ejecutar una segunda consulta COUNT(*).

Deprecación de SQL_CALC_FOUND_ROWS

A partir de MySQL 8.0.17, tanto SQL_CALC_FOUND_ROWS como FOUND_ROWS() fueron declaradas obsoletas. El equipo de MySQL determinó que, en la mayoría de los casos, ejecutar dos consultas separadas (una con LIMIT y otra con COUNT(*)) es más eficiente que usar SQL_CALC_FOUND_ROWS, porque el optimizador puede aplicar optimizaciones diferentes a cada consulta.

Si ejecutas SQL_CALC_FOUND_ROWS en MySQL 8.0.17 o superior, verás una advertencia (warning) indicando que la funcionalidad está deprecada y será eliminada en una futura versión. Tu código seguirá funcionando, pero es recomendable migrar a las alternativas modernas.

Alternativas modernas para paginación

La alternativa recomendada es ejecutar dos consultas: una para obtener los datos paginados y otra para obtener el total. El optimizador de MySQL maneja este patrón de forma muy eficiente, especialmente si tienes índices adecuados.

-- Alternativa 1: Dos consultas separadas (recomendada)
 
-- Consulta de datos
SELECT
  p.id,
  p.nombre,
  p.precio,
  c.nombre AS categoria
FROM productos p
JOIN categorias c ON p.categoria_id = c.id
WHERE p.precio BETWEEN 10.00 AND 100.00
  AND p.stock > 0
ORDER BY p.nombre
LIMIT 20 OFFSET 40;
 
-- Consulta de conteo
SELECT COUNT(*) AS total_productos
FROM productos p
JOIN categorias c ON p.categoria_id = c.id
WHERE p.precio BETWEEN 10.00 AND 100.00
  AND p.stock > 0;

Aunque parezca menos eficiente ejecutar dos consultas, el optimizador puede tomar atajos en la consulta COUNT(*) que no son posibles cuando se mezcla con la recuperación de datos. Por ejemplo, puede usar un índice de cobertura para el conteo sin necesidad de acceder a las filas completas.

Otra alternativa es usar una función de ventana para incluir el total directamente en cada fila del resultado.

-- Alternativa 2: Window function con COUNT(*) OVER()
SELECT
  id,
  nombre,
  precio,
  COUNT(*) OVER() AS total_resultados
FROM productos
WHERE precio BETWEEN 10.00 AND 100.00
  AND stock > 0
ORDER BY nombre
LIMIT 20 OFFSET 40;

Esta técnica devuelve el total en cada fila, lo que implica cierta redundancia, pero evita la segunda consulta. Es útil cuando las condiciones de filtrado son complejas y quieres garantizar que ambas operaciones (datos y conteo) usan exactamente los mismos criterios.

ROW_COUNT(): filas afectadas por la última operación

La función ROW_COUNT() devuelve el número de filas que fueron afectadas, insertadas o eliminadas por la última sentencia DML (INSERT, UPDATE, DELETE). A diferencia de FOUND_ROWS(), ROW_COUNT() no está deprecada y es una herramienta vigente y fundamental.

-- Insertar filas y verificar cuántas se insertaron
INSERT INTO clientes (nombre, email, ciudad)
VALUES
  ('Ana Torres', 'ana@ejemplo.com', 'Sevilla'),
  ('Luis Martín', 'luis@ejemplo.com', 'Valencia'),
  ('Carmen Ruiz', 'carmen@ejemplo.com', 'Bilbao');
 
SELECT ROW_COUNT() AS filas_insertadas;
-- Resultado: 3

Los valores que devuelve ROW_COUNT() tienen significados específicos según el contexto. Un valor positivo indica el número de filas afectadas. Un valor de 0 indica que la sentencia se ejecutó correctamente pero no afectó a ninguna fila. Un valor de -1 indica que la sentencia no aplica para el conteo de filas (como un SELECT o un DDL).

-- UPDATE que no afecta filas
UPDATE clientes
SET ciudad = 'Madrid'
WHERE email = 'inexistente@ejemplo.com';
 
SELECT ROW_COUNT() AS filas_afectadas;
-- Resultado: 0
 
-- SELECT no aplica para ROW_COUNT
SELECT * FROM clientes WHERE ciudad = 'Madrid';
 
SELECT ROW_COUNT() AS filas_afectadas;
-- Resultado: -1

Verificar si un UPDATE o DELETE tuvo efecto

Uno de los usos más prácticos de ROW_COUNT() es confirmar que una operación de modificación o eliminación realmente afectó a los registros esperados. Esto es especialmente útil en procedimientos almacenados donde necesitas tomar decisiones basadas en el resultado de la operación.

DELIMITER //
 
CREATE PROCEDURE actualizar_precio_producto(
  IN p_producto_id INT,
  IN p_nuevo_precio DECIMAL(10,2)
)
BEGIN
  UPDATE productos
  SET precio = p_nuevo_precio
  WHERE id = p_producto_id;
 
  IF ROW_COUNT() = 0 THEN
    SELECT CONCAT('No se encontró el producto con ID ', p_producto_id) AS resultado;
  ELSE
    SELECT CONCAT('Precio actualizado correctamente para el producto ',
                   p_producto_id, '. Nuevo precio: ', p_nuevo_precio) AS resultado;
  END IF;
END //
 
DELIMITER ;
-- Producto existente
CALL actualizar_precio_producto(5, 29.99);
-- Resultado: Precio actualizado correctamente para el producto 5. Nuevo precio: 29.99
 
-- Producto inexistente
CALL actualizar_precio_producto(9999, 29.99);
-- Resultado: No se encontró el producto con ID 9999

Este patrón es mucho más eficiente que hacer un SELECT previo para verificar la existencia del registro, porque evita una consulta adicional y elimina la posible condición de carrera entre la verificación y la actualización.

ROW_COUNT() con DELETE controlado

En operaciones de limpieza de datos, ROW_COUNT() te permite registrar exactamente cuántos registros fueron eliminados, lo que es esencial para auditoría y verificación.

DELIMITER //
 
CREATE PROCEDURE purgar_sesiones_expiradas()
BEGIN
  DECLARE v_eliminadas INT;
 
  DELETE FROM sesiones_usuario
  WHERE ultima_actividad < DATE_SUB(NOW(), INTERVAL 30 DAY);
 
  SET v_eliminadas = ROW_COUNT();
 
  INSERT INTO log_mantenimiento (operacion, detalles, fecha)
  VALUES (
    'purgar_sesiones',
    CONCAT('Sesiones expiradas eliminadas: ', v_eliminadas),
    NOW()
  );
 
  SELECT v_eliminadas AS sesiones_eliminadas;
END //
 
DELIMITER ;

Observa que ROW_COUNT() se captura inmediatamente en una variable después del DELETE. Esto es crucial porque el INSERT posterior en la tabla de log sobrescribiría el valor de ROW_COUNT().

Matiz importante de ROW_COUNT() con UPDATE

Con sentencias UPDATE, ROW_COUNT() devuelve el número de filas que realmente cambiaron, no el número de filas que coincidieron con la cláusula WHERE. Si ejecutas un UPDATE que establece un valor idéntico al que ya tenía la fila, ROW_COUNT() reportará 0 para esa fila.

-- Supongamos que el cliente 1 ya tiene ciudad = 'Madrid'
UPDATE clientes SET ciudad = 'Madrid' WHERE id = 1;
 
SELECT ROW_COUNT() AS filas_afectadas;
-- Resultado: 0 (la fila coincidió con WHERE, pero no cambió)

Este comportamiento depende de la configuración del cliente MySQL. La librería cliente puede solicitar que MySQL reporte las filas coincidentes (matched rows) en lugar de las filas cambiadas (changed rows) usando el flag CLIENT_FOUND_ROWS. La función ROW_COUNT() a nivel de SQL siempre reporta filas cambiadas, pero las APIs de los lenguajes de programación pueden diferir en este aspecto.

Errores comunes

El error más frecuente con FOUND_ROWS() es olvidar incluir SQL_CALC_FOUND_ROWS en la consulta SELECT. Sin este modificador, FOUND_ROWS() devuelve un valor impredecible que no corresponde con lo que esperas. También es un error llamar a FOUND_ROWS() si entre la consulta SELECT y la llamada a FOUND_ROWS() ejecutas cualquier otra sentencia: el valor se pierde.

Con ROW_COUNT(), el error más habitual es no capturar el valor inmediatamente después de la operación DML. Cualquier sentencia posterior, incluyendo otro INSERT, UPDATE o incluso un SET que no parezca relevante, puede restablecer el valor de ROW_COUNT().

-- INCORRECTO: el SELECT intermedio resetea ROW_COUNT
DELETE FROM pedidos_antiguos WHERE fecha < '2024-01-01';
SELECT 'Limpieza completada' AS mensaje;  -- Esto resetea ROW_COUNT
SELECT ROW_COUNT();  -- Devuelve -1, no el número de filas eliminadas
 
-- CORRECTO: capturar inmediatamente
DELETE FROM pedidos_antiguos WHERE fecha < '2024-01-01';
SET @eliminadas = ROW_COUNT();
SELECT 'Limpieza completada' AS mensaje;
SELECT @eliminadas AS filas_eliminadas;

Otro error común es usar SQL_CALC_FOUND_ROWS en MySQL 8.0.17+ sin ser consciente de la deprecación. Aunque el código funciona, genera advertencias que pueden saturar los logs del servidor. Es mejor migrar a las alternativas de dos consultas o funciones de ventana.

Cuándo usar cada función

Utiliza ROW_COUNT() siempre que necesites verificar el efecto de una operación DML: confirmar que un UPDATE modificó registros, registrar cuántas filas eliminó un DELETE, o validar que un INSERT masivo insertó el número esperado de filas. Es una función activa, vigente y sin planes de deprecación.

En cuanto a FOUND_ROWS(), si trabajas con código existente que la utiliza, comprende su funcionamiento para poder mantenerlo. Para código nuevo, opta por la alternativa de dos consultas separadas (datos + COUNT(*)) o por la técnica de COUNT(*) OVER() con funciones de ventana. Ambas alternativas son más eficientes en la mayoría de los escenarios y no dependen de funcionalidades deprecadas.

Escrito por Eduardo Lázaro