Filter concepts by levelShowing all levels.

PostgreSQL · Section 14

Triggers

Level
intermediate
Read
30 min
Concepts
5

The three trigger timings and what each can and cannot do to the row being written, row-level vs statement-level granularity and their very different cost on bulk operations, the genuine use cases where a trigger is the right tool versus where a plain CHECK constraint would do, and the two real operational costs — alphabetical-by-name fire ordering plus unbounded cascading — that make trigger-driven workflows harder to reason about than the equivalent application code.

What is true here

  1. BEFORE can modify NEW or return NULL to veto a write; AFTER only observes an already-committed write; INSTEAD OF exists only on views.
  2. FOR EACH ROW fires per row with OLD/NEW bound; FOR EACH STATEMENT fires once per statement regardless of row count.
  3. Triggers fit cross-row/cross-table invariants, audit trails and derived values — prefer a CHECK constraint whenever the rule only needs the row's own values.
  4. Same-timing triggers fire alphabetically by name, not creation order, and a trigger's own writes can cascade — including recursively — with no built-in depth limit.
  5. A trigger-only workflow is invisible from the application code that triggers it — no stack trace entry, no call-site signal for a reviewer.

What you will be able to do

  • Choose the right trigger timing (BEFORE/AFTER/INSTEAD OF) for a given job
  • Choose row-level vs statement-level based on whether the logic needs per-row values
  • Judge when a trigger is the right tool versus when a CHECK constraint would fully express the same rule
  • Anticipate and guard against alphabetical fire-order surprises and unbounded trigger cascades
From timing choice to the debuggability cost
decide what thetrigger should doaccept itsordering/debugging cost

Timing + granularity

BEFORE/AFTER/INSTEAD OF, row/statement

A genuine invariant?

or would a CHECK constraint do?

Ordering + cascades

alphabetical fire order, invisible fan-out

  • Timing + granularity — BEFORE/AFTER/INSTEAD OF, row/statement
    • leads to A genuine invariant? (decide what the trigger should do)
  • A genuine invariant? — or would a CHECK constraint do?
    • leads to Ordering + cascades (accept its ordering/debugging cost)
  • Ordering + cascades — alphabetical fire order, invisible fan-out

Timing and granularity

The three timings, and the row-vs-statement choice that governs how many times a trigger actually runs.

BEFORE, AFTER and INSTEAD OF Triggers

coreintermediate

BEFORE triggers fire before the write is attempted and can modify the row or cancel the operation entirely (by returning NULL). AFTER triggers fire once the write has actually happened and cannot change it — they see the final state. INSTEAD OF triggers replace the operation entirely and only exist on views, which have no storage of their own to write to.

Think of it as

The three timings map onto three different jobs. BEFORE is for shaping or vetoing a row before it becomes real — normalizing a value, rejecting an invalid state. AFTER is for reacting to something that has already, definitely happened — writing an audit log entry, sending a notification — precisely because by then nothing can undo it. INSTEAD OF exists because a view has no rows of its own to insert into; the trigger IS the insert, translating it into whatever real writes the view's owner decides.

sql
-- BEFORE: normalize a value before it is stored
CREATE TRIGGER normalize_email BEFORE INSERT OR UPDATE ON users
    FOR EACH ROW EXECUTE FUNCTION lower_email();

-- AFTER: react to a write that has already happened
CREATE TRIGGER log_order_insert AFTER INSERT ON orders
    FOR EACH ROW EXECUTE FUNCTION write_audit_log();

-- INSTEAD OF: a view has no rows to write to directly
CREATE TRIGGER insert_via_view INSTEAD OF INSERT ON active_users_view
    FOR EACH ROW EXECUTE FUNCTION redirect_insert_to_users();

What we're doing: Show a BEFORE trigger silently rejecting an invalid row (by returning NULL) versus an AFTER trigger, which by the time it runs can only observe, not stop, the same write.

