Say you are trying to keep an inventory table in sync every time a row gets inserted into orders. You write a trigger, run a test insert, and check the inventory table expecting to see a lower stock count. Nothing has changed. Or worse — something changed, but not the thing you expected, and now you’re staring at a table that looks like it updated itself twice.
Triggers are one of those SQL features that look simple in a tutorial and turn into a debugging headache the first time you use one for something real. Part of the problem is that a trigger runs invisibly, tucked inside the database, firing in response to an event rather than being called directly the way a function or stored procedure is. When it misbehaves, there’s no obvious place in your application code to set a breakpoint.
This guide is organized around the problems people actually run into with triggers, not around syntax in isolation. Each section names a symptom, explains the underlying cause, and walks through the fix — with the basic concepts introduced along the way, so you don’t need prior trigger experience to follow it.
Symptom: The Trigger Never Fires at All
You create the trigger, run an INSERT or UPDATE against the table it’s attached to, and whatever the trigger was supposed to do just doesn’t happen. No error, no side effect, nothing.
Cause: This almost always comes down to a mismatch between the event the trigger is listening for and the event that actually occurred. A trigger defined for AFTER INSERT will never fire on an UPDATE, no matter how similar the two operations feel from the application side. It’s also common to attach a trigger to the wrong table entirely — easy to do when a schema has several similarly named tables, or when the trigger was copy-pasted from another project.
A second common cause: the trigger exists but was never enabled, or was disabled during a migration and nobody re-enabled it. Some database systems let you disable a trigger without dropping it, which means it can sit there, defined and inert, indefinitely.
Fix: Confirm the trigger’s event type matches the operation you’re testing — query your database’s system catalog (information_schema.triggers in MySQL, or pg_trigger in Postgres) rather than trusting memory or old documentation. Then confirm the trigger is enabled. In Postgres, ALTER TABLE orders ENABLE TRIGGER trigger_name re-enables one that’s been switched off. If both of those check out, double-check you’re testing against the same table and the same database environment the trigger was actually created in — a surprising number of “the trigger isn’t firing” cases turn out to be testing against a different schema or a different environment than the one the trigger lives in.
Symptom: The Trigger Fires, But NEW and OLD Values Look Wrong
The trigger runs — you can tell, because whatever side effect it produces does show up — but the values it’s working with don’t match what you expected. An UPDATE trigger seems to be reading the pre-update values when you expected the post-update ones, or vice versa.
Cause: Triggers give you access to the row’s data through the NEW and OLD pseudo-records, and mixing these up is one of the most frequent beginner mistakes. OLD refers to the row’s values before the triggering statement runs; NEW refers to the row’s values after. For an INSERT, only NEW exists, since there’s no prior row. For a DELETE, only OLD exists, since there’s no resulting row. For an UPDATE, both exist simultaneously, and it’s entirely possible to reference OLD.quantity when you meant NEW.quantity, producing a value that looks stale.
A related cause: confusing BEFORE and AFTER timing. A BEFORE UPDATE trigger runs before the actual row change is committed to the table, which means a query inside that trigger against the same table will still see the old, unmodified data — even if you’re referencing NEW correctly inside the trigger body itself.
Fix: Write out, in plain language, exactly which point in time each value you’re referencing represents, before touching the trigger body. If the goal is to log what a row looked like before a change, that’s OLD. If the goal is to validate or adjust the incoming data before it’s written, that’s NEW, inside a BEFORE trigger. If the goal is to react to a change that has already been committed — say, updating a separate summary table — that calls for AFTER, referencing NEW. Getting this timing decision right up front eliminates most of the NEW/OLD confusion before it has a chance to happen.
Symptom: You Get a “Mutating Table” or Similar Constraint Error
The trigger throws an error the moment it tries to run, something along the lines of a table being mutated or locked, and the entire statement that triggered it fails.
Cause: This shows up specifically when a trigger tries to query or modify the very same table that triggered it, while that table’s data is still in the middle of being changed. Some database systems — Oracle is the classic example — enforce this restriction explicitly, refusing to let a row-level trigger read from the table it’s attached to mid-statement, since the table’s state is, at that instant, only partially updated and not safe to query reliably.
Fix: Where possible, restructure the trigger to work only with the NEW and OLD values already available to it, rather than issuing a fresh SELECT against the same table. If the trigger genuinely needs aggregate information from that table — a running total, a row count — consider moving that calculation to a statement-level trigger instead of a row-level one, since statement-level triggers fire once per statement rather than once per row and have different visibility rules around the table’s data. In some cases, the cleanest fix is to move the logic out of the trigger entirely and into the application layer or a scheduled job, particularly if the calculation is expensive or doesn’t need to happen instantly.
Symptom: A Single INSERT Sets Off a Chain Reaction
You insert one row, and far more happens than you expected — multiple tables update, or the same trigger appears to fire several times over for what should have been a single event.
Cause: Triggers can call other triggers. An INSERT into table A fires a trigger that updates table B, and table B has its own trigger that responds to that update by touching table C, and so on. Each link in that chain is reasonable in isolation, but the combined effect can be difficult to predict, and in the worst case — a trigger on table B that ends up updating table A again — you get a recursive loop that only stops because the database eventually hits a recursion limit and throws an error.
Fix: Map out the full chain of triggers involved before adding a new one to a table that already has several. Most database systems expose a way to list every trigger on a given table, and it’s worth doing this before assuming a table is “trigger-free” just because you didn’t write one yourself. If recursion is intentional and necessary, most systems provide a way to cap it explicitly (Postgres allows disabling a trigger for the current session; SQL Server has RECURSIVE_TRIGGERS at the database level) — use that instead of relying on an implicit recursion limit to catch runaway chains for you.
Symptom: Everything Works, But It’s Noticeably Slower
The trigger produces correct results, but bulk inserts or updates that used to run in a second or two now take considerably longer, especially as the table grows.
Cause: A row-level trigger runs once per row affected, not once per statement. An UPDATE that touches ten thousand rows fires that trigger ten thousand separate times, and any query, calculation, or additional write inside the trigger body gets multiplied by that same number. This cost is easy to miss during development, where test data is small, and painful to discover only after the table has grown to production size.
Fix: If the trigger’s logic doesn’t strictly need to run per row — say, it’s maintaining a single summary value — consider a statement-level trigger instead, which runs once regardless of how many rows the statement affected. Where a row-level trigger is unavoidable, keep its body as lean as possible: avoid additional SELECT queries against large tables inside the trigger, and avoid writing to tables that themselves have expensive triggers attached. For very large bulk operations, some teams disable non-essential triggers temporarily, run the bulk operation, then re-enable the triggers and reconcile any dependent tables in a single follow-up pass — trading a moment of manual coordination for a large performance gain.
Symptom: You Can’t Explain What a Trigger Did After the Fact
Something in your data changed, nobody on the team remembers writing code to change it, and after some digging the culprit turns out to be a trigger that’s been quietly running for months.
Cause: Triggers are, by design, invisible from the application layer. Nothing in your app code calls them directly, so anyone reading the application code alone has no way of knowing they exist. This is a reasonable trade-off when triggers are well documented and rare, but it becomes a real liability when a schema accumulates several of them over time with no central record of what each one does.
Fix: Keep an inventory — even a simple one — listing every trigger in the schema, the table it’s attached to, the event it responds to, and a one-line description of its purpose. Name triggers descriptively rather than accepting an auto-generated default name, so trg_update_inventory_after_order_insert shows up clearly in the system catalog instead of something like trigger_1. When a new engineer joins a project with an unfamiliar schema, that inventory is often the difference between a quick orientation and a frustrating afternoon spent reverse-engineering behavior from query logs.
A Quick Reference Before You Write Your Next Trigger
| Question to Ask | Why It Matters |
|---|---|
| Does the event type match what I’m testing? | Prevents the “nothing happens” symptom |
| BEFORE or AFTER — which timing does this logic need? | Prevents reading stale or premature values |
| Row-level or statement-level? | Determines both correctness and performance at scale |
| Does this trigger’s logic risk touching its own table? | Avoids mutating-table errors |
| Could this trigger set off other triggers downstream? | Avoids unexpected chains or recursion |
| Is this trigger documented somewhere outside the database itself? | Avoids becoming invisible logic later |
Most trigger problems trace back to one of these six questions being skipped rather than answered incorrectly. Working through them before writing the CREATE TRIGGER statement tends to catch the issue before it ever reaches a test run.
Triggers earn their place in a schema when the logic they hold truly belongs to the data itself — an audit log, a constraint no application layer should be allowed to bypass, a calculated column that must always stay in sync. Used for anything broader than that, they tend to become the kind of hidden behavior this guide has spent most of its space helping you debug.