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.
DELIMITER $$
-- AFTER INSERTCREATE OR REPLACE TRIGGER trg_books_after_insert
AFTER INSERT ON books
FOR EACH ROWBEGININSERT INTO books_audit (action, book_id, new_price, new_stock)
VALUES ('INSERT', NEW.book_id, NEW.price, NEW.stock_quantity);
END$$
-- AFTER UPDATECREATE OR REPLACE TRIGGER trg_books_after_update
AFTER UPDATE ON books
FOR EACH ROWBEGIN-- Only log if price or stock actually changedIF NEW.price <> OLD.price OR NEW.stock_quantity <> OLD.stock_quantity THENINSERT 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$$
-- AFTER DELETECREATE OR REPLACE TRIGGER trg_books_after_delete
AFTER DELETE ON books
FOR EACH ROWBEGININSERT 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 ROWBEGIN-- Reject negative pricesIF NEW.price < 0THENSIGNAL SQLSTATE'45000'SET MESSAGE_TEXT = 'Price cannot be negative';
END IF;
-- Clamp stock to zero if a negative value is suppliedIF NEW.stock_quantity < 0THENSET NEW.stock_quantity = 0;
END IF;
-- Trim whitespace from titleSET NEW.title = TRIM(NEW.title);
END$$
CREATE OR REPLACE TRIGGER trg_books_before_update
BEFORE UPDATE ON books
FOR EACH ROWBEGINIF NEW.price < 0THENSIGNAL SQLSTATE'45000'SET MESSAGE_TEXT = 'Price cannot be negative';
END IF;
IF NEW.stock_quantity < 0THENSET 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:
-- Add the derived column (or use a generated column in MySQL 5.7+)ALTER TABLE authors
ADD COLUMN full_name VARCHAR(200) AS
(CONCAT(first_name, ' ', last_name)) STORED;
-- MySQL's generated column syntax makes this specific example redundant,
-- but the same BEFORE trigger pattern applies to any non-trivial derivation:DELIMITER $$
CREATE OR REPLACE TRIGGER trg_orders_before_insert
BEFORE INSERT ON orders
FOR EACH ROWBEGIN-- Auto-compute order total from line items already in a staging table-- (illustrative pattern — adapt to your schema)SET NEW.total_amount = (
SELECTCOALESCE(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 DATETIMEDEFAULTNOW(),
ADD COLUMN archived_reason VARCHAR(200);
DELIMITER $$
CREATE OR REPLACE TRIGGER trg_customers_after_delete
AFTER DELETE ON customers
FOR EACH ROWBEGIN-- Move the deleted customer's orders into the archiveINSERT 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 $$
-- Decrement stock when an order line is insertedCREATE OR REPLACE TRIGGER trg_order_items_after_insert
AFTER INSERT ON order_items
FOR EACH ROWBEGINUPDATE books
SET stock_quantity = stock_quantity - NEW.quantity
WHERE book_id = NEW.book_id;
END$$
-- Restore stock when an order line is deleted (cancellation)CREATE OR REPLACE TRIGGER trg_order_items_after_delete
AFTER DELETE ON order_items
FOR EACH ROWBEGINUPDATE books
SET stock_quantity = stock_quantity + OLD.quantity
WHERE book_id = OLD.book_id;
END$$
-- Handle quantity changes on existing linesCREATE OR REPLACE TRIGGER trg_order_items_after_update
AFTER UPDATE ON order_items
FOR EACH ROWBEGINIF NEW.quantity <> OLD.quantity THENUPDATE 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
-- List all triggers on the current databaseSHOW TRIGGERS [FROM bookshop]\G
-- Filter by tableSHOW TRIGGERS WHERE `Table` = 'books'\G
-- Full information_schema querySELECT
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 a trigger's bodySHOW CREATE TRIGGER trg_books_after_update\G
-- Drop a triggerDROP 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.
-- Check scheduler statusSHOW VARIABLES LIKE'event_scheduler';
-- Enable it for this session (or add to my.cnf to persist across restarts)SET GLOBAL event_scheduler = ON;
-- Persist across restarts — add to [mysqld] section in my.cnf:-- 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 DATEPRIMARY KEY,
orders_placed INTNOT NULL DEFAULT0,
units_sold INTNOT NULL DEFAULT0,
revenue DECIMAL(12,2) NOT NULL DEFAULT0.00,
refreshed_at DATETIME
);
DELIMITER $$
CREATE OR REPLACE EVENT evt_nightly_sales_summary
ON SCHEDULE EVERY1DAYSTARTS (DATE(NOW()) + INTERVAL1DAY + INTERVAL2HOUR) -- next 02:00ON COMPLETION PRESERVE-- keep the event definition after it firesENABLECOMMENT'Rebuild daily_sales_summary for the previous day'DOBEGININSERT INTO daily_sales_summary
(summary_date, orders_placed, units_sold, revenue, refreshed_at)
SELECTDATE(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
WHEREDATE(o.order_date) = CURDATE() - INTERVAL1DAYON 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 EVERY1HOURON COMPLETION PRESERVECOMMENT'Delete login sessions older than 24 hours'DODELETE FROM user_sessions
WHERE created_at < NOW() - INTERVAL24HOUR;
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
-- Run a specific task at exactly one future moment then discard the eventCREATE EVENT evt_launch_day_price_reset
ON SCHEDULE AT'2026-09-01 09:00:00'ON COMPLETION NOT PRESERVE-- auto-drop after it firesDOUPDATE books
SET price = price * 1.05-- 5% price rise on 1 SeptWHERE publisher_id = 3;
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 INTPRIMARY KEY AUTO_INCREMENT,
to_email VARCHAR(200) NOT NULL,
subject VARCHAR(255) NOT NULL,
body TEXT,
queued_at DATETIMEDEFAULTNOW(),
sent_at DATETIME
);
CREATE OR REPLACE EVENT evt_weekly_review_requests
ON SCHEDULE EVERY1WEEKSTARTS'2026-06-16 08:00:00'-- first MondayON COMPLETION PRESERVEDO-- Queue a nudge for customers who bought something in the last week-- but haven't left a reviewINSERT 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() - INTERVAL7DAYAND r.review_id IS NULL;
7. Managing Events
-- List all eventsSHOW EVENTS [FROM bookshop]\G
-- Detailed query via information_schemaSELECT
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;
-- See the full event bodySHOW CREATE EVENT evt_nightly_sales_summary\G
-- Disable an event (keep definition, stop firing)ALTER EVENT evt_nightly_sales_summary DISABLE;
-- Re-enableALTER EVENT evt_nightly_sales_summary ENABLE;
-- Change scheduleALTER EVENT evt_purge_expired_sessions
ON SCHEDULE EVERY30MINUTE;
-- DropDROP 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).
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.