Junior 5 min · March 13, 2026

SQL Triggers — 45-Minute Cascading Deadlock

A 45-minute schema migration deadlock from circular trigger chains.

N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Everything here is grounded in real deployments.

Follow
Production
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide
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
✦ Definition~90s read
What is SQL Triggers?

SQL triggers are procedural code that automatically executes in response to specific database events—INSERT, UPDATE, DELETE—on a table or view. They exist to enforce business rules, maintain audit trails, or synchronize related tables without requiring application-level logic.

A trigger is like a tripwire in a warehouse.

Think of them as database-side event handlers that fire before or after the triggering statement, with full access to the old and new row values. The killer feature is that they run inside the same transaction as the triggering statement, meaning any failure in the trigger rolls back the entire operation.

This atomicity is both their superpower and their curse: a poorly written trigger can silently bring your production database to its knees with cascading deadlocks or unexpected row locks, especially under concurrent load.

In the ecosystem, triggers compete with application-level logic, materialized views, and change data capture (CDC) tools like Debezium or AWS DMS. You should NOT use triggers when you need debuggability—they're invisible to most ORMs and application logs—or when you're building a system that might need to migrate between database vendors, since trigger syntax varies wildly between PostgreSQL, MySQL, SQL Server, and Oracle.

They're also a poor fit for complex multi-step workflows that should be explicit in your application code. Where triggers shine is in enforcing invariants that must hold regardless of how data enters the system: audit logging, computed columns, or preventing orphaned rows in a denormalized schema.

The real danger with triggers is cascading: a trigger on table A updates table B, which has a trigger that updates table C, which has a trigger that updates table A. This creates a circular dependency that can deadlock under concurrent transactions, especially when row-level locking interacts with trigger execution order.

Production patterns like audit logging avoid this by writing to a separate append-only table with no triggers, using minimal columns, and never reading from the audit table inside the trigger. The 45-minute cascading deadlock scenario typically happens when a trigger acquires a lock on a row that another concurrent transaction holds, and both transactions are waiting on each other—exactly the kind of problem that's invisible in development but catastrophic under production load.

Plain-English First

A trigger is like a tripwire in a warehouse. You post a rule: 'whenever someone moves inventory from shelf A to shelf B, automatically log it in the logbook.' The warehouse worker does not need to remember to write in the logbook — the tripwire fires the logging action automatically. SQL triggers work the same way — you define them once and the database fires them automatically whenever the specified data change happens.

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.

Why SQL Triggers Are a Double-Edged Sword

An SQL trigger is a procedural block that automatically executes in response to a data change event — INSERT, UPDATE, or DELETE — on a specific table. Unlike constraints or application-level logic, triggers live inside the database engine and fire within the same transaction as the triggering statement. This means they inherit the transaction's locks, isolation level, and rollback scope, making them invisible but powerful modifiers of data integrity.

Triggers execute either BEFORE or AFTER the triggering event, and can be row-level (once per affected row) or statement-level (once per SQL statement). Row-level triggers are O(n) relative to the number of rows modified, which matters when a single UPDATE touches 100,000 rows — each trigger invocation adds latency and lock duration. Triggers also have access to OLD and NEW pseudo-rows, enabling audit trails, derived column updates, or cascading changes without application code.

Use triggers when you must enforce business rules that span tables and cannot be deferred to the application — for example, maintaining a materialized aggregate count, logging all deletions for compliance, or preventing orphaned rows in a denormalized schema. Avoid them for logic that can be expressed as a CHECK constraint, a foreign key with CASCADE, or a computed column. In production, triggers are often the root cause of deadlocks and unexpected write amplification because they execute under the caller's transaction context, silently extending lock duration.

