Skip to content
Home Database SQL Triggers — 45-Minute Cascading Deadlock

SQL Triggers — 45-Minute Cascading Deadlock

Where developers are forged. · Structured learning · Free forever.
📍 Part of: SQL Advanced → Topic 4 of 16
A 45-minute schema migration deadlock from circular trigger chains.
🔥 Advanced — solid Database foundation required
In this tutorial, you'll learn
A 45-minute schema migration deadlock from circular trigger chains.
  • 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.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • 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

A Cascading Trigger Chain Caused a 45-Minute Deadlock During a Schema Migration

A database had 11 triggers across 4 tables. A migration that updated the orders table triggered a chain that eventually tried to update a row being held by a different migration step, causing a deadlock that lasted 45 minutes.
SymptomA schema migration that should have run in 2 minutes locked for 45 minutes. The DBA team had to manually identify and kill the blocking sessions. Several application endpoints returned 500 errors during the window.
AssumptionThe team knew the database had triggers but assumed they were simple audit loggers. No one had mapped the trigger dependency chain before running the migration.
Root causeTrigger A on the orders table fired an UPDATE on the order_summary table. Trigger B on order_summary fired an INSERT into the notifications table. Trigger C on notifications fired an UPDATE back on the orders table — circular dependency. The migration held a lock on orders while executing, trigger C tried to UPDATE orders again, creating a deadlock.
FixDisabled non-essential triggers for the duration of the migration using ALTER TABLE orders DISABLE TRIGGER ALL. Audited and documented the full trigger dependency graph. Rewrote the notification trigger as application-level logic. Added a trigger dependency comment header to each remaining trigger.
Key Lesson
Map your trigger dependency graph before any schema migration — unknown cascades cause unknown deadlocksNever write triggers that can fire other triggers without explicit documentation of the chain depthFor migrations in trigger-heavy databases, disable triggers temporarily and re-enable after: ALTER TABLE t DISABLE TRIGGER ALL
Production Debug Guide

Diagnosing unexpected behaviour caused by triggers

DML operation is slower than expected with no obvious query explanationList all triggers on the table: SELECT trigger_name, event_manipulation FROM information_schema.triggers WHERE event_object_table = 'your_table'. Check trigger body for expensive operations — JOINs, aggregates, external table writes. A slow trigger is invisible to application-level profiling.
Data changes that no application code appears to be makingTriggers are the first suspect. List all triggers on affected tables. Add a temporary logging statement inside the trigger: INSERT INTO trigger_debug_log (event, ts) VALUES ('trigger_name fired', NOW()). Check trigger_debug_log to confirm the trigger is firing.
Trigger is firing but the expected side effect is not happeningCheck if the trigger is inside a transaction that is being rolled back by the caller. Trigger DML is part of the triggering transaction — if the outer transaction rolls back, all trigger side effects roll back too. Check INSTEAD OF triggers on views — they replace, not supplement, the original DML.
UPDATE trigger fires but OLD and NEW values appear incorrectIn PostgreSQL: use OLD.column and NEW.column inside the trigger function. In MySQL: use OLD.column and NEW.column directly in the trigger body. In SQL Server: use the INSERTED and DELETED virtual tables — INSERTED has new values, DELETED has old values for UPDATE.

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_after_triggers.sql · SQL
123456789101112131415161718192021222324252627282930313233343536
-- 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();
▶ Output
-- INSERT with uppercase email:
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"}
🔥RETURN NEW vs RETURN NULL in Triggers
In PostgreSQL BEFORE triggers, you must RETURN NEW to allow the operation to proceed with the (possibly modified) row. Returning NULL cancels the operation. In AFTER triggers, the return value is ignored — use RETURN NULL. Getting this wrong is a common source of INSERT/UPDATE mysteriously producing no rows.
📊 Production Insight
BEFORE triggers for normalisation (email lowercase, phone formatting) are valuable because they enforce consistency regardless of which application path writes the data.
AFTER triggers for audit logs are the canonical trigger use case — they fire only on successful DML and the log entry is in the same transaction.
Any trigger failure rolls back the entire triggering transaction — a bug in your audit trigger will cause the original INSERT to fail.
🎯 Key Takeaway
BEFORE = validate and transform before the row is written; AFTER = react to a successful write.
In BEFORE triggers: RETURN NEW to proceed, RETURN NULL to cancel the operation.
A trigger bug rolls back the triggering transaction — test trigger error paths explicitly.

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.

audit_log_trigger.sql · SQL
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
-- 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();
▶ Output
-- After UPDATE employees SET salary = 95000 WHERE employee_id = 42:
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
💡Pass Application User Context with SET LOCAL
Audit logs need to capture who made the change. Database connections don't automatically carry application user identity. Use SET LOCAL app.current_user_id = '42' at the start of each request transaction, then read it inside the trigger with current_setting('app.current_user_id', true). This threads user identity from the application into the database trigger context.
📊 Production Insight
Audit trigger audit_log tables grow fast — add a composite index on (table_name, record_id) and a partitioned index on changed_at from day one.
The trigger function is reusable across tables (change the table name and record_id column) — write it once, attach to multiple tables.
For compliance requirements (GDPR, SOX, HIPAA), trigger-based audit logs are often required because they cannot be bypassed by application code.
🎯 Key Takeaway
Audit triggers are the canonical trigger use case — they guarantee logging regardless of which code path makes the change.
Pass application user context via SET LOCAL + current_setting() to capture who changed what.
Audit tables need indexes on (table_name, record_id) and changed_at from the start — they will grow to millions of rows.

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.

