MySQL & Database Design

Cursors & Advanced Procedures

13 min Lesson 24 of 40

Cursors & Advanced Procedures

Cursors allow you to iterate through query results row by row within stored procedures. Combined with advanced techniques like dynamic SQL and sophisticated error handling, they enable complex data processing that would be difficult or impossible with standard SQL. In this lesson, we'll master cursors and advanced procedure patterns.

What are Cursors?

A cursor is a database object that allows you to traverse and manipulate result sets one row at a time. Think of it as a pointer that moves through query results.

Key Concept: Cursors are powerful but slower than set-based operations. Use them only when row-by-row processing is necessary.

Basic Cursor Syntax

Cursors follow a specific lifecycle: DECLARE, OPEN, FETCH, CLOSE:

DELIMITER // CREATE PROCEDURE process_customers() BEGIN -- Variables to hold cursor data DECLARE done INT DEFAULT FALSE; DECLARE v_customer_id INT; DECLARE v_customer_name VARCHAR(255); DECLARE v_total_spent DECIMAL(10,2); -- Declare the cursor DECLARE customer_cursor CURSOR FOR SELECT customer_id, customer_name, total_spent FROM customers WHERE status = 'active' ORDER BY total_spent DESC; -- Declare handler for end of cursor DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; -- Open the cursor OPEN customer_cursor; -- Loop through cursor read_loop: LOOP -- Fetch next row FETCH customer_cursor INTO v_customer_id, v_customer_name, v_total_spent; -- Exit loop if no more rows IF done THEN LEAVE read_loop; END IF; -- Process the row INSERT INTO customer_report (customer_id, name, spending, processed_at) VALUES (v_customer_id, v_customer_name, v_total_spent, NOW()); END LOOP; -- Close the cursor CLOSE customer_cursor; END // DELIMITER ;

Cursor with Complex Processing

DELIMITER // CREATE PROCEDURE calculate_customer_rewards() BEGIN DECLARE done INT DEFAULT FALSE; DECLARE v_customer_id INT; DECLARE v_total_spent DECIMAL(10,2); DECLARE v_order_count INT; DECLARE v_reward_points INT; DECLARE v_reward_tier VARCHAR(20); DECLARE customer_cursor CURSOR FOR SELECT c.customer_id, COALESCE(SUM(o.total_amount), 0) AS total_spent, COUNT(o.order_id) AS order_count FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id WHERE c.status = 'active' GROUP BY c.customer_id; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; OPEN customer_cursor; process_loop: LOOP FETCH customer_cursor INTO v_customer_id, v_total_spent, v_order_count; IF done THEN LEAVE process_loop; END IF; -- Calculate reward points (1 point per dollar spent) SET v_reward_points = FLOOR(v_total_spent); -- Determine tier based on spending SET v_reward_tier = CASE WHEN v_total_spent >= 10000 THEN 'Platinum' WHEN v_total_spent >= 5000 THEN 'Gold' WHEN v_total_spent >= 1000 THEN 'Silver' ELSE 'Bronze' END; -- Add bonus points for frequent orders IF v_order_count > 20 THEN SET v_reward_points = v_reward_points + 500; ELSEIF v_order_count > 10 THEN SET v_reward_points = v_reward_points + 250; END IF; -- Update customer rewards UPDATE customers SET reward_points = v_reward_points, reward_tier = v_reward_tier, rewards_updated_at = NOW() WHERE customer_id = v_customer_id; -- Log the update INSERT INTO reward_log (customer_id, points_awarded, tier, created_at) VALUES (v_customer_id, v_reward_points, v_reward_tier, NOW()); END LOOP; CLOSE customer_cursor; -- Summary message SELECT CONCAT('Processed rewards for ', ROW_COUNT(), ' customers') AS message; END // DELIMITER ;

Nested Cursors

You can use multiple cursors, even nested ones, for complex processing:

DELIMITER // CREATE PROCEDURE generate_order_summaries() BEGIN DECLARE done1, done2 INT DEFAULT FALSE; DECLARE v_customer_id INT; DECLARE v_customer_name VARCHAR(255); DECLARE v_order_id INT; DECLARE v_order_date DATE; DECLARE v_total_amount DECIMAL(10,2); DECLARE v_summary TEXT; -- Outer cursor: customers DECLARE customer_cursor CURSOR FOR SELECT customer_id, customer_name FROM customers WHERE status = 'active' LIMIT 100; -- Limit for performance -- Inner cursor: orders per customer DECLARE order_cursor CURSOR FOR SELECT order_id, order_date, total_amount FROM orders WHERE customer_id = v_customer_id ORDER BY order_date DESC; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done1 = TRUE; OPEN customer_cursor; customer_loop: LOOP FETCH customer_cursor INTO v_customer_id, v_customer_name; IF done1 THEN LEAVE customer_loop; END IF; -- Initialize summary for this customer SET v_summary = CONCAT('Orders for ', v_customer_name, ':\n'); SET done2 = FALSE; -- Process orders for this customer OPEN order_cursor; order_loop: LOOP FETCH order_cursor INTO v_order_id, v_order_date, v_total_amount; IF done2 THEN LEAVE order_loop; END IF; SET v_summary = CONCAT( v_summary, 'Order #', v_order_id, ' - ', v_order_date, ' - $', v_total_amount, '\n' ); END LOOP; CLOSE order_cursor; -- Store the summary INSERT INTO customer_summaries (customer_id, summary, generated_at) VALUES (v_customer_id, v_summary, NOW()); -- Reset handler for inner cursor SET done2 = FALSE; END LOOP; CLOSE customer_cursor; END // DELIMITER ;
Performance Warning: Nested cursors can be extremely slow. Always consider set-based alternatives first. Use cursors only when absolutely necessary.

