MENU
Stored Routines, Triggers, and Views
MySQL can store code beside the tables. Stored programs, written in BEGIN ... END blocks with variables, loops and handlers, come in four kinds: a procedure you CALL, a function used in expressions, a trigger fired by row changes, and an event run on a clock. A view stores a named SELECT. The listings run in order against shop. mysqldump saves routines and events only with --routines and --events (Section 3.17).3.15.1 Stored Procedures and the Delimiter Problem
A stored procedure is a named block of statements run with CALL; it takes parameters, can return result sets and can change data. Its body contains semicolons, and the mysql client splits its input at every ;. Typed without the DELIMITER lines, the procedure below reaches the server in two pieces and fails twice with ERROR 1064 (42000): You have an error in your SQL syntax. DELIMITER // makes the client split on // instead until you switch back:DELIMITER //
CREATE PROCEDURE customer_orders(IN p_email VARCHAR(255))
READS SQL DATA
BEGIN
SELECT o.id, o.status, o.ordered_at
FROM orders AS o JOIN customers AS c ON c.id = o.customer_id
WHERE c.email = p_email ORDER BY o.ordered_at;
END //
DELIMITER ;
CALL customer_orders('ana@example.com');
+----+---------+---------------------+
| id | status | ordered_at |
+----+---------+---------------------+
| 1 | shipped | 2026-03-02 10:15:00 |
| 4 | shipped | 2026-05-05 12:00:00 |
+----+---------+---------------------+
3.15.2 Stored Functions and Determinism
A stored function returns one value and goes wherever an expression may: a select list, WHERE, ORDER BY or another routine. It cannot return a result set, commit, or call itself. Binary logging is on by default, and there the first attempt at an order total fails:CREATE FUNCTION order_total(p_order INT UNSIGNED) RETURNS DECIMAL(10,2)
RETURN (SELECT SUM(quantity * unit_price) FROM order_items WHERE order_id = p_order);
CREATE FUNCTION order_total(p_order INT UNSIGNED) RETURNS DECIMAL(10,2) READS SQL DATA
RETURN (SELECT SUM(quantity * unit_price) FROM order_items WHERE order_id = p_order);
CREATE FUNCTION add_vat(p DECIMAL(10,2)) RETURNS DECIMAL(10,2) NO SQL DETERMINISTIC
RETURN ROUND(p * 1.08, 2);
CREATE FUNCTION stamp() RETURNS DATETIME DETERMINISTIC RETURN NOW();
SELECT id, order_total(id) AS total, add_vat(order_total(id)) AS with_vat,
stamp() = NOW() AS lie_accepted FROM orders WHERE order_total(id) > 88;
ERROR 1418 (HY000): This function has none of DETERMINISTIC, NO SQL, or READS SQL DATA in ...
+----+--------+----------+--------------+
| id | total | with_vat | lie_accepted |
+----+--------+----------+--------------+
| 2 | 88.47 | 95.55 | 1 |
| 6 | 128.80 | 139.10 | 1 |
+----+--------+----------+--------------+
3.15.3 Variables, Parameters, and Flow Control
Parameters are IN (the default: a copy), OUT (starts as NULL; its final value lands in the caller's variable) or INOUT (both). Local variables come from DECLARE at the top of a BEGIN ... END block and die with it. User variables such as @fee belong to the session and carry OUT values back to the client. A bare name means a local variable before a column, so prefix parameters p_ and locals v_.DELIMITER //
CREATE PROCEDURE shipping_quote(IN p_order INT UNSIGNED, OUT p_fee DECIMAL(6,2),
INOUT p_note VARCHAR(80))
BEGIN
DECLARE v_country CHAR(2);
SELECT c.country INTO v_country
FROM orders AS o JOIN customers AS c ON c.id = o.customer_id WHERE o.id = p_order;
CASE v_country
WHEN 'MY' THEN SET p_fee = 5.00;
WHEN 'US' THEN SET p_fee = 12.00;
END CASE;
IF order_total(p_order) >= 80 THEN
SET p_fee = 0, p_note = CONCAT(p_note, ' ships free');
END IF;
END //
DELIMITER ;
SET @note = 'Order 2:';
CALL shipping_quote(2, @fee, @note);
SELECT @fee, @note;
CALL shipping_quote(1, @fee, @note);
+------+---------------------+
| @fee | @note |
+------+---------------------+
| 0.00 | Order 2: ships free |
+------+---------------------+
ERROR 1339 (20000): Case not found for CASE statement
3.15.4 Cursors, Conditions, and Handlers
A cursor walks a result set row by row, forward only. A handler reacts to a condition (an error number, an SQLSTATE, SQLEXCEPTION, SQLWARNING or NOT FOUND) and then continues (CONTINUE) or leaves the block (EXIT). SIGNAL raises your own condition, and RESIGNAL re-raises the one being handled. Declare variables, then cursors, then handlers. place_order walks a JSON cart (Section 3.9.5) with the checkout pattern of Section 3.14.2:DELIMITER //
CREATE PROCEDURE place_order(IN p_customer INT UNSIGNED, IN p_cart JSON,
OUT p_order INT UNSIGNED)
BEGIN
DECLARE v_done BOOL DEFAULT FALSE;
DECLARE v_product, v_qty INT UNSIGNED;
DECLARE cart CURSOR FOR SELECT id, qty FROM JSON_TABLE(p_cart, '$[*]'
COLUMNS (id INT UNSIGNED PATH '$.id', qty INT UNSIGNED PATH '$.qty')) AS c;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = TRUE;
DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;
START TRANSACTION;
INSERT INTO orders (customer_id, ordered_at) VALUES (p_customer, NOW());
SET p_order = LAST_INSERT_ID();
OPEN cart;
cart_loop: LOOP
FETCH cart INTO v_product, v_qty;
IF v_done THEN LEAVE cart_loop; END IF;
UPDATE products SET stock = stock - v_qty WHERE id = v_product AND stock >= v_qty;
IF ROW_COUNT() = 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Out of stock', MYSQL_ERRNO = 5001;
END IF;
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
SELECT p_order, id, v_qty, price FROM products WHERE id = v_product;
END LOOP;
CLOSE cart;
COMMIT;
END //
DELIMITER ;
CALL place_order(7, '[{"id": 5, "qty": 2}, {"id": 7, "qty": 1}]', @id);
CALL place_order(7, '[{"id": 1, "qty": 1}, {"id": 3, "qty": 1}]', @id);
SELECT @id AS order_id, order_total(@id) AS total, (SELECT COUNT(*) FROM orders) AS orders,
(SELECT GROUP_CONCAT(stock ORDER BY id) FROM products WHERE id IN (1, 5, 7)) AS stock_1_5_7;
ERROR 5001 (45000): Out of stock
+----------+-------+--------+-------------+
| order_id | total | orders | stock_1_5_7 |
+----------+-------+--------+-------------+
| 10 | 80.50 | 10 | 25,5,59 |
+----------+-------+--------+-------------+
3.15.5 Triggers and Their Limits
A trigger runs FOR EACH ROW of an INSERT, UPDATE or DELETE on one table, BEFORE or AFTER the row is written. OLD.col is the row as it was, NEW.col as it will be; only a BEFORE trigger may assign NEW.col, and a SIGNAL there cancels the change. Here cancelling an order restocks its books, and every stock change is audited:CREATE TABLE stock_audit (id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, product_id INT UNSIGNED,
old_stock INT, new_stock INT, changed_by VARCHAR(100), changed_at DATETIME DEFAULT NOW());
DELIMITER //
CREATE TRIGGER orders_guard BEFORE UPDATE ON orders FOR EACH ROW
IF OLD.status = 'shipped' AND NEW.status = 'cancelled' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'A shipped order cannot be cancelled';
END IF //
CREATE TRIGGER orders_restock AFTER UPDATE ON orders FOR EACH ROW
IF NEW.status = 'cancelled' AND OLD.status <> 'cancelled' THEN
UPDATE products AS p JOIN order_items AS i ON i.product_id = p.id
SET p.stock = p.stock + i.quantity WHERE i.order_id = NEW.id;
END IF //
CREATE TRIGGER products_audit AFTER UPDATE ON products FOR EACH ROW
IF NEW.stock <> OLD.stock THEN
INSERT INTO stock_audit (product_id, old_stock, new_stock, changed_by)
VALUES (NEW.id, OLD.stock, NEW.stock, CURRENT_USER());
END IF //
DELIMITER ;
CALL place_order(8, '[{"id": 7, "qty": 3}]', @id);
UPDATE orders SET status = 'cancelled' WHERE id = @id;
UPDATE orders SET status = 'cancelled' WHERE id = 1;
SELECT product_id, old_stock, new_stock, changed_by FROM stock_audit;
ERROR 1644 (45000): A shipped order cannot be cancelled
+------------+-----------+-----------+----------------+
| product_id | old_stock | new_stock | changed_by |
+------------+-----------+-----------+----------------+
| 7 | 59 | 56 | root@localhost |
| 7 | 56 | 59 | root@localhost |
+------------+-----------+-----------+----------------+
A trigger cannot write to its own table (error 1442, Can't update table 'products' in stored function/trigger), and setting NEW.col in an AFTER trigger is error 1362.
There are no statement-level triggers, foreign-key actions such as ON DELETE CASCADE fire none, and a row-based replica applies logged rows without running its own.
FOLLOWS or PRECEDES orders triggers that share a timing and event. PHP code cannot see triggers, so keep them short and check SHOW TRIGGERS.
3.15.6 Scheduled Events
An event is a stored program on a schedule: AT a timestamp, or EVERY an interval between optional STARTS and ENDS. The global event_scheduler thread, ON by default, runs it as its DEFINER; toggle that thread only on a test server of your own (Section 3.1.6). This event cancels orders left unpaid for two weeks, first firing two seconds after creation:CREATE EVENT expire_pending
ON SCHEDULE EVERY 1 DAY STARTS CURRENT_TIMESTAMP + INTERVAL 2 SECOND
DO UPDATE orders SET status = 'cancelled'
WHERE status = 'pending' AND ordered_at < NOW() - INTERVAL 14 DAY;
DO SLEEP(3);
SELECT e.STATUS, e.LAST_EXECUTED, o.status AS order_8 FROM information_schema.EVENTS AS e
JOIN orders AS o ON o.id = 8 WHERE e.EVENT_NAME = 'expire_pending';
+---------+---------------------+-----------+
| STATUS | LAST_EXECUTED | order_8 |
+---------+---------------------+-----------+
| ENABLED | 2026-09-23 08:34:34 | cancelled |
+---------+---------------------+-----------+
3.15.7 Views, Algorithms, and Updatable Views
A view is a stored SELECT queried like a table; its columns are fixed at creation, its rows computed on each use. ALGORITHM = MERGE folds the view into your query, so base-table indexes still apply; TEMPTABLE materializes it first; UNDEFINED, the default, prefers MERGE. A view is updatable when each row maps to one base-table row, which aggregates, DISTINCT, GROUP BY, HAVING, UNION and TEMPTABLE rule out:CREATE VIEW in_stock AS SELECT id, sku, title, price, stock FROM products WHERE stock > 0
WITH CHECK OPTION;
CREATE VIEW order_summary AS
SELECT o.id, c.name, o.status, SUM(i.quantity * i.unit_price) AS total
FROM orders AS o JOIN customers AS c ON c.id = o.customer_id
JOIN order_items AS i ON i.order_id = o.id GROUP BY o.id;
UPDATE in_stock SET price = 36.00 WHERE sku = 'BK-UX-01';
UPDATE in_stock SET stock = 0 WHERE sku = 'BK-UX-01';
UPDATE order_summary SET status = 'paid' WHERE id = 8;
ERROR 1369 (HY000): CHECK OPTION failed 'shop.in_stock'
ERROR 1288 (HY000): The target table order_summary of the UPDATE is not updatable
3.15.8 JavaScript Stored Programs
CREATE FUNCTION gcd(a INT, b INT) RETURNS INT NO SQL LANGUAGE JAVASCRIPT AS $$
let x = Math.abs(a), y = Math.abs(b);
while (y) { [x, y] = [y, x % y]; }
return x;
$$;
SELECT gcd(75, 220);