SQL Triggers — 45-Minute Cascading Deadlock
A 45-minute schema migration deadlock from circular trigger chains.
20+ years shipping high-throughput database systems. Everything here is grounded in real deployments.
- 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
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.
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.
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.
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.
TRIGGER_NESTLEVEL() (SQL Server) let you guard against recursive trigger chains.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.
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.'
A Cascading Trigger Chain Caused a 45-Minute Deadlock During a Schema Migration
- 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
NOW()). Check trigger_debug_log to confirm the trigger is firing.Key takeaways
Common mistakes to avoid
3 patternsCreating triggers that fire other triggers without documenting the dependency chain
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
Not accounting for the trigger in performance profiling
Interview Questions on This Topic
What is the difference between a BEFORE and AFTER trigger, and when would you use each?
Frequently Asked Questions
20+ years shipping high-throughput database systems. Everything here is grounded in real deployments.
That's SQL Advanced. Mark it forged?
5 min read · try the examples if you haven't