MySQL Cursor MySQL · Cursor

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.

⚠️ Cursors are usually the wrong tool. They're slow compared to set-based SQL. A loop that fetches rows and runs 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:

PropertyMeaning
Read-onlyYou can't UPDATE or DELETE through the cursor — only FETCH
Non-scrollableForward-only — you can't go back to a previous row
AsensitiveWhether the cursor sees changes made to underlying data after the cursor opens is undefined — don't rely on it either way
-- 1. DECLARE the cursor (after variables, before handlers) DECLARE cursor_name CURSOR FOR SELECT col1, col2 FROM some_table WHERE ...; -- 2. OPEN the cursor — runs the SELECT, prepares the result set OPEN cursor_name; -- 3. FETCH rows in a loop FETCH cursor_name INTO var1, var2; -- ... process the row ... -- 4. CLOSE the cursor when done CLOSE cursor_name;

Inside a BEGIN ... END block, declarations must appear in this exact order:

  1. Local variables (DECLARE x INT;)
  2. Cursors (DECLARE c CURSOR FOR ...;)
  3. 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:

MySQL — NOT FOUND handler pattern
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:

MySQL — Loop pattern
my_loop: LOOP
    FETCH my_cur INTO ...;
    IF done = 1 THEN LEAVE my_loop; END IF;
    -- process the row
END LOOP my_loop;
💡 Use 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:

MySQL — Setup
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);
Example 1 — Build a Concatenated List with INOUT

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:

MySQL — Cursor with INOUT
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;
OUTPUT
mysql — listnames
mysql>CALL listnames(@result);
Query OK, 0 rows affected (0.00 sec)
mysql>SELECT @result AS concatenated_names;
+--------------------------------------------------------------------------------------------------------------------------------------------------+
| concatenated_names |
+--------------------------------------------------------------------------------------------------------------------------------------------------+
| Steven King;Neena Kochhar;Lex De Haan;Alexander Hunold;Bruce Ernst;David Austin;Valli Pataballa;Diana Lorentz;Nancy Greenberg;Daniel Faviet; |
+--------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)
📌 The variables, cursor, and handler are declared in the strict order MySQL requires. The original tutorial declared the cursor twice (once for the right query, once for a column that didn't exist) — that's a hard syntax error. Each cursor name can only be declared once per block.
Example 2 — Display Each Row Individually

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):

MySQL — Cursor with multiple variables
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();
OUTPUT
mysql — DisplayEmployeeInfo
mysql>CALL DisplayEmployeeInfo();
+-----------------------------------+
| info |
+-----------------------------------+
| Employee: Steven, Salary: 24000.00|
+-----------------------------------+
+-----------------------------------+
| info |
+-----------------------------------+
| Employee: Neena, Salary: 17000.00 |
+-----------------------------------+
| ... (8 more single-row tables) |
+-----------------------------------+
| info |
+-----------------------------------+
| Employee: Daniel, Salary: 9000.00 |
+-----------------------------------+
10 rows total returned
⚠️ Bugs corrected from the original tutorial:
  • emp_salary was 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 labeled read_loop). MySQL would reject this.
  • The first cursor procedure declared the same cursor name cur twice — once correctly, once for a non-existent column employee_name. Hard error.
The bugs above are real and would prevent the procedures from running.
Use a cursor when…Skip the cursor when…
Each row needs procedural logic that pure SQL can't expressYou're computing aggregates (use SUM/COUNT/etc. directly)
You need to call a stored procedure per rowYou're updating or inserting based on a query — set-based DML is faster
You're building a structure that requires sequential processingYou'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
💡 Almost every cursor loop has a faster set-based alternative. Before writing one, ask: could I do this with 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.
  1. Always close the cursor. MySQL closes it when the procedure returns, but explicit CLOSE is clearer and frees resources sooner.
  2. Use CONTINUE HANDLER, not EXIT HANDLER, for the NOT FOUND case.
  3. Declare variables → cursors → handlers in that exact order, or MySQL will reject the procedure.
  4. 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.
  5. Use the loop label correctlyLEAVE my_loop; must match exactly the label on my_loop: LOOP ... END LOOP my_loop;.
  6. 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 FOUND to 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.