Dynamic SQL with PREPARE

Dynamic SQL allows you to build and execute SQL statements at runtime:

DELIMITER // CREATE PROCEDURE dynamic_table_query( IN table_name VARCHAR(64), IN condition VARCHAR(255) ) BEGIN SET @sql = CONCAT( 'SELECT * FROM ', table_name, ' WHERE ', condition ); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; END // -- More complex dynamic SQL CREATE PROCEDURE generate_report( IN report_type VARCHAR(50), IN start_date DATE, IN end_date DATE ) BEGIN SET @sql = CASE report_type WHEN 'sales' THEN CONCAT( 'SELECT DATE(order_date) AS date, ', 'SUM(total_amount) AS revenue, ', 'COUNT(*) AS orders ', 'FROM orders ', 'WHERE order_date BETWEEN \'', start_date, '\' AND \'', end_date, '\' ', 'GROUP BY DATE(order_date)' ) WHEN 'customers' THEN CONCAT( 'SELECT customer_id, customer_name, ', 'COUNT(order_id) AS total_orders ', 'FROM customers c ', 'JOIN orders o ON c.customer_id = o.customer_id ', 'WHERE order_date BETWEEN \'', start_date, '\' AND \'', end_date, '\' ', 'GROUP BY c.customer_id, c.customer_name' ) ELSE 'SELECT "Invalid report type" AS error' END; PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; END // DELIMITER ;

Advanced Error Handling

Sophisticated error handling with specific error codes:

DELIMITER // CREATE PROCEDURE safe_customer_update( IN p_customer_id INT, IN p_email VARCHAR(255), OUT p_success BOOLEAN, OUT p_message VARCHAR(255) ) BEGIN DECLARE duplicate_entry CONDITION FOR 1062; DECLARE foreign_key_error CONDITION FOR 1452; DECLARE EXIT HANDLER FOR duplicate_entry BEGIN SET p_success = FALSE; SET p_message = 'Email address already exists'; END; DECLARE EXIT HANDLER FOR foreign_key_error BEGIN SET p_success = FALSE; SET p_message = 'Invalid customer ID'; END; DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN GET DIAGNOSTICS CONDITION 1 @sqlstate = RETURNED_SQLSTATE, @errno = MYSQL_ERRNO, @text = MESSAGE_TEXT; SET p_success = FALSE; SET p_message = CONCAT('Error ', @errno, ': ', @text); END; -- Attempt the update UPDATE customers SET email = p_email, updated_at = NOW() WHERE customer_id = p_customer_id; SET p_success = TRUE; SET p_message = 'Customer updated successfully'; END // DELIMITER ;

Cursor with Transaction Control

DELIMITER // CREATE PROCEDURE batch_order_processing() BEGIN DECLARE done INT DEFAULT FALSE; DECLARE v_order_id INT; DECLARE v_customer_id INT; DECLARE v_error_count INT DEFAULT 0; DECLARE v_success_count INT DEFAULT 0; DECLARE order_cursor CURSOR FOR SELECT order_id, customer_id FROM orders WHERE status = 'pending' LIMIT 1000; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; DECLARE CONTINUE HANDLER FOR SQLEXCEPTION SET v_error_count = v_error_count + 1; OPEN order_cursor; process_loop: LOOP FETCH order_cursor INTO v_order_id, v_customer_id; IF done THEN LEAVE process_loop; END IF; -- Start transaction for each order START TRANSACTION; -- Process order UPDATE orders SET status = 'processing', processed_at = NOW() WHERE order_id = v_order_id; -- Update inventory UPDATE products p JOIN order_items oi ON p.product_id = oi.product_id SET p.stock_quantity = p.stock_quantity - oi.quantity WHERE oi.order_id = v_order_id; -- Update customer stats UPDATE customers SET total_orders = total_orders + 1 WHERE customer_id = v_customer_id; -- Commit if no errors IF v_error_count = 0 THEN COMMIT; SET v_success_count = v_success_count + 1; ELSE ROLLBACK; SET v_error_count = 0; -- Reset for next iteration END IF; END LOOP; CLOSE order_cursor; -- Return summary SELECT v_success_count AS orders_processed, (SELECT COUNT(*) FROM orders WHERE status = 'pending') AS pending_orders; END // DELIMITER ;

Cursor-Free Alternatives

Often, you can avoid cursors entirely with set-based operations:

