MySQL Triggers
A trigger is a named SQL routine that MySQL automatically executes when a specific data-modification event (INSERT, UPDATE, or DELETE) occurs on a table. Triggers are perfect for audit logging, enforcing business rules, maintaining derived columns, and preventing invalid changes — all without requiring application-layer code changes.
Trigger Anatomy
Every trigger is defined by three things:
The timing: BEFORE or AFTER the triggering statement
The event: INSERT, UPDATE, or DELETE
The table it watches
DELIMITER //
CREATE TRIGGER trigger_name
{BEFORE | AFTER} {INSERT | UPDATE | DELETE}
ON table_name
FOR EACH ROW
BEGIN
-- trigger body
END //
DELIMITER ;NEW and OLD Row References
Reference | INSERT | UPDATE | DELETE |
|---|---|---|---|
NEW.column | The value being inserted | The new value being written | Not available |
OLD.column | Not available | The value before the update | The value being deleted |
-- In a BEFORE UPDATE trigger you can read AND modify NEW values
CREATE TRIGGER before_price_update
BEFORE UPDATE ON products
FOR EACH ROW
BEGIN
-- Force price to never go negative
IF NEW.price < 0 THEN
SET NEW.price = 0;
END IF;
END;Audit Log Trigger — After Insert
-- First, create an audit log table
CREATE TABLE customers_audit (
audit_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
action ENUM('INSERT','UPDATE','DELETE') NOT NULL,
customer_id INT NOT NULL,
changed_by VARCHAR(100),
changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
old_email VARCHAR(255),
new_email VARCHAR(255),
old_status VARCHAR(50),
new_status VARCHAR(50)
);
DELIMITER //
-- Log every new customer
CREATE TRIGGER trg_customers_after_insert
AFTER INSERT ON customers
FOR EACH ROW
BEGIN
INSERT INTO customers_audit
(action, customer_id, changed_by, new_email, new_status)
VALUES
('INSERT', NEW.customer_id, USER(), NEW.email, NEW.status);
END //
-- Log every customer update
CREATE TRIGGER trg_customers_after_update
AFTER UPDATE ON customers
FOR EACH ROW
BEGIN
INSERT INTO customers_audit
(action, customer_id, changed_by, old_email, new_email, old_status, new_status)
VALUES
('UPDATE', NEW.customer_id, USER(),
OLD.email, NEW.email,
OLD.status, NEW.status);
END //
-- Log every customer deletion
CREATE TRIGGER trg_customers_after_delete
AFTER DELETE ON customers
FOR EACH ROW
BEGIN
INSERT INTO customers_audit
(action, customer_id, changed_by, old_email, old_status)
VALUES
('DELETE', OLD.customer_id, USER(), OLD.email, OLD.status);
END //
DELIMITER ;BEFORE Trigger — Enforcing Business Rules
DELIMITER //
-- Prevent order items from having quantity <= 0
CREATE TRIGGER trg_order_items_before_insert
BEFORE INSERT ON order_items
FOR EACH ROW
BEGIN
IF NEW.quantity <= 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Order item quantity must be greater than zero';
END IF;
IF NEW.unit_price < 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Unit price cannot be negative';
END IF;
END //
-- Auto-set updated_at timestamp before any update
CREATE TRIGGER trg_products_before_update
BEFORE UPDATE ON products
FOR EACH ROW
BEGIN
SET NEW.updated_at = NOW();
END //
DELIMITER ;SIGNAL SQLSTATE — Preventing Changes
Use SIGNAL inside a BEFORE trigger to raise an error and cancel the triggering statement:
DELIMITER //
CREATE TRIGGER trg_prevent_order_delete
BEFORE DELETE ON orders
FOR EACH ROW
BEGIN
-- Prevent deleting shipped or completed orders
IF OLD.status IN ('shipped', 'completed') THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Cannot delete a shipped or completed order';
END IF;
END //
-- Prevent salary reduction using BEFORE UPDATE
CREATE TRIGGER trg_no_salary_cut
BEFORE UPDATE ON employees
FOR EACH ROW
BEGIN
IF NEW.salary < OLD.salary THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Salary cannot be reduced';
END IF;
END //
DELIMITER ;
-- These will now raise errors:
DELETE FROM orders WHERE order_id = 100; -- if status = 'shipped'
UPDATE employees SET salary = 30000 WHERE employee_id = 5; -- if current > 30000Maintaining Derived / Summary Data
-- orders table has a total_amount column we keep in sync automatically
DELIMITER //
CREATE TRIGGER trg_order_items_after_insert
AFTER INSERT ON order_items
FOR EACH ROW
BEGIN
UPDATE orders
SET total_amount = (
SELECT SUM(quantity * unit_price)
FROM order_items
WHERE order_id = NEW.order_id
)
WHERE order_id = NEW.order_id;
END //
CREATE TRIGGER trg_order_items_after_update
AFTER UPDATE ON order_items
FOR EACH ROW
BEGIN
UPDATE orders
SET total_amount = (
SELECT SUM(quantity * unit_price)
FROM order_items
WHERE order_id = NEW.order_id
)
WHERE order_id = NEW.order_id;
END //
CREATE TRIGGER trg_order_items_after_delete
AFTER DELETE ON order_items
FOR EACH ROW
BEGIN
UPDATE orders
SET total_amount = COALESCE((
SELECT SUM(quantity * unit_price)
FROM order_items
WHERE order_id = OLD.order_id
), 0)
WHERE order_id = OLD.order_id;
END //
DELIMITER ;Trigger Ordering — MySQL 5.7+
Since MySQL 5.7 you can have multiple triggers for the same event/timing combination on one table. Use FOLLOWS or PRECEDES to set their execution order:
DELIMITER //
-- First trigger for AFTER INSERT on orders
CREATE TRIGGER trg_orders_after_insert_log
AFTER INSERT ON orders
FOR EACH ROW
BEGIN
INSERT INTO order_log (order_id, event, created_at)
VALUES (NEW.order_id, 'created', NOW());
END //
-- Second trigger runs AFTER the first
CREATE TRIGGER trg_orders_after_insert_notify
AFTER INSERT ON orders
FOR EACH ROW
FOLLOWS trg_orders_after_insert_log
BEGIN
INSERT INTO notification_queue (type, reference_id, queued_at)
VALUES ('new_order', NEW.order_id, NOW());
END //
DELIMITER ;Inspecting Triggers
-- Show all triggers in the current database SHOW TRIGGERSG -- Show triggers on a specific table SHOW TRIGGERS FROM mydb LIKE 'customers'G -- Query information_schema for full detail SELECT TRIGGER_NAME, EVENT_MANIPULATION, EVENT_OBJECT_TABLE, ACTION_TIMING, ACTION_STATEMENT, CREATED FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE() ORDER BY EVENT_OBJECT_TABLE, ACTION_TIMING, EVENT_MANIPULATION;
Dropping Triggers
-- Drop a trigger DROP TRIGGER IF EXISTS trg_customers_after_insert; -- Triggers are also dropped when the table is dropped DROP TABLE customers; -- removes all triggers on customers automatically
Trigger Limitations
Triggers cannot call stored procedures that return a result set
Triggers cannot use COMMIT, ROLLBACK, or SAVEPOINT directly
Triggers cannot modify the same table that fired them (causes infinite loop / error)
Triggers cannot use dynamic SQL (PREPARE / EXECUTE)
Heavy trigger logic increases INSERT/UPDATE/DELETE latency — keep triggers lightweight
Triggers are invisible to application developers reading the code — document them clearly
Triggers are NOT fired by TRUNCATE TABLE
Practical: Full Inventory Audit System
CREATE TABLE inventory_log (
log_id BIGINT AUTO_INCREMENT PRIMARY KEY,
product_id INT NOT NULL,
change_type VARCHAR(20) NOT NULL,
qty_before INT,
qty_after INT,
changed_by VARCHAR(100),
changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
note TEXT
);
DELIMITER //
CREATE TRIGGER trg_products_stock_after_update
AFTER UPDATE ON products
FOR EACH ROW
BEGIN
IF OLD.stock_qty <> NEW.stock_qty THEN
INSERT INTO inventory_log
(product_id, change_type, qty_before, qty_after, changed_by, note)
VALUES (
NEW.product_id,
CASE
WHEN NEW.stock_qty > OLD.stock_qty THEN 'restock'
ELSE 'deduction'
END,
OLD.stock_qty,
NEW.stock_qty,
USER(),
CONCAT('Changed from ', OLD.stock_qty, ' to ', NEW.stock_qty)
);
END IF;
END //
DELIMITER ;Best Practices
Name triggers consistently: trg_tablename_timing_event (e.g. trg_orders_before_insert)
Keep trigger bodies short — complex logic belongs in stored procedures called from the trigger
Always test triggers with ROLLBACK-wrapped transactions in development
Document every trigger in your schema migration files so developers know they exist
Use AFTER triggers for audit logging and BEFORE triggers for validation and data normalization
Monitor trigger execution time via slow query log — triggers appear as part of the DML statement cost