before_vs_after.sqlsql
CREATE FUNCTION reject_negative_price() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
    IF NEW.price < 0 THEN
        RETURN NULL;  -- BEFORE only: silently skips this row's insert
    END IF;
    RETURN NEW;
END;
$$;
CREATE TRIGGER guard_price BEFORE INSERT ON products
    FOR EACH ROW EXECUTE FUNCTION reject_negative_price();

INSERT INTO products (name, price) VALUES ('Widget', -5);
SELECT count(*) FROM products WHERE name = 'Widget';
3
The row is inspected before it is ever written — this is only possible in a BEFORE trigger.
4
Returning NULL from a row-level BEFORE trigger tells the executor to skip this row entirely — no error, no row.
11
The row genuinely never existed; an AFTER trigger could never have prevented this, since it fires only once the write already succeeded.
Output
INSERT 0 0

 count
-------
     0

Why this works: A BEFORE trigger sits in the one place in the pipeline where "should this row exist at all" can still be decided — by the time an AFTER trigger runs, that decision has already been made and committed within the transaction, which is exactly why AFTER triggers are reserved for reacting to facts rather than vetoing them.

Trying to cancel a write from an AFTER trigger

Wrong

sql
CREATE FUNCTION reject_negative_price() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
    IF NEW.price < 0 THEN
        RETURN NULL;  -- has NO effect in an AFTER trigger -- the row is already written
    END IF;
    RETURN NEW;
END;
$$;
CREATE TRIGGER guard_price AFTER INSERT ON products
    FOR EACH ROW EXECUTE FUNCTION reject_negative_price();

Better

sql
-- either use BEFORE to actually prevent it...
CREATE TRIGGER guard_price BEFORE INSERT ON products
    FOR EACH ROW EXECUTE FUNCTION reject_negative_price();

-- ...or, better still, a real CHECK constraint, which needs no trigger at all
ALTER TABLE products ADD CONSTRAINT price_non_negative CHECK (price >= 0);

What you see: The trigger appears to "work" in casual testing (no error is raised), but the invalid row is actually inserted and remains in the table — RETURN NULL from an AFTER trigger is simply ignored by the executor.

Why: An AFTER trigger's return value is discarded entirely, because by the time it fires the write is already a committed part of the current transaction — there is no operation left to skip. This is a common source of confusion precisely because the trigger runs without error, giving no signal that the intended guard silently did nothing.

Where each trigger timing sits relative to the write
may vetoor reshapealready committedto this transaction

BEFORE

can modify NEW or return NULL to skip

the actual write

row is inserted/updated/deleted

AFTER

sees the final state, cannot change it

  • BEFORE — can modify NEW or return NULL to skip
    • leads to the actual write (may veto or reshape)
  • the actual write — row is inserted/updated/deleted
    • leads to AFTER (already committed to this transaction)
  • AFTER — sees the final state, cannot change it

BEFORE vs AFTER vs INSTEAD OF

BEFORE vs AFTER vs INSTEAD OF
TimingCan modify/cancel the write?Valid on
BEFOREyes — modify NEW, or return NULL to skiptables, foreign tables (row); tables, views, foreign tables (statement)
AFTERno — the write already happenedtables, views, foreign tables
INSTEAD OFreplaces the operation entirelyviews only, row-level only

Remember: BEFORE can modify NEW or return NULL to veto the write; AFTER only observes a write that already happened and cannot undo it; INSTEAD OF replaces the operation entirely and exists only on views, which have nothing of their own to write to.

See also: row level vs statement level triggers · primary foreign unique not null check

Row-Level vs Statement-Level Triggers

coreintermediate

FOR EACH ROW fires the trigger once per affected row — a DELETE removing 10 rows fires it 10 times, with OLD/NEW bound to each one. FOR EACH STATEMENT fires exactly once per SQL statement regardless of how many rows it touches, even zero, and has no access to OLD/NEW for individual rows.

Think of it as

