Stored Procedures
A stored procedure is a named program stored in the database and executed with CALL.
Concept
A stored procedure is a named program stored in the database and executed with CALL. MySQL stored programs (procedures, functions, triggers, events) use a SQL/PSM-style language with variables, conditionals, loops, cursors, condition handlers, and SIGNAL/RESIGNAL for errors.
Why It Matters
Procedures encapsulate multi-step operations (place an order, transfer funds, archive old test data) so they run atomically, close to the data, with fewer network round trips, and can be secured with EXECUTE privileges instead of direct table access.
Syntax
DELIMITER is a client command: it changes the statement terminator so the ; characters inside the body are not sent early. (Workbench and most GUI tools handle this automatically.)
DELIMITER //
CREATE PROCEDURE [IF NOT EXISTS] proc_name (
[IN | OUT | INOUT] param_name data_type, ...
)
[DETERMINISTIC | NOT DETERMINISTIC]
[CONTAINS SQL | NO SQL | READS SQL DATA | MODIFIES SQL DATA]
[SQL SECURITY {DEFINER | INVOKER}]
[COMMENT 'text']
BEGIN
DECLARE var_name data_type [DEFAULT value];
-- statements
END //
DELIMITER ;
CALL proc_name(args);
DROP PROCEDURE [IF EXISTS] proc_name;
SHOW CREATE PROCEDURE proc_name;
SHOW PROCEDURE STATUS WHERE Db = 'eh_company';Example
19.1 IN parameter returning a result set 19.2 OUT and INOUT parameters
@name are user-defined session variables; DECLAREd variables are local to the procedure block.
19.3 Variables, IF, CASE, and loops (2026: Jan 1 is a Thursday; the year has 52 full weeks plus one extra Thursday, giving 104 weekend days.)
Prefer set-based SQL over loops for data processing; loops are fine for small control logic like calendar generation.
A cursor iterates over a result set row by row (read-only, forward-only in MySQL).
Declaration order inside a block is mandatory: variables and conditions → cursors → handlers → statements.
19.5 Error handling: handlers, SIGNAL, RESIGNAL, GET DIAGNOSTICS (SELECT... INTO with no row found leaves the variables unchanged — here NULL — and raises a "No data" warning; the IS NULL check handles that.) Handler types:
DECLARE CONTINUE HANDLER FOR... Run handler, then continue after the failing statement DECLARE EXIT HANDLER FOR...
Run handler, then exit the current BEGIN...END block SQLEXCEPTION, SQLWARNING, NOT FOUND, a specific SQLSTATE 'xxxxx', a MySQL error number (e.g., 1062), or a named condition Inspecting the error inside a handler:
(Note: an INSERT into error_log after ROLLBACK is committed separately with autocommit; this is a common logging pattern.)
19.6 Dynamic SQL with prepared statements 19.7 Managing procedures
MySQL has no CREATE OR REPLACE PROCEDURE; use DROP PROCEDURE IF EXISTS then CREATE (or CREATE PROCEDURE IF NOT EXISTS, 8.0.29+).
DELIMITER //
CREATE PROCEDURE get_customer_orders(IN p_customer_id INT UNSIGNED)
BEGIN
SELECT order_id, order_date, status, total_amount
FROM orders
WHERE customer_id = p_customer_id
ORDER BY order_date;
END //
DELIMITER ;
CALL get_customer_orders(1);+----------+------------+-----------+--------------+
| order_id | order_date | status | total_amount |
+----------+------------+-----------+--------------+
| 1001 | 2026-01-05 | DELIVERED | 75800.00 |
| 1003 | 2026-02-02 | SHIPPED | 18100.00 |
+----------+------------+-----------+--------------+DELIMITER //
CREATE PROCEDURE customer_stats(
IN p_customer_id INT UNSIGNED,
OUT p_orders INT,
OUT p_spent DECIMAL(12,2)
)
READS SQL DATA
BEGIN
SELECT COUNT(*), IFNULL(SUM(total_amount), 0)
INTO p_orders, p_spent
FROM orders
WHERE customer_id = p_customer_id AND status <> 'CANCELLED';
END //
CREATE PROCEDURE apply_discount(INOUT p_amount DECIMAL(12,2), IN p_percent DECIMAL(5,2))
NO SQL
BEGIN
SET p_amount = ROUND(p_amount * (1 - p_percent / 100), 2);
END //
DELIMITER ;
CALL customer_stats(2, @n, @spent);
SELECT @n AS orders, @spent AS spent; -- 2, 105000.00
SET @amt = 18000;
CALL apply_discount(@amt, 10);
SELECT @amt; -- 16200.00DELIMITER //
CREATE PROCEDURE classify_stock()
READS SQL DATA
BEGIN
SELECT product_name, stock,
CASE
WHEN stock = 0 THEN 'OUT OF STOCK'
WHEN stock < 20 THEN 'LOW'
ELSE 'OK'
END AS stock_status
FROM products
ORDER BY stock;
END //
CREATE PROCEDURE fill_calendar(IN p_start DATE, IN p_end DATE)
MODIFIES SQL DATA
BEGIN
DECLARE d DATE DEFAULT p_start;
CREATE TABLE IF NOT EXISTS calendar (cal_date DATE PRIMARY KEY, is_weekend BOOLEAN NOT NULL);
WHILE d <= p_end DO
INSERT IGNORE INTO calendar VALUES (d, DAYOFWEEK(d) IN (1, 7));
SET d = d + INTERVAL 1 DAY;
END WHILE;
END //
DELIMITER ;
CALL fill_calendar('2026-01-01', '2026-12-31');
SELECT COUNT(*), SUM(is_weekend) FROM calendar; -- 365, 104WHILE condition DO ... END WHILE;
REPEAT ... UNTIL condition END REPEAT;
my_loop: LOOP
IF done THEN LEAVE my_loop; END IF; -- LEAVE = break
IF skip THEN ITERATE my_loop; END IF; -- ITERATE = continue
END LOOP my_loop;DELIMITER //
CREATE PROCEDURE low_stock_report(OUT p_report TEXT)
READS SQL DATA
BEGIN
DECLARE v_name VARCHAR(100);
DECLARE v_stock INT;
DECLARE done BOOLEAN DEFAULT FALSE;
DECLARE cur CURSOR FOR
SELECT product_name, stock FROM products WHERE stock < 20 ORDER BY stock;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
SET p_report = '';
OPEN cur;
read_loop: LOOP
FETCH cur INTO v_name, v_stock;
IF done THEN LEAVE read_loop; END IF;
SET p_report = CONCAT(p_report, v_name, '=', v_stock, '; ');
END LOOP;
CLOSE cur;
END //
DELIMITER ;
CALL low_stock_report(@r);
SELECT @r; -- 'Standing Desk=0; Laptop=15; 'DELIMITER //
CREATE PROCEDURE place_order(
IN p_order_id INT UNSIGNED, IN p_customer_id INT UNSIGNED,
IN p_product_id INT UNSIGNED, IN p_qty INT UNSIGNED
)
MODIFIES SQL DATA
BEGIN
DECLARE v_price DECIMAL(10,2);
DECLARE v_stock INT;
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL; -- re-raise the original error to the caller
END;
START TRANSACTION;
SELECT price, stock INTO v_price, v_stock
FROM products WHERE product_id = p_product_id
FOR UPDATE;
IF v_price IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Product does not exist';
END IF;
IF v_stock < p_qty THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Insufficient stock', MYSQL_ERRNO = 5001;
END IF;
INSERT INTO orders (order_id, customer_id, order_date, status, total_amount)
VALUES (p_order_id, p_customer_id, CURDATE(), 'PENDING', v_price * p_qty);
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES (p_order_id, p_product_id, p_qty, v_price);
UPDATE products SET stock = stock - p_qty WHERE product_id = p_product_id;
…DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
GET DIAGNOSTICS CONDITION 1 @sqlstate = RETURNED_SQLSTATE, @errno = MYSQL_ERRNO, @msg = MESSAGE_TEXT;
ROLLBACK;
INSERT INTO error_log (proc_name, sqlstate, errno, message, logged_at)
VALUES ('place_order', @sqlstate, @errno, @msg, NOW());
RESIGNAL;
END;DELIMITER //
CREATE PROCEDURE count_rows(IN p_table VARCHAR(64), OUT p_count BIGINT)
READS SQL DATA
BEGIN
-- Allowlist identifiers: they cannot be parameters
IF p_table NOT IN ('orders', 'customers', 'products') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Table not allowed';
END IF;
SET @sql = CONCAT('SELECT COUNT(*) INTO @cnt FROM `', p_table, '`');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET p_count = @cnt;
END //
DELIMITER ;
-- Values should always be bound with USING (never concatenated):
SET @status = 'PENDING';
PREPARE s FROM 'SELECT order_id FROM orders WHERE status = ?';
EXECUTE s USING @status;
DEALLOCATE PREPARE s;SHOW PROCEDURE STATUS WHERE Db = 'eh_company';
SHOW CREATE PROCEDURE place_order\G
SELECT routine_name, routine_type, definer, security_type
FROM information_schema.routines WHERE routine_schema = 'eh_company';
DROP PROCEDURE IF EXISTS classify_stock;
GRANT EXECUTE ON PROCEDURE eh_company.place_order TO 'eh_app'@'localhost';Expected Result
CALL customer_stats(1, @n, @s);
SELECT @n, @s;+------+----------+
| @n | @s |
+------+----------+
| 2 | 93900.00 |
+------+----------+Common Mistakes
Forgetting DELIMITER in the CLI.
Parameter names equal to column names (WHERE customer_id = customer_id is always true) — prefix parameters with p_.
Declarations in the wrong order (ERROR 1337).
Missing NOT FOUND handler in cursor loops (infinite loop or error 1329).
Catching errors without ROLLBACK/RESIGNAL.
Concatenating values into dynamic SQL (injection).
Dropped DEFINER accounts breaking procedures.
Best Practices
Prefix parameters/variables (p_, v_).
One transaction per business operation, with an EXIT handler that rolls back and re-signals.
Set-based SQL instead of cursors where possible.
Keep routine source in version control; deploy with migrations.
Use SQL SECURITY INVOKER unless the procedure is a deliberate privilege boundary.
Interview Relevance
Medium-high: "What is DELIMITER?", "IN vs OUT vs INOUT?", "How do you handle errors in MySQL procedures?", "What is SIGNAL?", "CONTINUE vs EXIT handler?", "How do cursors work?".
Practice Questions
Write get_dept_employees(IN p_dept_id) returning employees of a department.
Write raise_salary(IN p_emp_id, IN p_percent) that rejects increases above 20% with SIGNAL.
Write a procedure that archives DELIVERED orders older than a date into orders_archive and deletes them, atomically.
Rewrite a cursor loop as a single UPDATE.