Trigger ≠ Constraint
A trigger cannot enforce referential integrity reliably — it runs per row, not per statement, and can miss violations if the triggering statement is rolled back partially.
Production Insight
A team added a row-level AFTER INSERT trigger on an orders table to update a customer's last_order_date. A bulk import of 50,000 orders caused the trigger to hold row-level locks on the customers table for the entire import transaction, leading to a cascading deadlock with concurrent read replicas. The rule: never use row-level triggers on high-volume write paths — use statement-level triggers or batch jobs instead.
Key Takeaway
Triggers run in the caller's transaction — they extend lock duration and can cause deadlocks.
Row-level triggers are O(n) per modified row — avoid them on bulk operations.
Prefer constraints, computed columns, or application logic over triggers unless you need cross-table side effects within the same transaction.
SQL Trigger Pitfalls: Cascading Deadlock THECODEFORGE.IO SQL Trigger Pitfalls: Cascading Deadlock Flow from trigger types to production audit and deadlock trap BEFORE vs AFTER Triggers Choose timing: BEFORE for validation, AFTER for audit Audit Logging Pattern AFTER trigger inserts into audit table per row Cascading Triggers Trigger fires another trigger, chain reaction Row-Level vs Statement-Level Row-level fires per row; statement-level once per statement Error Handling & Transaction Safety Wrap in TRY-CATCH, use XACT_ABORT ON ⚠ Cascading triggers cause deadlocks in high concurrency Avoid triggers that modify other tables with triggers THECODEFORGE.IO
thecodeforge.io
SQL Trigger Pitfalls: Cascading Deadlock
Sql Triggers

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.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
-- 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.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
-- 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.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
-- 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.

Row-Level vs Statement-Level Triggers — The Hidden Performance Killer

Most developers don't know there are two distinct execution modes for triggers. Row-level triggers fire once per affected row. Statement-level triggers fire once per SQL statement regardless of how many rows changed. The difference will destroy your production database if you get it wrong. Imagine an UPDATE that touches 100,000 rows. A row-level trigger runs 100,000 times. A statement-level trigger runs once. That's the difference between a 50ms operation and a 5-minute outage. MySQL only supports row-level triggers. PostgreSQL and Oracle give you both. SQL Server defaults to statement-level. Always check your dialect's default. Batch operations with row-level triggers are the #1 cause of unexpected performance degradation in trigger-heavy systems. Test with realistic dataset sizes, not your 12-row development table. Profile execution plans. That row-level audit trigger you wrote yesterday? It's now the bottleneck for your nightly bulk update job.

trigger_benchmark.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge
-- PostgreSQL: Statement-level trigger for batch operations
-- Note: No FOR EACH ROW clause makes this statement-level

CREATE OR REPLACE FUNCTION log_bulk_update()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO audit_log (event_type, affected_rows, timestamp)
    VALUES ('BULK_UPDATE', TG_NARG, NOW());
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_bulk_update_audit
AFTER UPDATE ON orders
-- No FOR EACH ROW → fires once per statement
EXECUTE FUNCTION log_bulk_update();
Output
INSERT 0 1
-- Single audit row regardless of 50,000 updated orders
Production Trap:
Row-level triggers on tables with >500,000 rows and batch updates will turn your ETL pipeline into a crawl. Always ask: do I need per-row granularity or just need to know the operation happened?
Key Takeaway
Statement-level triggers for batch operations. Row-level triggers for per-row validation. Mixing them up causes outages.

Error Handling and Transaction Safety — Don't Let a Trigger Take Down a Billion-Dollar Transaction

Triggers execute inside the calling transaction. A trigger failure rolls back the entire transaction. That sounds safe, but here's the reality: a poorly handled validation trigger on a customers table can cancel a payments batch, a user registration, and a subscription renewal in one atomic failure. Your trigger's error handling must be bulletproof. Never catch exceptions silently. Always use SIGNAL or RAISE with descriptive messages. Store trigger-specific errors in a separate error log table — not the main application log. Design for idempotency. Triggers that run multiple times (replays, retries) should produce the same result. Test with concurrent writes. Race conditions in triggers cause deadlocks. Put a timeout on trigger execution. Fifteen seconds is the absolute max for customer-facing operations. Document every trigger with its transaction boundary implications. Your team needs to know: 'This trigger on the orders table can take down the entire checkout flow if it fails.'