Row-level is for logic that genuinely depends on a specific row's values — validating this row, logging what changed about this row. Statement-level is for logic that only cares that an operation of a certain kind happened at all — refreshing a cache, recording "a bulk delete occurred on this table" once rather than once per row. Choosing row-level for something statement-level actually needs multiplies the trigger's cost by however many rows a bulk operation touches, for no extra information gained.

sql
-- row-level: needs each row's own OLD/NEW
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON orders
    FOR EACH ROW EXECUTE FUNCTION set_updated_at();

-- statement-level: only cares that a bulk delete happened at all
CREATE TRIGGER refresh_stats AFTER DELETE ON orders
    FOR EACH STATEMENT EXECUTE FUNCTION refresh_order_stats();

What we're doing: Compare a row-level trigger and a statement-level trigger on the same bulk DELETE, counting how many times each actually fires.

row_vs_statement_count.sqlsql
CREATE TABLE fire_log (kind text);

CREATE FUNCTION log_row() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN INSERT INTO fire_log VALUES ('row'); RETURN OLD; END; $$;
CREATE TRIGGER t_row AFTER DELETE ON orders
    FOR EACH ROW EXECUTE FUNCTION log_row();

CREATE FUNCTION log_stmt() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN INSERT INTO fire_log VALUES ('statement'); RETURN NULL; END; $$;
CREATE TRIGGER t_stmt AFTER DELETE ON orders
    FOR EACH STATEMENT EXECUTE FUNCTION log_stmt();

DELETE FROM orders WHERE status = 'cancelled';  -- deletes 4 rows
SELECT kind, count(*) FROM fire_log GROUP BY kind;
3–6
The row-level trigger logs once for every row DELETE actually removes.
8–11
The statement-level trigger logs once, no matter how many rows the same DELETE removed.
Output
   kind    | count
-----------+-------
 row       |     4
 statement |     1

Why this works: The single DELETE statement removed 4 rows, and each trigger honored its own granularity exactly as declared — the row-level trigger cannot "know" about the other rows in the same statement, while the statement-level trigger never had access to any individual row in the first place, which is precisely why it is far cheaper for bulk operations that do not need per-row logic.

Using FOR EACH ROW for logic that only needs to know an operation happened once

Wrong

sql
CREATE TRIGGER refresh_stats AFTER DELETE ON orders
    FOR EACH ROW EXECUTE FUNCTION refresh_order_stats();
-- refresh_order_stats() recomputes a materialized view or cache --
-- doing that once per deleted row is enormously wasteful on a bulk DELETE

Better

sql
CREATE TRIGGER refresh_stats AFTER DELETE ON orders
    FOR EACH STATEMENT EXECUTE FUNCTION refresh_order_stats();
-- fires exactly once regardless of how many rows the DELETE removed

What you see: A bulk DELETE that removes thousands of rows becomes dramatically slower than expected, and profiling shows nearly all the time is spent inside a trigger function whose logic does not actually depend on which row triggered it.

Why: FOR EACH ROW pays the trigger function's full cost once per affected row even when the function's work (refreshing a cache, recomputing an aggregate) is identical regardless of which row caused it — for logic like that, FOR EACH STATEMENT does the same job for a fixed cost independent of how many rows the statement touches.

A DELETE removing 4 rows — how many times does each trigger fire?

FOR EACH ROW

  • +Fires once per affected row
  • +OLD/NEW bound to that specific row
  • +4-row DELETE → 4 invocations

FOR EACH STATEMENT

  • Fires exactly once per statement
  • No per-row OLD/NEW (unless using transition tables)
  • 4-row DELETE → 1 invocation
  • FOR EACH ROW
    • Fires once per affected row
    • OLD/NEW bound to that specific row
    • 4-row DELETE → 4 invocations
  • FOR EACH STATEMENT
    • Fires exactly once per statement
    • No per-row OLD/NEW (unless using transition tables)
    • 4-row DELETE → 1 invocation

