The Trigger Model
A trigger is a function PostgreSQL calls automatically when a specific DML event happens on a table, and the entire model is built around answering one question: exactly when, and how many times, does that call happen relative to the write it's reacting to.
Getting that timing model right is what separates a trigger that quietly validates or audits data from one that silently corrupts it, deadlocks under load, or fires far more often than the author intended.
This page builds the mental model behind timing, row-versus-statement firing, and the executor's interaction with MVCC so that Triggers Basics, Transition Tables, Audit Triggers, and Rules (Legacy) read as one coherent system rather than a pile of syntax variants.
Summary
- Core Idea: A trigger binds a function to a DML event with a specific timing (BEFORE/AFTER/INSTEAD OF) and granularity (ROW/STATEMENT), and PostgreSQL calls that function automatically whenever the event matches.
- Why It Matters: The timing and granularity you choose determine whether the trigger can still change the data, how many times it runs, and what row state it's actually allowed to see.
- Key Concepts: timing (BEFORE/AFTER/INSTEAD OF), granularity (ROW/STATEMENT), NEW/OLD, transition table, trigger ordering.
- When to Use: Enforcing invariants a
CHECKconstraint can't express, maintaining derived or audit data automatically, or intercepting writes to a view withINSTEAD OF. - Limitations / Trade-offs: Every trigger adds invisible cost to every matching write, and logic hidden in a trigger is easy to forget about when someone is debugging unexpected data changes months later.
- Related Topics: MVCC row visibility, constraint enforcement, logical replication, the legacy rule system.
Foundations
Every trigger is defined by two independent choices: its timing (BEFORE, AFTER, or INSTEAD OF) and its granularity (FOR EACH ROW or FOR EACH STATEMENT).
BEFORE ROW triggers run once per affected row before the write happens, and they are the only kind that can modify the row through NEW or cancel the operation entirely by returning NULL.
AFTER ROW triggers run once per affected row after the write has happened, and they can look at the final row but cannot change what got written, they can only react to it.
INSTEAD OF triggers exist only on views, and they replace the write entirely, which is how a normally read-only view can accept INSERT, UPDATE, or DELETE statements.
STATEMENT-level triggers fire exactly once per SQL statement regardless of how many rows it touched, trading per-row detail for a single, cheap invocation.
A useful analogy is a security checkpoint on an assembly line: a BEFORE ROW trigger is a checkpoint that can still reject or relabel an item before it leaves the station, while an AFTER ROW trigger is a camera further down the line that only records what already shipped.
Mechanics & Interactions
Every trigger function returns the special pseudo-type trigger, and for row-level triggers the return value has meaning: a BEFORE trigger returning NULL cancels the operation for that row, while returning NEW (possibly modified) lets it proceed.
CREATE FUNCTION app.touch_updated() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;When multiple triggers share the same table and timing, PostgreSQL fires them in alphabetical order by trigger name, not creation order, which is why teams that need a predictable sequence prefix trigger names deliberately.
AFTER triggers run within the same transaction as the write that fired them, so any row they read reflects that transaction's own uncommitted changes under normal MVCC visibility rules, but nothing about the trigger being AFTER means the outer transaction has committed yet.
Transition tables, declared with REFERENCING NEW TABLE AS ... OLD TABLE AS ..., expose the full set of rows a statement-level trigger affected as if it were a real table, giving bulk access to changed rows without paying the per-row cost of a row-level trigger.
Row-level: row 1 -> fire row 2 -> fire row 3 -> fire (N calls)
Statement-level: [row1, row2, row3] -> fire once (1 call, sees all rows via transition table)A WHEN clause lets a row-level trigger skip firing entirely when a condition on OLD/NEW isn't met, which is evaluated before the trigger function is even invoked and is meaningfully cheaper than checking the same condition as the first line inside the function body.
Advanced Considerations & Applications
The older rule system (CREATE RULE) works by rewriting the incoming query itself into a different query before execution, which is a fundamentally different mechanism from a trigger's per-row or per-statement function call, and it is why rules can silently change row counts returned by commands in ways triggers never do.
PostgreSQL's own documentation steers most new code toward triggers and INSTEAD OF triggers on views instead of rules, since rules interact confusingly with multi-statement commands and are much harder to reason about; the one place rules still matter in practice is INSTEAD OF-style view rewriting that predates the cleaner view-trigger mechanism.
Triggers also interact with logical replication in a way that surprises people: by default, a trigger on a subscriber-side table does not fire for rows applied by replication, and you have to explicitly mark it ENABLE REPLICA TRIGGER or ENABLE ALWAYS TRIGGER if you need it to run there too.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
| BEFORE ROW trigger | Can validate or transform data before it's written | Adds per-row overhead to every matching write | Enforcing invariants, setting derived columns like updated_at |
| AFTER ROW trigger | Sees the final committed-within-transaction row | Cannot change what was written | Audit logging, cascading side effects |
| Statement-level + transition table | One invocation per statement, bulk access to changed rows | No per-row NEW/OLD, must query the transition table | Bulk audit capture, aggregate side effects on large writes |
| INSTEAD OF trigger on view | Makes a complex view writable | Must reimplement the write logic by hand | Updatable views over joins or computed columns |
| Legacy rule | Rewrites the query before execution | Confusing interaction with multi-statement commands, hard to debug | Narrow, legacy view-rewrite cases only |
A well-known operational failure mode is the cascading trigger chain, where a trigger on table A updates table B, which fires a trigger that updates table A again, and without a deliberate stop condition that chain can loop or simply make an innocuous-looking write far slower than it appears.
Common Misconceptions
- "Triggers run in their own separate transaction." A trigger executes inside the exact same transaction as the statement that fired it, so if that transaction rolls back, everything the trigger did rolls back too.
- "Returning
NULLfrom any trigger cancels the write." OnlyBEFORE ROWtriggers can cancel an operation this way, anAFTERtrigger's return value is ignored entirely. - "Trigger firing order follows creation order." PostgreSQL fires same-timing triggers in alphabetical order by trigger name, which is why deliberate naming prefixes matter for ordering-sensitive logic.
- "Rules and triggers are basically the same feature with different syntax." A rule rewrites the query itself before execution, while a trigger calls a function per row or per statement after the query is already planned, and the two produce very different behavior on multi-row commands.
- "A statement-level trigger can't see which rows changed." A statement-level trigger with a transition table can see every affected row at once, it just can't reference
NEW/OLDfor a single row the way a row-level trigger can.
FAQs
What are the two independent choices that define every trigger?
Its timing (BEFORE, AFTER, or INSTEAD OF) and its granularity (FOR EACH ROW or FOR EACH STATEMENT).
Which trigger type can actually change the data being written?
Only a BEFORE ROW trigger, since it runs before the write happens and can modify NEW or cancel the operation by returning NULL.
Can an AFTER trigger stop the write from happening?
No, an AFTER trigger's return value is ignored, it can only observe the row and react to it, for example by raising an exception to roll back the whole transaction.
What order do multiple triggers on the same table and timing fire in?
Alphabetical order by trigger name, not the order they were created, which is why teams prefix trigger names when a specific sequence matters.
What's the point of a transition table?
- Gives a statement-level trigger bulk access to every row a statement affected
- Avoids the per-row overhead of a row-level trigger on large writes
- Is referenced with
REFERENCING NEW TABLE AS ... OLD TABLE AS ...
How does a `WHEN` clause help performance?
It's evaluated before the trigger function is invoked at all, so a row that doesn't match the condition never pays the cost of entering the function body.
Do triggers fire when logical replication applies changes on a subscriber?
Not by default, a trigger needs to be explicitly marked ENABLE REPLICA TRIGGER or ENABLE ALWAYS TRIGGER to run against replication-applied rows.
What's actually different between a rule and a trigger?
A rule rewrites the incoming query into a different query before execution, while a trigger calls a function per row or per statement after the query is already planned and running.
Are rules still recommended for new code?
Generally no, PostgreSQL's own guidance favors triggers, including INSTEAD OF triggers on views, because rules interact confusingly with multi-statement commands.
What causes a cascading trigger chain?
A trigger on one table modifies another table whose own trigger modifies the first table again, and without an explicit stop condition that loop can degrade performance or run indefinitely.
Can I temporarily disable a trigger for a bulk load?
Yes, with ALTER TABLE ... DISABLE TRIGGER trigger_name, though you should re-enable it before the table returns to normal application traffic and document the maintenance window.
When should I avoid triggers entirely?
When the same logic can live cleanly in application code or a database function called explicitly, since a trigger hides logic from anyone reading a plain INSERT/UPDATE statement.
Related
- Triggers Basics - BEFORE/AFTER, row vs statement, hands-on
- Transition Tables - statement-level bulk OLD/NEW access
- Audit Triggers - change capture patterns without killing write throughput
- Rules (Legacy) - the query-rewrite mechanism triggers mostly replaced
- When Not to Use Triggers - the trade-off against application-level logic
- PL/pgSQL Explained - the language every trigger function is written in
Stack versions: This page was written for PostgreSQL 18.4 (stable line 18, maintenance line 17), where the trigger timing model, transition tables, and replica-trigger firing behavior described here are current.