Chapter 9 — Triggers and Scheduled Events
Triggers are stored programs that MySQL runs automatically whenever a specific
data-change event occurs on a table — no application code involved, no CALL needed.
Scheduled events are stored programs that MySQL runs on a clock — once at a
future time, or repeatedly on an interval.
Together they move certain kinds of logic directly into the database engine:
audit trails, constraint enforcement beyond what CHECK can express, derived-column
maintenance, and background maintenance tasks like expiring old rows or refreshing
summary tables.
Power and responsibility: Triggers and events run invisibly to application
code. A developer INSERT-ing a row has no obvious way to know a trigger is also writing
to three other tables. Always document them, keep them focused, and treat the "magic
invisible side effect" quality as a cost — not a feature — to be used sparingly.
Part 1 — Triggers
1. Trigger Syntax and Timing
CREATE [OR REPLACE]
TRIGGER trigger_name
{ BEFORE | AFTER } { INSERT | UPDATE | DELETE }
ON table_name
FOR EACH ROW
[{ FOLLOWS | PRECEDES } other_trigger_name]
trigger_body
Each combination of timing (BEFORE/AFTER) and event (INSERT/UPDATE/DELETE) is a
distinct trigger slot. MySQL 5.7+ allows multiple triggers per slot — ordered with
FOLLOWS / PRECEDES.
|
INSERT |
UPDATE |
DELETE |
| BEFORE |
✓ Validate or modify data before the row is written |
✓ Block invalid changes; normalise values |
✓ Check business rules before the delete |
| AFTER |
✓ Write to audit log; update summary tables |
✓ Propagate changes to related tables |
✓ Cascade soft-delete; archive removed rows |
OLD and NEW pseudo-rows
INSERT
NEW.col — the value being inserted
OLD — does not exist
UPDATE
NEW.col — value after the update
OLD.col — value before the update
DELETE
OLD.col — the value being deleted
NEW — does not exist
In a BEFORE trigger you can assign to NEW.col to change the value that
will actually be written. In an AFTER trigger NEW is read-only —
the row is already committed.
2. Practical Trigger Patterns
Pattern 1 — Audit log (AFTER INSERT / UPDATE / DELETE)
First, create the audit table:
CREATE TABLE IF NOT EXISTS books_audit (
audit_id INT PRIMARY KEY AUTO_INCREMENT,
action ENUM('INSERT','UPDATE','DELETE') NOT NULL,
book_id INT NOT NULL,
old_price DECIMAL(10,2),
new_price DECIMAL(10,2),
old_stock INT,
new_stock INT,
changed_by VARCHAR(100) DEFAULT USER(),
changed_at DATETIME DEFAULT NOW()
);
DELIMITER $$
CREATE OR REPLACE TRIGGER trg_books_after_insert
AFTER INSERT ON books
FOR EACH ROW
BEGIN
INSERT INTO books_audit (action, book_id, new_price, new_stock)
VALUES ('INSERT', NEW.book_id, NEW.price, NEW.stock_quantity);
END$$
CREATE OR REPLACE TRIGGER trg_books_after_update
AFTER UPDATE ON books
FOR EACH ROW
BEGIN
IF NEW.price <> OLD.price OR NEW.stock_quantity <> OLD.stock_quantity THEN
INSERT INTO books_audit
(action, book_id, old_price, new_price, old_stock, new_stock)
VALUES
('UPDATE', NEW.book_id,
OLD.price, NEW.price,
OLD.stock_quantity, NEW.stock_quantity);
END IF;
END$$
CREATE OR REPLACE TRIGGER trg_books_after_delete
AFTER DELETE ON books
FOR EACH ROW
BEGIN
INSERT INTO books_audit (action, book_id, old_price, old_stock)
VALUES ('DELETE', OLD.book_id, OLD.price, OLD.stock_quantity);
END$$
DELIMITER ;
Pattern 2 — Validate and normalise with BEFORE INSERT / UPDATE
BEFORE triggers can modify NEW values before they hit the table,
or signal an error to reject the write entirely:
DELIMITER $$
CREATE OR REPLACE TRIGGER trg_books_before_insert
BEFORE INSERT ON books
FOR EACH ROW
BEGIN
IF NEW.price < 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Price cannot be negative';
END IF;
IF NEW.stock_quantity < 0 THEN
SET NEW.stock_quantity = 0;
END IF;
SET NEW.title = TRIM(NEW.title);
END$$
CREATE OR REPLACE TRIGGER trg_books_before_update
BEFORE UPDATE ON books
FOR EACH ROW
BEGIN
IF NEW.price < 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Price cannot be negative';
END IF;
IF NEW.stock_quantity < 0 THEN
SET NEW.stock_quantity = 0;
END IF;
END$$
DELIMITER ;
SIGNAL SQLSTATE '45000' raises a user-defined error. 45000 is
the reserved state for generic application errors. The error propagates back to the caller
exactly like any other MySQL error — the INSERT or UPDATE is rolled back and the application
receives an exception.
Pattern 3 — Maintain a derived column automatically
Keep a full_name column on the authors table in sync without
relying on application code to remember:
ALTER TABLE authors
ADD COLUMN full_name VARCHAR(200) AS
(CONCAT(first_name, ' ', last_name)) STORED;
DELIMITER $$
CREATE OR REPLACE TRIGGER trg_orders_before_insert
BEFORE INSERT ON orders
FOR EACH ROW
BEGIN
SET NEW.total_amount = (
SELECT COALESCE(SUM(quantity * unit_price), 0)
FROM order_items_staging
WHERE session_id = NEW.session_id
);
END$$
DELIMITER ;
Pattern 4 — Cascade soft-delete (AFTER DELETE)
When a customer is deleted, archive their orders rather than hard-deleting them:
CREATE TABLE IF NOT EXISTS orders_archive LIKE orders;
ALTER TABLE orders_archive
ADD COLUMN archived_at DATETIME DEFAULT NOW(),
ADD COLUMN archived_reason VARCHAR(200);
DELIMITER $$
CREATE OR REPLACE TRIGGER trg_customers_after_delete
AFTER DELETE ON customers
FOR EACH ROW
BEGIN
INSERT INTO orders_archive
(order_id, customer_id, order_date, status,
total_amount, archived_reason)
SELECT
order_id, customer_id, order_date, status,
total_amount, CONCAT('Customer ', OLD.customer_id, ' deleted')
FROM orders
WHERE customer_id = OLD.customer_id;
END$$
DELIMITER ;
Pattern 5 — Maintain a running stock count on order_items
DELIMITER $$
CREATE OR REPLACE TRIGGER trg_order_items_after_insert
AFTER INSERT ON order_items
FOR EACH ROW
BEGIN
UPDATE books
SET stock_quantity = stock_quantity - NEW.quantity
WHERE book_id = NEW.book_id;
END$$
CREATE OR REPLACE TRIGGER trg_order_items_after_delete
AFTER DELETE ON order_items
FOR EACH ROW
BEGIN
UPDATE books
SET stock_quantity = stock_quantity + OLD.quantity
WHERE book_id = OLD.book_id;
END$$
CREATE OR REPLACE TRIGGER trg_order_items_after_update
AFTER UPDATE ON order_items
FOR EACH ROW
BEGIN
IF NEW.quantity <> OLD.quantity THEN
UPDATE books
SET stock_quantity = stock_quantity - (NEW.quantity - OLD.quantity)
WHERE book_id = NEW.book_id;
END IF;
END$$
DELIMITER ;
3. Trigger Dangers
Triggers are invisible to application developers. An INSERT that takes
50ms instead of 1ms, or a DELETE that inexplicably fails a foreign key constraint
on a different table, is extremely difficult to debug if the developer doesn't know
a trigger exists. Always document triggers prominently.
- No triggers on triggers. A trigger cannot directly fire another trigger
on the same table. It can fire a trigger on a different table — creating
cascade chains that are very hard to trace.
- Triggers slow down writes. Every affected row runs the trigger body.
A BEFORE UPDATE trigger on a table that receives 10,000 updates per second is now
10,000 extra operations per second.
- Bulk operations pay per row.
UPDATE books SET price = price * 1.1
with an AFTER UPDATE trigger runs the trigger once per matching row — not once for
the whole statement. A million-row update fires the trigger a million times.
- SIGNAL is your friend for validation. Without SIGNAL, a BEFORE trigger
that silently discards an invalid value gives the caller no feedback. Always SIGNAL
for true constraint violations.
- Avoid long-running work in triggers. Triggers run inside the originating
transaction. Any work they do holds the same locks. A trigger that sends an HTTP
request or sleeps will block other transactions for the duration.
Test triggers with SHOW TRIGGERS and manual testing. After writing a trigger,
explicitly INSERT/UPDATE/DELETE a test row and verify the side effect. Triggers with a
bug fail silently in surprising ways — the original write may still succeed while the
trigger's secondary write silently errors (unless you have a handler or the error propagates).
4. Inspecting and Dropping Triggers
SHOW TRIGGERS [FROM bookshop]\G
SHOW TRIGGERS WHERE `Table` = 'books'\G
SELECT
trigger_name,
event_manipulation AS event,
action_timing AS timing,
event_object_table AS `table`,
action_order,
created
FROM information_schema.triggers
WHERE trigger_schema = DATABASE()
ORDER BY event_object_table, action_timing, event_manipulation, action_order;
SHOW CREATE TRIGGER trg_books_after_update\G
DROP TRIGGER IF EXISTS trg_books_after_insert;
DROP TRIGGER IF EXISTS trg_books_after_update;
DROP TRIGGER IF EXISTS trg_books_after_delete;
Part 2 — Scheduled Events
5. The Event Scheduler
MySQL's event scheduler is a background thread that fires stored SQL on a clock.
Think of it as a cron job that lives inside the database — no OS-level cron, no external
script, no network connection needed.
SHOW VARIABLES LIKE 'event_scheduler';
SET GLOBAL event_scheduler = ON;
The scheduler is OFF by default. Events defined while it is off are stored but
never fire. Always verify event_scheduler = ON in production. On managed databases
(AWS RDS, Google Cloud SQL) the parameter group or flags control this — check your provider's docs.
One-time vs recurring events
One-time (AT)
Fires once at a specific date/time. MySQL can optionally drop it automatically afterwards with ON COMPLETION NOT PRESERVE.
Recurring (EVERY)
Fires on a repeating interval — seconds, minutes, hours, days, weeks, months. Can have an optional start time and end time.
CREATE [OR REPLACE]
EVENT [IF NOT EXISTS] event_name
ON SCHEDULE
{ AT timestamp | EVERY interval [STARTS timestamp] [ENDS timestamp] }
[ON COMPLETION [NOT] PRESERVE]
[ENABLE | DISABLE]
[COMMENT 'description']
DO
event_body;
6. Practical Event Patterns
Pattern 1 — Nightly sales summary table
Refresh a denormalised summary table every night at 02:00 so daytime reports hit
a pre-computed table instead of joining millions of order rows:
CREATE TABLE IF NOT EXISTS daily_sales_summary (
summary_date DATE PRIMARY KEY,
orders_placed INT NOT NULL DEFAULT 0,
units_sold INT NOT NULL DEFAULT 0,
revenue DECIMAL(12,2) NOT NULL DEFAULT 0.00,
refreshed_at DATETIME
);
DELIMITER $$
CREATE OR REPLACE EVENT evt_nightly_sales_summary
ON SCHEDULE EVERY 1 DAY
STARTS (DATE(NOW()) + INTERVAL 1 DAY + INTERVAL 2 HOUR)
ON COMPLETION PRESERVE
ENABLE
COMMENT 'Rebuild daily_sales_summary for the previous day'
DO
BEGIN
INSERT INTO daily_sales_summary
(summary_date, orders_placed, units_sold, revenue, refreshed_at)
SELECT
DATE(o.order_date) AS summary_date,
COUNT(DISTINCT o.order_id) AS orders_placed,
SUM(oi.quantity) AS units_sold,
SUM(oi.quantity * oi.unit_price) AS revenue,
NOW()
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
WHERE DATE(o.order_date) = CURDATE() - INTERVAL 1 DAY
ON DUPLICATE KEY UPDATE
orders_placed = VALUES(orders_placed),
units_sold = VALUES(units_sold),
revenue = VALUES(revenue),
refreshed_at = VALUES(refreshed_at);
END$$
DELIMITER ;
Pattern 2 — Hourly expired session cleanup
CREATE OR REPLACE EVENT evt_purge_expired_sessions
ON SCHEDULE EVERY 1 HOUR
ON COMPLETION PRESERVE
COMMENT 'Delete login sessions older than 24 hours'
DO
DELETE FROM user_sessions
WHERE created_at < NOW() - INTERVAL 24 HOUR;
Batch deletes in events. If the table is large, a single DELETE that wipes
millions of rows holds a heavy lock. Use a loop procedure called from the event to
delete in batches of a few thousand rows at a time, sleeping briefly between each batch.
Pattern 3 — One-time future job
CREATE EVENT evt_launch_day_price_reset
ON SCHEDULE AT '2026-09-01 09:00:00'
ON COMPLETION NOT PRESERVE
DO
UPDATE books
SET price = price * 1.05
WHERE publisher_id = 3;
Pattern 4 — Weekly review request emails (via a log table)
Events can't send emails directly — but they can write to a
queue table that an external process polls:
CREATE TABLE IF NOT EXISTS email_queue (
queue_id INT PRIMARY KEY AUTO_INCREMENT,
to_email VARCHAR(200) NOT NULL,
subject VARCHAR(255) NOT NULL,
body TEXT,
queued_at DATETIME DEFAULT NOW(),
sent_at DATETIME
);
CREATE OR REPLACE EVENT evt_weekly_review_requests
ON SCHEDULE EVERY 1 WEEK
STARTS '2026-06-16 08:00:00'
ON COMPLETION PRESERVE
DO
INSERT INTO email_queue (to_email, subject)
SELECT DISTINCT
c.email,
'How was your recent purchase from the Bookshop?'
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
JOIN order_items oi ON oi.order_id = o.order_id
LEFT JOIN reviews r ON r.book_id = oi.book_id
AND r.customer_id = c.customer_id
WHERE o.order_date >= CURDATE() - INTERVAL 7 DAY
AND r.review_id IS NULL;
7. Managing Events
SHOW EVENTS [FROM bookshop]\G
SELECT
event_name,
status,
execute_at,
interval_value,
interval_field,
starts,
ends,
last_executed,
event_comment
FROM information_schema.events
WHERE event_schema = DATABASE()
ORDER BY event_name;
SHOW CREATE EVENT evt_nightly_sales_summary\G
ALTER EVENT evt_nightly_sales_summary DISABLE;
ALTER EVENT evt_nightly_sales_summary ENABLE;
ALTER EVENT evt_purge_expired_sessions
ON SCHEDULE EVERY 30 MINUTE;
DROP EVENT IF EXISTS evt_launch_day_price_reset;
Quick Reference
| Concept | Syntax / rule | Notes |
| Create trigger |
CREATE OR REPLACE TRIGGER name {BEFORE|AFTER} {INSERT|UPDATE|DELETE} ON table FOR EACH ROW BEGIN … END |
One trigger per timing+event combination per table (multiple allowed with FOLLOWS/PRECEDES). |
| NEW / OLD |
NEW.col (INSERT, UPDATE) · OLD.col (UPDATE, DELETE) |
Assign to NEW in BEFORE trigger to change the written value. NEW is read-only in AFTER. |
| Raise an error |
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '…'; |
Rolls back the triggering statement. Always SIGNAL for genuine constraint violations. |
| Trigger ordering |
FOLLOWS other_trigger / PRECEDES other_trigger |
Controls execution order when multiple triggers exist for the same event. MySQL 5.7+. |
| Inspect triggers |
SHOW TRIGGERS [FROM db]; · SHOW CREATE TRIGGER name; |
Also available in information_schema.triggers. |
| Drop trigger |
DROP TRIGGER IF EXISTS name; |
Triggers are dropped with the table when the table is dropped. |
| Enable scheduler |
SET GLOBAL event_scheduler = ON; · add event_scheduler=ON to my.cnf |
Off by default. Events are stored but never fire while it is off. |
| One-time event |
ON SCHEDULE AT 'YYYY-MM-DD HH:MM:SS' |
Use ON COMPLETION NOT PRESERVE to auto-drop after firing. |
| Recurring event |
ON SCHEDULE EVERY n {SECOND|MINUTE|HOUR|DAY|WEEK|MONTH|YEAR} [STARTS …] [ENDS …] |
Use ON COMPLETION PRESERVE so the definition survives after each run. |
| Disable / enable event |
ALTER EVENT name DISABLE; · ALTER EVENT name ENABLE; |
Useful during maintenance windows or deployments. |
| Change schedule |
ALTER EVENT name ON SCHEDULE EVERY 30 MINUTE; |
ALTER EVENT can also change the body, comment, and status. |
| Drop event |
DROP EVENT IF EXISTS name; |
Always IF EXISTS in scripts. |
Next: Chapter 10 — Query optimisation:
EXPLAIN and EXPLAIN ANALYZE output, index strategy (composite, covering, prefix indexes),
avoiding full table scans, and profiling with the slow query log.