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.
PostgreSQL triggers are database functions that automatically execute in response to specified DML events (INSERT, UPDATE, DELETE, or TRUNCATE) on a table or view. Unlike application-level callbacks or event hooks, triggers run inside the database transaction—they share the same atomicity, visibility, and rollback semantics as the triggering statement.
This means a trigger failure aborts the entire operation, and any changes made by the trigger are invisible to concurrent transactions until the outer transaction commits. Triggers exist because they enforce business logic, maintain audit trails, or synchronize derived data at the database layer, where no application code can bypass them—critical for multi-service architectures or legacy systems where you can't trust every client.
PostgreSQL supports two timing modes: BEFORE triggers (fire before the row is modified, allowing you to veto or mutate the incoming row) and AFTER triggers (fire after the modification, useful for cascading side effects or logging). Statement-level triggers fire once per SQL statement regardless of row count, while row-level triggers fire once per affected row.
For views, INSTEAD OF triggers intercept operations that would otherwise fail on read-only views, letting you redirect inserts/updates/deletes to underlying tables. Transition tables (available since PostgreSQL 10) give statement-level triggers access to the full set of old and new rows via OLD TABLE and NEW TABLE references, avoiding the performance cliff of row-level triggers on bulk operations.
The critical production concern is trigger recursion: a trigger that modifies the same table (or a related table with its own trigger) can chain indefinitely, causing infinite loops or stack overflow. PostgreSQL provides pg_trigger_depth() to detect and break these cycles—it returns the current nesting level of trigger calls, and you can guard against runaway recursion by checking it early in your trigger function.
Other gotchas include deferred triggers (constraint triggers that delay execution until commit), the pg_trigger catalog for introspection, and privilege escalation risks: triggers run with the security context of their definer (by default), so a malicious user who can create triggers on a table owned by a superuser can execute arbitrary SQL with elevated privileges. Always audit trigger ownership and use SECURITY DEFINER with care.
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.
-- =========================================================== -- Demonstrate PostgreSQL trigger firing order and NEW visibility -- =========================================================== -- 1. Create a simple orders table CREATE TABLE orders ( order_id SERIAL PRIMARY KEY, customer_name TEXT NOT NULL, total_amount NUMERIC(10,2) NOT NULL, status TEXT NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ ); -- 2. Create an audit log table to capture every change CREATE TABLE order_audit_log ( log_id SERIAL PRIMARY KEY, order_id INT, event_type TEXT, -- INSERT / UPDATE / DELETE old_status TEXT, -- NULL on INSERT new_status TEXT, -- NULL on DELETE changed_at TIMESTAMPTZ NOT NULL DEFAULT now(), changed_by TEXT NOT NULL -- the DB role that made the change ); -- =========================================================== -- TRIGGER FUNCTION 1: Stamp updated_at before the row is written -- This is a BEFORE trigger — we mutate NEW directly -- =========================================================== CREATE OR REPLACE FUNCTION stamp_updated_at() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- NEW is the incoming row record. Mutating it here changes -- what actually gets written to the heap. NEW.updated_at := now(); -- Returning NEW tells PostgreSQL: "use this (possibly modified) -- row as the actual row to insert/update". -- Returning NULL would cancel the operation entirely. RETURN NEW; END; $$; -- =========================================================== -- TRIGGER FUNCTION 2: Write an audit log entry AFTER the row lands -- This is an AFTER trigger — OLD and NEW are both fully visible -- =========================================================== CREATE OR REPLACE FUNCTION log_order_status_change() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- On INSERT, OLD is NULL — use COALESCE to handle it safely INSERT INTO order_audit_log (order_id, event_type, old_status, new_status, changed_by) VALUES ( COALESCE(NEW.order_id, OLD.order_id), TG_OP, -- built-in variable: 'INSERT','UPDATE','DELETE' OLD.status, -- NULL for INSERT operations NEW.status, -- NULL for DELETE operations current_user -- the authenticated PostgreSQL role ); -- AFTER row triggers must still return NEW (or OLD for DELETE) -- The return value is ignored, but NULL would suppress further -- triggers in the chain — always return the right record. RETURN NEW; END; $$; -- =========================================================== -- Attach triggers — note the naming convention: numeric prefix -- controls alphabetical (therefore execution) order -- =========================================================== -- Fires BEFORE insert or update — stamps the timestamp CREATE TRIGGER orders_10_stamp_updated_at BEFORE INSERT OR UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION stamp_updated_at(); -- Fires AFTER insert, update, or delete — writes the audit log CREATE TRIGGER orders_20_audit_status_change AFTER INSERT OR UPDATE OR DELETE ON orders FOR EACH ROW EXECUTE FUNCTION log_order_status_change(); -- =========================================================== -- TEST IT -- =========================================================== -- Insert a new order INSERT INTO orders (customer_name, total_amount) VALUES ('Alice Johnson', 149.99); -- Update the status UPDATE orders SET status = 'shipped' WHERE customer_name = 'Alice Johnson'; -- Review the audit trail SELECT log_id, order_id, event_type, old_status, new_status, changed_at, changed_by FROM order_audit_log ORDER BY log_id;
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.
-- =========================================================== -- Writable view pattern using INSTEAD OF triggers -- Use case: e-commerce product catalog with a separate -- inventory table — expose a unified view to the API layer -- =========================================================== CREATE TABLE products ( product_id SERIAL PRIMARY KEY, sku TEXT NOT NULL UNIQUE, product_name TEXT NOT NULL, unit_price NUMERIC(10,2) NOT NULL ); CREATE TABLE inventory ( inventory_id SERIAL PRIMARY KEY, product_id INT NOT NULL REFERENCES products(product_id), warehouse TEXT NOT NULL DEFAULT 'main', stock_qty INT NOT NULL DEFAULT 0, UNIQUE (product_id, warehouse) ); -- The view the application sees — a clean, flat product+stock surface CREATE VIEW product_catalog AS SELECT p.product_id, p.sku, p.product_name, p.unit_price, i.stock_qty, i.warehouse FROM products p JOIN inventory i ON i.product_id = p.product_id; -- =========================================================== -- INSTEAD OF INSERT: fan the write out to both base tables -- =========================================================== CREATE OR REPLACE FUNCTION insert_into_product_catalog() RETURNS TRIGGER LANGUAGE plpgsql AS $$ DECLARE new_product_id INT; BEGIN -- Step 1: insert the core product record and capture its generated PK INSERT INTO products (sku, product_name, unit_price) VALUES (NEW.sku, NEW.product_name, NEW.unit_price) RETURNING product_id INTO new_product_id; -- Step 2: create the corresponding inventory row INSERT INTO inventory (product_id, warehouse, stock_qty) VALUES ( new_product_id, COALESCE(NEW.warehouse, 'main'), -- default to main warehouse COALESCE(NEW.stock_qty, 0) ); -- Return NEW so PostgreSQL knows the operation "succeeded" RETURN NEW; END; $$; -- =========================================================== -- INSTEAD OF UPDATE: update each base table independently -- =========================================================== CREATE OR REPLACE FUNCTION update_product_catalog() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- Only update columns that actually changed (compare OLD vs NEW) UPDATE products SET product_name = NEW.product_name, unit_price = NEW.unit_price WHERE product_id = OLD.product_id; UPDATE inventory SET stock_qty = NEW.stock_qty WHERE product_id = OLD.product_id AND warehouse = OLD.warehouse; RETURN NEW; END; $$; -- Attach both INSTEAD OF triggers to the view CREATE TRIGGER product_catalog_insert INSTEAD OF INSERT ON product_catalog FOR EACH ROW EXECUTE FUNCTION insert_into_product_catalog(); CREATE TRIGGER product_catalog_update INSTEAD OF UPDATE ON product_catalog FOR EACH ROW EXECUTE FUNCTION update_product_catalog(); -- =========================================================== -- TEST: application writes to the view as if it's a table -- =========================================================== INSERT INTO product_catalog (sku, product_name, unit_price, stock_qty, warehouse) VALUES ('SKU-9001', 'Wireless Keyboard', 79.99, 250, 'main'); UPDATE product_catalog SET unit_price = 69.99, stock_qty = 230 WHERE sku = 'SKU-9001'; -- Verify data landed in the correct base tables SELECT p.sku, p.unit_price, i.stock_qty FROM products p JOIN inventory i ON i.product_id = p.product_id WHERE p.sku = 'SKU-9001';
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.
-- =========================================================== -- High-performance bulk audit using transition tables -- Scenario: price update job runs nightly, touching thousands -- of product rows — we need a full audit trail without -- 10,000 individual trigger invocations -- =========================================================== CREATE TABLE product_prices ( product_id INT PRIMARY KEY, sku TEXT NOT NULL, current_price NUMERIC(10,2) NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE price_change_audit ( audit_id SERIAL PRIMARY KEY, product_id INT, sku TEXT, old_price NUMERIC(10,2), new_price NUMERIC(10,2), price_delta NUMERIC(10,2), -- computed: new - old changed_at TIMESTAMPTZ NOT NULL DEFAULT now(), batch_job_id TEXT -- passed via SET LOCAL ); -- =========================================================== -- Statement-level trigger function using transition tables -- OLD TABLE contains all rows BEFORE the UPDATE -- NEW TABLE contains all rows AFTER the UPDATE -- Both are queryable with standard SQL inside this function -- =========================================================== CREATE OR REPLACE FUNCTION audit_price_changes_bulk() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- Single INSERT joining transition tables — processes ALL -- modified rows in one statement regardless of batch size. -- current_setting() reads a session-level variable set by -- the calling application for traceability. INSERT INTO price_change_audit (product_id, sku, old_price, new_price, price_delta, batch_job_id) SELECT old_rows.product_id, new_rows.sku, old_rows.current_price AS old_price, new_rows.current_price AS new_price, new_rows.current_price - old_rows.current_price AS price_delta, current_setting('app.batch_job_id', true) -- true = return NULL if unset FROM old_table AS old_rows -- transition table: pre-update snapshot JOIN new_table AS new_rows -- transition table: post-update snapshot ON old_rows.product_id = new_rows.product_id WHERE old_rows.current_price <> new_rows.current_price; -- only log actual changes -- Statement-level triggers: return value is always NULL / ignored RETURN NULL; END; $$; -- =========================================================== -- Attach as a statement-level AFTER UPDATE trigger -- REFERENCING declares the transition table aliases -- =========================================================== CREATE TRIGGER product_prices_bulk_audit AFTER UPDATE ON product_prices REFERENCING OLD TABLE AS old_table NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE FUNCTION audit_price_changes_bulk(); -- =========================================================== -- Seed some test data -- =========================================================== INSERT INTO product_prices (product_id, sku, current_price) VALUES (1, 'SKU-001', 29.99), (2, 'SKU-002', 49.99), (3, 'SKU-003', 99.99); -- =========================================================== -- Simulate a nightly batch price update -- The app sets a session variable for traceability -- =========================================================== SET LOCAL app.batch_job_id = 'NIGHTLY-PRICE-JOB-2024-03-15'; UPDATE product_prices SET current_price = current_price * 0.90, -- 10% discount run updated_at = now() WHERE product_id IN (1, 2, 3); -- One trigger invocation processed all 3 rows SELECT product_id, sku, old_price, new_price, price_delta, batch_job_id FROM price_change_audit ORDER BY product_id;
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.
-- =========================================================== -- Pattern 1: Guard against trigger recursion using pg_trigger_depth() -- Scenario: updating a 'categories' table that recalculates -- a materialized path — which itself triggers the same function -- =========================================================== CREATE TABLE categories ( category_id SERIAL PRIMARY KEY, parent_id INT REFERENCES categories(category_id), category_name TEXT NOT NULL, full_path TEXT -- e.g. 'Electronics > Laptops > Gaming' ); CREATE OR REPLACE FUNCTION rebuild_category_path() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- pg_trigger_depth() = 0 means we're the outermost trigger call. -- If > 0, we're inside a recursive trigger chain — skip execution -- to prevent infinite recursion without raising an error. IF pg_trigger_depth() > 1 THEN RETURN NEW; END IF; -- Build the full path by recursively walking parent_id WITH RECURSIVE ancestor_path AS ( -- Base case: start from the current row's parent SELECT parent_id, category_name, 1 AS depth FROM categories WHERE category_id = NEW.parent_id UNION ALL -- Recursive case: walk up to the root SELECT c.parent_id, c.category_name, ap.depth + 1 FROM categories c JOIN ancestor_path ap ON ap.parent_id = c.category_id ) SELECT string_agg(category_name, ' > ' ORDER BY depth DESC) || ' > ' || NEW.category_name INTO NEW.full_path FROM ancestor_path; -- If no parent exists, the category IS the root NEW.full_path := COALESCE(NEW.full_path, NEW.category_name); RETURN NEW; END; $$; CREATE TRIGGER categories_rebuild_path BEFORE INSERT OR UPDATE OF parent_id, category_name ON categories FOR EACH ROW EXECUTE FUNCTION rebuild_category_path(); -- =========================================================== -- Pattern 2: Deferred constraint trigger for referential -- integrity that needs to survive mid-transaction inconsistency -- Scenario: batch import that inserts child rows before parents -- =========================================================== CREATE TABLE departments ( dept_id SERIAL PRIMARY KEY, dept_name TEXT NOT NULL ); CREATE TABLE employees ( employee_id SERIAL PRIMARY KEY, full_name TEXT NOT NULL, dept_id INT -- intentionally no FK — trigger enforces it ); CREATE OR REPLACE FUNCTION check_dept_exists() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- This check runs at COMMIT time, not statement time, -- so mid-transaction inconsistency is acceptable. IF NOT EXISTS ( SELECT 1 FROM departments WHERE dept_id = NEW.dept_id ) THEN RAISE EXCEPTION 'dept_id % does not exist in departments table', NEW.dept_id; END IF; RETURN NEW; END; $$; -- CONSTRAINT TRIGGER + DEFERRABLE: validation fires at end of transaction CREATE CONSTRAINT TRIGGER employees_check_dept_deferred AFTER INSERT OR UPDATE OF dept_id ON employees DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_dept_exists(); -- =========================================================== -- Pattern 3: Query pg_trigger to audit every trigger in the DB -- =========================================================== SELECT t.tgname AS trigger_name, c.relname AS table_name, p.proname AS function_name, CASE t.tgtype & 2 WHEN 2 THEN 'BEFORE' ELSE 'AFTER' END AS timing, CASE t.tgtype & 1 WHEN 1 THEN 'ROW' ELSE 'STATEMENT' END AS scope, CASE WHEN t.tgenabled = 'O' THEN 'ENABLED' WHEN t.tgenabled = 'D' THEN 'DISABLED' WHEN t.tgenabled = 'R' THEN 'REPLICA' WHEN t.tgenabled = 'A' THEN 'ALWAYS' END AS status FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_proc p ON p.oid = t.tgfoid WHERE NOT t.tgisinternal -- exclude FK constraint triggers ORDER BY c.relname, t.tgname;
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.
-- =========================================================== -- Secure trigger with least privilege — dedicated audit role -- =========================================================== -- Create a dedicated audit role with minimal permissions CREATE ROLE trigger_audit_role; GRANT INSERT ON TABLE order_audit_log TO trigger_audit_role; -- Create the trigger function with SECURITY DEFINER, -- so it runs as the owner of the function (which is the audit role) CREATE OR REPLACE FUNCTION log_order_status_change_secure() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = 'public' -- avoid search_path attacks AS $$ BEGIN INSERT INTO order_audit_log (order_id, event_type, old_status, new_status, changed_by) VALUES ( COALESCE(NEW.order_id, OLD.order_id), TG_OP, OLD.status, NEW.status, current_user ); RETURN NEW; END; $$; -- Alter the function owner to the audit role ALTER FUNCTION log_order_status_change_secure() OWNER TO trigger_audit_role; -- Attach the trigger — now the calling user only needs INSERT on orders, -- not on the audit table. The trigger function handles audit insertion -- as the audit role. CREATE TRIGGER orders_20_audit_secure AFTER INSERT OR UPDATE OR DELETE ON orders FOR EACH ROW EXECUTE FUNCTION log_order_status_change_secure(); -- =========================================================== -- Test: user 'app_user' has INSERT on orders but not on order_audit_log -- The trigger still works because it runs as trigger_audit_role. -- =========================================================== SET ROLE app_user; INSERT INTO orders (customer_name, total_amount) VALUES ('Bob Test', 59.99); -- Verify audit entry exists SELECT * FROM order_audit_log; -- User can see the result because they have SELECT on the audit table -- (granted separately for reporting) RESET ROLE;
- 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.
// io.thecodeforge — database tutorial -- BEFORE: modify input before it's written CREATE OR REPLACE FUNCTION sanitize_email_insert() RETURNS TRIGGER AS $$ BEGIN NEW.email := lower(trim(NEW.email)); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_sanitize_email_before BEFORE INSERT ON user_accounts FOR EACH ROW EXECUTE FUNCTION sanitize_email_insert(); -- AFTER: log the final committed row CREATE TABLE audit_log ( table_name text, operation text, old_data jsonb, new_data jsonb, changed_at timestamptz DEFAULT now() ); CREATE OR REPLACE FUNCTION log_user_changes() RETURNS TRIGGER AS $$ BEGIN INSERT INTO audit_log (table_name, operation, old_data, new_data) VALUES ('user_accounts', TG_OP, row_to_json(OLD), row_to_json(NEW)); RETURN NULL; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_audit_users AFTER INSERT OR UPDATE OR DELETE ON user_accounts FOR EACH ROW EXECUTE FUNCTION log_user_changes();
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.
// io.thecodeforge — database tutorial CREATE TABLE orders ( id serial PRIMARY KEY, customer_id integer, total numeric ); CREATE OR REPLACE FUNCTION log_before_stmt() RETURNS TRIGGER AS $$ BEGIN RAISE NOTICE 'BEFORE STATEMENT: %', TG_OP; RETURN NULL; END; $$ LANGUAGE plpgsql; CREATE OR REPLACE FUNCTION log_before_row() RETURNS TRIGGER AS $$ BEGIN RAISE NOTICE 'BEFORE ROW: % on order %', TG_OP, NEW.id; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER z_last_before_stmt BEFORE UPDATE ON orders FOR EACH STATEMENT EXECUTE FUNCTION log_before_stmt(); CREATE TRIGGER a_first_before_row BEFORE UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION log_before_row(); UPDATE orders SET total = 10 WHERE id = 1; -- NOTICE: BEFORE STATEMENT: UPDATE (z_last says 'last' alphabetically) -- NOTICE: BEFORE ROW: UPDATE on order 1 (a_first fires first alphabetically)
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.
// io.thecodeforge — database tutorial CREATE TABLE user_accounts ( id serial PRIMARY KEY, email text, status text DEFAULT 'active' ); CREATE TABLE auth_log ( id serial PRIMARY KEY, changed_at timestamptz DEFAULT now(), operation text, old_email text, new_email text ); CREATE OR REPLACE FUNCTION log_bulk_changes() RETURNS TRIGGER AS $$ BEGIN INSERT INTO auth_log (operation, old_email, new_email) SELECT TG_OP, OLD.email, NEW.email FROM OLD_TABLE OLD JOIN NEW_TABLE NEW ON OLD.id = NEW.id; RETURN NULL; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_audit_bulk AFTER UPDATE ON user_accounts REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows FOR EACH STATEMENT EXECUTE FUNCTION log_bulk_changes(); UPDATE user_accounts SET status = 'inactive' WHERE id IN (1, 2, 3); SELECT operation, old_email, new_email FROM auth_log; -- UPDATE | old1@x | new1@x -- UPDATE | old2@y | new2@y -- UPDATE | old3@z | new3@z
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 sourceEXPLAIN ANALYZE UPDATE big_table SET col = val; -- watch for 'Trigger Time'SELECT total_time, self_time, calls FROM pg_stat_user_functions ORDER BY total_time DESC LIMIT 5;SELECT pg_trigger_depth(); -- run inside the trigger function to see current depthSHOW max_stack_depth; -- default is 2MB, adjust only if necessarypg_trigger_depth() > 1 THEN RETURN NEW; END IF; at the start of the trigger function.SELECT tgenabled, tgname FROM pg_trigger WHERE tgrelid = 'your_table'::regclass;\! psql -c "SELECT current_setting('session_replication_role');"| Dimension | FOR EACH ROW | FOR EACH STATEMENT |
|---|---|---|
| Invocation count per 10K-row UPDATE | 10,000 separate calls | 1 single call |
| Access to individual OLD/NEW values | Yes — OLD.col, NEW.col | No — use transition tables instead |
| Transition table support | Not available | Available (PostgreSQL 10+) |
| Can modify the row being written | Yes — mutate NEW in BEFORE trigger | No — statement already determined |
| Performance on bulk DML | Catastrophic at scale (context switches) | Excellent — single execution |
| Use case sweet spot | Validation, auto-stamping, per-row logic | Bulk audit, cache invalidation, aggregates |
| INSTEAD OF trigger support | Yes (views only) | No — not supported |
| Fires when 0 rows are affected | No | Yes — always fires once |
| Can suppress the DML (return NULL) | Yes in BEFORE triggers | No — return value is ignored |
| Security model | Runs as table owner | Runs as table owner |
| Privilege escalation risk | Medium — SECURITY DEFINER can open access | Medium — same as row level |
| Memory usage | Negligible per call | Transition table consumes memory proportional to affected rows |
| 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 |
Key takeaways
Common mistakes to avoid
4 patternsUsing FOR EACH ROW for bulk audit logging
Forgetting that RETURN NULL in a BEFORE row trigger silently cancels the write
Creating a trigger that fires on its own table's UPDATE, then doing an UPDATE inside the trigger function without a recursion guard
pg_trigger_depth() > 1 THEN RETURN NEW; END IF; guard at the top of the function, or restructure so the trigger updates a different column than the one it fires on using the OF column_name syntax in the trigger definition.Not setting search_path in SECURITY DEFINER trigger functions
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?
You have a trigger that writes to an audit table. A junior dev suggests making it BEFORE INSERT instead of AFTER INSERT so the audit record is guaranteed to exist even if the main insert fails. What's wrong with this reasoning, and what's the correct approach?
Walk me through exactly what happens — step by step, including internal PostgreSQL machinery — when you execute UPDATE products SET price = price * 0.9 on a table that has both a BEFORE row trigger and an AFTER statement trigger with a transition table.
How would you debug a production issue where an audit trigger is not logging any changes, but table modifications are happening?
Frequently Asked Questions
Yes — and it happens automatically. If Trigger A on Table A runs an UPDATE on Table B, and Table B has its own trigger, that trigger fires immediately within the same transaction. This is called cascading or nested triggers. Use pg_trigger_depth() inside your function to detect the nesting level and add a guard if recursion is possible.
PL/pgSQL is the default and most common choice, but PostgreSQL supports any trusted procedural language installed as an extension: PL/Python (plpython3u), PL/Perl, PL/Tcl, and even PL/v8 (JavaScript). The trigger function must return TRIGGER as its return type regardless of the language used.
Rules (CREATE RULE) rewrite the query before execution at the planner level — they're older, harder to debug, and have confusing semantics around statement-level visibility. Triggers fire at execution time and have access to the actual row data. The PostgreSQL documentation itself recommends preferring triggers over rules for almost all practical use cases, and rules are largely considered a legacy feature.
No — PostgreSQL prohibits user-defined triggers on system catalog tables. The catalog is managed internally and any user trigger would destabilize the database. If you need to react to DDL changes (like CREATE TABLE), use event triggers (CREATE EVENT TRIGGER) which fire at DDL events, not row-level changes.
Use custom session variables via SET LOCAL. For example, before executing your DML, run SET LOCAL app.user_id = '42';. Inside the trigger function, retrieve it with current_setting('app.user_id', true). The 'true' parameter returns NULL if the variable is not set, avoiding errors. This pattern is widely used in multi-tenant audit systems.
20+ years shipping high-throughput database systems. Drawn from code that ran under real load.
That's MySQL & PostgreSQL. Mark it forged?
7 min read · try the examples if you haven't