Chapter 11 · Views, Stored Programs, Triggers, Events, and SQL/PSM
Triggers, OLD/NEW Rows, Ordering, Auditing, and Hidden Coupling
Make MariaDB trigger behavior visible: reason about OLD/NEW rows, timing, trigger order, transactional effects and audit patterns without hiding surprising coupling inside data changes.
Learning outcomes
ServiceHub has three writers: the web API, a bulk-import tool
and an operations console. All of them must record status
changes. A trigger is server-side code that
MariaDB executes automatically for each affected row when a
configured table event occurs. That centrality is useful for
invariants and audit capture, but it creates hidden coupling: an
application can issue one UPDATE while triggers
perform additional writes, acquire more locks, raise errors or
consume substantial time.
Use BEFORE/AFTER INSERT, UPDATE and DELETE triggers with the correct OLD/NEW row images.
Inspect and control ordering when multiple triggers share the same timing/event.
Build an InnoDB audit trigger and prove its transactional rollback behavior.
Diagnose hidden trigger failures that surprise bulk loads, migrations and application tests.
Identify replication, engine and security boundaries that make trigger behavior topology-sensitive.
MariaDB triggers are row-level: the server fires them FOR EACH ROW, not once per statement. Current MariaDB supports multiple triggers for the same timing/event and exposes their order through INFORMATION_SCHEMA.TRIGGERS.ACTION_ORDER. DEFINER remains a security boundary; creating a trigger requires TRIGGER privilege on the associated table, and setting another definer is privilege-sensitive.
1. OLD and NEW are row images, not magic variables
| DML event | OLD available | NEW available | Can assign NEW in BEFORE trigger? |
|---|---|---|---|
| INSERT | No | Yes | Yes, for writable columns |
| UPDATE | Yes | Yes | Yes, for writable columns |
| DELETE | Yes | No | Not applicable |
OLD.column represents the row before the triggering
operation. NEW.column represents the candidate row
after the operation. A BEFORE trigger can validate or normalize
the candidate row before storage. An AFTER trigger sees the
stored result and is a common place to append an audit record.
Keep business rules in explicit constraints when a constraint
can express them; use triggers when the behavior truly needs
row-event context.
DROP DATABASE IF EXISTS servicehub_programmability_lab;CREATE DATABASE servicehub_programmability_lab;USE servicehub_programmability_lab;CREATE TABLE work_orders ( work_order_id BIGINT PRIMARY KEY, status ENUM('open','assigned','closed','cancelled') NOT NULL, priority TINYINT NOT NULL, assigned_team VARCHAR(40) NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB;CREATE TABLE work_order_audit ( audit_id BIGINT AUTO_INCREMENT PRIMARY KEY, work_order_id BIGINT NOT NULL, old_status VARCHAR(20) NULL, new_status VARCHAR(20) NULL, changed_by VARCHAR(128) NOT NULL, changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, source_tag VARCHAR(40) NOT NULL) ENGINE=InnoDB;INSERT INTO work_orders VALUES(3001,'open',5,'alpha','2026-08-20 09:00:00'),(3002,'assigned',3,'beta','2026-08-20 09:05:00');
2. Use BEFORE for validation/normalization and AFTER for durable audit capture
The first trigger normalizes updated_at and rejects
a deliberately forbidden transition from
cancelled back to assigned. The second
records status changes only when the value actually changes. The
application sets an optional session tag so the audit row can
distinguish API, migration and lab traffic.
DELIMITER $$CREATE OR REPLACE TRIGGER tr_wo_before_updateBEFORE UPDATE ON work_ordersFOR EACH ROWBEGIN SET NEW.updated_at=CURRENT_TIMESTAMP; IF OLD.status='cancelled' AND NEW.status='assigned' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='cancelled work order cannot be reassigned'; END IF;END$$CREATE OR REPLACE TRIGGER tr_wo_after_update_auditAFTER UPDATE ON work_ordersFOR EACH ROWBEGIN IF NOT (OLD.status <=> NEW.status) THEN INSERT INTO work_order_audit( work_order_id,old_status,new_status,changed_by,source_tag ) VALUES ( NEW.work_order_id,OLD.status,NEW.status,CURRENT_USER(), COALESCE(@servicehub_source,'unspecified') ); END IF;END$$DELIMITER ;SET @servicehub_source='chapter11-lab';UPDATE work_orders SET status='assigned' WHERE work_order_id=3001;SELECT * FROM work_orders WHERE work_order_id=3001;SELECT work_order_id,old_status,new_status,changed_by,source_tagFROM work_order_audit WHERE work_order_id=3001;SHOW CREATE TRIGGER tr_wo_after_update_audit\G
The expected result is one base-row change and one audit row
from open to assigned.
CURRENT_USER() reflects the security identity used
by the stored object, not necessarily an end-user identity from
the application. If the business needs a real human/application
principal, carry that identity explicitly and validate how it is
set rather than confusing database account identity with
business actor identity.
3. Multiple triggers make execution order part of the interface
Current MariaDB allows multiple triggers for the same table,
event and timing. FOLLOWS and
PRECEDES let you position a new trigger relative to
an existing one. The server records an
ACTION_ORDER value; inspect it instead of trusting
creation history from memory.
CREATE TABLE trigger_probe ( probe_id BIGINT AUTO_INCREMENT PRIMARY KEY, work_order_id BIGINT NOT NULL, note VARCHAR(100) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB;DELIMITER $$CREATE OR REPLACE TRIGGER tr_wo_after_update_probeAFTER UPDATE ON work_ordersFOR EACH ROWFOLLOWS tr_wo_after_update_auditBEGIN INSERT INTO trigger_probe(work_order_id,note) VALUES(NEW.work_order_id,CONCAT('after audit: ',OLD.status,' -> ',NEW.status));END$$DELIMITER ;SELECT TRIGGER_NAME,ACTION_TIMING,EVENT_MANIPULATION,ACTION_ORDER,DEFINERFROM information_schema.TRIGGERSWHERE TRIGGER_SCHEMA='servicehub_programmability_lab' AND EVENT_OBJECT_TABLE='work_orders'ORDER BY ACTION_TIMING,EVENT_MANIPULATION,ACTION_ORDER;
The metadata should place the probe after the audit trigger for
the same AFTER UPDATE event. Ordering is now part of deployment
state: capture it in SHOW CREATE TRIGGER/metadata
and test it after restore or migration. MariaDB documentation
states that trigger order is preserved by dump/restore tooling,
but your deployment validation should still verify the target
rather than assume it.
4. Deliberately wrong: hide a required application context inside the trigger
A common audit design requires an application to set a session
variable such as @app_actor before every write. If
the trigger blindly inserts that variable into a NOT NULL
column, bulk tools and migration scripts that do not know the
convention can fail. This is a hidden contract.
DROP TRIGGER tr_wo_after_update_audit;DELIMITER $$CREATE TRIGGER tr_wo_after_update_auditAFTER UPDATE ON work_ordersFOR EACH ROWBEGIN IF NOT (OLD.status <=> NEW.status) THEN INSERT INTO work_order_audit( work_order_id,old_status,new_status,changed_by,source_tag ) VALUES ( NEW.work_order_id,OLD.status,NEW.status,@app_actor,'fragile-trigger' ); END IF;END$$DELIMITER ;SET @app_actor=NULL;UPDATE work_orders SET status='closed' WHERE work_order_id=3002;SELECT work_order_id,status FROM work_orders WHERE work_order_id=3002;SELECT * FROM work_order_audit WHERE work_order_id=3002;
The update should fail because changed_by is NOT
NULL. With both tables using InnoDB, the trigger error makes the
statement fail and the base-row update is not committed. That
rollback is valuable, but the operational surprise is not: the
client thought it was updating one row and did not know about a
mandatory session variable.
Repair the contract by either making the actor explicit in the write path (for example through a reviewed procedure), or defining a safe fallback that is semantically acceptable. Do not invent a fake human actor merely to satisfy NOT NULL.
DROP TRIGGER tr_wo_after_update_audit;DELIMITER $$CREATE TRIGGER tr_wo_after_update_auditAFTER UPDATE ON work_ordersFOR EACH ROWBEGIN IF NOT (OLD.status <=> NEW.status) THEN INSERT INTO work_order_audit( work_order_id,old_status,new_status,changed_by,source_tag ) VALUES ( NEW.work_order_id,OLD.status,NEW.status, COALESCE(@app_actor,CURRENT_USER()), COALESCE(@servicehub_source,'unspecified') ); END IF;END$$DELIMITER ;SET @app_actor=NULL;SET @servicehub_source='migration-test';UPDATE work_orders SET status='closed' WHERE work_order_id=3002;SELECT work_order_id,status FROM work_orders WHERE work_order_id=3002;SELECT work_order_id,old_status,new_status,changed_by,source_tagFROM work_order_audit WHERE work_order_id=3002;
This repair removes the hard failure for clients without the session variable, but it changes audit meaning. Production teams must decide whether a database account fallback is acceptable. If not, route status changes through an explicit procedure that requires an actor parameter and make direct table DML unavailable to the application.
5. Hidden coupling appears in performance, migrations, recursion and topology
| Risk | Mechanism | Operational response |
|---|---|---|
| Bulk load surprise | Trigger fires once for every affected row. | Benchmark with triggers enabled; include audit-table write volume and locks. |
| Trigger chain | A trigger writes another table that has its own triggers. | Map the dependency graph and test the complete transaction, not one table. |
| Same-table mutation | Trigger tries to modify a table already being changed by the invoking statement. | Avoid recursive self-maintenance; redesign with BEFORE NEW assignment or a separate workflow. |
| Mixed storage engines | A trigger writes to a non-transactional engine. | Do not assume rollback gives cross-engine atomicity. |
| Replication | Row-based replication may not re-run source-side triggers on a replica by default; MariaDB has topology-sensitive trigger-on-replica behavior. | Verify binlog format, replica settings and target version explicitly. |
| Galera / multi-primary | Every write path can encounter trigger work and conflicts. | Test certification/lock impact and never treat triggers as a single-node-only concern. |
An audit trigger is also not a tamper-proof compliance ledger. Privileged users who can alter triggers or audit tables can change the mechanism. For stronger audit requirements, combine least privilege, immutable/off-server evidence, database audit facilities where appropriate, and monitored deployment controls.
6. Production judgment, verification, and cleanup
Use triggers for small, data-local invariants or audit capture that truly must follow every SQL writer. Prefer explicit procedures or application workflows when logic needs rich business context, external calls, complex retries or independent deployment cadence. Triggers should not call external services; the database transaction must remain deterministic and observable.
-
Run
SHOW TRIGGERSand queryINFORMATION_SCHEMA.TRIGGERSincludingACTION_ORDER. - Test INSERT/UPDATE/DELETE cases relevant to each trigger's OLD/NEW usage.
- Failure-inject a trigger error and verify expected rollback on InnoDB.
- Measure bulk operations with triggers enabled; do not extrapolate from one-row latency.
- Document trigger dependencies in migrations and restore tests.
SHOW TRIGGERS FROM servicehub_programmability_lab;SELECT TRIGGER_NAME,EVENT_MANIPULATION,ACTION_TIMING,ACTION_ORDER,DEFINERFROM information_schema.TRIGGERSWHERE TRIGGER_SCHEMA='servicehub_programmability_lab'ORDER BY EVENT_OBJECT_TABLE,EVENT_MANIPULATION,ACTION_TIMING,ACTION_ORDER;DROP DATABASE IF EXISTS servicehub_programmability_lab;
Check your understanding
- Why can a one-row UPDATE cause more writes than the application SQL shows?
- Which row images exist for DELETE?
- How do you verify multiple-trigger order in MariaDB?
- Why did the fragile audit trigger roll back the base update in the InnoDB lab?
- Why is a trigger audit table not automatically a tamper-proof audit system?
Review the answers
Triggers execute automatically and can write additional tables. DELETE exposes OLD but not NEW. ACTION_ORDER in INFORMATION_SCHEMA.TRIGGERS, plus SHOW CREATE TRIGGER, verifies ordering. The trigger raised an error inside the same transactional InnoDB statement, so the base write failed. A privileged actor can change stored objects or audit tables, so stronger audit controls require separate privilege and evidence design.
Lesson 4 keeps automatic server-side execution but moves from “when a row changes” to “when time reaches a schedule,” where ownership, overlap, time zones and failure visibility become the main risks.
Authoritative references
- MariaDB Documentation — CREATE TRIGGER
- MariaDB Documentation — Trigger Overview
- MariaDB Documentation — Trigger Limitations
- MariaDB Documentation — Information Schema TRIGGERS Table
- MariaDB Documentation — SHOW CREATE TRIGGER
- MariaDB Documentation — Running Triggers on the Replica for Row-based Events