PostgreSQL Triggers - Recursion Guard pg_trigger_depth()
Batch job fails with stack depth limit exceeded due to recursive triggers.
20+ years shipping high-throughput database systems. Drawn from code that ran under real load.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- PostgreSQL triggers execute functions automatically when INSERT, UPDATE, DELETE, or TRUNCATE happens on a table.
- Timing: BEFORE (mutate row pre-write), AFTER (post-write, pre-commit), INSTEAD OF (for views).
- Scope: FOR EACH ROW fires per affected row; FOR EACH STATEMENT fires once per SQL command.
- Transition tables (REFERENCING OLD TABLE/NEW TABLE) enable bulk processing for statement-level triggers — one invocation for 50,000 rows.
- Key gotcha: RETURN NULL in a BEFORE row trigger silently cancels the operation — no error, no rows affected.
Imagine you work at a library. Every time someone checks out a book, a librarian automatically stamps a card, updates a log, and sends a receipt — without the borrower doing anything extra. A PostgreSQL trigger is that automatic librarian: you define a rule once, and the database fires it every time a specific thing happens to your data. You don't call it, you don't remember it — it just runs.
Most applications treat the database as a dumb storage bucket — data goes in, data comes out. But production systems have real cross-cutting concerns: audit trails that must never be skipped, denormalized caches that must stay in sync, business rules that must fire regardless of which application layer touches the data. Triggers are PostgreSQL's answer to this: executable logic that lives inside the database engine itself, tightly coupled to data events, invisible to application code.
Why PostgreSQL Triggers Are Not Callbacks
A PostgreSQL trigger is a function that executes automatically before, after, or instead of a DML event (INSERT, UPDATE, DELETE, TRUNCATE) on a table. The core mechanic: the trigger fires in the same transaction as the triggering statement — no separate commit, no async delivery. This means any error inside the trigger rolls back the entire operation, making triggers atomic by default.
Triggers can be row-level (once per affected row) or statement-level (once per SQL statement). Row-level triggers have access to OLD and NEW pseudo-records, letting you compare pre- and post-change values. They execute in a defined order per table (BEFORE, AFTER, INSTEAD OF) and can be constrained by WHEN conditions to skip unnecessary invocations. Performance matters: a row-level trigger on a 10k-row UPDATE fires 10k times, each with its own function call overhead.
Use triggers for cross-table integrity rules that can't be expressed as foreign keys or CHECK constraints — for example, maintaining a materialized audit log, enforcing complex business rules like “an order cannot be updated after shipment,” or syncing a denormalized column. Avoid triggers for logic that belongs in application code, like sending emails or calling external APIs, because the database cannot retry or handle partial failures gracefully.
How PostgreSQL Executes Triggers — The Internal Firing Order
Before you write a single trigger, you need to understand what the engine actually does when you fire one. PostgreSQL separates trigger execution into four dimensions: timing (BEFORE, AFTER, INSTEAD OF), scope (FOR EACH ROW vs FOR EACH STATEMENT), event (INSERT, UPDATE, DELETE, TRUNCATE), and transition visibility.
When a row-level BEFORE trigger fires, the NEW record hasn't been written to the heap yet. That means you can modify NEW before it lands — this is how validation and auto-stamping work. A row-level AFTER trigger fires after the row is committed to the heap within the transaction, but before the transaction commits to disk. Statement-level triggers fire once per SQL statement regardless of how many rows were affected — even zero rows.
The firing order when multiple triggers exist on the same table and event is alphabetical by trigger name. This isn't a quirk — it's documented and guaranteed. In practice, naming your triggers with numeric prefixes like '10_audit_' and '20_cache_invalidate_' lets you control execution order explicitly without relying on creation order.
INSTEAD OF Triggers on Views — Making Read-Only Views Writable
PostgreSQL views are non-updatable by default the moment they contain a JOIN, aggregation, DISTINCT, or subquery. INSTEAD OF triggers solve this by intercepting the write operation and redirecting it to the underlying base tables manually. The trigger fires in place of the attempted DML — the original operation never touches the view's definition at all.
This pattern is extremely common in reporting databases and data-access layers where you want to expose a clean, denormalized surface to application code while keeping the normalized schema underneath. The application writes to the view as if it were a table; the trigger handles the fan-out.
One subtle difference from regular triggers: INSTEAD OF triggers are always FOR EACH ROW. PostgreSQL doesn't support FOR EACH STATEMENT on views because statement-level transition tables aren't available in view context. Also, INSTEAD OF DELETE must handle the row using OLD, not NEW — NEW doesn't exist for a deletion.
Transition Tables — Statement-Level Triggers That Actually Scale
Row-level triggers have a dirty secret: they don't scale. If you UPDATE 50,000 rows, a FOR EACH ROW trigger fires 50,000 times — 50,000 context switches between the executor and the trigger runtime. For audit logging or cache invalidation, this is catastrophic.
PostgreSQL 10 introduced transition tables (REFERENCING OLD TABLE AS / REFERENCING NEW TABLE AS) for statement-level triggers. These are ephemeral, in-memory result sets containing all the affected rows, queryable like a regular table inside the trigger function. You process the entire batch in a single trigger invocation — one context switch, one INSERT into your audit table, done.
The performance difference is orders of magnitude. In one internal benchmark, moving from row-level audit logging to a statement-level trigger with a transition table reduced audit INSERT time from 4.2 seconds to 0.09 seconds for a 10,000-row bulk update. Transition tables are available for AFTER INSERT, AFTER UPDATE, and AFTER DELETE — not BEFORE triggers, and not INSTEAD OF.
Production Gotchas — Trigger Recursion, Deferred Triggers, and the pg_trigger Catalog
Triggers in production introduce failure modes you won't find in documentation tutorials. The most dangerous is mutual recursion: Trigger A updates Table B, which fires Trigger B, which updates Table A, which fires Trigger A again — stack overflow, transaction rollback, very confused DBA at 2am.
PostgreSQL provides two safety valves. First, session_replication_role: setting it to 'replica' disables all triggers that aren't explicitly marked as ALWAYS or REPLICA — useful for bulk data loads. Second, pg_trigger_depth() returns the current nesting level of trigger calls. Checking this at the start of a trigger function lets you conditionally skip execution when you're already inside a trigger chain.
Deferred constraint triggers (CONSTRAINT TRIGGER ... DEFERRABLE INITIALLY DEFERRED) are another production power tool. They fire at COMMIT time rather than at statement time — perfect for referential integrity checks that would fail mid-transaction but resolve before commit. Finally, pg_trigger in the system catalog is your observability layer: query it to see every trigger on every table, its function, its timing, and whether it's enabled.
pg_trigger_depth().Trigger Security and Privilege Escalation — The Hidden Attack Surface
Triggers run with the privileges of the table owner, not the user who executed the DML. This is both a feature and a landmine. If a trigger function references objects that the table owner can access but the calling user cannot, that's a privilege escalation path. For example, a trigger on a public INSERT that writes audit data into a table only the owner can see — the calling user never needs direct access to the audit table.
Conversely, if you write a trigger with SECURITY DEFINER, it runs with the privileges of the function definer. This is useful when you want the trigger to bypass row-level security or access restricted tables. But it also means that if the trigger logic is flawed, a low-privilege user can indirectly perform actions they shouldn't be able to.
Common mistakes: forgetting that triggers fire on TRUNCATE by default (if you specify ON TRUNCATE), which can bypass row-level security because TRUNCATE doesn't fire row-level triggers. Also, event triggers (CREATE EVENT TRIGGER) fire at DDL events like CREATE TABLE — they run with superuser privileges and can be used for aggressive monitoring, but also represent a huge security surface.
Best practice: always explicitly define which events a trigger fires on (INSERT, UPDATE, DELETE, or TRUNCATE). Avoid ON TRUNCATE unless you need it. For audit triggers, set them as SECURITY DEFINER with the definer being a dedicated audit role that has only INSERT on the audit table — least privilege applies.
- Calling user needs only DML on the target table — trigger functions can access other tables the owner owns.
- SECURITY DEFINER flips the model: trigger runs as function owner. Use it to isolate audit from application schema.
- Never use SECURITY INVOKER for audit triggers — a user with limited rights could bypass audit logging if they can't insert into the audit table.
- Event triggers (DDL) run as superuser — restrict their use to a few trusted roles.
Before vs After – When Each Phase Can Bite You
The timing keyword isn't a suggestion. BEFORE triggers fire before the row is written to disk. AFTER triggers fire after the visibility is committed to the heap. That order changes what data you can touch and what errors you can swallow.
BEFORE triggers let you modify the incoming row before it hits the table — useful for coercing data, setting defaults, or sanitising input. But if you try to read the same row from within a BEFORE trigger, you're reading stale state. The row doesn't exist yet. AFTER triggers see the final committed version, which is why audit logs and materialised summaries always use AFTER.
Here's the trap most juniors hit: BEFORE triggers can't see other transactions' writes because the row isn't visible yet. AFTER triggers can, but they also fire after any constraints have been checked, so a NOT NULL violation will abort before your AFTER trigger ever runs.
Statement-level triggers don't get per-row access to OLD and NEW unless you use transition tables. That's the next level of subtlety — and where production incidents live.
How Triggers Actually Fire – The Execution Order Nobody Documents
You need the mental model. The query planner doesn't just run your trigger function — it builds an ordered list of trigger invocations per row or statement. Here's the real firing order for a single UPDATE statement on a table with multiple triggers:
- BEFORE STATEMENT triggers (one per statement, in alphabetical order of trigger name)
- BEFORE ROW triggers (per row, one by one, in alphabetical order)
- Execute the actual UPDATE (row-level operations, constraint checks, index updates)
- AFTER ROW triggers (per row, in alphabetical order)
- AFTER STATEMENT triggers (one per statement, in alphabetical order)
If a trigger at step 2 returns NULL, the row operation is cancelled — the row is skipped entirely. The remaining AFTER ROW triggers for that row never fire. Statement-level triggers still execute because they fire per statement, not per row.
Most devs assume triggers run in creation order. They don't. They run alphabetically by trigger name. Name your triggers with a prefix to enforce priority — '001_before_sanitize', '002_before_validate'. It's ugly, but it's deterministic.
If any trigger function raises an exception, all changes in the current transaction are rolled back. The AFTER STATEMENT triggers might see partial data if you're not careful with subtransactions or savepoints. Production incidents start here.
The Audit-Log Recipe That Won't Eat Your Performance
Audit logs are the most common trigger use case. They're also the most common performance-killer. The naive approach — one audit row per row change — explodes under bulk operations. Statement-level triggers are faster, but you lose per-row detail. The fix is transition tables.
Transition tables let you capture the full set of changed rows inside a statement-level trigger. No per-row function call overhead. No thousands of individual INSERT statements. One batch INSERT per batch UPDATE.
Here's the pattern: create a logging table, write a single trigger function with REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows, then INSERT the entire set. PostgreSQL handles the memory for the transition tables internally, so you don't pay the row-by-row tax.
This pattern handles a million-row update in under a second. The per-row equivalent would take minutes and might lock your application out.
One gotcha: transition tables are only available in AFTER triggers. Not BEFORE. Don't ask me why — that's the Postgres design. Plan accordingly.
If you need row-level detail, you get it from the OLD TABLE and NEW TABLE sets. You join them on the primary key to compare values. It's a SQL join, not a loop. Use it.
PostgreSQL Trigger Functions: PL/pgSQL vs C vs Python
PostgreSQL supports multiple languages for writing trigger functions, each with distinct trade-offs. PL/pgSQL is the most common choice due to its tight integration with SQL, ease of use, and built-in support for trigger-specific variables like NEW, OLD, TG_OP, and TG_TABLE_NAME. It is ideal for most business logic and audit trails. C-language triggers offer maximum performance and access to low-level PostgreSQL internals, but require compilation, careful memory management, and deep knowledge of PostgreSQL's internal APIs. Python (via PL/Python) provides a rich ecosystem of libraries and is excellent for complex data transformations or integration with external services, but introduces overhead from the Python interpreter and potential security risks if untrusted code is executed. When choosing, consider performance requirements, team expertise, and deployment complexity. For production, PL/pgSQL is recommended unless performance benchmarks prove a bottleneck that C can solve, or Python is needed for specific libraries.
Transition Tables: NEW, OLD, and Referencing Clauses
Transition tables allow statement-level triggers to access the full set of rows affected by the triggering statement, not just one row at a time. Introduced in PostgreSQL 10, they are declared using the REFERENCING clause with OLD TABLE and NEW TABLE aliases. This is a game-changer for bulk operations: instead of firing a trigger for each row (which can be thousands), a single trigger invocation processes all rows at once. For example, an audit trigger can capture all changes from an UPDATE that modifies 10,000 rows in one go, drastically reducing overhead. Transition tables are available only in AFTER triggers (both row and statement level) and in INSTEAD OF triggers on views. They are particularly useful for maintaining summary tables, enforcing complex constraints across multiple rows, or logging bulk operations. However, they consume memory proportional to the number of affected rows, so for extremely large batches, consider batching or using a logging table with COPY.
Event Triggers: DDL Auditing with PostgreSQL Event Triggers
Event triggers fire in response to DDL commands (e.g., CREATE TABLE, ALTER TABLE, DROP TABLE) rather than DML on specific tables. They are defined at the database level and can capture events like ddl_command_start, ddl_command_end, sql_drop, and login. This makes them ideal for auditing schema changes, enforcing naming conventions, or preventing destructive operations. Unlike regular triggers, event triggers are written in C or PL/pgSQL (but not other languages) and must be created by a superuser. The function receives event data via the pg_event_trigger_ddl_commands() function, which returns a set of records describing each DDL command. A common use case is logging all DDL changes to an audit table. However, event triggers cannot be used for DML (INSERT/UPDATE/DELETE) or on specific tables; they are global. They also add overhead to every DDL command, so use sparingly. For example, you can create an event trigger that logs all CREATE TABLE statements with the user and timestamp.
Trigger Recursion Brought Down a Multitenant SaaS at Peak Load
pg_trigger_depth()) was present. PostgreSQL's default max_stack_depth (2MB) was hit after ~2,000 nested trigger invocations.pg_trigger_depth() > 1 THEN RETURN NEW; END IF; at the top of both trigger functions. Restructured the logic to use a statement-level trigger with transition tables instead of row-level looping.- Always add a recursion guard using
pg_trigger_depth()in any trigger that might modify a different table. - Profile trigger performance with transition tables for bulk DML — they eliminate recursive loops.
- Monitor pg_stat_activity for deep trigger nesting during batch operations.
SELECT tgname, tgenabled FROM pg_trigger WHERE NOT tgisinternal AND tgrelid = 'your_table'::regclass;\sf+ function_name -- view the trigger function source| File | Command / Code | Purpose |
|---|---|---|
| trigger_firing_order_demo.sql | CREATE TABLE orders ( | How PostgreSQL Executes Triggers |
| instead_of_trigger_writable_view.sql | CREATE TABLE products ( | INSTEAD OF Triggers on Views |
| transition_table_bulk_audit.sql | CREATE TABLE product_prices ( | Transition Tables |
| production_trigger_patterns.sql | CREATE TABLE categories ( | Production Gotchas |
| trigger_security_least_privilege.sql | CREATE ROLE trigger_audit_role; | Trigger Security and Privilege Escalation |
| BeforeVsAfterTiming.sql | CREATE OR REPLACE FUNCTION sanitize_email_insert() | Before vs After – When Each Phase Can Bite You |
| TriggerExecutionOrder.sql | CREATE TABLE orders ( | How Triggers Actually Fire – The Execution Order Nobody Docu |
| AuditLogTransition.sql | CREATE TABLE user_accounts ( | The Audit-Log Recipe That Won't Eat Your Performance |
| trigger_function_languages.sql | CREATE OR REPLACE FUNCTION audit_trigger() RETURNS TRIGGER AS $$ | PostgreSQL Trigger Functions |
| transition_tables_example.sql | CREATE TABLE audit_log ( | Transition Tables |
| event_trigger_ddl_audit.sql | CREATE TABLE ddl_audit_log ( | Event Triggers |
Key takeaways
Interview Questions on This Topic
What's the difference between a BEFORE trigger returning NULL versus raising an exception — and when would you choose each approach?
Frequently Asked Questions
20+ years shipping high-throughput database systems. Drawn from code that ran under real load.
That's MySQL & PostgreSQL. Mark it forged?
8 min read · try the examples if you haven't