FOR EACH ROW vs FOR EACH STATEMENT

FOR EACH ROW vs FOR EACH STATEMENT
PropertyFOR EACH ROWFOR EACH STATEMENT
Firesonce per affected rowonce per statement
Access to OLD/NEWyes, for that rowno (unless using transition tables)
Cost on a 10,000-row UPDATE10,000 invocations1 invocation
Good fitper-row validation or derived valuescache refresh, bulk-operation notification

Remember: FOR EACH ROW fires once per affected row with that row's OLD/NEW bound; FOR EACH STATEMENT fires exactly once per statement regardless of row count. Use row-level when the logic genuinely needs a specific row's values; use statement-level when it only needs to know an operation of a certain kind happened.

See also: before after and instead of triggers · trigger ordering and hidden side effects

Advertisement

When triggers earn their keep

The genuine use cases, and why a CHECK constraint is usually the simpler choice when it can express the same rule.

Appropriate Uses for Triggers

standardintermediate

Triggers are the right tool when the logic must hold true regardless of which application or code path performs the write — maintaining an audit trail, keeping a derived column (like updated_at, or a denormalized total) consistent, or enforcing an invariant too complex for a plain CHECK constraint.

Think of it as

The common thread across every good trigger use case is "this must be true no matter who writes the row" — a raw SQL client, a bulk import script, and the main application should all trigger the same audit entry or the same derived-value update, because the guarantee lives with the data, not with any particular caller. If only one application path needs the behavior, a trigger is usually the wrong layer; if every possible writer needs it, a trigger (or a constraint, if the logic is simple enough) is exactly right.

sql
CREATE FUNCTION log_order_change() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
    INSERT INTO order_audit (order_id, changed_at, old_status, new_status)
    VALUES (NEW.id, now(), OLD.status, NEW.status);
    RETURN NEW;
END;
$$;
CREATE TRIGGER audit_order_status AFTER UPDATE OF status ON orders
    FOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status)
    EXECUTE FUNCTION log_order_change();

What we're doing: Write a trigger that keeps a denormalized order_items_count column on orders in sync whenever order_items rows are inserted or deleted — a genuine cross-table invariant a CHECK constraint cannot express.

sync_item_count.sqlsql
CREATE FUNCTION sync_item_count() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        UPDATE orders SET item_count = item_count + 1 WHERE id = NEW.order_id;
    ELSIF TG_OP = 'DELETE' THEN
        UPDATE orders SET item_count = item_count - 1 WHERE id = OLD.order_id;
    END IF;
    RETURN NULL;  -- AFTER trigger, return value is ignored
END;
$$;

CREATE TRIGGER keep_item_count_in_sync
    AFTER INSERT OR DELETE ON order_items
    FOR EACH ROW EXECUTE FUNCTION sync_item_count();
3
TG_OP tells the trigger function which operation fired it — a standard PL/pgSQL trigger variable.
12–13
The trigger lives on order_items but keeps a column on the separate orders table correct — exactly the cross-table invariant a CHECK constraint structurally cannot express.
Output
CREATE FUNCTION
CREATE TRIGGER

Why this works: A CHECK constraint can only look at the row currently being written, never at other rows or other tables, so a "denormalized count on a different table" is squarely a job that requires a trigger — this is the honest case where triggers exist to do something no simpler mechanism can.

Reaching for a trigger when a CHECK constraint would fully express the same rule

Wrong

sql
CREATE FUNCTION enforce_non_negative_price() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
    IF NEW.price < 0 THEN
        RAISE EXCEPTION 'price cannot be negative';
    END IF;
    RETURN NEW;
END;
$$;
CREATE TRIGGER guard_price BEFORE INSERT OR UPDATE ON products
    FOR EACH ROW EXECUTE FUNCTION enforce_non_negative_price();

Better

sql
ALTER TABLE products ADD CONSTRAINT price_non_negative CHECK (price >= 0);
-- same guarantee, visible directly in \d products, cannot be silently disabled
-- the way a trigger can with ALTER TABLE ... DISABLE TRIGGER

