Cursores
Un cursor permite recorrer un conjunto de resultados fila por fila dentro de un procedimiento almacenado. Los cursores de MySQL son de solo lectura, no permiten desplazamiento hacia atrás y son sensibles a los datos subyacentes. Siempre se usan en combinación con LOOP u otro bucle para iterar sobre las filas.
En SQL, las operaciones normalmente trabajan sobre conjuntos completos: un SELECT devuelve todas las filas que cumplen la condición, un UPDATE modifica todas las filas que coinciden. Pero hay situaciones donde necesitas procesar cada fila individualmente, aplicando lógica diferente según los datos de esa fila en concreto. Ahí es donde entran los cursores.
Un cursor es básicamente un puntero que recorre el resultado de un SELECT fila por fila. En cada iteración, lee los valores de la fila actual en variables y avanza a la siguiente. Es similar a un iterador en lenguajes de programación: abre un resultado, lee de él secuencialmente y lo cierra cuando termina.
Sintaxis
El uso de un cursor siempre sigue cinco pasos en este orden:
-- 1. Declarar el cursor
DECLARE nombre_cursor CURSOR FOR sentencia_select;
-- 2. Declarar handler para fin de datos
DECLARE CONTINUE HANDLER FOR NOT FOUND SET variable = TRUE;
-- 3. Abrir el cursor
OPEN nombre_cursor;
-- 4. Leer filas
FETCH nombre_cursor INTO variable1, variable2, ...;
-- 5. Cerrar el cursor
CLOSE nombre_cursor;Cada paso es obligatorio: no puedes hacer FETCH sin OPEN, y olvidar CLOSE puede dejar recursos del servidor ocupados innecesariamente.
Orden de declaración
El orden dentro de un bloque BEGIN...END cuando usas cursores es estricto y no negociable. MySQL genera un error si lo alteras:
- Variables con
DECLARE - Cursores con
DECLARE CURSOR - Handlers con
DECLARE HANDLER - Sentencias ejecutables
Este orden puede parecer arbitrario, pero tiene sentido: las variables deben existir antes de que el cursor las use en su SELECT, y el handler debe estar declarado antes de que el primer FETCH pueda activarlo. No es solo una convención de estilo, sino un requisito del parser de MySQL.
Ejemplo básico
Este procedimiento recorre los productos de alto precio y los clasifica en rangos usando lógica que sería más difícil de expresar con un solo SELECT:
DELIMITER //
CREATE PROCEDURE listar_productos_cursor()
BEGIN
DECLARE v_nombre VARCHAR(100);
DECLARE v_precio DECIMAL(10,2);
DECLARE v_done INT DEFAULT FALSE;
DECLARE cur_productos CURSOR FOR
SELECT nombre, precio FROM productos
WHERE activo = TRUE AND precio > 1000
ORDER BY precio DESC;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = TRUE;
DROP TEMPORARY TABLE IF EXISTS tmp_resultado;
CREATE TEMPORARY TABLE tmp_resultado (
nombre VARCHAR(100),
precio DECIMAL(10,2),
rango_precio VARCHAR(20)
);
OPEN cur_productos;
leer: LOOP
FETCH cur_productos INTO v_nombre, v_precio;
IF v_done THEN
LEAVE leer;
END IF;
INSERT INTO tmp_resultado VALUES (
v_nombre,
v_precio,
CASE
WHEN v_precio > 1500 THEN 'Muy alto'
ELSE 'Alto'
END
);
END LOOP leer;
CLOSE cur_productos;
SELECT * FROM tmp_resultado;
DROP TEMPORARY TABLE tmp_resultado;
END //
DELIMITER ;CALL listar_productos_cursor();| nombre | precio | rango_precio |
|---|---|---|
| ASUS ROG Zephyrus | 1899.99 | Muy alto |
| Lenovo ThinkPad X1 | 1549.00 | Muy alto |
| MacBook Air M3 | 1399.00 | Alto |
| iPhone 15 Pro | 1299.99 | Alto |
El patrón OPEN → LOOP (FETCH, comprobar done, procesar) → CLOSE es el esqueleto básico de cualquier procedimiento con cursor. Una vez lo memorices, solo cambia la consulta del cursor y la lógica de procesamiento dentro del bucle.
Handler NOT FOUND
El handler NOT FOUND es la pieza que detecta cuándo el cursor ha llegado al final del resultado. Sin él, MySQL genera un error cuando FETCH intenta leer después de la última fila (error 1329: "No data - zero rows fetched"). Los handlers se explican en detalle en el artículo sobre DECLARE HANDLER.
MySQL ofrece dos tipos de handlers para NOT FOUND:
-- CONTINUE HANDLER: continúa la ejecución después de NOT FOUND
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = TRUE;
-- EXIT HANDLER: sale del bloque BEGIN...END actual
DECLARE EXIT HANDLER FOR NOT FOUND BEGIN END;CONTINUE HANDLER es el más utilizado con diferencia, porque permite verificar la variable de control (v_done) en el bucle y decidir qué hacer. Con EXIT HANDLER, MySQL sale automáticamente del bloque, lo que es menos flexible pero puede ser útil en casos simples donde no necesitas lógica adicional después del último FETCH.
Es importante entender cuándo se activa el handler: se activa cuando FETCH intenta leer y no encuentra más filas. Esto significa que el handler se activa después de un FETCH fallido, no antes. Por eso siempre debes comprobar v_done inmediatamente después del FETCH y antes de procesar los datos, ya que las variables del FETCH contienen valores residuales de la iteración anterior cuando NOT FOUND se activa.
Cursor con cálculos acumulados
Los cursores brillan cuando necesitas hacer cálculos que dependen de datos de múltiples tablas o que requieren lógica que no se puede expresar en una sola consulta. Este ejemplo genera un resumen de ventas por cliente:
DELIMITER //
CREATE PROCEDURE resumen_ventas_por_cliente()
BEGIN
DECLARE v_cliente_id INT;
DECLARE v_nombre VARCHAR(100);
DECLARE v_total DECIMAL(10,2);
DECLARE v_pedidos INT;
DECLARE v_done INT DEFAULT FALSE;
DECLARE cur_clientes CURSOR FOR
SELECT c.id, c.nombre
FROM clientes c
WHERE EXISTS (SELECT 1 FROM pedidos p WHERE p.cliente_id = c.id)
ORDER BY c.nombre;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = TRUE;
DROP TEMPORARY TABLE IF EXISTS tmp_resumen;
CREATE TEMPORARY TABLE tmp_resumen (
cliente VARCHAR(100),
total_pedidos INT,
total_gastado DECIMAL(10,2),
ticket_medio DECIMAL(10,2)
);
OPEN cur_clientes;
leer: LOOP
FETCH cur_clientes INTO v_cliente_id, v_nombre;
IF v_done THEN
LEAVE leer;
END IF;
SELECT COUNT(*), COALESCE(SUM(total), 0)
INTO v_pedidos, v_total
FROM pedidos WHERE cliente_id = v_cliente_id;
INSERT INTO tmp_resumen VALUES (
v_nombre, v_pedidos, v_total,
ROUND(v_total / v_pedidos, 2)
);
END LOOP leer;
CLOSE cur_clientes;
SELECT * FROM tmp_resumen ORDER BY total_gastado DESC;
DROP TEMPORARY TABLE tmp_resumen;
END //
DELIMITER ;CALL resumen_ventas_por_cliente();| cliente | total_pedidos | total_gastado | ticket_medio |
|---|---|---|---|
| María | 3 | 3899.97 | 1300.00 |
| Carlos | 2 | 2399.98 | 1200.00 |
| Ana | 2 | 1649.98 | 825.00 |
| Pedro | 2 | 1499.98 | 750.00 |
| ... | ... | ... | ... |
En cada iteración del cursor, se ejecuta una consulta adicional (el SELECT COUNT/SUM) para obtener las estadísticas del cliente actual. Este tipo de "consulta dentro del bucle del cursor" es el caso de uso típico: el cursor recorre una tabla maestra y para cada fila consulta datos relacionados.
Múltiples cursores
Un procedimiento puede usar varios cursores, pero hay una complicación: un solo handler NOT FOUND afecta a todos los cursores del mismo bloque. Si necesitas cursores independientes, la solución es usar bloques BEGIN...END separados, cada uno con su propio handler:
DELIMITER //
CREATE PROCEDURE comparar_categorias(IN cat1 INT, IN cat2 INT)
BEGIN
DECLARE v_nombre VARCHAR(100);
DECLARE v_precio DECIMAL(10,2);
DECLARE v_done1 INT DEFAULT FALSE;
DECLARE v_done2 INT DEFAULT FALSE;
DECLARE cur1 CURSOR FOR
SELECT nombre, precio FROM productos WHERE categoria_id = cat1;
DECLARE cur2 CURSOR FOR
SELECT nombre, precio FROM productos WHERE categoria_id = cat2;
DECLARE CONTINUE HANDLER FOR NOT FOUND
SET v_done1 = TRUE;
-- Procesar primer cursor
OPEN cur1;
SELECT CONCAT('Categoría ', cat1) AS titulo;
leer1: LOOP
FETCH cur1 INTO v_nombre, v_precio;
IF v_done1 THEN LEAVE leer1; END IF;
SELECT v_nombre AS nombre, v_precio AS precio;
END LOOP leer1;
CLOSE cur1;
-- Reset handler y procesar segundo cursor
SET v_done1 = FALSE;
OPEN cur2;
SELECT CONCAT('Categoría ', cat2) AS titulo;
leer2: LOOP
FETCH cur2 INTO v_nombre, v_precio;
IF v_done1 THEN LEAVE leer2; END IF;
SELECT v_nombre AS nombre, v_precio AS precio;
END LOOP leer2;
CLOSE cur2;
END //
DELIMITER ;Fíjate en el SET v_done1 = FALSE entre los dos cursores: es imprescindible resetear la variable de control, ya que el handler del primer cursor la dejó como TRUE. Si olvidas este reset, el segundo cursor no leerá ni una fila porque la condición de salida ya estará activada. Este es uno de los errores más comunes y difíciles de diagnosticar al trabajar con múltiples cursores.
Características de los cursores en MySQL
Los cursores de MySQL tienen limitaciones importantes comparados con los de otros motores de base de datos:
| Característica | Valor |
|---|---|
| Solo lectura | Sí, no se puede usar UPDATE WHERE CURRENT OF |
| Solo avance | Sí, no se puede retroceder ni saltar filas |
| Sensible | Los cambios en datos subyacentes pueden ser visibles |
| Anidable | Sí, con bloques BEGIN...END separados |
Solo lectura significa que no puedes modificar la fila actual directamente a través del cursor (como UPDATE WHERE CURRENT OF en otros motores). Si necesitas actualizar la fila actual, debes usar un UPDATE normal con la clave primaria que almacenaste en una variable.
Solo avance significa que solo puedes ir hacia adelante. No hay FETCH PRIOR ni FETCH ABSOLUTE como en SQL Server. Si necesitas volver atrás, deberías reabrir el cursor o usar una tabla temporal.
Cuándo usar cursores
Los cursores son más lentos que las operaciones basadas en conjuntos porque procesan una fila a la vez en lugar de operar sobre todo el conjunto de una vez. Por eso, la regla general es: evita los cursores cuando puedas resolver el problema con SQL estándar.
| Situación | Usar cursor | Alternativa |
|---|---|---|
| Procesamiento fila por fila con lógica compleja | Sí | - |
| Lógica diferente por fila según datos de otras tablas | Sí | - |
| Operaciones en lote simples | No | UPDATE/INSERT con subconsulta |
| Agregaciones | No | GROUP BY |
| Transformaciones simples de valores | No | CASE en SELECT |
| Generar filas dinámicamente | Sí | Recursive CTE (MySQL 8.0+) |
Antes de crear un cursor, pregúntate: "¿Puedo hacer esto con un solo SELECT, UPDATE o INSERT...SELECT?". Si la respuesta es sí, la operación basada en conjuntos será más eficiente. Los cursores deben reservarse para los casos donde la lógica por fila es realmente demasiado compleja para expresar en SQL.
Limpieza
DROP PROCEDURE IF EXISTS listar_productos_cursor;
DROP PROCEDURE IF EXISTS resumen_ventas_por_cliente;
DROP PROCEDURE IF EXISTS comparar_categorias;En el siguiente artículo veremos cómo eliminar procedimientos con DROP PROCEDURE.
Escrito por Eduardo Lázaro
