Part · Chapter

Triggers and scheduled events

Two ways the database runs code without anyone calling it: a trigger fires when a row changes, an event fires when the clock says so.

The short version

  • A trigger is code the engine runs for you. It fires before or after an INSERT, UPDATE or DELETE on one table.
  • NEW and OLD are the row. The incoming version, and the previous one.
  • It runs in the statement's transaction. If it raises an error, the row change rolls back.
  • PostgreSQL has more of them. Statement-level, INSTEAD OF, transition tables, and disabling without dropping.
  • Clock work differs. MySQL has the Event Scheduler built in; PostgreSQL needs pg_cron or Task Scheduler.
  • They hide. List them on any new server, and dump with --routines --events.

What a trigger is

A trigger is code the engine runs by itself, before or after an INSERT, UPDATE or DELETE on one table. Nobody calls it: the statement that touched the table sets it off, and it runs inside that statement's transaction (see Transactions and concurrency). NEW is the incoming row, OLD is the row as it was. A BEFORE trigger may change NEW or refuse the change with an error, and that error fails the original statement.

Four typical jobs:

  • Audit trail. Copy who changed what, and when, into an orders_audit table.
  • Derived column. Keep orders.total equal to the sum of its order_items.
  • Validation beyond CHECK. Compare old and new values, then refuse with SIGNAL in MySQL or RAISE in PostgreSQL.
  • Cross-table bookkeeping. Decrement stock, bump a counter, mark a customer active.

Keeping orders.total honest

Order 12 holds two mice at 20.00, so total is 40.00. Add a 45.00 keyboard, and an AFTER INSERT trigger on order_items corrects total in the same transaction.

Illustration · MySQL
-- NEW is the row that was just inserted into order_items
CREATE TRIGGER trg_items_ai
AFTER INSERT ON order_items
FOR EACH ROW
  UPDATE orders
  SET total = total + NEW.qty * NEW.unit_price
  WHERE id = NEW.order_id;

INSERT INTO order_items (order_id, product_id, qty, unit_price)
VALUES (12, 100, 1, 45.00);   -- a keyboard for Ben's order
orders, before
idcustomer_idstatustotal
111paid180.00
122pending40.00
13NULLpaid20.00
orders, after the trigger
idcustomer_idstatustotal
111paid180.00
122pending85.00
13NULLpaid20.00

PostgreSQL splits this in two: the body lives in a trigger function returning type trigger, and CREATE TRIGGER says when to call it (see Stored procedures and functions).

Remember

A trigger is code the engine runs before or after a row changes, inside the same transaction. If it raises an error, the original statement fails too.

What each engine can do

Both engines have triggers. PostgreSQL has the extras. This table is the map.

CapabilityMySQL 8.4 / 9.7 / 26.xPostgreSQL 17 / 18
Row-level triggers (FOR EACH ROW)Yes (the only kind)Yes
Statement-level triggers (once per statement)NoYes (the default when FOR EACH ROW is left out)
INSTEAD OF triggers on viewsNo (no triggers on views at all)Yes
Triggers on TRUNCATENo (TRUNCATE fires no DELETE triggers either)Yes (statement-level only)
Several triggers on one event, with orderingYes: creation order, or FOLLOWS / PRECEDESYes, fired in alphabetical order by name
Transition tables (the whole set of changed rows)NoYes (REFERENCING OLD TABLE / NEW TABLE, AFTER triggers)
NEW and OLD row accessYes (NEW.col, OLD.col in the body)Yes (NEW.col, OLD.col inside the trigger function)
Body in a separate, reusable trigger functionNo (the body is written inside CREATE TRIGGER)Yes (required: EXECUTE FUNCTION)
WHEN condition on the trigger itselfNo (use IF in the body)Yes
Deferrable constraint triggers (checked at COMMIT)NoYes
Event triggers on DDL (CREATE, ALTER, DROP)NoYes
Fired by foreign-key cascades (ON DELETE CASCADE, SET NULL)Partial off by default; 9.7 added enable_cascade_triggersYes
Disable a trigger without dropping itNo (drop it, or guard the body with a session variable)Yes (ALTER TABLE … DISABLE TRIGGER)
COMMIT or ROLLBACK inside a triggerNoNo

Two rows are worth knowing by name: PostgreSQL constraint triggers can be deferred to COMMIT, and its event triggers fire on DDL.

Trigger hazards

  • Hidden logic. A developer updates one column and finds three others changed. When a deadlock report names a table nobody's code mentions, suspect a trigger.
  • Bulk loads. A million rows means a million trigger runs, each with its own writes and locks.
  • Recursion. MySQL refuses to let a trigger modify a table the invoking statement is already using; PostgreSQL lets triggers nest, so guard with pg_trigger_depth().
  • No transaction control. MySQL forbids START TRANSACTION, COMMIT and ROLLBACK in a trigger body. Raising an error is all a trigger can do.

Finding the triggers you inherited

Assume every important table may have triggers until you have looked. Both catalogs answer in seconds.

Illustration · MySQL
SHOW TRIGGERS FROM shop;                   -- one row per trigger, with the body

