MySQL Cursor
Master MySQL cursors for stored programs — DECLARE/OPEN/FETCH/CLOSE lifecycle, the strict variables→cursors→handlers DECLARE order, the CONTINUE HANDLER FOR NOT FOUND idiom, and the read-only/forward-only/asensitive properties. Includes the critical advice that set-based SQL is almost always faster than cursor loops.
A cursor is a database object that lets you walk through the rows returned by a SELECT statement one at a time. Instead of receiving the entire result set at once, you fetch row-by-row, process each, then move to the next.
Cursors only exist inside stored procedures, functions, and triggers — there's no top-level cursor syntax in MySQL. You declare the cursor, open it, fetch rows in a loop until exhausted, then close it.
UPDATE ... WHERE id = x for each one is many times slower than a single UPDATE ... WHERE id IN (...). Reach for cursors only when (a) you genuinely need procedural per-row logic that can't be expressed in SQL, or (b) you need to call a stored procedure for each row. Otherwise, prefer plain SQL.MySQL cursors have specific characteristics that distinguish them from cursors in other databases:
| Property | Meaning |
|---|---|
| Read-only | You can't UPDATE or DELETE through the cursor — only FETCH |
| Non-scrollable | Forward-only — you can't go back to a previous row |
| Asensitive | Whether the cursor sees changes made to underlying data after the cursor opens is undefined — don't rely on it either way |
Inside a BEGIN ... END block, declarations must appear in this exact order:
- Local variables (
DECLARE x INT;) - Cursors (
DECLARE c CURSOR FOR ...;) - Handlers (
DECLARE ... HANDLER FOR ...;)
Get the order wrong and MySQL throws a syntax error. The cursor must come after any variable it might use, and before any handler that references the cursor's NOT FOUND condition.
When FETCH runs out of rows, MySQL raises a NOT FOUND condition (SQLSTATE 02000). Without a handler, this would terminate the procedure. The standard idiom catches it and sets a flag:
DECLARE done INT DEFAULT 0;
DECLARE my_cur CURSOR FOR SELECT ...;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
Inside the loop, check the flag and LEAVE when set:
my_loop: LOOP
FETCH my_cur INTO ...;
IF done = 1 THEN LEAVE my_loop; END IF;
-- process the row
END LOOP my_loop;
CONTINUE HANDLER, not EXIT HANDLER. CONTINUE sets the flag and lets execution continue — that's what you want for cursor loops. EXIT would terminate the entire BEGIN ... END block at the moment NOT FOUND fires, before you've processed the last row.Both examples below use the same employees table. Run this once if it's not already loaded:
DROP TABLE IF EXISTS employees;
CREATE TABLE employees (
employee_id INT UNSIGNED PRIMARY KEY,
first_name VARCHAR(20),
last_name VARCHAR(25) NOT NULL,
salary DECIMAL(10, 2) NOT NULL,
department_id INT
);
INSERT INTO employees VALUES
(100, 'Steven', 'King', 24000.00, 9),
(101, 'Neena', 'Kochhar', 17000.00, 9),
(102, 'Lex', 'De Haan', 17000.00, 9),
(103, 'Alexander', 'Hunold', 9000.00, 6),
(104, 'Bruce', 'Ernst', 6000.00, 6),
(105, 'David', 'Austin', 4800.00, 6),
(106, 'Valli', 'Pataballa', 4800.00, 6),
(107, 'Diana', 'Lorentz', 4200.00, 6),
(108, 'Nancy', 'Greenberg', 12000.00, 10),
(109, 'Daniel', 'Faviet', 9000.00, 10);
Walk through the employees and build a single semicolon-separated string of full names. The procedure takes an INOUT parameter — meaning it can both read and write the value:
DROP PROCEDURE IF EXISTS listnames;
DELIMITER $$
CREATE PROCEDURE listnames(INOUT name TEXT)
BEGIN
-- 1. Variables
DECLARE done BOOL DEFAULT FALSE;
DECLARE emp_name VARCHAR(255) DEFAULT '';
-- 2. Cursor
DECLARE cur CURSOR FOR
SELECT CONCAT(first_name, ' ', last_name) AS full_name
FROM employees;
-- 3. Handler
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
OPEN cur;
SET name = '';
process_list: LOOP
FETCH cur INTO emp_name;
IF done THEN LEAVE process_list; END IF;
SET name = CONCAT(name, emp_name, ';');
END LOOP process_list;
CLOSE cur;
END$$
DELIMITER ;
-- Run it
SET @result = '';
CALL listnames(@result);
SELECT @result AS concatenated_names;
Same cursor pattern, different output style — emit a separate result row per employee. Notice emp_salary must be declared just like emp_first_name (the original tutorial used it without declaring):
DROP PROCEDURE IF EXISTS DisplayEmployeeInfo;
DELIMITER $$
CREATE PROCEDURE DisplayEmployeeInfo()
BEGIN
-- All variables must be declared
DECLARE done INT DEFAULT FALSE;
DECLARE emp_first_name VARCHAR(50);
DECLARE emp_salary DECIMAL(10, 2); -- ← was missing in original
DECLARE emp_cursor CURSOR FOR
SELECT first_name, salary FROM employees;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
OPEN emp_cursor;
read_loop: LOOP
FETCH emp_cursor INTO emp_first_name, emp_salary;
IF done THEN
LEAVE read_loop; -- ← matches the loop label, not "process_list"
END IF;
SELECT CONCAT('Employee: ', emp_first_name,
', Salary: ', emp_salary) AS info;
END LOOP read_loop;
CLOSE emp_cursor;
END$$
DELIMITER ;
CALL DisplayEmployeeInfo();
emp_salarywas used in the FETCH but never declared — the procedure as written would have failed to compile.LEAVE process_list;referred to a label that didn't exist (the loop was labeledread_loop). MySQL would reject this.- The first cursor procedure declared the same cursor name
curtwice — once correctly, once for a non-existent columnemployee_name. Hard error.
| Use a cursor when… | Skip the cursor when… |
|---|---|
| Each row needs procedural logic that pure SQL can't express | You're computing aggregates (use SUM/COUNT/etc. directly) |
| You need to call a stored procedure per row | You're updating or inserting based on a query — set-based DML is faster |
| You're building a structure that requires sequential processing | You're filtering or transforming data — use SELECT with WHERE / CASE |
| The result set is small (dozens to thousands of rows) | The result set is large (hundreds of thousands+) — cursor will be very slow |
UPDATE ... JOIN, an aggregate, a CASE expression, or a single INSERT ... SELECT? The answer is usually yes, and the set-based version will run 10–100× faster on real data.- Always close the cursor. MySQL closes it when the procedure returns, but explicit
CLOSEis clearer and frees resources sooner. - Use
CONTINUE HANDLER, notEXIT HANDLER, for the NOT FOUND case. - Declare variables → cursors → handlers in that exact order, or MySQL will reject the procedure.
- Don't declare the same cursor twice in one block — it's a syntax error, however tempting it might be to copy a similar declaration.
- Use the loop label correctly —
LEAVE my_loop;must match exactly the label onmy_loop: LOOP ... END LOOP my_loop;. - Prefer set-based SQL. Almost every cursor can be replaced with a faster
UPDATE ... JOIN, aggregate, or window function.
- A MySQL cursor lets you walk through a SELECT result set one row at a time, inside a stored procedure, function, or trigger.
- The four-step lifecycle is DECLARE → OPEN → FETCH → CLOSE, and you must declare a
CONTINUE HANDLER FOR NOT FOUNDto detect the end of the result. - MySQL cursors are read-only, forward-only, and asensitive — no UPDATE through the cursor, no scrolling backward.
- The strict declaration order is variables → cursors → handlers; MySQL rejects any other order.
- Cursors are slow compared to set-based SQL. Use them only when procedural per-row logic is genuinely required.