What you see: A future DBA runs ALTER TABLE products DISABLE TRIGGER guard_price for an unrelated maintenance task and forgets to re-enable it, silently removing the price validation with no visible trace in the schema itself.

Why: A trigger is strictly more powerful, and more fragile, than a constraint: it can be disabled independently, its logic is not visible in \d output the way a CHECK constraint is, and it costs a function call per row instead of an inline check the planner understands. Whenever the rule is expressible as a CHECK constraint — a single row's own values — the constraint is the simpler, more visible, harder-to-accidentally-disable choice.

Good trigger use cases

Good trigger use cases
Use caseWhy a trigger fits
Audit trailmust capture every write, regardless of caller
updated_at maintenancea data-level fact true for every row, every writer
Cross-row invariantCHECK constraints cannot reference other rows/tables
Denormalized total kept in syncmust stay correct even if maintained outside the app's main write path

Remember: Triggers earn their keep for cross-row/cross-table invariants, audit trails, and derived values that must stay correct regardless of which caller writes the data — but whenever a plain CHECK constraint can express the same rule, prefer it: simpler, self-documenting, and cannot be silently disabled.

See also: before after and instead of triggers · primary foreign unique not null check

Advertisement

The hidden costs

Alphabetical fire ordering, unbounded cascades, and why a trigger-only workflow is invisible to the application code that sets it off.

Trigger Ordering and Hidden Side Effects

coreintermediate

When multiple triggers of the same timing and level exist on the same table and event, PostgreSQL fires them in alphabetical order by trigger name — not creation order. If a trigger function runs SQL that fires other triggers (including, potentially, itself), that is a cascading trigger, and nothing in PostgreSQL limits how many levels deep that cascade can go.

Think of it as

A trigger firing another trigger is invisible from the statement that started it — a single INSERT can silently fan out through several trigger functions across several tables, and PostgreSQL will not warn about it. The alphabetical-by-name ordering rule matters because it is not obvious from CREATE TRIGGER order — two developers adding triggers in a different order than they intended can produce a different, silent execution order purely because of naming, with no error at creation time.

sql
-- naming convention that makes fire order visible and deliberate
CREATE TRIGGER "01_validate" BEFORE INSERT ON orders
    FOR EACH ROW EXECUTE FUNCTION validate_order();
CREATE TRIGGER "02_normalize" BEFORE INSERT ON orders
    FOR EACH ROW EXECUTE FUNCTION normalize_order();
-- fires 01_validate, then 02_normalize -- alphabetical, made intentional by the prefix

What we're doing: Show two same-timing triggers on one table firing in alphabetical order regardless of creation order, then show a genuinely recursive trigger with a guard condition to stop it.

ordering_and_recursion.sqlsql
-- created in this order: "zzz_last" first, "aaa_first" second
CREATE TRIGGER zzz_last BEFORE INSERT ON logs
    FOR EACH ROW EXECUTE FUNCTION log_step('zzz_last');
CREATE TRIGGER aaa_first BEFORE INSERT ON logs
    FOR EACH ROW EXECUTE FUNCTION log_step('aaa_first');
-- fires aaa_first, THEN zzz_last -- alphabetical by name, ignoring creation order

CREATE FUNCTION split_large_order() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
    IF NEW.qty > 100 AND NOT NEW.already_split THEN
        INSERT INTO orders (qty, already_split) VALUES (NEW.qty - 100, true);
        NEW.qty := 100;
        NEW.already_split := true;  -- the guard: prevents infinite recursion
    END IF;
    RETURN NEW;
END;
$$;
CREATE TRIGGER auto_split BEFORE INSERT ON orders
    FOR EACH ROW EXECUTE FUNCTION split_large_order();