SELECT TRIGGER_NAME, EVENT_MANIPULATION, ACTION_TIMING, ACTION_ORDER, DEFINER
FROM information_schema.TRIGGERS
WHERE EVENT_OBJECT_SCHEMA = 'shop' AND EVENT_OBJECT_TABLE = 'order_items';

DROP TRIGGER shop.trg_items_ai;            -- the only way to switch it off

MySQL cannot pause a trigger: drop and recreate it around a bulk load, or guard the body with a session variable. PostgreSQL disables one in place.

Illustration · PostgreSQL
\d order_items          -- psql lists a "Triggers:" block at the bottom

SELECT tgname, tgenabled            -- 'O' = enabled, 'D' = disabled
FROM pg_trigger
WHERE tgrelid = 'order_items'::regclass AND NOT tgisinternal;

ALTER TABLE order_items DISABLE TRIGGER trg_items_ai;   -- pause it
ALTER TABLE order_items DISABLE TRIGGER USER;           -- all yours, not the FK ones

USER leaves the internal triggers that implement foreign keys alone.

Scheduled jobs

MySQL has a built-in Event Scheduler: a server thread that runs each named event's SQL once, or on a repeating interval. It is on by default (event_scheduler=ON), creating one needs the EVENT privilege, and it runs as its DEFINER. Typical jobs are housekeeping: purge old rows, refresh a summary table, expire sessions.

Illustration · MySQL
CREATE EVENT purge_cancelled_orders
ON SCHEDULE EVERY 1 DAY STARTS '2026-10-01 03:30:00'
DO
  DELETE FROM orders
  WHERE status = 'cancelled'
    AND order_date < CURRENT_DATE - INTERVAL 1 YEAR;

SHOW EVENTS FROM shop;
SELECT EVENT_NAME, STATUS, LAST_EXECUTED, DEFINER
FROM information_schema.EVENTS WHERE EVENT_SCHEMA = 'shop';
ALTER EVENT purge_cancelled_orders DISABLE;   -- keep it, stop it running

LAST_EXECUTED is NULL until the first run, then it is your cheapest health check: older than the interval means something is wrong.

PostgreSQL has no built-in scheduler. The usual answer is pg_cron, an extension loaded at startup through shared_preload_libraries; it takes cron expressions and logs every run in cron.job_run_details. It is not in the Windows EDB installer, so on a Windows server you will more often let Task Scheduler (or schtasks) run psql or mysql with a script file.

Careful

mysqldump includes triggers by default but leaves out events and stored routines. Dump with --routines --events --triggers, or the nightly purge is silently gone after a restore.

PowerShell
# Stored programs are not dumped unless you ask for them
mysqldump --login-path=backup --single-transaction --routines --events --triggers shop --result-file=C:\backups\shop.sql

The DBA angle

Triggers and events are the parts of a schema most easily forgotten, because they work without anyone touching them.

  • They run as their definer. Check DEFINER in information_schema.TRIGGERS and information_schema.EVENTS before you touch a leaver's login (see Users, roles and security).
  • They fail quietly. A failed MySQL event writes to the server error log, hostname.err in the data directory, and nowhere else. Nothing pages you.
  • Replicas keep a disabled copy. Events arrive with status REPLICA_SIDE_DISABLED, so a promoted replica needs ALTER EVENT … ENABLE. Put it on the failover checklist.

For you as a DBA

In your first month, list every trigger and event on every production schema, what each is for, and whose privileges it runs with.

Ask the AI

Name the engine, version, table, timing and outcome: "MySQL 8.4: write an AFTER UPDATE trigger on shop.orders that logs old and new status into orders_audit when status changes."

Quick check

1. Which of these can a MySQL 8.4 trigger NOT do?

A trigger runs inside the statement's transaction and MySQL forbids START TRANSACTION, COMMIT and ROLLBACK in a trigger body. It can read NEW and OLD, raise an error, and write to other tables. See "Trigger hazards".

2. You need one piece of code to run once per UPDATE statement and see every row that statement changed. Which fits?

Only PostgreSQL has statement-level triggers, and REFERENCING NEW TABLE gives that trigger the whole set of changed rows. MySQL triggers are row-level only. See "What each engine can do".

3. A developer says orders.total "changed by itself" after they inserted one order_items row. What do you check first?

A derived column that updates when a related row changes is the signature of a trigger. The catalog lists them in seconds; the other logs would only show the effect, never the cause. See "Finding the triggers you inherited".

4. The nightly MySQL purge event stopped working two weeks ago and nobody was alerted. Where do you look?

MySQL events fail quietly: the scheduler writes errors to the server error log, which is hostname.err in the data directory by default, and LAST_EXECUTED in information_schema.EVENTS shows the last run. cron.job_run_details belongs to pg_cron, and the general log is off by default. See "The DBA angle".

5. You restore a mysqldump file taken with default options onto a new server. The tables and triggers are there, but the purge event is missing. Why?

Triggers are dumped by default; events and routines are only included with --events and --routines. Or use the MySQL Shell dump utilities, which include all three by default. Reread the "Careful" callout under "Scheduled jobs".