MySQLCursors

MySQL Cursors

A cursor lets you process a query result set one row at a time inside a stored procedure or stored function. Instead of returning all rows at once, you open a cursor, fetch rows in a loop, process each one, then close the cursor. Cursors are the MySQL equivalent of iterating over a dataset in procedural code.

When to Use (and Avoid) Cursors

Before writing a cursor, ask: can I do this with a single set-based SQL statement? In most cases you can — and that will be 10–100x faster. Cursors are appropriate when:

  • Processing logic cannot be expressed in a single SQL statement

  • Each row requires a different action that depends on row content

  • You need to call a stored procedure per row with results that affect the next iteration

  • Generating rows for a table where the next row depends on the previous one

Avoid cursors when you can instead use:

  • A single UPDATE ... JOIN or UPDATE ... WHERE

  • An INSERT ... SELECT or INSERT INTO ... SELECT with CASE

  • A GROUP BY aggregate query

  • A recursive CTE (WITH RECURSIVE)

Cursor Limitations

Limitation

Detail

Read-only

You cannot UPDATE or DELETE rows through a cursor — modify the table separately

Forward-only

You can only FETCH the next row — no backward navigation, no random access

Single result set

A cursor handles one SELECT — not multiple

Stored routines only

Cursors can only be used inside stored procedures, functions, or triggers

The Four Cursor Steps

Every cursor in MySQL follows the same four-step lifecycle:

SQL
-- 1. DECLARE the cursor
DECLARE cursor_name CURSOR FOR
  SELECT col1, col2 FROM some_table WHERE condition;

-- 2. OPEN the cursor (executes the query)
OPEN cursor_name;

-- 3. FETCH rows one by one into variables
FETCH cursor_name INTO var1, var2;

-- 4. CLOSE the cursor when done
CLOSE cursor_name;
The NOT FOUND Handler Pattern

When FETCH reaches the last row, MySQL sets a "not found" condition. Declare a CONTINUE HANDLER for it to break out of the loop cleanly:

SQL
DECLARE done INT DEFAULT FALSE;

-- This handler fires when FETCH finds no more rows
DECLARE CONTINUE HANDLER FOR NOT FOUND
  SET done = TRUE;
Note
The NOT FOUND handler must be declared AFTER cursor declarations but before the OPEN statement. DECLARE order matters: variables first, then cursors, then handlers.
Complete Cursor Loop Pattern

SQL
DELIMITER //

CREATE PROCEDURE process_pending_orders()
BEGIN
  -- 1. Declare variables for the fetched columns
  DECLARE v_order_id    INT;
  DECLARE v_customer_id INT;
  DECLARE v_total       DECIMAL(10,2);
  DECLARE done          INT DEFAULT FALSE;

  -- 2. Declare the cursor
  DECLARE cur_orders CURSOR FOR
    SELECT order_id, customer_id, total_amount
    FROM orders
    WHERE status = 'pending'
    ORDER BY order_date;

  -- 3. Declare NOT FOUND handler
  DECLARE CONTINUE HANDLER FOR NOT FOUND
    SET done = TRUE;

  -- 4. Open the cursor
  OPEN cur_orders;

  -- 5. Loop
  order_loop: LOOP
    FETCH cur_orders INTO v_order_id, v_customer_id, v_total;

    IF done THEN
      LEAVE order_loop;
    END IF;

    -- 6. Process the current row
    IF v_total > 1000 THEN
      UPDATE orders SET priority = 'high' WHERE order_id = v_order_id;
    ELSE
      UPDATE orders SET priority = 'normal' WHERE order_id = v_order_id;
    END IF;

    -- Log the action
    INSERT INTO order_log (order_id, note, logged_at)
    VALUES (v_order_id, 'Priority assigned', NOW());

  END LOOP order_loop;

  -- 7. Close the cursor
  CLOSE cur_orders;
END //

DELIMITER ;

CALL process_pending_orders();
Multiple Cursors in One Procedure

SQL
DELIMITER //

CREATE PROCEDURE sync_customer_tiers()
BEGIN
  DECLARE v_customer_id  INT;
  DECLARE v_total_spent  DECIMAL(10,2);
  DECLARE v_new_tier     VARCHAR(20);
  DECLARE done1          INT DEFAULT FALSE;

  DECLARE cur_customers CURSOR FOR
    SELECT customer_id FROM customers WHERE status = 'active';

  DECLARE CONTINUE HANDLER FOR NOT FOUND
    SET done1 = TRUE;

  OPEN cur_customers;

  cust_loop: LOOP
    FETCH cur_customers INTO v_customer_id;
    IF done1 THEN LEAVE cust_loop; END IF;

    -- Calculate lifetime value for this customer
    SELECT COALESCE(SUM(total_amount), 0)
    INTO   v_total_spent
    FROM   orders
    WHERE  customer_id = v_customer_id AND status = 'completed';

    -- Assign tier
    SET v_new_tier = CASE
      WHEN v_total_spent = 0     THEN 'New'
      WHEN v_total_spent < 500   THEN 'Bronze'
      WHEN v_total_spent < 2000  THEN 'Silver'
      WHEN v_total_spent < 10000 THEN 'Gold'
      ELSE                            'Platinum'
    END;

    UPDATE customers SET tier = v_new_tier WHERE customer_id = v_customer_id;

  END LOOP cust_loop;

  CLOSE cur_customers;