safe_trigger.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// io.thecodeforge
-- MySQL/MariaDB: Safe trigger with error logging

CREATE TABLE trigger_error_log (
    error_id INT AUTO_INCREMENT PRIMARY KEY,
    error_message TEXT,
    table_name VARCHAR(100),
    error_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TRIGGER trg_prevent_overdraft
BEFORE INSERT ON transactions
FOR EACH ROW
BEGIN
    DECLARE current_balance DECIMAL(10,2);
    
    SELECT balance INTO current_balance
    FROM accounts WHERE account_id = NEW.account_id
    FOR UPDATE;  -- Prevent race condition
    
    IF (current_balance - NEW.amount) < -1000 THEN
        INSERT INTO trigger_error_log (
            error_message, table_name
        ) VALUES (
            CONCAT('Overdraft limit exceeded for account '
                   , NEW.account_id, '. Balance: '
                   , current_balance, ', Amount: ', NEW.amount),
            'transactions'
        );
        SIGNAL SQLSTATE '45000'
            SET MESSAGE_TEXT = 'Overdraft limit of -1000 exceeded';
    END IF;
END;
Output
Query OK, 0 rows affected (Transaction rolled back)
-- Error logged in trigger_error_log for investigation
Architecture Decision:
Use BEFORE triggers for validation (fail fast, cheap). Use AFTER triggers for logging (non-critical, can degrade to best-effort). Never do expensive computation in AFTER triggers.
Key Takeaway
Your trigger is part of a transaction. A failed trigger kills the whole operation. Log errors externally, fail explicitly.
● Production incidentPOST-MORTEMseverity: high

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

Symptom
A 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.
Assumption
The 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 cause
Trigger 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.
Fix
Disabled 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 deadlocks
  • Never write triggers that can fire other triggers without explicit documentation of the chain depth
  • For migrations in trigger-heavy databases, disable triggers temporarily and re-enable after: ALTER TABLE t DISABLE TRIGGER ALL
Production debug guideDiagnosing unexpected behaviour caused by triggers4 entries
Symptom · 01
DML operation is slower than expected with no obvious query explanation
Fix
List 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.
Symptom · 02
Data changes that no application code appears to be making
Fix
Triggers 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.
Symptom · 03
Trigger is firing but the expected side effect is not happening
Fix
Check 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.
Symptom · 04
UPDATE trigger fires but OLD and NEW values appear incorrect
Fix
In 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.
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

1
Triggers execute invisibly to the caller
audit logging and data normalisation are their appropriate use cases, not business logic.
2
BEFORE triggers validate and transform incoming data; AFTER triggers log and cascade after successful writes.
3
Cascading triggers require explicit dependency documentation
map them before every migration.
4
A trigger bug rolls back the triggering transaction
test failure paths explicitly, especially in audit triggers.

Common mistakes to avoid

3 patterns
×

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 PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between a BEFORE and AFTER trigger, and when woul...
Q02SENIOR
What are the risks of cascading triggers and how do you guard against th...
Q03JUNIOR
When would you use a trigger for audit logging versus handling it in app...
Q01 of 03JUNIOR

What is the difference between a BEFORE and AFTER trigger, and when would you use each?

ANSWER
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.
FAQ · 2 QUESTIONS

Frequently Asked Questions

01
Can a trigger see the values before and after an UPDATE?
02
Do triggers fire when data is imported with BULK INSERT or COPY?
N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Everything here is grounded in real deployments.

Follow
Verified
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's SQL Advanced. Mark it forged?

5 min read · try the examples if you haven't

Previous
SQL Stored Procedures
4 / 16 · SQL Advanced
Next
SQL Transactions and ACID