2–5
"zzz_last" was created first, but its name sorts after "aaa_first" — it fires second regardless.
12
The guard condition (already_split) is what stops this trigger from firing itself indefinitely on the row it just inserted.
Output
-- (no direct output -- the point is fire order and the recursion guard, not a query result)

Why this works: The alphabetical-ordering rule is a real, documented PostgreSQL behavior that silently overrides whatever order a migration file created triggers in — without a deliberate naming convention, two triggers whose relative order matters can produce a subtly wrong result with no error anywhere. The recursion guard on split_large_order() is required precisely because PostgreSQL enforces no depth limit on its own — an unguarded version of this trigger would recurse until it errored out or exhausted resources.

Assuming triggers fire in the order they were created

Wrong

sql
CREATE TRIGGER compute_total BEFORE INSERT ON orders
    FOR EACH ROW EXECUTE FUNCTION compute_total();       -- created first, needs to run first
CREATE TRIGGER apply_discount BEFORE INSERT ON orders
    FOR EACH ROW EXECUTE FUNCTION apply_discount();       -- created second, expected to run after
-- "apply_discount" sorts BEFORE "compute_total" alphabetically -- runs in the WRONG order

Better

sql
CREATE TRIGGER "01_compute_total" BEFORE INSERT ON orders
    FOR EACH ROW EXECUTE FUNCTION compute_total();
CREATE TRIGGER "02_apply_discount" BEFORE INSERT ON orders
    FOR EACH ROW EXECUTE FUNCTION apply_discount();
-- explicit numeric prefixes make the real, alphabetical firing order match the intended order

What you see: apply_discount ends up running before compute_total has set the total, silently discounting a stale or zero value — no error is raised anywhere, the bug only surfaces as visibly wrong totals in production data.

Why: PostgreSQL documents alphabetical-by-name ordering explicitly, but nothing in CREATE TRIGGER's syntax hints at this — it is easy to assume creation order matters, the way statement order matters everywhere else in SQL, and only discover otherwise once two same-timing triggers whose relative order is load-bearing produce a wrong result.

A cascade: one INSERT quietly fans out
AFTER INSERTthe UPDATE itruns fires thisthe INSERT itruns fires this

INSERT INTO orders

the only statement the caller sees

Trigger: deduct_inventory

fires an UPDATE on inventory

Trigger: check_low_stock

fires an INSERT on stock_alerts

Trigger: notify

pg_notify(), no further writes

  • INSERT INTO orders — the only statement the caller sees
    • leads to Trigger: deduct_inventory (AFTER INSERT)
  • Trigger: deduct_inventory — fires an UPDATE on inventory
    • leads to Trigger: check_low_stock (the UPDATE it runs fires this)
  • Trigger: check_low_stock — fires an INSERT on stock_alerts
    • leads to Trigger: notify (the INSERT it runs fires this)
  • Trigger: notify — pg_notify(), no further writes

What can silently go wrong with triggers

What can silently go wrong with triggers
HazardWhy it is hidden
Fire order depends on trigger namenot visible from CREATE TRIGGER statement order in a migration file
Cascading across tablesa single INSERT can fan out with no indication in the original statement
Recursive self-firingno automatic depth limit — an unguarded case can loop indefinitely

Remember: Same-timing triggers on one table fire in alphabetical order by name, not creation order — use a numeric prefix if order matters. A trigger's own writes can fire other triggers (cascading), including itself (recursion), with no automatic depth limit — any trigger that might re-fire itself needs its own explicit guard.

See also: appropriate uses for triggers · why trigger only workflows are harder to reason about

Why Trigger-Only Workflows Are Harder to Reason About

standardintermediate

A workflow built entirely out of triggers is invisible from the application code that ultimately causes it — a developer reading "INSERT INTO orders" has no way to know, without reading the schema's trigger definitions directly, that the insert also updates inventory, writes an audit row and sends a notification. That invisibility is exactly what makes trigger-only workflows hard to debug, test and change safely.

Think of it as

