Lesson 42 • Advanced Track
Triggers vs Application Logic
By the end of this lesson you'll be able to write database triggers with CREATE TRIGGER, use NEW and OLD to capture row changes, choose the right BEFORE/AFTER/INSTEAD OF timing — and, just as importantly, recognise the architectural traps that mean some logic belongs in your application instead. Triggers are powerful and invisible, which makes knowing when not to use them a senior-level skill.
What You'll Learn
- ✓Write a trigger function and bind it with CREATE TRIGGER
- ✓Use NEW and OLD to read the before/after row
- ✓Pick the right timing: BEFORE, AFTER, INSTEAD OF
- ✓Build an unbypassable audit-log trigger
- ✓Keep a derived/denormalised column in sync
- ✓Decide when logic belongs in the app, not the database
plpgsql). Copy them into a free playground like db-fiddle.com (pick PostgreSQL) or sqliteonline.com. Each example states the expected behaviour so you can check yourself.Real-World Analogy
A trigger is like a security camera wired into a doorway. You don't press a button — the moment anyone passes through (a row changes), it records automatically and nobody can sneak past it. That's perfect for an audit trail. But you wouldn't wire that camera to also phone the police, email the owner, and reboil the kettle — pile too much onto an automatic reflex and a slow phone line freezes the whole doorway. Cameras (triggers) are for guaranteed, instant, in-house reactions; phoning the outside world (emails, APIs, workflows) belongs to a person on the other side of the door: your application.
1. What a Trigger Is (and How It's Built)
A trigger is a block of SQL the database runs automatically whenever a row is inserted, updated, or deleted on a table. You never call it yourself — that's the whole point. In PostgreSQL a trigger comes in two pieces: a trigger function that holds the logic, and the CREATE TRIGGER statement that binds that function to a table and an event.
Inside the function you get two special row variables: NEW (the row as it will be saved) and OLD (the row as it was before the change). An INSERT has only NEW; a DELETE has only OLD; an UPDATE has both.
SET NEW.col = .... SQL Server exposes changes as inserted/deleted pseudo-tables instead of NEW/OLD. The concepts transfer; the syntax differs per engine.Anatomy: function + trigger (Postgres)
A BEFORE UPDATE trigger that stamps updated_at automatically.
-- A trigger is a stored block of SQL the database runs
-- AUTOMATICALLY whenever a row is INSERTed, UPDATEd, or DELETEd.
-- You never call it — the database fires it for you.
-- In Postgres, a trigger has TWO parts:
-- 1) a FUNCTION that holds the logic
-- 2) a TRIGGER that binds that function to a table + event
CREATE OR REPLACE FUNCTION touch_updated_at()
RETURNS TRIGGER AS $$ -- $$ marks the function body
BEGIN
NEW.updated_at = NOW(); -- NEW = the row as it will be
...2. Timing & Events — BEFORE, AFTER, INSTEAD OF
The timing decides when the trigger runs relative to the change. BEFORE runs first and can still modify NEW before it is written — ideal for normalising or stamping values. AFTER runs once the row is already saved, so NEW/OLD are read-only — ideal for logging or syncing other tables. INSTEAD OF replaces the action entirely and is used on views to make them writable.
Quick rule: if you need to change the row being saved, use BEFORE. If you just need to react to a change that already happened, use AFTER.
MySQL syntax + the timing cheat-sheet
Same updated_at trigger in MySQL, plus when NEW/OLD exist.
-- The same idea on MySQL/MariaDB uses inline trigger bodies
-- (no separate function). Same concepts, different syntax:
CREATE TRIGGER trg_products_set_updated_at
BEFORE UPDATE ON products
FOR EACH ROW
SET NEW.updated_at = NOW(); -- MySQL: logic lives in the trigger itself
-- TIMING + EVENT decide WHEN a trigger runs:
-- BEFORE INSERT/UPDATE → can read AND change NEW before it is saved
-- AFTER INSERT/UPDATE → row is already saved; NEW/OLD are read-only
-- AFTER DELETE
...3. A Great Use: The Unbypassable Audit Log
This is the strongest argument for triggers. Because the database fires them itself, an audit trigger records every change — including ones from a buggy code path, a forgotten admin script, or a query typed by hand at 2am. Application-level logging can be skipped; a trigger cannot. Here TG_OP tells the function which event fired and TG_TABLE_NAME which table, so one function audits many tables.
AFTER INSERT/UPDATE/DELETE audit trigger
One reusable function that logs every change to audit_log.
-- ✅ GREAT use of a trigger: an audit log you CANNOT bypass.
-- Even a buggy app or a hand-typed UPDATE gets recorded.
CREATE TABLE audit_log (
id SERIAL PRIMARY KEY,
table_name TEXT NOT NULL,
operation TEXT NOT NULL, -- INSERT / UPDATE / DELETE
old_data JSONB, -- the row before (NULL for INSERT)
new_data JSONB, -- the row after (NULL for DELETE)
changed_by TEXT DEFAULT current_user,
changed_at TIMESTAMP
...After running UPDATE products SET price = 27.99 WHERE id = 1; then DELETE FROM products WHERE id = 4;, the audit_log table fills itself in — no app code involved:
Result — audit_log after the two statements:
| id | table_name | operation | old_data | new_data | changed_by |
|---|---|---|---|---|---|
| 1 | products | UPDATE | {...price: 24.99} | {...price: 27.99} | app_user |
| 2 | products | DELETE | {...id: 4, name: 'Notebook'} | (null) | app_user |
4. A Great Use: Keeping a Derived Column in Sync
Sometimes you store a denormalised value — a number deliberately duplicated so reads stay fast, like a product_count on each category. A trigger can keep that cached value correct on every insert, update, and delete, so it never drifts from reality.
line_total = quantity * unit_price), use a GENERATED column, not a trigger. Reach for a trigger only when the value depends on other rows or tables, as product_count does.AFTER trigger that maintains a cached count
Increment/decrement categories.product_count automatically.
-- ✅ GREAT use of a trigger: keep a DENORMALISED count in sync.
-- categories.product_count is a cached number we maintain on writes,
-- so reads never have to COUNT the products table.
CREATE OR REPLACE FUNCTION sync_product_count()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE categories SET product_count = product_count + 1
WHERE id = NEW.category_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE categories SET product_count = product_count - 1
WHER
...Your Turn: complete the audit trigger
Finish this AFTER UPDATE trigger that logs price changes using OLD and NEW. Fill the three blanks; the expected answers are in the comments so you can check yourself.
🎯 Your Turn: log OLD & NEW price
Replace the ___ blanks with OLD.price, NEW.price, and the timing keyword.
-- 🎯 YOUR TURN — finish this AFTER UPDATE audit trigger.
-- Goal: when a product's price changes, log the old and new price.
CREATE OR REPLACE FUNCTION log_price_change()
RETURNS TRIGGER AS $$
BEGIN
-- only log when the price actually changed
IF NEW.price IS DISTINCT FROM OLD.price THEN
INSERT INTO price_audit (product_id, old_price, new_price)
VALUES (NEW.id, ___, ___); -- 👉 the OLD price, then the NEW price
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CRE
...5. When to Push Logic to the Application Instead
Triggers run inside the transaction that caused the change. That guarantee is exactly why audit logs work — and exactly why side-effects are dangerous. If a trigger emails a customer or calls a payment API and that external service is slow or down, your simple INSERT hangs or rolls back. The right move is to commit the database change, then perform the side-effect in your app, often on a background queue.
✅ Use a Trigger / DB feature for
- • Audit logs that must never be skipped
- •
updated_atand other stamps - • Denormalised counts / cached aggregates
- • Complex invariants
CHECKcan't express - • Same-row math → a
GENERATEDcolumn
❌ Use Application Code for
- • Sending emails / push notifications
- • Calling external / payment APIs
- • Multi-step business workflows
- • Anything slow, async, or non-deterministic
- • Logic that needs unit tests & code review
Generated columns & views instead of triggers
Where a trigger is the wrong tool — and what to use instead.
-- ❌ When NOT to use a trigger: hidden side-effects.
-- Triggers run INSIDE your transaction. Anything slow or external
-- (email, HTTP call, payment API) makes the whole write hang or
-- roll back in surprising ways.
-- ❌ BAD: emailing from a trigger
-- CREATE TRIGGER send_welcome_email AFTER INSERT ON users ...
-- If the mail server is down, the INSERT itself fails!
-- ✅ GOOD: do the write, then handle the side-effect in the app
-- BEGIN;
-- INSERT INTO users (email) VALUES ('sam@x.c
...Your Turn: trigger or app logic?
For each scenario, decide whether the logic belongs in a trigger or in application code. Replace each ___ with one word. The answers are in the comments.
🎯 Your Turn: TRIGGER or APP?
Classify four scenarios as TRIGGER or APP.
-- 🎯 YOUR TURN — trigger or application logic? Decide each scenario.
-- Replace each ___ with exactly one word: TRIGGER or APP.
-- 1) Stamp updated_at on every row UPDATE ............ ___ -- 👉 simple, must always be consistent
-- 2) Send a "your order shipped" email .............. ___ -- 👉 slow + external service
-- 3) Keep an unbypassable audit_log of all deletes .. ___ -- 👉 must fire even on hand-typed SQL
-- 4) Charge a customer's card via a payment API ..... ___ -- 👉 external ca
...6. The Architectural Pitfalls
Triggers earn their bad reputation from four failure modes. None are reasons to avoid triggers entirely — they're reasons to use them deliberately: guard against recursion, watch row-by-row performance, name triggers so their execution order is intentional (Postgres fires same-event triggers in alphabetical name order), and document them so they aren't invisible to the next developer.
Recursion, performance, ordering, hidden logic
The four classic trigger traps and how to defuse each one.
-- The four pitfalls that make people fear triggers.
-- PITFALL 1 — Cascading / recursive triggers.
-- A trigger on orders that UPDATEs orders fires itself again.
-- Fix: guard on what actually changed, IS DISTINCT FROM is your friend.
CREATE OR REPLACE FUNCTION on_status_change()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.status IS DISTINCT FROM OLD.status THEN -- only when it really changed
INSERT INTO order_history (order_id, status) VALUES (NEW.order_id, NEW.status);
END IF;
R
...Common Errors (and the fix)
- Hidden business logic in a trigger: a teammate spends hours asking "why did this row change?" because the cause is an invisible trigger. Keep business rules in tested app code; reserve triggers for stamps, audits, and invariants — and document every one.
- "stack depth limit exceeded" / infinite loop: a trigger on a table that updates the same table fires itself again. Guard with
IF NEW.col IS DISTINCT FROM OLD.col THEN ...so it only acts when something relevant actually changed. - Writes suddenly slow: a heavy
FOR EACH ROWtrigger runs once per row, so a bulk update multiplies the cost. Move expensive work to aFOR EACH STATEMENTtrigger, or out to the app. - Relying on trigger execution order: two triggers on the same event and you assume one runs first. Postgres orders them alphabetically by name — rename them (
trg_10_validate,trg_20_audit) so the order is explicit, not accidental. - "control reached end of trigger function without RETURN": a
BEFORErow trigger mustRETURN NEW(orRETURN OLDfor delete). ReturningNULLfrom aBEFOREtrigger silently cancels the operation.
📘 Quick Reference
| Syntax / concept | Meaning |
|---|---|
| CREATE FUNCTION ... RETURNS TRIGGER | Holds the trigger logic (Postgres) |
| CREATE TRIGGER t BEFORE INSERT ON tbl | Bind logic to a table + event |
| FOR EACH ROW | Runs once per affected row |
| FOR EACH STATEMENT | Runs once per statement (bulk-safe) |
| NEW / OLD | Row after / before the change |
| TG_OP, TG_TABLE_NAME | Which event / which table fired it |
| BEFORE | Can modify NEW before save |
| AFTER | Row already saved; react / log |
| INSTEAD OF | Replaces the action (on VIEWs) |
| IS DISTINCT FROM | Null-safe change check (anti-recursion) |
Trigger vs App — Decision Table
| Scenario | Use | Why |
|---|---|---|
| Audit logging | Trigger | Can't be bypassed by app bugs |
updated_at stamp | Trigger (BEFORE) | Simple, always consistent |
| Cached count / aggregate | Trigger (AFTER) | Depends on other rows |
| Same-row calculation | Generated column | Simpler & safer than a trigger |
| Email / notification | App code | Async, external dependency |
| Pricing / workflow logic | App code | Complex, needs tests & review |
| Referential integrity | Constraints (FK) | Built-in, fastest, declarative |
Frequently Asked Questions
Q: What's the difference between BEFORE and AFTER?
BEFORE runs before the row is written and can still change NEW (e.g. stamp a timestamp). AFTER runs once the row is committed to the table, so NEW/OLD are read-only — use it to log or to update other tables.
Q: Do NEW and OLD always both exist?
No. INSERT has only NEW, DELETE has only OLD, and UPDATE has both. Touching OLD in an insert trigger (or NEW in a delete trigger) is an error.
Q: How do I stop a trigger calling itself forever?
Guard the work with IF NEW.col IS DISTINCT FROM OLD.col THEN ... so it only acts when the column you care about actually changed, and avoid updating the same table the trigger is attached to.
Q: If triggers are unbypassable, why not put all logic in them?
Because they're invisible to app developers, hard to unit-test, run inside the transaction (so anything slow or external blocks writes), and their relative order can surprise you. Use them for guarantees the database must enforce; keep workflows and side-effects in well-tested app code.
Mini-Challenge: Soft-Delete Audit
Put it all together — a brief, a blank canvas, and the expected behaviour in the comments. Write the function and the trigger, then run it on PostgreSQL to confirm.
🎯 Mini-Challenge
An AFTER DELETE trigger that records removed products using OLD.
-- 🎯 MINI-CHALLENGE: a soft-delete audit trigger
-- Using ONLY what this lesson covered (CREATE FUNCTION, OLD/NEW, TG_OP,
-- a BEFORE/AFTER trigger, and a guard condition):
--
-- 1. Write a function deleted_items_log() that, AFTER a DELETE on
-- products, inserts the removed row's id and name into a
-- deleted_items table (columns: product_id, product_name).
-- 2. Use OLD (there is no NEW on a DELETE).
-- 3. Attach it with an AFTER DELETE ... FOR EACH ROW trigger.
--
-- ✅ Expect
...🎉 Lesson Complete
- ✅ A trigger = a function bound to a table event, fired automatically by the database
- ✅
NEW/OLDexpose the after/before row;BEFOREcan change it,AFTERreacts to it - ✅ Great uses: unbypassable audit logs, stamps, derived/denormalised columns, complex invariants
- ✅ Push side-effects (email, APIs, workflows) and anything slow into the application instead
- ✅ Defuse the traps: recursion guards, statement-level triggers, intentional naming/order, documentation
- ✅ Next: Schema Versioning — manage and ship database changes safely
Sign up for free to track which lessons you've completed and get learning reminders.