Stored Procedures and Triggers
When to Use Server-Side Logic
Stored procedures and triggers run inside MySQL — reducing network round trips and centralizing logic. However, they are harder to version-control, test, and debug than application code.
Use when: batch ETL, consistent audit logging, legacy systems requiring DB-side logic
Avoid when: complex business rules, rapid iteration, microservices with independent deploy cycles
Stored Procedures
DELIMITER //
CREATE PROCEDURE get_user_orders(
IN p_user_id INT,
IN p_limit INT
)
BEGIN
SELECT o.id, o.total, o.status, o.created_at
FROM orders o
WHERE o.user_id = p_user_id
ORDER BY o.created_at DESC
LIMIT p_limit;
END //
DELIMITER ;
CALL get_user_orders(42, 10);
IN, OUT, and INOUT Parameters
DELIMITER //
CREATE PROCEDURE count_by_status(
IN p_status VARCHAR(20),
OUT p_count INT
)
BEGIN
SELECT COUNT(*) INTO p_count FROM orders WHERE status = p_status;
END //
DELIMITER ;
CALL count_by_status('pending', @cnt);
SELECT @cnt;
Stored Functions
DELIMITER //
CREATE FUNCTION order_item_count(p_order_id INT) RETURNS INT
READS SQL DATA
DETERMINISTIC
BEGIN
DECLARE cnt INT;
SELECT COUNT(*) INTO cnt FROM order_items WHERE order_id = p_order_id;
RETURN cnt;
END //
DELIMITER ;
SELECT id, order_item_count(id) AS items FROM orders LIMIT 10;
Functions return a single value and cannot modify data (in strict SQL mode).
Error Handling
DELIMITER //
CREATE PROCEDURE safe_transfer(
IN p_from INT,
IN p_to INT,
IN p_amount DECIMAL(10,2)
)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;
START TRANSACTION;
UPDATE accounts SET balance = balance - p_amount
WHERE id = p_from AND balance >= p_amount;
IF ROW_COUNT() = 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Insufficient funds or invalid account';
END IF;
UPDATE accounts SET balance = balance + p_amount WHERE id = p_to;
COMMIT;
END //
DELIMITER ;
Triggers
Triggers fire automatically on INSERT, UPDATE, or DELETE.
AFTER INSERT — Audit Trail
DELIMITER //
CREATE TRIGGER trg_orders_audit_insert
AFTER INSERT ON orders
FOR EACH ROW
BEGIN
INSERT INTO order_audit (order_id, action, new_total, changed_at)
VALUES (NEW.id, 'INSERT', NEW.total, NOW());
END //
DELIMITER ;
BEFORE UPDATE — Validation
DELIMITER //
CREATE TRIGGER trg_orders_validate_update
BEFORE UPDATE ON orders
FOR EACH ROW
BEGIN
IF NEW.total < 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Total cannot be negative';
END IF;
SET NEW.updated_at = NOW();
END //
DELIMITER ;
NEW = new row values; OLD = previous row values (UPDATE/DELETE only).
Trigger Limitations
- One trigger per event per table (MySQL 8.0 allows multiple)
- Cannot call stored procedures that modify the same table (recursion risk)
- Hidden side effects complicate debugging
- Performance overhead on high-write tables
Cursors (Procedural Loops)
DELIMITER //
CREATE PROCEDURE apply_bulk_discount(IN p_pct DECIMAL(5,2))
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE v_id INT;
DECLARE cur CURSOR FOR SELECT id FROM products WHERE category = 'clearance';
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
START TRANSACTION;
OPEN cur;
read_loop: LOOP
FETCH cur INTO v_id;
IF done THEN LEAVE read_loop; END IF;
UPDATE products SET price = price * (1 - p_pct/100) WHERE id = v_id;
END LOOP;
CLOSE cur;
COMMIT;
END //
DELIMITER ;
Prefer set-based SQL over cursors when possible — cursors are row-by-row and slow.
Managing Procedures and Triggers
SHOW PROCEDURE STATUS WHERE Db = 'myapp';
SHOW CREATE PROCEDURE get_user_orders\G
SHOW TRIGGERS FROM myapp;
DROP TRIGGER IF EXISTS trg_orders_audit_insert;
DROP PROCEDURE IF EXISTS get_user_orders;
Store definitions in migration files:
-- migrations/V004__order_audit_trigger.sql
DROP TRIGGER IF EXISTS trg_orders_audit_insert;
-- CREATE TRIGGER ...
Best Practices
- Keep procedures focused — one responsibility per procedure
- Always include error handlers with ROLLBACK
- Use
SIGNALfor business rule violations - Document parameters and side effects
- Test triggers with integration tests against a real MySQL instance
Common Mistakes
| Mistake | Consequence |
|---|---|
| Business logic only in triggers | Hidden failures, ORM bypass issues |
| No error handler in procedure | Partial commits, unclear failures |
| Cursor for bulk updates | Minutes instead of seconds |
| Trigger modifies same table | MySQL error or infinite recursion |
Troubleshooting
“Trigger already exists” — use DROP TRIGGER IF EXISTS in migrations.
“Can’t update table in stored function/trigger” — restructure to audit a different table or use AFTER trigger with INSERT to log table.
Binary logging blocks creation:
SET GLOBAL log_bin_trust_function_creators = 1;
-- Or create as SUPER user in dev; use DEFINER carefully in production
Production Scenario
A compliance team requires immutable audit logs for financial tables. AFTER INSERT/UPDATE/DELETE triggers write to audit_log with OLD and NEW values as JSON. The audit table is append-only (no UPDATE/DELETE grants for application users). Quarterly, audit data is exported to cold storage.
Procedures and triggers are powerful — deploy them deliberately, document them thoroughly, and prefer application logic when team velocity matters more than round-trip latency.