Stored procedures and functions let you save a block of SQL — with variables, flow control,
and error handling — directly in the database under a name. Any authorised client can call them
without knowing the SQL inside. They are the closest thing MySQL has to general-purpose
programming.
Stored Procedure
Called with CALL name(…)
Can return zero, one, or many result sets
Can have IN, OUT, and INOUT parameters
Cannot be used inside a SELECT or expression
Can execute DDL (CREATE TABLE, ALTER …)
Best for: multi-step workflows, batch jobs, encapsulating business logic
Stored Function
Called like a built-in function: SELECT name(…)
Returns exactly one scalar value via RETURN
Only IN parameters (no OUT/INOUT)
Can be used inside SELECT, WHERE, ORDER BY
Cannot execute DDL or use CALL
Best for: reusable calculations, custom formatting, derived values
1. The DELIMITER Problem
MySQL uses ; to end statements. Inside a procedure body you also use ;
to end each sub-statement — which would confuse the client into thinking the procedure definition
ended too early. The fix: temporarily change the client's statement delimiter to something
that won't appear in your code, define the procedure, then restore ;.
mysql> DELIMITER $$ -- change delimiter to $$mysql> CREATE PROCEDURE example()
BEGINSELECT'hello'; -- semicolons inside are fine nowSELECT'world';
END$$ -- $$ ends the CREATE PROCEDUREmysql> DELIMITER ; -- restore normal semicolons
DELIMITER is a client command, not SQL. It only works in the mysql CLI and
MySQL Workbench. Application code (PHP, Python, Node …) sends one statement at a time, so
the DELIMITER trick is never needed there — you pass the entire CREATE PROCEDURE body as
a single string to the driver.
2. CREATE PROCEDURE
Simplest possible procedure
DELIMITER $$
CREATE PROCEDURE sp_all_books()
BEGINSELECT book_id, title, price
FROM books
ORDER BY title;
END$$
DELIMITER ;
-- Call itCALL sp_all_books();
Parameter modes
IN (default)
Caller passes a value in. The procedure can read it but any changes are local — the caller's variable is unchanged.
OUT
Procedure writes a value back to the caller's variable. The initial value is NULL inside the procedure regardless of what the caller passed.
INOUT
Both directions: caller passes a value in AND the procedure can write a new value back. Use when the procedure modifies the caller's value in place.
IN parameter — filter books by genre
DELIMITER $$
CREATE PROCEDURE sp_books_by_genre(IN p_genre_name VARCHAR(80))
BEGINSELECT
b.title,
b.price,
b.stock_quantity
FROM books b
JOIN book_genres bg ON bg.book_id = b.book_id
JOIN genres g ON g.genre_id = bg.genre_id
WHERE g.name = p_genre_name
ORDER BY b.title;
END$$
DELIMITER ;
CALL sp_books_by_genre('Science Fiction');
OUT parameter — return a computed value
DELIMITER $$
CREATE PROCEDURE sp_customer_total(
IN p_customer_id INT,
OUT p_order_count INT,
OUT p_total_spent DECIMAL(10,2)
)
BEGINSELECTCOUNT(DISTINCT o.order_id),
SUM(oi.quantity * oi.unit_price)
INTO
p_order_count,
p_total_spent
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.customer_id = p_customer_id;
END$$
DELIMITER ;
-- Call: pass user variables to receive OUT valuesCALL sp_customer_total(7, @orders, @spent);
SELECT @orders AS order_count, @spent AS total_spent;
INOUT parameter — apply a discount and return the new price
my_loop: LOOPSET v_i = v_i + 1;
IF v_i > 10THENLEAVE my_loop; -- break out of the loopEND IF;
IF v_i = 5THENITERATE my_loop; -- continue (skip rest of body)END IF;
INSERT INTO log_table (msg) VALUES (v_i);
END LOOP my_loop;
4. A Realistic Procedure — Place an Order
This procedure handles the full order-placement workflow: validate stock, insert the order
header, insert line items, decrement stock, and return the new order ID — all in one atomic
transaction.
DELIMITER $$
CREATE PROCEDURE sp_place_order(
IN p_customer_id INT,
IN p_book_id INT,
IN p_quantity INT,
OUT p_order_id INT,
OUT p_error_msg VARCHAR(255)
)
BEGINDECLARE v_stock INT;
DECLARE v_price DECIMAL(10,2);
-- Default outputsSET p_order_id = NULL;
SET p_error_msg = NULL;
-- 1. Check the book exists and has stockSELECT stock_quantity, price
INTO v_stock, v_price
FROM books
WHERE book_id = p_book_id;
IF v_stock IS NULL THENSET p_error_msg = 'Book not found';
LEAVE sp_place_order; -- MySQL allows LEAVE on the proc labelEND IF;
IF v_stock < p_quantity THENSET p_error_msg = CONCAT('Only ', v_stock, ' copies in stock');
LEAVE sp_place_order;
END IF;
-- 2. Everything valid — run inside a transactionSTART TRANSACTION;
INSERT INTO orders (customer_id, order_date, status)
VALUES (p_customer_id, NOW(), 'pending');
SET p_order_id = LAST_INSERT_ID();
INSERT INTO order_items (order_id, book_id, quantity, unit_price)
VALUES (p_order_id, p_book_id, p_quantity, v_price);
UPDATE books
SET stock_quantity = stock_quantity - p_quantity
WHERE book_id = p_book_id;
COMMIT;
END$$
DELIMITER ;
-- UsageCALL sp_place_order(3, 12, 2, @new_order_id, @err);
SELECT @new_order_id, @err;
Always pair START TRANSACTION with error handling in real code.
The example above is simplified — a production procedure should declare an
EXIT HANDLER FOR SQLEXCEPTION that rolls back the transaction on any error.
See the error handling section below.
5. Error Handling — DECLARE … HANDLER
MySQL stored procedures have a structured error-handling mechanism.
You declare a handler that fires when a specific condition occurs.
DECLARE {EXIT | CONTINUE} HANDLER FOR condition_value
handler_statement;
EXIT HANDLER — runs the statement then exits the current BEGIN…END block.
CONTINUE HANDLER — runs the statement then continues from the line after the error.
Transaction rollback on any error
DELIMITER $$
CREATE PROCEDURE sp_safe_transfer(
IN p_from_book INT,
IN p_to_book INT,
IN p_units INT
)
BEGINDECLARE v_error BOOLEANDEFAULTFALSE;
-- Fire on any SQL error or warning-escalated-to-errorDECLAREEXIT HANDLER FOR SQLEXCEPTIONBEGINROLLBACK;
SET v_error = TRUE;
SELECT'Transfer failed — rolled back'AS result;
END;
START TRANSACTION;
UPDATE books SET stock_quantity = stock_quantity - p_units
WHERE book_id = p_from_book;
UPDATE books SET stock_quantity = stock_quantity + p_units
WHERE book_id = p_to_book;
COMMIT;
SELECT'Transfer complete'AS result;
END$$
DELIMITER ;
CONTINUE handler — cursor NOT FOUND signal
DECLARE v_done INTDEFAULTFALSE;
-- Fires when a SELECT INTO finds no rows, or a cursor runs out of rowsDECLARE CONTINUE HANDLER FOR NOT FOUNDSET v_done = TRUE;
A cursor lets you iterate over a result set one row at a time inside a procedure.
Use them reluctantly — set-based SQL is almost always faster. But for logic that
genuinely can't be expressed as a single query (e.g. each row triggers a different
branching action), cursors are the right tool.
DELIMITER $$
CREATE PROCEDURE sp_apply_bulk_discount(
IN p_genre_name VARCHAR(80),
IN p_discount_pct DECIMAL(5,2),
OUT p_books_updated INT
)
BEGINDECLARE v_done BOOLEANDEFAULTFALSE;
DECLARE v_book_id INT;
DECLARE v_price DECIMAL(10,2);
SET p_books_updated = 0;
-- 1. Declare the cursorDECLARE cur_books CURSOR FORSELECT b.book_id, b.price
FROM books b
JOIN book_genres bg ON bg.book_id = b.book_id
JOIN genres g ON g.genre_id = bg.genre_id
WHERE g.name = p_genre_name;
-- 2. Handler fires when the cursor has no more rowsDECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = TRUE;
-- 3. Open the cursorOPEN cur_books;
read_loop: LOOP-- 4. Fetch one rowFETCH cur_books INTO v_book_id, v_price;
IF v_done THENLEAVE read_loop;
END IF;
-- 5. Row-level logicUPDATE books
SET price = ROUND(v_price * (1 - p_discount_pct / 100), 2)
WHERE book_id = v_book_id;
SET p_books_updated = p_books_updated + 1;
END LOOP;
-- 6. Close the cursorCLOSE cur_books;
END$$
DELIMITER ;
CALL sp_apply_bulk_discount('Fantasy', 15, @updated);
SELECT @updated AS books_discounted;
Cursor order: DECLARE variables → DECLARE cursor → DECLARE handlers → statements.
The order matters — MySQL enforces it. Handlers must be declared after cursors.
7. CREATE FUNCTION
A stored function returns exactly one value and can be used anywhere a built-in
function can — in SELECT lists, WHERE clauses, ORDER BY, computed column defaults.
It's the right choice for reusable logic that produces a single derived value.
CREATE [OR REPLACE]
FUNCTION function_name ([param datatype [, …]])
RETURNSdatatype
[DETERMINISTIC | NOT DETERMINISTIC]
[READS SQL DATA | MODIFIES SQL DATA | NO SQL | CONTAINS SQL]
BEGIN-- bodyRETURN value;
END
DETERMINISTIC means the function always returns the same output for the same
input. Mark it DETERMINISTIC when true — the optimiser can cache results and binary
logging is safer. If your function reads from a table it is NOT DETERMINISTIC (data
can change between calls). MySQL requires you to declare one or the other.
Function — customer segment label
DELIMITER $$
CREATE OR REPLACE FUNCTION fn_customer_segment(p_lifetime_value DECIMAL(10,2))
RETURNS VARCHAR(20)
DETERMINISTICNO SQLBEGINDECLARE v_segment VARCHAR(20);
IF p_lifetime_value >= 200THEN SET v_segment = 'VIP';
ELSEIF p_lifetime_value >= 75THEN SET v_segment = 'Regular';
ELSESET v_segment = 'Casual';
END IF;
RETURN v_segment;
END$$
DELIMITER ;
Now it works anywhere an expression is valid:
-- Use in SELECT listSELECT
customer,
lifetime_value,
fn_customer_segment(lifetime_value) AS segment
FROM v_customer_ltv
ORDER BY lifetime_value DESC;
-- Use in WHERESELECT customer, lifetime_value
FROM v_customer_ltv
WHEREfn_customer_segment(lifetime_value) = 'VIP';
-- Use in ORDER BYSELECT * FROM v_customer_ltv
ORDER BYfn_customer_segment(lifetime_value), lifetime_value DESC;
Function — formatted price with currency symbol
DELIMITER $$
CREATE OR REPLACE FUNCTION fn_format_price(
p_amount DECIMAL(10,2),
p_currency CHAR(3)
)
RETURNS VARCHAR(20)
DETERMINISTIC NO SQLBEGINDECLARE v_symbol CHAR(3);
SET v_symbol = CASE p_currency
WHEN'GBP'THEN'£'WHEN'USD'THEN'$'WHEN'EUR'THEN'€'ELSE p_currency
END;
RETURNCONCAT(v_symbol, FORMAT(p_amount, 2));
END$$
DELIMITER ;
SELECT title, fn_format_price(price, 'GBP') AS display_price
FROM books
ORDER BY price DESC;
8. Inspecting and Dropping Stored Code
-- List all procedures in the current databaseSHOW PROCEDURE STATUS WHERE Db = DATABASE()\G
-- List all functionsSHOW FUNCTION STATUS WHERE Db = DATABASE()\G
-- Show a procedure's full definitionSHOW CREATE PROCEDURE sp_place_order\G
-- Show a function's full definitionSHOW CREATE FUNCTION fn_customer_segment\G
-- information_schema alternativeSELECT routine_name, routine_type, created, last_altered
FROM information_schema.routines
WHERE routine_schema = DATABASE()
ORDER BY routine_type, routine_name;
-- DropDROP PROCEDURE IF EXISTS sp_place_order;
DROP FUNCTION IF EXISTS fn_customer_segment;
9. Calling from Application Code
Python (mysql-connector-python)
import mysql.connector
conn = mysql.connector.connect(host='localhost', database='bookshop',
user='admin', password='secret')
cursor = conn.cursor()
# Call a procedure with IN parameters and a result set
cursor.callproc('sp_books_by_genre', ('Science Fiction',))
for result in cursor.stored_results():
for row in result.fetchall():
print(row)
# Call a procedure with OUT parameters# Pass None for OUT params; retrieve via the returned args tuple
args = (7, None, None) # customer_id, order_count (OUT), total_spent (OUT)
results = cursor.callproc('sp_customer_total', args)
order_count, total_spent = results[1], results[2]
cursor.close()
conn.close()
PHP (PDO)
$pdo = newPDO('mysql:host=localhost;dbname=bookshop', 'admin', 'secret');
// Call a stored function — just use it in SQL$stmt = $pdo->prepare('SELECT fn_customer_segment(:ltv)');
$stmt->execute([':ltv' => 250.00]);
$segment = $stmt->fetchColumn();
// Call a procedure with OUT params via user variables$pdo->exec('CALL sp_customer_total(7, @cnt, @total)');
$row = $pdo->query('SELECT @cnt AS order_count, @total AS total_spent')->fetch();
10. When to Use Stored Code vs Application Logic
Use stored procedures when: Multiple different applications (PHP web app,
Python ETL, reporting tool) all need the same workflow — centralise it in the database
so each caller doesn't re-implement it. Also good for batch jobs that run entirely
inside the database, and for encapsulating privileged operations (a low-privilege app
user can CALL a procedure without needing direct table access).
Use stored functions when: You have a reusable calculation or formatting
rule that should work like a built-in MySQL function — customer segment labels, price
formatting, age-from-date, business day calculation.
Avoid stored code when: Your team's primary language is not SQL (stored
procedures are harder to test, version-control, and debug than application code).
Complex business logic buried in procedures is difficult to unit test, impossible to
profile with standard tools, and invisible to application-layer observability
(tracing, logging). Prefer application code for logic that changes frequently or
needs thorough test coverage.
Never put secrets or API calls in stored procedures. The database has no
HTTP client, no secret manager, and no safe way to handle credentials. Business logic
that needs to call external services belongs in application code, not the database.
Quick Reference
Concept
Syntax / rule
Notes
Delimiter
DELIMITER $$ … DELIMITER ;
CLI / Workbench only. Not needed in application code.
Create procedure
CREATE PROCEDURE name(IN p1 type, OUT p2 type) BEGIN … END
Use OR REPLACE (MySQL 8.0.29+) or DROP first.
Call procedure
CALL name(arg1, @out_var);
OUT/INOUT params take user variables (prefixed @).
Read OUT result
SELECT @out_var;
User variables persist for the session after CALL returns.
Local variable
DECLARE v_name type DEFAULT value;
Must be first statements in BEGIN block. Scoped to block.
Assign variable
SET v_name = expr; or SELECT col INTO v_name FROM …
SELECT INTO assigns to local variables, not user variables.
Create function
CREATE FUNCTION name(p type) RETURNS type DETERMINISTIC BEGIN … RETURN val; END
Must declare DETERMINISTIC or NOT DETERMINISTIC.
Error handler
DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; END;
EXIT exits the block; CONTINUE resumes after the error.
Cursor lifecycle
DECLARE → OPEN → FETCH loop (check NOT FOUND) → CLOSE
Declare NOT FOUND handler before opening cursor.
Flow control
IF … ELSEIF … ELSE … END IF · CASE … END CASE · WHILE … END WHILE · REPEAT … UNTIL … END REPEAT · labelled LOOP … END LOOP
LEAVE label = break · ITERATE label = continue
Inspect
SHOW PROCEDURE STATUS WHERE Db = DATABASE(); · SHOW CREATE PROCEDURE name;
Also available via information_schema.routines.
Drop
DROP PROCEDURE IF EXISTS name; · DROP FUNCTION IF EXISTS name;
Always use IF EXISTS in scripts.
Next: Chapter 9 — Triggers and scheduled events:
BEFORE/AFTER INSERT/UPDATE/DELETE triggers, CREATE EVENT, practical use cases and the dangers to watch for.