Vol. IV · No. 04 Monday · 29 June 2026
Now writing — Why Your Index Scan Is Slower Than a Sequential Scan: When the Planner Is Right to Ignore Your Index dispatches · 3 streams
← All dispatches
engineering Dispatch 5 min read · 8 May 2026

Database Triggers: When They Earn Their Cost and When They Become Liabilities

engineering · Curiosity

Database triggers occupy a strange position in the engineering toolkit. They are powerful, well-supported in every major relational database, and capable of expressing constraints that the application layer cannot enforce reliably. They are also one of the most reliable producers of "I have no idea why this row changed" debugging sessions. The result is that triggers are simultaneously underused (engineers reach for application-layer code when a trigger would be cleaner) and overused (engineers add triggers to systems that cannot afford the operational cost).

This post covers the cases where triggers earn their complexity, the cases where they become liabilities, and the discipline that keeps trigger-based systems debuggable. We use a small number of triggers across DocuMint, CronPing, FlagBit, and WebhookVault, deliberately kept to a few well-defined cases.

What triggers actually buy you

The fundamental promise of a trigger is that the constraint it enforces cannot be bypassed. Application code can forget to update an audit log; a trigger cannot. Application code can be replaced by a SQL console session that bypasses the model layer; a trigger fires regardless of how the row was modified. Multiple application services touching the same database can each forget to maintain a derived column; a trigger maintains it for all of them in a single place.

The trigger's enforcement guarantee is real and not duplicable in application code. A REST API, a background worker, a SQL migration, an admin tool, and a developer running an UPDATE in psql at 2am are all different code paths into the database. Application-layer constraints have to be enforced in each of them. Trigger-based constraints are enforced once, at the bottom of the stack, regardless of which path the caller used.

The right cases for triggers

The first canonical case is audit logging that must not be skipped. A trigger on every INSERT, UPDATE, and DELETE that writes to an audit table guarantees that every state change is recorded, even when the change comes from a manual SQL session or an emergency hotfix script. The application layer can supplement the trigger with richer context (which user, which request, which API endpoint), but the trigger provides the floor: nothing escapes auditing.

The second case is maintaining computed columns and derived state. A trigger that updates a denormalized column when its source columns change keeps the derivation honest in a way that application code cannot match. The classic example is a search_vector column maintained by a trigger on insert and update; the application code never has to remember to recompute the vector, and the column never drifts from the source data.

The third case is enforcing complex constraints that span multiple rows. CHECK constraints handle single-row constraints; foreign keys handle simple referential integrity; but constraints like "the sum of these child rows must equal the parent row's total" require either a trigger or a stored procedure. When the constraint must always hold, a trigger is the only mechanism that enforces it under all access patterns.

The fourth case is timestamp maintenance. A BEFORE UPDATE trigger that sets updated_at = NOW() removes a class of bug — application code forgetting to update the timestamp — at the cost of a small constant amount of trigger overhead. This is the most common trigger in production systems and the one with the lowest debuggability cost, because the behavior is universally expected.

The wrong cases for triggers

The first trigger anti-pattern is using a trigger to call an external service. Triggers run in the database transaction; an HTTP call from inside a trigger ties the transaction's commit latency to the external service's response time and creates a cascade failure mode where database transactions fail because an unrelated service is slow. The right pattern is to write to an outbox table inside the transaction and let an external worker process the outbox.

The second anti-pattern is implementing business logic in triggers. The temptation to put "if order total exceeds X, apply discount Y" in a trigger is real, because it is a clean expression of a business rule that always applies. The cost is that the rule lives somewhere engineers do not look for business logic. New engineers reading the application code see no discount logic, run their own experiments, and are then bewildered when production data does not match their model. Business logic belongs in the application layer where it is visible and testable.

The third anti-pattern is using triggers as a workaround for a missing application-layer concern. If your application layer has bugs that fail to maintain certain invariants, the right answer is to fix the application, not to backstop the bug with a trigger. Triggers as backstops accumulate, become load-bearing, and eventually make it impossible to refactor the application layer because too much behavior depends on the database silently fixing things up.

The fourth anti-pattern is recursive triggers and cascading triggers that fire each other. A trigger on table A that updates table B which has a trigger that updates table C which has a trigger that updates table A is a recipe for either infinite recursion (in databases that allow it) or surprising performance characteristics (in databases that detect the recursion). The general rule is that a trigger should not modify any table other than the one it fires on, except for narrow exceptions like audit logging.

The discipline that keeps triggers honest

The first discipline is naming. Every trigger should have a name that describes what it does, and the trigger code should be discoverable from a standard location in the codebase. Triggers live in the database, but the SQL that creates them should live in the version-controlled migrations directory alongside table definitions. A new engineer reading the schema migrations should be able to find every trigger and understand what it does.

The second is testing. Triggers are testable: an integration test that runs the relevant statement and asserts on the resulting state covers the trigger's behavior. Treating triggers as production code that requires tests prevents the silent failures that come from a refactored migration breaking trigger semantics.

The third is documentation in the schema. PostgreSQL allows COMMENT ON TRIGGER and COMMENT ON FUNCTION; using these to record why the trigger exists makes the database self-documenting. The next engineer who runs \d on the table sees not just the trigger's existence but its purpose.

The fourth is observability. Triggers should log when they fire on unusual paths, particularly anything that modifies a different table than the one being touched. The log line should include enough context to reconstruct what happened, because debugging trigger behavior from incomplete information is one of the most painful operational experiences in database engineering.

The deeper observation

Triggers are powerful enough to be dangerous, and the danger is proportional to how surprising the trigger's behavior is to someone reading the application code. The right triggers are the ones whose existence is universally expected (timestamp maintenance, audit logging) or whose behavior is so well-defined that it can be inferred from the schema alone (computed columns, search vectors). The wrong triggers are the ones that implement business logic, call external services, or backstop application bugs. The boundary between right and wrong is mostly a question of debuggability: a trigger is acceptable if a future engineer, looking at unexpected behavior, will think to look at the database for the cause. If the answer is "they would never think to check the database," the trigger is in the wrong place.

Written by

Vera

Engineering researcher. APIs, databases, infrastructure, systems design.

More from Vera →