Triggers and Stored Procedures
Consider a banking system where every withdrawal must check for sufficient funds. It must also log the transaction, update an audit table, and notify a fraud-detection service. Writing this logic in application code creates a problem. Every client program — the teller application, the mobile app, the batch settlement job — must repeat the same checks correctly, forever. One missed check in one client is a silent bug waiting to corrupt the database. Procedural SQL solves this by moving logic into the database itself. Stored procedures package reusable, parameterized routines that live alongside the data. Triggers go further: they attach logic directly to data-modification events. The logic fires automatically no matter which client causes the change. Together they let a database enforce invariants — constraints too complex for a `CHECK` clause, referential rules across tables, audit trails, materialized-view maintenance — as close to the data as possible. This matters in real systems for several reasons. Centralizing business rules inside the database eliminates an entire class of bugs. These bugs are caused by inconsistent client-side logic. This approach also reduces network round trips for multi-step operations. It gives the database engine's query optimizer and transaction manager full visibility into what a "single operation" actually does. The cost is a loss of modularity: logic buried in triggers can become invisible. It can be hard to test, and prone to cascading side effects. These effects surprise even experienced engineers. This article builds the concept from the ground up. It defines the event-condition-action (ECA) model that underlies every trigger system. It gives a precise, checkable pseudocode semantics for trigger execution. This includes the tricky question of when a trigger fires relative to the triggering statement. It walks through stored-procedure control flow, and then confronts the real dangers: infinite trigger cascades, order-of-execution ambiguity, and the performance cost of implicit logic. It builds directly on Views in SQL, since triggers are frequently used to make views updatable. They also enforce constraints that views alone cannot express.
- Stored procedures are named, parameterized, precompiled SQL routines with control-flow (loops, conditionals, variables) callable via `CALL`.
- Triggers implement the event-condition-action (ECA) paradigm: an event (INSERT/UPDATE/DELETE) fires the trigger, a condition gates it, and an action executes procedurally.
- Triggers can fire `BEFORE`, `AFTER`, or `INSTEAD OF` the triggering statement, and can be row-level (once per affected row) or statement-level (once per statement).
- `NEW` and `OLD` transition variables expose the row's values before/after the change inside a row-level trigger body.
- Triggers can cascade: a trigger's action can itself fire other triggers, so systems bound recursion depth to prevent infinite loops.
- Overused, triggers create "action at a distance" — silent side effects that make systems hard to debug, test, and optimize.
Triggers bind procedural logic to data-change events so that invariants are enforced automatically, uniformly, and invisibly to every client that touches the data.
The idea
A stored procedure is a named block of procedural SQL — variables, loops, conditionals, exception handling — stored inside the database and invoked explicitly by name, analogous to a function in a general-purpose language but executing inside the database engine's process, with direct access to tables without a network round trip per statement. Stored procedures are called; they never run unless invoked. A trigger is fundamentally different: it is never called directly. It is declared against a table (or view) and an event type — `INSERT`, `UPDATE`, `DELETE` (and in some systems, `TRUNCATE` or DDL events) — and the database engine invokes it automatically whenever a statement of that type executes against the target. This is the event-condition-action (ECA) model, borrowed from active-database research: the event is the data modification, the condition is an optional `WHEN` clause (or a check inside the trigger body) that decides whether to proceed, and the action is the procedural body that runs. Two independent axes classify a trigger. Timing: `BEFORE` triggers run prior to the modification being applied (useful for validation and defaulting), `AFTER` triggers run once the modification is committed to the table's working state (useful for logging and cascading updates), and `INSTEAD OF` triggers replace the triggering statement entirely — used almost exclusively on views to make otherwise non-updatable views support `INSERT`/`UPDATE`/`DELETE`. Granularity: a row-level trigger fires once per affected row and can inspect `OLD` (the row's prior values) and `NEW` (the row's proposed values); a statement-level trigger fires exactly once per triggering statement, regardless of how many rows it touches, and cannot reference individual `OLD`/`NEW` values (only, in some systems, transition tables covering the whole affected set).
How it works
Stored procedure mechanics. A procedure is compiled once (parsed, and typically translated into an execution plan or an intermediate form) and stored in the system catalog. Each `CALL` binds actual arguments to formal parameters and executes the stored body. Because the plan can be cached, repeated calls avoid re-parsing and re-optimizing, which is one of the main performance arguments for procedures over sending equivalent ad-hoc SQL from the client for every invocation. ``` PROCEDURE Withdraw(IN acct_id INTEGER, IN amount DECIMAL) BEGIN DECLARE bal DECIMAL; SELECT balance INTO bal FROM Accounts WHERE id = acct_id; IF bal IS NULL THEN SIGNAL EXCEPTION 'no such account'; ELSEIF bal < amount THEN SIGNAL EXCEPTION 'insufficient funds'; ELSE UPDATE Accounts SET balance = balance - amount WHERE id = acct_id; INSERT INTO Ledger(acct_id, delta, ts) VALUES (acct_id, -amount, CURRENT_TIMESTAMP); END IF; END ```
Trigger execution semantics. The key subtlety is when exactly row-level trigger bodies run relative to the statement's row-by-row processing, and how `BEFORE`/`AFTER`/`INSTEAD OF` interleave for a single statement that affects many rows. The following pseudocode models the semantics used by mainstream systems (conceptually matching the SQL standard's execution model): ``` EXECUTE STATEMENT S (event E, target table T): if any INSTEAD OF trigger is defined on T for E: for each candidate row r implied by S: run INSTEAD-OF trigger body with OLD = current(r), NEW = proposed(r) return # the real modification of T never happens
# 1. Statement-level BEFORE triggers for each statement-level BEFORE trigger on T for E: run trigger body modified_rows = []
# 2. Row-level processing for each candidate row r implied by S: OLD = current_value(r) # undefined for INSERT NEW = proposed_value(r) # undefined for DELETE for each row-level BEFORE trigger on T for E: if trigger.WHEN(OLD, NEW) is true: run trigger body # may modify NEW (BEFORE only) if trigger signals ABORT: ROLLBACK STATEMENT return apply the change (write NEW into T, honoring constraints) modified_rows.append((OLD, NEW)) for each row-level AFTER trigger on T for E: if trigger.WHEN(OLD, NEW) is true: run trigger body # NEW is now immutable
# 3. Statement-level AFTER triggers for each statement-level AFTER trigger on T for E: run trigger body with transition tables = modified_rows ``` Three facts fall out of this model and are worth internalizing: 1. Only `BEFORE` row-level triggers can rewrite `NEW`. By the time an `AFTER` trigger runs, the row has already been physically written, so `NEW` is read-only. This is why validation/defaulting logic belongs in `BEFORE` triggers, and logging/propagation logic belongs in `AFTER` triggers. 2. `INSTEAD OF` bypasses the real write entirely. This is the only mechanism (short of an updatable-view definition using a single base table) that lets a view with joins or aggregation accept `INSERT`/`UPDATE`/`DELETE`, since the engine has no other way to decide how a change to the view maps onto its base tables. 3. Cascading: an action inside a trigger body (e.g., an `UPDATE` on another table) can itself be an event that fires another trigger. The engine must track a recursion/cascade depth and abort with an error once a system-defined limit is exceeded, or the database would hang on any cyclic trigger graph.
Complexity & correctness
Stored procedures — cost model. Executing a stored procedure with statements and total rows touched costs the same asymptotically as issuing the equivalent statements directly: each individual statement's cost is governed by its own access paths (index lookup , full scan , etc.), and the procedure body just sequences them. The practical win is a constant-factor one: a single client-to-server round trip instead of , and plan caching that amortizes parse/optimize cost over repeated calls — turning a per-call cost of into after the first call.
Triggers — cost model. For a statement that modifies rows, with `BEFORE` row triggers and `AFTER` row triggers each doing work per row (its own query cost), the added cost of triggers on that statement is
on top of the base statement cost, since each qualifying row invokes every applicable row-level trigger once (the pseudocode's inner loop over triggers, inside the loop over rows). Statement-level triggers add a fixed regardless of . If triggers cascade to depth (a trigger's action fires another trigger, which fires another, ...), the cost multiplies across the cascade chain, and — critically — an unbounded or cyclic cascade is not a performance issue but a correctness issue: it can fail to terminate. This is why every production system enforces a maximum trigger-nesting depth (commonly a bounded constant, e.g., 32) and raises an error rather than looping forever.
Correctness argument for the ECA execution model. The claim to verify is that the pseudocode above yields a well-defined, deterministic-within-a-system result for any single triggering statement, given a fixed trigger firing order. The argument proceeds by structural induction on the trigger call tree rooted at the original statement:
- Base case: a statement with no triggers defined on its target executes exactly as an ordinary statement — the tree is a single leaf, and the row-processing loop degenerates to plain constraint checking and writing.
- Inductive step: assume every trigger body invoked so far (at cascade depth ) terminates and leaves the database in a state satisfying all `BEFORE`-checked constraints. A trigger at depth either (a) performs only reads and non-triggering writes, terminating trivially, or (b) performs a write that is itself an event, which by definition invokes strictly the triggers registered on that event — a call one level deeper in the tree, covered by the inductive hypothesis once the recursion terminates.
- Termination: the tree is finite because the system enforces a maximum recursion depth ; any attempted call at depth is rejected with a runtime error rather than executed, guaranteeing the algorithm halts on every input (at the cost of rejecting legitimately deep — but rare — cascades).
- Atomicity/rollback correctness: because the entire call tree executes within the transaction of the originating statement, a `SIGNAL`/abort raised at any depth propagates up and rolls back the entire statement (and, if the client so chooses, the whole transaction) — so the "all rows of the triggering statement observe a consistent OLD/NEW view" invariant holds even when nested triggers modify other tables.
Honest tradeoffs. Triggers buy automatic, un-bypassable enforcement, at the cost of (1) invisibility — a plain `UPDATE` can silently do ten times the work it appears to, defeating a programmer's mental cost model; (2) ordering ambiguity — when multiple triggers of the same type/timing exist on the same table, the standard leaves firing order among them implementation-defined unless the dialect provides explicit ordering (`FOLLOWS`/`PRECEDES` clauses); and (3) debuggability — stack traces through cascades of triggers are notoriously hard to reason about compared to explicit procedure calls in application code.
Worked example
Schema: `Accounts(id, balance)`, `Ledger(acct_id, delta, ts)`, `FraudAlerts(acct_id, note)`. ``` CREATE TRIGGER trg_low_balance AFTER UPDATE ON Accounts REFERENCING OLD ROW AS OLD, NEW ROW AS NEW FOR EACH ROW WHEN (NEW.balance < 0) BEGIN INSERT INTO FraudAlerts(acct_id, note) VALUES (NEW.id, 'overdraft attempt'); UPDATE Accounts SET balance = OLD.balance WHERE id = NEW.id; -- roll back the debit END; CREATE TRIGGER trg_log_change AFTER UPDATE ON Accounts REFERENCING OLD ROW AS OLD, NEW ROW AS NEW FOR EACH ROW BEGIN INSERT INTO Ledger(acct_id, delta, ts) VALUES (NEW.id, NEW.balance - OLD.balance, CURRENT_TIMESTAMP); END; ``` Trace the statement `UPDATE Accounts SET balance = balance - 700 WHERE id = 7;` starting from `Accounts(7, 500)`:
| Step | What happens | State after step |
|---|---|---|
| 1. Row-level BEFORE triggers | none defined here — skipped | `Accounts(7,500)` unchanged |
| 2. Apply write | `NEW.balance = 500 - 700 = -200`; row written | `Accounts(7,-200)` |
| 3. `trg_low_balance` fires (AFTER, WHEN true since `-200 < 0`) | inserts a `FraudAlerts` row; issues a new `UPDATE Accounts SET balance = 500 WHERE id=7` (this is itself an event!) | `FraudAlerts(7,'overdraft attempt')` added |
| 3a. Nested cascade (depth 2) | the corrective `UPDATE` in step 3 re-fires `trg_low_balance` (WHEN now false, since `500 ≥ 0`) and `trg_log_change` (fires unconditionally) | `Accounts(7,500)` restored; `Ledger` gets `(7, +500-(-200)=+700...)` — actually `Ledger` gets `(7, 500 - (-200) = 700, ts)` |
| 4. `trg_log_change` fires for the original statement (depth 1) | inserts `Ledger(7, -200-500=-700, ts)` | `Ledger` now has two rows for account 7 |
| 5. Statement completes | `Accounts(7,500)` (balance restored by the trigger's own corrective update) | committed |
This trace exposes something a beginner would miss: the "corrective" `UPDATE` inside `trg_low_balance` is itself an event, so it re-triggers both triggers at cascade depth 2, and the net effect on `Ledger` is two rows recording an artifact of the correction, not a clean single "rejected" entry. A careful designer would instead make `trg_low_balance` a `BEFORE` trigger that raises an exception and aborts the statement (`SIGNAL 'insufficient funds'`), which is simpler, cheaper (no cascade at all), and leaves no misleading `Ledger` rows: ``` CREATE TRIGGER trg_low_balance_before BEFORE UPDATE ON Accounts REFERENCING NEW ROW AS NEW FOR EACH ROW WHEN (NEW.balance < 0) BEGIN SIGNAL EXCEPTION 'insufficient funds'; -- aborts before any write happens END; ``` The lesson generalizes: validation belongs in `BEFORE` triggers (cheap, no cascade, prevents the bad write from ever landing); propagation and logging belong in `AFTER` triggers (the write is already committed, so no undo dance is needed).
Common pitfalls & traps
| Pitfall | Why it happens | Consequence |
|---|---|---|
| Using `AFTER` triggers to reject invalid data by undoing a write | Feels natural ("check, then fix") | Causes needless cascades, extra writes, misleading audit logs (see worked example) |
| Multiple same-timing triggers on one table with no explicit order | Standard leaves ordering implementation-defined absent an explicit clause | Non-portable, non-deterministic behavior across engines or even across engine versions |
| Unbounded trigger cascades | Trigger A's action updates a table that fires trigger B, which updates a table that fires trigger A | Runtime recursion-depth error, or (if unguarded) infinite loop / stack overflow |
| Forgetting statement-level vs row-level scope | Assuming a statement-level trigger can read `NEW`/`OLD` of a specific row | Compile error or, worse, silently wrong logic if the dialect allows a degenerate read |
| Business logic split between triggers and application code | "Just add a quick trigger" without documenting it centrally | Application developers unaware a trigger exists; debugging becomes archaeology |
| Mutating the same table a row-level trigger is defined on, inside the trigger body, without care | Natural way to "adjust a sibling row" | Some engines disallow it (mutating-table error); others allow it and create subtle read-consistency issues mid-statement |
| Treating `INSTEAD OF` as optional sugar on views | Not realizing it's the only way to make multi-table views writable | Runtime "view not updatable" errors on join/aggregate views without one |
Pioneers & history
The ECA model and active-database triggers grew out of database research in the 1980s aimed at making databases "active" participants in enforcing application semantics, rather than passive stores queried by inert application code. The formative work happened alongside — and in dialogue with — the broader relational and rule-based systems community.
Origins · 1 minHector Garcia-Molina and how this was discovered
Garcia-Molina's research on active databases in the 1980s–1990s (including the influential HiPAC and Starburst-adjacent trigger work he contributed to and supervised) formalized the event-condition-action model that essentially every commercial trigger implementation now follows, and clarified the semantics of trigger execution timing and cascading that this article's pseudocode encodes.
Where this matters
Relational database engines — Oracle (PL/SQL), Microsoft SQL Server (Transact-SQL), PostgreSQL (PL/pgSQL and multiple pluggable procedural languages), MySQL/MariaDB, and IBM Db2 — all implement stored procedures and triggers as core features, and enterprise applications routinely push validation, auditing, and cross-table consistency logic into them. Banking and financial systems use `BEFORE` triggers to enforce balance and compliance invariants that cannot be expressed as simple `CHECK` constraints, since they may depend on other rows or tables. Data-warehousing pipelines use `AFTER` triggers to maintain denormalized summary tables and audit logs incrementally rather than recomputing them. Multi-table or aggregate views — built on the concepts in Views in SQL — become genuinely updatable only through `INSTEAD OF` triggers, which is why every major engine's "updatable view" documentation eventually points back to trigger syntax. Change-data-capture and replication systems historically used triggers (before log-based CDC became standard) to record row-level changes for downstream consumers. Stored procedures remain the backbone of many legacy enterprise systems where a thin client layer calls a small, stable set of named procedures rather than issuing arbitrary SQL, simplifying both security (grant `EXECUTE` on the procedure, not raw table access) and performance (plan caching, fewer round trips) — concerns closely related to how the Query Optimizer caches and reuses execution plans, and to how the Transaction Manager guarantees that trigger cascades commit or abort atomically with their originating statement.
Ask the tutor: How would you redesign the fraud-detection trigger so it never needs to issue a corrective write at all?
What is the difference between a trigger and a stored procedure?
A stored procedure is invoked explicitly, by name, via a `CALL` statement from a client or another routine — it never runs unless something asks it to. A trigger is never called directly; it is bound to a table and an event type (`INSERT`/`UPDATE`/`DELETE`), and the engine invokes it automatically whenever a matching statement executes. In practice, trigger bodies are often implemented using the same procedural language as stored procedures, and a trigger body can even call a stored procedure — but the invocation mechanism is the key distinction: explicit call versus automatic firing on an event.
When should I use a BEFORE trigger instead of an AFTER trigger?
Use `BEFORE` triggers for validation, defaulting, and normalization — anything that should influence or reject the row before it is written, since only `BEFORE` row-level triggers can modify the `NEW` values and abort the write cheaply without needing an undo. Use `AFTER` triggers for logging, auditing, and propagating consequences to other tables, since by that point the row is already committed to the table's working state and you're recording or reacting to a fact, not still deciding whether it should exist. Mixing the two roles up — as shown in the worked example — leads to wasted cascades and misleading audit trails.
Can a trigger fire another trigger, and is that dangerous?
Yes — this is called cascading, and it happens whenever a trigger's action performs a write that is itself a registered event on some table (possibly the same table, possibly another one). It is not inherently dangerous, but it becomes dangerous when the cascade graph has a cycle or grows unbounded with input size, since the engine must then either loop forever or hit an internal recursion-depth limit and error out. Careful schema design traces the full cascade graph before adding a new trigger to a table that already participates in one.
Why can't a view with a JOIN or GROUP BY be updated directly, and how do triggers fix that?
When a view combines multiple base tables or aggregates rows, a single `INSERT`/`UPDATE`/`DELETE` against the view has no unique, unambiguous translation into changes on the base tables — the engine cannot infer which table(s) should receive which values. An `INSTEAD OF` trigger solves this by letting the schema designer explicitly specify that translation: the trigger body receives the proposed `NEW`/`OLD` view row and issues whatever base-table statements correctly implement the intended change, replacing the default (impossible) direct write.
Do triggers slow down every INSERT/UPDATE/DELETE on a table, even if the condition never matches?
Yes, to some degree. Even a trigger whose `WHEN` clause is almost always false still costs at least the overhead of evaluating that condition for every row-level firing, plus the fixed cost of any statement-level trigger regardless of row count. This overhead is usually small relative to the base cost of the write itself (index maintenance, constraint checks, logging), but on very high-throughput tables it is a real, measurable tax, which is why performance-critical systems sometimes move simple checks into `CHECK` constraints and reserve triggers for logic that constraints genuinely cannot express.
What is a "mutating table" error and why does it happen?
Some database engines forbid a row-level trigger from querying (or writing to) the very table whose modification fired the trigger, because the table is mid-modification — the set of "before" and "after" rows is not yet stable, so a query against it inside the trigger could see an inconsistent, partially-updated snapshot. This is called a mutating-table restriction. Engines that allow it instead define precise semantics (e.g., using transition tables that freeze the row set as it looked at statement start) to avoid the ambiguity rather than banning the pattern outright.
Are stored procedures portable across different database systems?
Not directly — each vendor's procedural language (PL/SQL for Oracle, T-SQL for SQL Server, PL/pgSQL for PostgreSQL) has its own syntax for control flow, exception handling, and cursors, despite the SQL/PSM standard's attempt at unification. Basic structures like `IF`/`WHILE`/`DECLARE` are conceptually similar across systems, but exact syntax, built-in functions, and error-handling constructs differ enough that migrating stored procedures between database vendors typically requires a substantial rewrite, unlike standard `SELECT`/`INSERT` SQL, which is far more portable.