SQL Triggers — 45-Minute Cascading Deadlock
- Triggers execute invisibly to the caller — audit logging and data normalisation are their appropriate use cases, not business logic.
- BEFORE triggers validate and transform incoming data; AFTER triggers log and cascade after successful writes.
- Cascading triggers require explicit dependency documentation — map them before every migration.
- A trigger is a stored procedure that fires automatically in response to INSERT, UPDATE, or DELETE on a table
- BEFORE triggers can modify or reject the incoming row; AFTER triggers run after the DML succeeds
- ROW-level triggers fire once per affected row; STATEMENT-level triggers fire once per DML statement
- Triggers are invisible to the caller — they execute silently, making debugging difficult
- Cascading triggers (trigger → triggers another trigger) are the most dangerous pattern in production databases
- Use triggers for audit logging and integrity enforcement; avoid them for business logic that belongs in application code
Production Incident
Production Debug GuideDiagnosing unexpected behaviour caused by triggers
NOW()). Check trigger_debug_log to confirm the trigger is firing.Every production database eventually reaches a moment where you need something to happen automatically when data changes — log who changed a salary record, prevent a negative inventory count, or replicate a row to an audit table without trusting every application developer to remember. Triggers are the database's mechanism for this.
But triggers are one of the most misused features in SQL. They execute invisibly to the calling application, complicate debugging, can cascade into catastrophic chains, and can silently degrade INSERT/UPDATE/DELETE performance when they grow complex. Understanding their mechanics — and their failure modes — is what separates engineers who use them appropriately from those who create unmaintainable databases.
BEFORE vs AFTER Triggers — Choosing the Right Timing
A BEFORE trigger fires before the DML operation executes. You can inspect and modify the incoming row values, or raise an error to reject the operation entirely. BEFORE triggers are the right tool for input validation and data normalisation that must happen at the database layer.
An AFTER trigger fires after the DML operation has succeeded. The row is already in the table when the trigger runs. AFTER triggers are the right tool for audit logging, cascading updates to related tables, and any side effect that should only happen if the primary operation succeeded.
In SQL Server, the equivalent of BEFORE triggers are INSTEAD OF triggers — they replace the DML operation entirely. You must explicitly perform the INSERT, UPDATE, or DELETE inside the trigger body if you want the original operation to proceed. They are most commonly used on views to make views updatable.
-- BEFORE INSERT trigger (PostgreSQL): normalise email case and validate CREATE OR REPLACE FUNCTION normalise_user_before_insert() RETURNS TRIGGER AS $$ BEGIN -- Normalise email to lowercase at DB layer regardless of application input NEW.email = LOWER(TRIM(NEW.email)); -- Reject obviously invalid emails at DB layer as a last defence IF NEW.email NOT LIKE '%@%' THEN RAISE EXCEPTION 'Invalid email format: %', NEW.email; END IF; RETURN NEW; -- RETURN NEW is required in BEFORE triggers to allow the INSERT END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_normalise_user_before_insert BEFORE INSERT ON users FOR EACH ROW EXECUTE FUNCTION normalise_user_before_insert(); -- AFTER INSERT trigger (PostgreSQL): audit log the creation CREATE OR REPLACE FUNCTION log_user_creation() RETURNS TRIGGER AS $$ BEGIN INSERT INTO audit_log (table_name, action, record_id, changed_at, new_values) VALUES ('users', 'INSERT', NEW.user_id, NOW(), jsonb_build_object('email', NEW.email, 'created_at', NEW.created_at)); RETURN NULL; -- AFTER triggers: return value is ignored; use NULL END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_log_user_creation AFTER INSERT ON users FOR EACH ROW EXECUTE FUNCTION log_user_creation();
INSERT INTO users (email, name) VALUES ('ALICE@EXAMPLE.COM', 'Alice');
-- BEFORE trigger normalises it:
-- users: email = 'alice@example.com'
-- AFTER trigger logs it:
-- audit_log: {"email": "alice@example.com", "created_at": "2024-03-15T09:42:11"}
Audit Logging with Triggers — The Production Pattern
Audit logging is the most legitimate and common trigger use case. The requirement: every change to sensitive data (salaries, prices, access permissions) must be recorded with who changed it, when, what it was before, and what it became after. Doing this reliably at the application layer is fragile — developers forget to add logging to new code paths. A trigger guarantees it happens regardless of how the data change occurs.
The standard pattern uses the INSERTED and DELETED virtual tables in SQL Server, or the OLD and NEW row references in PostgreSQL and MySQL. These give you the pre- and post-change values inside the trigger body. Store them in a generic audit table with a JSONB column (PostgreSQL) or a structured audit table with before/after value columns.
-- Generic audit table that works for any table CREATE TABLE audit_log ( audit_id BIGSERIAL PRIMARY KEY, table_name VARCHAR(100) NOT NULL, action VARCHAR(10) NOT NULL CHECK (action IN ('INSERT','UPDATE','DELETE')), record_id BIGINT NOT NULL, changed_by VARCHAR(100), changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), old_values JSONB, new_values JSONB ); CREATE INDEX idx_audit_log_table_record ON audit_log(table_name, record_id); CREATE INDEX idx_audit_log_changed_at ON audit_log(changed_at); -- Reusable trigger function for salary changes CREATE OR REPLACE FUNCTION audit_employee_changes() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'INSERT' THEN INSERT INTO audit_log (table_name, action, record_id, changed_by, new_values) VALUES ('employees', 'INSERT', NEW.employee_id, current_setting('app.current_user_id', true), to_jsonb(NEW)); RETURN NEW; ELSIF TG_OP = 'UPDATE' THEN -- Only log if salary actually changed (not every field update) IF OLD.salary <> NEW.salary THEN INSERT INTO audit_log (table_name, action, record_id, changed_by, old_values, new_values) VALUES ('employees', 'UPDATE', NEW.employee_id, current_setting('app.current_user_id', true), jsonb_build_object('salary', OLD.salary), jsonb_build_object('salary', NEW.salary)); END IF; RETURN NEW; ELSIF TG_OP = 'DELETE' THEN INSERT INTO audit_log (table_name, action, record_id, changed_by, old_values) VALUES ('employees', 'DELETE', OLD.employee_id, current_setting('app.current_user_id', true), to_jsonb(OLD)); RETURN OLD; END IF; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_audit_employees AFTER INSERT OR UPDATE OR DELETE ON employees FOR EACH ROW EXECUTE FUNCTION audit_employee_changes();
audit_log row:
action: UPDATE
record_id: 42
changed_by: 'jane.admin'
old_values: {"salary": 85000}
new_values: {"salary": 95000}
changed_at: 2024-03-15 14:22:31+00
current_setting() to capture who changed what.Cascading Triggers and When NOT to Use Triggers
A cascading trigger occurs when Trigger A fires a DML operation that activates Trigger B, which in turn fires Trigger C, and so on. Most databases allow cascading triggers up to a configured depth (SQL Server defaults to 32 levels). Cascades make debugging a nightmare: a single INSERT can cause dozens of side effects that are invisible to the calling application and to monitoring tools that track queries.
The worst cascading scenario is a trigger cycle: Trigger A on table T1 updates table T2, Trigger B on T2 updates T1, which fires Trigger A again — an infinite loop that runs until the recursion depth limit throws an error. This type of bug is introduced gradually as triggers are added over time without a dependency map.
Business logic does not belong in triggers. Logic like 'when an order is placed, send a welcome email' or 'recalculate the customer's tier based on total spend' has external dependencies, needs to be tested independently, and needs to be deployed with the rest of the application code. Putting it in a trigger makes it invisible, hard to test, and impossible to roll back independently of the database schema.
-- DANGEROUS: Trigger on orders that modifies customer_stats -- which has another trigger that modifies orders -- circular! CREATE TRIGGER trg_orders_update_stats AFTER INSERT ON orders FOR EACH ROW EXECUTE FUNCTION update_customer_stats(); -- updates customer_stats table -- If customer_stats has a trigger that updates orders... -- circular cascade -- do not do this -- SAFE PATTERN: Use a flag to prevent recursive firing (SQL Server) CREATE TRIGGER trg_orders_safe AFTER INSERT ON orders FOR EACH ROW AS BEGIN IF TRIGGER_NESTLEVEL() > 1 -- already inside a trigger call chain RETURN; -- ... safe to proceed END; -- PostgreSQL equivalent: check trigger nesting level CREATE OR REPLACE FUNCTION safe_trigger_function() RETURNS TRIGGER AS $$ BEGIN IF pg_trigger_depth() > 1 THEN RETURN NEW; -- skip to prevent cascade END IF; -- ... proceed with side effect RETURN NEW; END; $$ LANGUAGE plpgsql; -- List all triggers and their dependencies (PostgreSQL) SELECT event_object_table AS table_name, trigger_name, event_manipulation AS event, action_timing AS timing FROM information_schema.triggers WHERE trigger_schema = 'public' ORDER BY event_object_table, action_timing;
employees | trg_audit_employees | INSERT | AFTER
employees | trg_audit_employees | UPDATE | AFTER
employees | trg_audit_employees | DELETE | AFTER
orders | trg_normalise_order_before | INSERT | BEFORE
users | trg_normalise_user_before_insert| INSERT | BEFORE
TRIGGER_NESTLEVEL() (SQL Server) let you guard against recursive trigger chains.| Trigger Type | Fires When | Can Modify Row? | Best For |
|---|---|---|---|
| BEFORE INSERT/UPDATE | Before the DML executes | Yes — modify NEW values | Input validation, data normalisation |
| AFTER INSERT/UPDATE/DELETE | After DML succeeds | No — row is already written | Audit logging, cascading updates to related tables |
| INSTEAD OF (SQL Server) | Replaces the DML | Yes — must implement DML explicitly | Making views updatable |
| STATEMENT-level | Once per DML statement | No row-level access | Table-level statistics, bulk operation logging |
🎯 Key Takeaways
- Triggers execute invisibly to the caller — audit logging and data normalisation are their appropriate use cases, not business logic.
- BEFORE triggers validate and transform incoming data; AFTER triggers log and cascade after successful writes.
- Cascading triggers require explicit dependency documentation — map them before every migration.
- A trigger bug rolls back the triggering transaction — test failure paths explicitly, especially in audit triggers.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat is the difference between a BEFORE and AFTER trigger, and when would you use each?JuniorReveal
- QWhat are the risks of cascading triggers and how do you guard against them?SeniorReveal
- QWhen would you use a trigger for audit logging versus handling it in application code?Reveal
Frequently Asked Questions
Can a trigger see the values before and after an UPDATE?
Yes. In PostgreSQL and MySQL, inside an UPDATE trigger you have access to OLD.column (the previous value) and NEW.column (the new value). In SQL Server, you access DELETED (old values) and INSERTED (new values) virtual tables. These are essential for audit logging that records what changed.
Do triggers fire when data is imported with BULK INSERT or COPY?
It depends on the database and the import method. In SQL Server, BULK INSERT with the FIRE_TRIGGERS option fires triggers; without it, triggers are bypassed. In PostgreSQL, COPY fires triggers by default. Always verify trigger behaviour for your specific bulk import method — silently skipped audit triggers during bulk imports are a common compliance gap.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.