END //

DELIMITER ;
Tip
When you need to reset a cursor and loop again from the beginning, you must CLOSE it and re-OPEN it — the only way to rewind is to reopen.
Cursor with Error Handling

SQL
DELIMITER //

CREATE PROCEDURE safe_process_refunds()
BEGIN
  DECLARE v_order_id   INT;
  DECLARE v_amount     DECIMAL(10,2);
  DECLARE done         INT DEFAULT FALSE;
  DECLARE v_error      INT DEFAULT FALSE;

  DECLARE cur_refunds CURSOR FOR
    SELECT order_id, total_amount FROM orders WHERE status = 'refund_requested';

  DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
  DECLARE CONTINUE HANDLER FOR SQLEXCEPTION SET v_error = TRUE;

  OPEN cur_refunds;

  refund_loop: LOOP
    SET v_error = FALSE;  -- Reset error flag for each row

    FETCH cur_refunds INTO v_order_id, v_amount;
    IF done THEN LEAVE refund_loop; END IF;

    START TRANSACTION;

    UPDATE orders SET status = 'refunded' WHERE order_id = v_order_id;
    INSERT INTO refund_ledger (order_id, amount, processed_at)
    VALUES (v_order_id, v_amount, NOW());

    IF v_error THEN
      ROLLBACK;
      INSERT INTO error_log (message, created_at)
      VALUES (CONCAT('Refund failed for order ', v_order_id), NOW());
    ELSE
      COMMIT;
    END IF;

  END LOOP refund_loop;

  CLOSE cur_refunds;
END //

DELIMITER ;
Set-Based Alternative to a Cursor

Before writing a cursor, always try the set-based version first. The cursor-based tier assignment above can be replaced with a single UPDATE:

SQL
-- Set-based version -- NO cursor, single statement
UPDATE customers c
JOIN (
  SELECT customer_id, COALESCE(SUM(total_amount), 0) AS total_spent
  FROM   orders
  WHERE  status = 'completed'
  GROUP BY customer_id
) AS spending ON c.customer_id = spending.customer_id
SET c.tier = CASE
  WHEN spending.total_spent = 0     THEN 'New'
  WHEN spending.total_spent < 500   THEN 'Bronze'
  WHEN spending.total_spent < 2000  THEN 'Silver'
  WHEN spending.total_spent < 10000 THEN 'Gold'
  ELSE                                   'Platinum'
END
WHERE c.status = 'active';

-- This runs as one query -- 100x faster than a row-by-row cursor
Warning
Cursor-based row-by-row processing can be dramatically slower than set-based SQL. For 100,000 rows, a cursor loop may take minutes while a single UPDATE finishes in seconds. Always benchmark.
Practical: Generating Slugs for Existing Rows

SQL
-- Real use case where cursor is justified: generating unique slugs
-- Each row needs a slug, and we must check uniqueness per-row
DELIMITER //

CREATE PROCEDURE generate_unique_slugs()
BEGIN
  DECLARE v_id      INT;
  DECLARE v_name    VARCHAR(255);
  DECLARE v_slug    VARCHAR(255);
  DECLARE v_suffix  INT;
  DECLARE v_exists  INT;
  DECLARE done      INT DEFAULT FALSE;

  DECLARE cur CURSOR FOR
    SELECT product_id, product_name FROM products WHERE slug IS NULL;

  DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;

  OPEN cur;

  slug_loop: LOOP
    FETCH cur INTO v_id, v_name;
    IF done THEN LEAVE slug_loop; END IF;

    -- Generate base slug
    SET v_slug   = LOWER(REGEXP_REPLACE(TRIM(v_name), '[^a-zA-Z0-9]+', '-'));
    SET v_suffix = 1;

    -- Ensure uniqueness
    uniqueness: LOOP
      SELECT COUNT(*) INTO v_exists
      FROM products WHERE slug = v_slug AND product_id <> v_id;

      IF v_exists = 0 THEN LEAVE uniqueness; END IF;

      SET v_slug   = CONCAT(LOWER(REGEXP_REPLACE(TRIM(v_name), '[^a-zA-Z0-9]+', '-')), '-', v_suffix);
      SET v_suffix = v_suffix + 1;
    END LOOP uniqueness;

    UPDATE products SET slug = v_slug WHERE product_id = v_id;

  END LOOP slug_loop;

  CLOSE cur;
END //

DELIMITER ;
Best Practices
  • Always declare the NOT FOUND handler — without it your loop will run forever after the last row

  • Reset the done flag before re-opening a cursor if you need to loop through it again

  • Close cursors explicitly — MySQL closes them at END of the procedure block, but explicit CLOSE is clearer

  • Add LIMIT to cursor queries to batch-process large tables and avoid locking for too long

  • Benchmark the cursor version vs the equivalent UPDATE/INSERT...SELECT — set-based is almost always faster

  • Document why a cursor is necessary — future maintainers will look for the set-based alternative first