-- Instead of cursor to update customer tiers -- BAD: Using cursor (slow) DELIMITER // CREATE PROCEDURE update_tiers_cursor() BEGIN DECLARE done INT DEFAULT FALSE; DECLARE v_id INT; DECLARE v_spent DECIMAL(10,2); DECLARE cur CURSOR FOR SELECT customer_id, total_spent FROM customers; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; OPEN cur; LOOP FETCH cur INTO v_id, v_spent; IF done THEN LEAVE; END IF; UPDATE customers SET tier = CASE WHEN v_spent > 10000 THEN 'Platinum' WHEN v_spent > 5000 THEN 'Gold' ELSE 'Bronze' END WHERE customer_id = v_id; END LOOP; CLOSE cur; END // DELIMITER ; -- GOOD: Using set-based operation (fast) UPDATE customers SET tier = CASE WHEN total_spent > 10000 THEN 'Platinum' WHEN total_spent > 5000 THEN 'Gold' ELSE 'Bronze' END;
Performance Tip: A set-based operation can be 100-1000x faster than a cursor. Always try to solve problems with standard SQL before resorting to cursors.

Real-World Example: Data Migration

DELIMITER // CREATE PROCEDURE migrate_legacy_data() BEGIN DECLARE done INT DEFAULT FALSE; DECLARE v_old_id INT; DECLARE v_old_data VARCHAR(255); DECLARE v_new_id INT; DECLARE v_migrated INT DEFAULT 0; DECLARE v_errors INT DEFAULT 0; DECLARE legacy_cursor CURSOR FOR SELECT id, data_field FROM legacy_table WHERE migrated = 0 LIMIT 10000; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; DECLARE CONTINUE HANDLER FOR SQLEXCEPTION BEGIN SET v_errors = v_errors + 1; ROLLBACK; END; OPEN legacy_cursor; migrate_loop: LOOP FETCH legacy_cursor INTO v_old_id, v_old_data; IF done THEN LEAVE migrate_loop; END IF; START TRANSACTION; -- Transform and insert data INSERT INTO new_table (data, legacy_id, migrated_at) VALUES (UPPER(v_old_data), v_old_id, NOW()); SET v_new_id = LAST_INSERT_ID(); -- Mark as migrated UPDATE legacy_table SET migrated = 1, new_id = v_new_id WHERE id = v_old_id; COMMIT; SET v_migrated = v_migrated + 1; -- Progress reporting every 1000 records IF v_migrated % 1000 = 0 THEN INSERT INTO migration_log (records_migrated, timestamp) VALUES (v_migrated, NOW()); END IF; END LOOP; CLOSE legacy_cursor; -- Final summary INSERT INTO migration_log (records_migrated, errors, timestamp) VALUES (v_migrated, v_errors, NOW()); END // DELIMITER ;

Debugging Stored Procedures

DELIMITER // CREATE PROCEDURE debug_example() BEGIN DECLARE v_step INT DEFAULT 1; -- Create temp table for debugging CREATE TEMPORARY TABLE IF NOT EXISTS debug_log ( step INT, message VARCHAR(255), logged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); INSERT INTO debug_log (step, message) VALUES (v_step, 'Procedure started'); SET v_step = v_step + 1; -- Your logic here INSERT INTO debug_log (step, message) VALUES (v_step, CONCAT('Processing order ', order_id)); SET v_step = v_step + 1; -- More logic... INSERT INTO debug_log (step, message) VALUES (v_step, 'Procedure completed'); -- View debug log SELECT * FROM debug_log ORDER BY step; END // DELIMITER ;

Practice Exercise:

Create an Advanced Order Fulfillment System:

  1. Create a procedure that uses a cursor to process pending orders:
    • Check inventory availability for all items
    • If sufficient stock, reserve inventory and mark order as confirmed
    • If insufficient stock, mark order as back-ordered
    • Update customer notification status
    • Log all actions with timestamps
  2. Add transaction control for each order
  3. Implement comprehensive error handling
  4. Return a summary of processed, confirmed, and back-ordered orders

Best Practices

✓ Use cursors only when row-by-row processing is unavoidable ✓ Always close cursors when done ✓ Limit cursor result sets (use WHERE, LIMIT) ✓ Use CONTINUE handlers for cursor loops ✓ Prefer set-based operations whenever possible ✓ Add transaction control for data modifications ✓ Implement comprehensive error handling ✓ Log progress for long-running procedures ✓ Use temporary tables for complex transformations ✗ Don't use nested cursors unless absolutely necessary ✗ Avoid processing millions of rows with cursors ✗ Don't forget to DEALLOCATE prepared statements

Summary

In this lesson, you learned:

  • Cursors allow row-by-row processing of result sets
  • Cursor lifecycle: DECLARE, OPEN, FETCH, CLOSE
  • Use CONTINUE HANDLER for cursor iteration
  • Dynamic SQL with PREPARE/EXECUTE/DEALLOCATE
  • Advanced error handling with specific conditions
  • Transaction control within cursor loops
  • Set-based operations are almost always faster than cursors
  • Cursors should be a last resort, not the first solution
Next Up: In the final lesson of this module, we'll explore the Information Schema for database introspection and metadata queries!