trigger_safety_patterns.sql · SQL
123456789101112131415161718192021222324252627282930313233343536373839404142
-- 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;
▶ Output
table_name | trigger_name | event | 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
⚠ Never Put Business Logic in Triggers
Triggers are invisible to the application layer. Business logic in triggers cannot be unit tested without a database, cannot be rolled back independently of schema changes, cannot be traced in application logs, and cannot be disabled during a hotfix. Logic that has external dependencies (email sending, API calls) is impossible to put in a trigger safely. Keep triggers for database-layer concerns only: data validation, normalisation, and audit logging.
📊 Production Insight
Always map your trigger dependency graph before any migration — ALTER TABLE t DISABLE TRIGGER ALL is the emergency lever for migrations in trigger-heavy databases.
pg_trigger_depth() (PostgreSQL) and TRIGGER_NESTLEVEL() (SQL Server) let you guard against recursive trigger chains.
Document every trigger with a comment block: what it fires on, what it does, and what other triggers it might activate.
🎯 Key Takeaway
Cascading triggers are the most dangerous pattern in production SQL — map the dependency graph before any migration.
Business logic does not belong in triggers — visibility, testability, and deployability all suffer.
Audit logging and data normalisation are the appropriate trigger use cases — everything else is usually better in application code.
Trigger TypeFires WhenCan Modify Row?Best For
BEFORE INSERT/UPDATEBefore the DML executesYes — modify NEW valuesInput validation, data normalisation
AFTER INSERT/UPDATE/DELETEAfter DML succeedsNo — row is already writtenAudit logging, cascading updates to related tables
INSTEAD OF (SQL Server)Replaces the DMLYes — must implement DML explicitlyMaking views updatable
STATEMENT-levelOnce per DML statementNo row-level accessTable-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

    Creating triggers that fire other triggers without documenting the dependency chain
    Symptom

    Schema migrations cause unexpected deadlocks or infinite recursion errors; data changes produce side effects that no application code appears to be responsible for

    Fix

    Map all trigger dependencies before any migration. Use pg_trigger_depth() (PostgreSQL) or TRIGGER_NESTLEVEL() (SQL Server) to guard against unintended recursion. Document each trigger with a comment block listing what it activates.

    Putting business logic with external dependencies in triggers
    Symptom

    Trigger fails silently or rolls back the triggering transaction when an external dependency is unavailable; logic cannot be unit tested or deployed independently

    Fix

    Move external-dependency logic to application code or a message queue. Triggers should only contain database-layer operations (INSERT, UPDATE on related tables, raising exceptions) — never network calls, file writes, or complex business rules.

    Not accounting for the trigger in performance profiling
    Symptom

    INSERT or UPDATE operations are slower than the query plan explains — the overhead is invisible in the application's query profiler

    Fix

    List all triggers on the table with information_schema.triggers. Profile the trigger function separately. Complex AFTER triggers with JOINs or aggregates are the most common hidden performance sink in production databases.

Interview Questions on This Topic

  • QWhat is the difference between a BEFORE and AFTER trigger, and when would you use each?JuniorReveal
    A BEFORE trigger fires before the DML operation executes. It has access to the incoming row values (NEW) and can modify them or raise an exception to reject the operation. Use BEFORE triggers for input validation (rejecting negative prices) and data normalisation (lowercasing emails). An AFTER trigger fires after the DML has succeeded — the row is already in the table. AFTER triggers cannot modify the triggering row. Use them for audit logging (the log should only exist if the change succeeded), cascading updates to related tables, and any side effect that should be conditional on the primary operation succeeding. In SQL Server, INSTEAD OF triggers replace the DML operation entirely and are primarily used to make views updatable.
  • QWhat are the risks of cascading triggers and how do you guard against them?SeniorReveal
    Cascading triggers occur when a trigger fires a DML operation that activates another trigger, potentially creating a chain reaction or circular dependency. Risks: deadlocks when a circular chain tries to lock a row already held by the triggering transaction; performance degradation from compounded DML operations per user operation; debugging complexity because the side effects are invisible to the calling application. Guards: use pg_trigger_depth() (PostgreSQL) or TRIGGER_NESTLEVEL() (SQL Server) at the top of trigger functions to detect and exit if already inside a trigger chain. Before any schema migration on trigger-heavy tables, list all triggers and map the dependency graph. Use ALTER TABLE t DISABLE TRIGGER ALL for migrations where trigger execution would cause conflicts, then re-enable after.
  • QWhen would you use a trigger for audit logging versus handling it in application code?Reveal
    Triggers are superior for audit logging when: (1) multiple application code paths can modify the table and you cannot trust all of them to implement logging consistently — a trigger guarantees logging regardless of how the change happens, including direct SQL updates by DBAs; (2) compliance requirements mandate that the audit log cannot be bypassed by application code; (3) the log needs to capture the exact before and after values at the database level rather than at the application level. Application-level logging is preferable when: the log requires business context that is not available at the database layer (session user role, feature flag state, request ID); the logging operation has external dependencies (writing to Elasticsearch, sending to a message queue); or when you need to test the logging logic independently of the database.

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.

🔥
Naren Founder & Author

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.

← PreviousSQL Stored ProceduresNext →SQL Transactions and ACID
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged