The short version
- A trigger is code the engine runs for you. It fires before or after an
INSERT,UPDATEorDELETEon one table. NEWandOLDare 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_audittable. - Derived column. Keep
orders.totalequal to the sum of itsorder_items. - Validation beyond
CHECK. Compare old and new values, then refuse withSIGNALin MySQL orRAISEin 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.
-- 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
| id | customer_id | status | total |
|---|---|---|---|
| 11 | 1 | paid | 180.00 |
| 12 | 2 | pending | 40.00 |
| 13 | NULL | paid | 20.00 |
| id | customer_id | status | total |
|---|---|---|---|
| 11 | 1 | paid | 180.00 |
| 12 | 2 | pending | 85.00 |
| 13 | NULL | paid | 20.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.
| Capability | MySQL 8.4 / 9.7 / 26.x | PostgreSQL 17 / 18 |
|---|---|---|
Row-level triggers (FOR EACH ROW) | Yes (the only kind) | Yes |
| Statement-level triggers (once per statement) | No | Yes (the default when FOR EACH ROW is left out) |
INSTEAD OF triggers on views | No (no triggers on views at all) | Yes |
Triggers on TRUNCATE | No (TRUNCATE fires no DELETE triggers either) | Yes (statement-level only) |
| Several triggers on one event, with ordering | Yes: creation order, or FOLLOWS / PRECEDES | Yes, fired in alphabetical order by name |
| Transition tables (the whole set of changed rows) | No | Yes (REFERENCING OLD TABLE / NEW TABLE, AFTER triggers) |
NEW and OLD row access | Yes (NEW.col, OLD.col in the body) | Yes (NEW.col, OLD.col inside the trigger function) |
| Body in a separate, reusable trigger function | No (the body is written inside CREATE TRIGGER) | Yes (required: EXECUTE FUNCTION) |
WHEN condition on the trigger itself | No (use IF in the body) | Yes |
| Deferrable constraint triggers (checked at COMMIT) | No | Yes |
| Event triggers on DDL (CREATE, ALTER, DROP) | No | Yes |
Fired by foreign-key cascades (ON DELETE CASCADE, SET NULL) | Partial off by default; 9.7 added enable_cascade_triggers | Yes |
| Disable a trigger without dropping it | No (drop it, or guard the body with a session variable) | Yes (ALTER TABLE … DISABLE TRIGGER) |
COMMIT or ROLLBACK inside a trigger | No | No |
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,COMMITandROLLBACKin 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.
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.
\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.
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.
# 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
DEFINERininformation_schema.TRIGGERSandinformation_schema.EVENTSbefore 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.errin 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 needsALTER 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."
- MySQL 8.4: CREATE TRIGGER
Syntax, ordering, DEFINER rules.
- PostgreSQL: CREATE TRIGGER
Statement level, INSTEAD OF, transition tables.
- MySQL 8.4: Using the Event Scheduler
Configuration, syntax, metadata, privileges.
- pg_cron on GitHub
Install notes and cron.schedule() examples.
- Microsoft: schtasks
The command line face of Task Scheduler.