Application code that calls three functions in sequence is traceable by reading top to bottom, stepping through a debugger, or grepping for a function name. A trigger-driven workflow has none of that: the "call sequence" is implicit in trigger names, timing and table relationships scattered across CREATE TRIGGER statements that do not appear anywhere near the INSERT that sets them off. The workflow still exists and still runs — it is just missing from the place a developer would naturally look for it.

sql
-- what a reviewer sees in the application's pull request:
-- INSERT INTO orders (customer_id, total) VALUES ($1, $2);

-- what actually happens, invisible from that line alone:
-- \d+ orders  -- lists every trigger attached to this table
--   Triggers:
--     deduct_inventory AFTER INSERT ON orders FOR EACH ROW EXECUTE FUNCTION deduct_inventory_fn()
--     audit_order_insert AFTER INSERT ON orders FOR EACH ROW EXECUTE FUNCTION write_audit_log()

What we're doing: Show how to make an implicit trigger-driven workflow explicit again — using \d+ to surface what a single INSERT actually triggers, as a concrete debugging/documentation step.

surface_hidden_triggers.sqlsql
\d+ orders

-- Triggers:
--   deduct_inventory AFTER INSERT ON orders FOR EACH ROW EXECUTE FUNCTION deduct_inventory_fn()
--   audit_order_insert AFTER INSERT ON orders FOR EACH ROW EXECUTE FUNCTION write_audit_log()
--   check_low_stock AFTER UPDATE ON inventory FOR EACH ROW EXECUTE FUNCTION check_low_stock_fn()
--   (the last one only becomes relevant because deduct_inventory_fn() updates the inventory table)

-- to actually see it run, query pg_stat_user_functions before/after,
-- or add RAISE NOTICE statements temporarily inside each trigger function
1
\d+ is the single most direct way to make a table's hidden trigger behavior visible again before touching it.
3–6
None of these three triggers would be found by grepping the application's codebase for "orders" — they only exist in the schema.
Output
(psql \d+ output listing every trigger attached to the table)

Why this works: Making the implicit explicit — by habitually checking \d+ before modifying a table with triggers, or documenting the trigger-driven workflow directly alongside the schema — is the practical mitigation for the fact that trigger call graphs do not show up anywhere application tooling looks by default.

Debugging a "missing" side effect by only reading application code

Wrong

sql
-- developer reads the application code, sees only:
--   db.execute("INSERT INTO orders (customer_id, total) VALUES (%s, %s)", ...)
-- concludes inventory deduction must be a bug in a DIFFERENT part of the app,
-- and spends an hour searching application code that was never involved

Better

sql
\d+ orders
-- immediately reveals deduct_inventory AFTER INSERT ON orders ...
-- the "missing" behavior was never missing -- it was in the database schema,
-- not the application code being searched

What you see: A significant amount of debugging time is spent searching application code for logic that was never there, because the actual behavior lives entirely in database triggers the developer did not know to check.

Why: The instinct to search application code first is reasonable for most bugs, but a trigger-only workflow specifically breaks that instinct — the fix is procedural, not technical: when a table's behavior does not match what the application code alone would predict, checking \d+ (or the schema's trigger definitions directly) needs to become a standard early step, not a last resort.

Debuggability: application code vs trigger-only workflow

Debuggability: application code vs trigger-only workflow
PropertyApplication codeTrigger-only workflow
Visible in a stack traceyesno
Testable without a real databaseoftenrarely
Visible to a reviewer reading the call siteyesno — must already know the schema
Findable by searching application codeyesno — lives in migrations/pg_trigger

Remember: A trigger-only workflow is invisible from the application code that triggers it — no stack trace entry, no easy unit test, no signal at the call site for a reviewer. That cost does not make triggers wrong, but it means the decision to put critical business logic only in triggers should be deliberate, with \d+ or equivalent documentation as the mitigation, not an afterthought.

See also: trigger ordering and hidden side effects · avoiding an unnecessarily complex database runtime

Advertisement