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.
✦ Definition~90s read
What is PostgreSQL Triggers?
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.
★
Imagine you work at a library.
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.
Plain-English First
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.
Trigger ≠ Application Hook
Triggers run inside the database transaction — a failure in a trigger aborts the entire operation, including the triggering statement. Never put side effects you cannot roll back inside a trigger.
Production Insight
A payment service used a trigger to update a 'last_updated' timestamp on every row change. During a bulk backfill of 500k rows, the trigger caused a 30-second lock on the table, blocking all reads and causing a 5-minute P0 outage.
Symptom: pg_stat_activity showed 'idle in transaction' sessions piling up, and application logs showed 'deadlock detected' errors.
Rule of thumb: Never use row-level triggers on tables with bulk operations unless you test with production-scale data volumes first.
Key Takeaway
Triggers execute synchronously in the same transaction — they are not async callbacks.
Row-level triggers on bulk operations can cause severe performance degradation and lock contention.
Use triggers only for database-enforceable logic; never for side effects that can't be rolled back.
thecodeforge.io
PostgreSQL Trigger Execution and Recursion Guard
Postgresql Triggers
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.
trigger_firing_order_demo.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
-- ===========================================================-- Demonstrate PostgreSQL trigger firing order and NEW visibility-- ===========================================================-- 1. Create a simple orders tableCREATETABLEorders (
order_id SERIALPRIMARYKEY,
customer_name TEXTNOTNULL,
total_amount NUMERIC(10,2) NOTNULL,
status TEXTNOTNULLDEFAULT'pending',
created_at TIMESTAMPTZNOTNULLDEFAULTnow(),
updated_at TIMESTAMPTZ
);
-- 2. Create an audit log table to capture every changeCREATETABLEorder_audit_log (
log_id SERIALPRIMARYKEY,
order_id INT,
event_type TEXT, -- INSERT / UPDATE / DELETE
old_status TEXT, -- NULL on INSERT
new_status TEXT, -- NULL on DELETE
changed_at TIMESTAMPTZNOTNULLDEFAULTnow(),
changed_by TEXTNOTNULL-- 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-- ===========================================================CREATEORREPLACEFUNCTIONstamp_updated_at()
RETURNSTRIGGERLANGUAGE 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.RETURNNEW;
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-- ===========================================================CREATEORREPLACEFUNCTIONlog_order_status_change()
RETURNSTRIGGERLANGUAGE plpgsql
AS $$
BEGIN-- On INSERT, OLD is NULL — use COALESCE to handle it safelyINSERTINTO 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 operationsNEW.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.RETURNNEW;
END;
$$;
-- ===========================================================-- Attach triggers — note the naming convention: numeric prefix-- controls alphabetical (therefore execution) order-- ===========================================================-- Fires BEFORE insert or update — stamps the timestampCREATETRIGGER orders_10_stamp_updated_at
BEFOREINSERTORUPDATEON orders
FOREACHROWEXECUTEFUNCTIONstamp_updated_at();
-- Fires AFTER insert, update, or delete — writes the audit logCREATETRIGGER orders_20_audit_status_change
AFTERINSERTORUPDATEORDELETEON orders
FOREACHROWEXECUTEFUNCTIONlog_order_status_change();
-- ===========================================================-- TEST IT-- ===========================================================-- Insert a new orderINSERTINTOorders (customer_name, total_amount)
VALUES ('Alice Johnson', 149.99);
-- Update the statusUPDATE orders
SET status = 'shipped'WHERE customer_name = 'Alice Johnson';
-- Review the audit trailSELECT
log_id,
order_id,
event_type,
old_status,
new_status,
changed_at,
changed_by
FROM order_audit_log
ORDERBY log_id;
Watch Out: Returning NULL from a Row Trigger Silently Cancels the Operation
In a BEFORE row trigger, returning NULL aborts the INSERT or UPDATE for that row without raising an error. The caller gets no exception — the statement just silently affects 0 rows. Use this intentionally for conditional skipping, but never accidentally. Always end your BEFORE triggers with RETURN NEW (for INSERT/UPDATE) or RETURN OLD (for DELETE).
Production Insight
Returning NULL in a BEFORE trigger is the #1 cause of 'disappearing data' tickets in production.
The application sees 'UPDATE 0' and assumes the row didn't exist — wrong.
Rule: always end BEFORE triggers with RETURN NEW unless you explicitly want to veto the operation.
Key Takeaway
BEFORE triggers see NEW before the heap write.
Return NULL = cancel silently.
Alphabetical trigger names control firing 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.
instead_of_trigger_writable_view.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
-- ===========================================================-- 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-- ===========================================================CREATETABLEproducts (
product_id SERIALPRIMARYKEY,
sku TEXTNOTNULLUNIQUE,
product_name TEXTNOTNULL,
unit_price NUMERIC(10,2) NOTNULL
);
CREATETABLEinventory (
inventory_id SERIALPRIMARYKEY,
product_id INTNOTNULLREFERENCESproducts(product_id),
warehouse TEXTNOTNULLDEFAULT'main',
stock_qty INTNOTNULLDEFAULT0,
UNIQUE (product_id, warehouse)
);
-- The view the application sees — a clean, flat product+stock surfaceCREATEVIEW product_catalog ASSELECT
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-- ===========================================================CREATEORREPLACEFUNCTIONinsert_into_product_catalog()
RETURNSTRIGGERLANGUAGE plpgsql
AS $$
DECLARE
new_product_id INT;
BEGIN-- Step 1: insert the core product record and capture its generated PKINSERTINTOproducts (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 rowINSERTINTOinventory (product_id, warehouse, stock_qty)
VALUES (
new_product_id,
COALESCE(NEW.warehouse, 'main'), -- default to main warehouseCOALESCE(NEW.stock_qty, 0)
);
-- Return NEW so PostgreSQL knows the operation "succeeded"RETURNNEW;
END;
$$;
-- ===========================================================-- INSTEAD OF UPDATE: update each base table independently-- ===========================================================CREATEORREPLACEFUNCTIONupdate_product_catalog()
RETURNSTRIGGERLANGUAGE 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;
RETURNNEW;
END;
$$;
-- Attach both INSTEAD OF triggers to the viewCREATETRIGGER product_catalog_insert
INSTEADOFINSERTON product_catalog
FOREACHROWEXECUTEFUNCTIONinsert_into_product_catalog();
CREATETRIGGER product_catalog_update
INSTEADOFUPDATEON product_catalog
FOREACHROWEXECUTEFUNCTIONupdate_product_catalog();
-- ===========================================================-- TEST: application writes to the view as if it's a table-- ===========================================================INSERTINTOproduct_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 = 230WHERE sku = 'SKU-9001';
-- Verify data landed in the correct base tablesSELECT 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';
Output
sku | unit_price | stock_qty
------------+------------+-----------
SKU-9001 | 69.99 | 230
(1 row)
Pro Tip: Use INSTEAD OF Triggers for API Compatibility Without Schema Exposure
When you need to refactor a normalized schema but can't change the API contracts, expose the old column layout as a view and use INSTEAD OF triggers to translate writes. Your application code stays identical — the trigger handles the impedance mismatch invisibly. This is a legitimate zero-downtime migration pattern used in large production systems.
Production Insight
INSTEAD OF triggers are FOR EACH ROW only — no transition tables, no bulk mode.
For views that receive batch writes, the trigger fires per row — can be a bottleneck.
Rule: if a view needs high-volume writes, consider materializing the aggregation and using a real table instead.
Key Takeaway
INSTEAD OF triggers make non-updatable views writable.
Always FOR EACH ROW — batch writes become sequential.
Use for API compatibility during schema refactoring.
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.
transition_table_bulk_audit.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
-- ===========================================================-- 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-- ===========================================================CREATETABLEproduct_prices (
product_id INTPRIMARYKEY,
sku TEXTNOTNULL,
current_price NUMERIC(10,2) NOTNULL,
updated_at TIMESTAMPTZDEFAULTnow()
);
CREATETABLEprice_change_audit (
audit_id SERIALPRIMARYKEY,
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 TIMESTAMPTZNOTNULLDEFAULTnow(),
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-- ===========================================================CREATEORREPLACEFUNCTIONaudit_price_changes_bulk()
RETURNSTRIGGERLANGUAGE 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.INSERTINTO 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 unsetFROM old_table AS old_rows -- transition table: pre-update snapshotJOIN new_table AS new_rows -- transition table: post-update snapshotON 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 / ignoredRETURNNULL;
END;
$$;
-- ===========================================================-- Attach as a statement-level AFTER UPDATE trigger-- REFERENCING declares the transition table aliases-- ===========================================================CREATETRIGGER product_prices_bulk_audit
AFTERUPDATEON product_prices
REFERENCINGOLDTABLEAS old_table NEWTABLEAS new_table
FOREACHSTATEMENTEXECUTEFUNCTIONaudit_price_changes_bulk();
-- ===========================================================-- Seed some test data-- ===========================================================INSERTINTOproduct_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-- ===========================================================SETLOCAL 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 rowsSELECT
product_id,
sku,
old_price,
new_price,
price_delta,
batch_job_id
FROM price_change_audit
ORDERBY product_id;
Interview Gold: Why Can't You Use Transition Tables in BEFORE Triggers?
Transition tables represent a snapshot of row states — OLD TABLE is the pre-change state, NEW TABLE is the post-change state. In a BEFORE trigger, the change hasn't happened yet, so NEW TABLE doesn't exist as a committed set. PostgreSQL enforces this at DDL time: trying to use REFERENCING on a BEFORE trigger raises an error immediately. This is a question interviewers love to ask, and most candidates guess wrong.
Production Insight
Transition tables are in-memory — they consume shared_buffers while the trigger runs.
For a 1-million row UPDATE, the transition table can exceed memory limits and spill to disk.
Rule: size your shared_buffers to accommodate the largest expected transition table, or implement row-level batching in the application layer.
Key Takeaway
Transition tables: one invocation per statement, not per row.
Available only in AFTER triggers.
Use for audit, cache invalidation, bulk aggregation.
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.
production_trigger_patterns.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
-- ===========================================================-- 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-- ===========================================================CREATETABLEcategories (
category_id SERIALPRIMARYKEY,
parent_id INTREFERENCEScategories(category_id),
category_name TEXTNOTNULL,
full_path TEXT-- e.g. 'Electronics > Laptops > Gaming'
);
CREATEORREPLACEFUNCTIONrebuild_category_path()
RETURNSTRIGGERLANGUAGE 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.IFpg_trigger_depth() > 1THENRETURNNEW;
ENDIF;
-- Build the full path by recursively walking parent_idWITHRECURSIVE ancestor_path AS (
-- Base case: start from the current row's parentSELECT parent_id, category_name, 1AS depth
FROM categories
WHERE category_id = NEW.parent_id
UNIONALL-- Recursive case: walk up to the rootSELECT c.parent_id, c.category_name, ap.depth + 1FROM categories c
JOIN ancestor_path ap ON ap.parent_id = c.category_id
)
SELECTstring_agg(category_name, ' > 'ORDERBY depth DESC) || ' > ' || NEW.category_name
INTONEW.full_path
FROM ancestor_path;
-- If no parent exists, the category IS the rootNEW.full_path := COALESCE(NEW.full_path, NEW.category_name);
RETURNNEW;
END;
$$;
CREATETRIGGER categories_rebuild_path
BEFOREINSERTORUPDATEOF parent_id, category_name
ON categories
FOREACHROWEXECUTEFUNCTIONrebuild_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-- ===========================================================CREATETABLEdepartments (
dept_id SERIALPRIMARYKEY,
dept_name TEXTNOTNULL
);
CREATETABLEemployees (
employee_id SERIALPRIMARYKEY,
full_name TEXTNOTNULL,
dept_id INT-- intentionally no FK — trigger enforces it
);
CREATEORREPLACEFUNCTIONcheck_dept_exists()
RETURNSTRIGGERLANGUAGE plpgsql
AS $$
BEGIN-- This check runs at COMMIT time, not statement time,-- so mid-transaction inconsistency is acceptable.IFNOTEXISTS (
SELECT1FROM departments WHERE dept_id = NEW.dept_id
) THENRAISEEXCEPTION'dept_id % does not exist in departments table', NEW.dept_id;
ENDIF;
RETURNNEW;
END;
$$;
-- CONSTRAINT TRIGGER + DEFERRABLE: validation fires at end of transactionCREATECONSTRAINTTRIGGER employees_check_dept_deferred
AFTERINSERTORUPDATEOF dept_id
ON employees
DEFERRABLEINITIALLYDEFERREDFOREACHROWEXECUTEFUNCTIONcheck_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 & 2WHEN2THEN'BEFORE'ELSE'AFTER'ENDAS timing,
CASE t.tgtype & 1WHEN1THEN'ROW'ELSE'STATEMENT'ENDAS scope,
CASEWHEN t.tgenabled = 'O'THEN'ENABLED'WHEN t.tgenabled = 'D'THEN'DISABLED'WHEN t.tgenabled = 'R'THEN'REPLICA'WHEN t.tgenabled = 'A'THEN'ALWAYS'ENDAS status
FROM pg_trigger t
JOIN pg_class c ON c.oid = t.tgrelid
JOIN pg_proc p ON p.oid = t.tgfoid
WHERENOT t.tgisinternal -- exclude FK constraint triggersORDERBY c.relname, t.tgname;
Watch Out: Disabling Triggers with SET session_replication_role = 'replica'
This session variable disables all non-ALWAYS, non-REPLICA triggers — including your audit triggers. If a DBA runs a bulk fix using this flag to speed things up, your audit trail will have silent gaps. The fix: mark critical audit triggers with ALTER TRIGGER ... ENABLE ALWAYS, which fires regardless of replication role. Check your pg_trigger.tgenabled values regularly — 'A' means ALWAYS, 'O' means normal enabled.
Production Insight
Deferred constraint triggers are powerful but confusing — they fire at COMMIT, not at the write.
If the COMMIT fails, the trigger never runs — side effects are lost.
Rule: never use DEFERRED for audit or cache invalidation; use AFTER triggers with transition tables instead.
Key Takeaway
Guard recursion with pg_trigger_depth().
Deferred triggers fire at COMMIT — safe for integrity, dangerous for side effects.
Query pg_trigger to audit your trigger landscape.
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.
trigger_security_least_privilege.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
-- ===========================================================-- Secure trigger with least privilege — dedicated audit role-- ===========================================================-- Create a dedicated audit role with minimal permissionsCREATEROLE trigger_audit_role;
GRANTINSERTONTABLE 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)CREATEORREPLACEFUNCTIONlog_order_status_change_secure()
RETURNSTRIGGERLANGUAGE plpgsql
SECURITYDEFINERSET search_path = 'public' -- avoid search_path attacksAS $$
BEGININSERTINTO 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
);
RETURNNEW;
END;
$$;
-- Alter the function owner to the audit roleALTERFUNCTIONlog_order_status_change_secure()
OWNERTO 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.CREATETRIGGER orders_20_audit_secure
AFTERINSERTORUPDATEORDELETEON orders
FOREACHROWEXECUTEFUNCTIONlog_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.-- ===========================================================SETROLE app_user;
INSERTINTOorders (customer_name, total_amount)
VALUES ('Bob Test', 59.99);
-- Verify audit entry existsSELECT * FROM order_audit_log;
-- User can see the result because they have SELECT on the audit table-- (granted separately for reporting)RESETROLE;
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.
Production Insight
A trigger that writes to a separate schema can accidentally expose data.
If the trigger function uses SET search_path, it's vulnerable to schema hijacking.
Rule: always set search_path inside SECURITY DEFINER triggers to a fixed, trusted schema.
Key Takeaway
Triggers inherit table owner privileges — use SECURITY DEFINER for cross-schema access.
Set explicit search_path to avoid schema hijacking attacks.
Event triggers (DDL) are superuser-only — restrict tightly.
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.
Before trigger runs, email ' JOHN@EXAMPLE.COM ' becomes 'john@example.com'.
After trigger runs, audit_log contains a row with old and new jsonb values.
Production Trap:
Don't use BEFORE triggers for cross-row validation — you can't see what other concurrent transactions just inserted. Use AFTER with statement-level triggers and transition tables if you need the full set.
Key Takeaway
BEFORE for mutating input before write; AFTER for reacting to the committed row. Never rely on BEFORE to enforce uniqueness across rows.
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.
TriggerExecutionOrder.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// io.thecodeforge — database tutorial
CREATETABLEorders (
id serial PRIMARYKEY,
customer_id integer,
total numeric
);
CREATEORREPLACEFUNCTIONlog_before_stmt()
RETURNSTRIGGERAS $$
BEGINRAISENOTICE'BEFORE STATEMENT: %', TG_OP;
RETURNNULL;
END;
$$ LANGUAGE plpgsql;
CREATEORREPLACEFUNCTIONlog_before_row()
RETURNSTRIGGERAS $$
BEGINRAISENOTICE'BEFORE ROW: % on order %', TG_OP, NEW.id;
RETURNNEW;
END;
$$ LANGUAGE plpgsql;
CREATETRIGGER z_last_before_stmt
BEFOREUPDATEON orders
FOREACHSTATEMENTEXECUTEFUNCTIONlog_before_stmt();
CREATETRIGGER a_first_before_row
BEFOREUPDATEON orders
FOREACHROWEXECUTEFUNCTIONlog_before_row();
UPDATE orders SET total = 10WHERE id = 1;
-- NOTICE: BEFORE STATEMENT: UPDATE (z_last says 'last' alphabetically)-- NOTICE: BEFORE ROW: UPDATE on order 1 (a_first fires first alphabetically)
Output
NOTICE: BEFORE STATEMENT: UPDATE
NOTICE: BEFORE ROW: UPDATE on order 1
Senior Shortcut:
Use 'pg_trigger' catalog view to inspect all triggers on a table: SELECT tgname, tgtype, tgenabled FROM pg_trigger WHERE tgrelid = 'table_name'::regclass; Trigger execution order depends on tgname alphabetically, not creation order.
Key Takeaway
Trigger execution order is alphabetical by name, not creation order. Use prefixes to enforce priority. Always test with multiple triggers on the same table.
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.
AuditLogTransition.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// io.thecodeforge — database tutorial
CREATETABLEuser_accounts (
id serial PRIMARYKEY,
email text,
status text DEFAULT'active'
);
CREATETABLEauth_log (
id serial PRIMARYKEY,
changed_at timestamptz DEFAULTnow(),
operation text,
old_email text,
new_email text
);
CREATEORREPLACEFUNCTIONlog_bulk_changes()
RETURNSTRIGGERAS $$
BEGININSERTINTOauth_log (operation, old_email, new_email)
SELECT TG_OP, OLD.email, NEW.email
FROM OLD_TABLE OLDJOIN NEW_TABLE NEWONOLD.id = NEW.id;
RETURNNULL;
END;
$$ LANGUAGE plpgsql;
CREATETRIGGER trg_audit_bulk
AFTERUPDATEON user_accounts
REFERENCINGOLDTABLEAS old_rows NEWTABLEAS new_rows
FOREACHSTATEMENTEXECUTEFUNCTIONlog_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
Output
operation | old_email | new_email
-----------+-----------+----------
UPDATE | old1@x | new1@x
UPDATE | old2@y | new2@y
UPDATE | old3@z | new3@z
Performance Win:
In production, you don't need separate audit columns per table. Use a generic audit table with jsonb for old and new data. Extend with table_name and operation. One trigger function serves all tables.
Key Takeaway
Transition tables in statement-level AFTER triggers give you per-row detail without per-row overhead. One trigger, one batch INSERT. That's how you scale audit logging.
● Production incidentPOST-MORTEMseverity: high
Trigger Recursion Brought Down a Multitenant SaaS at Peak Load
Symptom
Batch job fails with 'stack depth limit exceeded' error; pg_stat_activity shows a single session nested deeply in trigger functions; all application API calls time out because the batch transaction holds locks.
Assumption
The team assumed triggers only fire once per statement. They didn't realize that an UPDATE inside a trigger function can fire other triggers, creating a recursive chain.
Root cause
Trigger A on Table A runs an UPDATE on Table B. Trigger B on Table B runs an UPDATE on Table A. No recursion guard (pg_trigger_depth()) was present. PostgreSQL's default max_stack_depth (2MB) was hit after ~2,000 nested trigger invocations.
Fix
Added IF 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.
Key lesson
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.
Production debug guideCommon trigger failures and how to diagnose them fast in production.4 entries
Symptom · 01
UPDATE or INSERT returns 'UPDATE 0' or 'INSERT 0 0' with no error — data never appears in table.
→
Fix
Check if a BEFORE trigger on that table returns NULL. Run pg_trigger to list triggers and their functions. Review the function code for unconditional 'RETURN NULL' branches.
Symptom · 02
Batch UPDATE on 50,000 rows takes 45 seconds; CPU spikes; one backend session shows high time in trigger function.
→
Fix
Check if the trigger is FOR EACH ROW. Query pg_stat_user_functions for total_time. Migrate to FOR EACH STATEMENT with REFERENCING OLD TABLE/NEW TABLE transition tables. Test with EXPLAIN ANALYZE.
Symptom · 03
Trigger function raises 'permission denied for relation audit_log' — but the function owner has privileges.
→
Fix
Triggers run with the privileges of the table owner, not the function definer. Use SECURITY DEFINER on the function or grant permissions to the table owner role.
Symptom · 04
Data in audit table has gaps — some changes are not logged.
→
Fix
Verify the trigger is ENABLED. Check pg_trigger.tgenabled. If session_replication_role was set to 'replica', triggers with normal enabled status (O) are disabled. Mark critical audit triggers with ENABLE ALWAYS.
★ Trigger Debugging Cheat SheetFive signs your trigger is the problem, with commands to confirm and fix it.
UPDATE returns 0 rows affected but no error−
Immediate action
Check BEFORE trigger functions for accidental RETURN NULL
Commands
SELECT tgname, tgenabled FROM pg_trigger WHERE NOT tgisinternal AND tgrelid = 'your_table'::regclass;
\sf+ function_name -- view the trigger function source
Fix now
Add explicit RETURN NEW (for INSERT/UPDATE) or RETURN OLD (for DELETE) at the end of each BEFORE trigger.
Bulk update is extremely slow (seconds per thousand rows)+
Immediate action
Switch from FOR EACH ROW to FOR EACH STATEMENT with transition tables
Commands
EXPLAIN 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;
Fix now
DROP the row-level trigger and CREATE a new trigger using REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows FOR EACH STATEMENT.
'stack depth limit exceeded' error+
Immediate action
Add pg_trigger_depth() guard to prevent recursion
Commands
SELECT pg_trigger_depth(); -- run inside the trigger function to see current depth
SHOW max_stack_depth; -- default is 2MB, adjust only if necessary
Fix now
Add IF pg_trigger_depth() > 1 THEN RETURN NEW; END IF; at the start of the trigger function.
Audit table missing entries after a batch job+
Immediate action
Check if session_replication_role was changed and the trigger is not ENABLE ALWAYS
Commands
SELECT tgenabled, tgname FROM pg_trigger WHERE tgrelid = 'your_table'::regclass;
ALTER TRIGGER audit_trigger ON your_table ENABLE ALWAYS; -- survives replica role
PostgreSQL Trigger Types Compared
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
Key takeaways
1
BEFORE row triggers modify NEW before it hits the heap
perfect for validation and auto-stamping. AFTER row triggers see the committed row state — use them for side effects like audit logging.
2
FOR EACH ROW triggers fire once per affected row
at 50,000 rows that's 50,000 context switches. Transition tables (REFERENCING OLD TABLE / NEW TABLE) let you process the entire batch in a single AFTER STATEMENT trigger invocation — orders of magnitude faster.
3
INSTEAD OF triggers on views intercept writes before they hit the view engine
this is the production-grade pattern for keeping complex JOINed views writable without exposing the underlying normalized schema.
4
Always query pg_trigger to audit your trigger landscape
check tgenabled values, because SET session_replication_role = 'replica' silently disables normal triggers, and ALTER TRIGGER ... ENABLE ALWAYS is the escape hatch for audit triggers that must never be skipped.
5
Security
triggers run as table owner by default. Use SECURITY DEFINER with a set search_path to control privilege escalation. Never rely on SECURITY INVOKER for cross-schema audit.
Common mistakes to avoid
4 patterns
×
Using FOR EACH ROW for bulk audit logging
Symptom
A batch UPDATE of 50,000 rows takes 45 seconds instead of 0.5 seconds; pg_stat_activity shows the session spinning in the trigger function.
Fix
Replace with a FOR EACH STATEMENT trigger using REFERENCING OLD TABLE / NEW TABLE transition tables and a single INSERT...SELECT to process all rows at once.
×
Forgetting that RETURN NULL in a BEFORE row trigger silently cancels the write
Symptom
Your INSERT or UPDATE returns 'UPDATE 0' or 'INSERT 0 0' with no error; data never reaches the table, and the application has no idea anything went wrong.
Fix
Always end BEFORE row triggers with RETURN NEW for INSERT/UPDATE and RETURN OLD for DELETE; only return NULL when you deliberately want to veto the operation and you've documented that intent.
×
Creating a trigger that fires on its own table's UPDATE, then doing an UPDATE inside the trigger function without a recursion guard
Symptom
ERROR: stack depth limit exceeded after the trigger calls itself hundreds of times until PostgreSQL hits max_stack_depth and rolls back the transaction.
Fix
Add an IF 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
Symptom
An attacker creates a malicious function with the same name as a table used in the trigger, in a schema that appears earlier in the search_path. The trigger executes the malicious function instead of the intended table operation.
Fix
Always include SET search_path = 'public' (or a specific schema) in the trigger function definition when using SECURITY DEFINER. Never rely on the default schema search path.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
What's the difference between a BEFORE trigger returning NULL versus rai...
Q02SENIOR
You have a trigger that writes to an audit table. A junior dev suggests ...
Q03SENIOR
Walk me through exactly what happens — step by step, including internal ...
Q04SENIOR
How would you debug a production issue where an audit trigger is not log...
Q01 of 04SENIOR
What's the difference between a BEFORE trigger returning NULL versus raising an exception — and when would you choose each approach?
ANSWER
Returning NULL from a BEFORE row trigger silently skips the row — the caller sees 'UPDATE 0' or 'INSERT 0 0' with no exception. Use this when you want to conditionally skip rows based on data quality without breaking the whole batch. Raising an exception (RAISE EXCEPTION) aborts the entire transaction and propagates an error to the caller. Use this when the condition is a hard business rule violation that must stop all processing. For example, NULL return is good for 'skip rows where status is already final', while exception is correct for 'cannot insert a negative price'.
Q02 of 04SENIOR
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?
ANSWER
The reasoning is flawed because in a BEFORE INSERT trigger, the main insert can still fail after the trigger runs — for example, due to a constraint violation, a unique index collision, or a later trigger that returns NULL. The audit record written in the BEFORE trigger would be a false positive: it logs an insert that never actually happened. The correct approach is to keep the trigger as AFTER INSERT. If you need to guarantee no phantom audit records, wrap both the insert and the trigger in a single transaction — the AFTER trigger fires within the same transaction, so if the main insert commits, the audit record is committed atomically. Alternatively, use a statement-level trigger with a transition table to avoid false positives from partial rollbacks.
Q03 of 04SENIOR
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.
ANSWER
1. The executor begins the UPDATE statement. It scans the products table and identifies rows matching the WHERE clause (all rows in this case). 2. For each qualifying row, the executor locks the row and reads the current version into a work memory area as OLD. 3. The new row image (price multiplied by 0.9) is computed. 4. The BEFORE row trigger fires for each row: the trigger function receives OLD and NEW records. It may modify NEW, and unless it returns NULL, the modified NEW is accepted. If it returns NULL, that row is skipped from the UPDATE entirely. 5. After all BEFORE triggers succeed, the executor writes the new row version to the heap (within the transaction). 6. The AFTER statement trigger is queued to run after all rows are processed. The executor collects all OLD and NEW rows into in-memory transition tables (old_table, new_table). 7. The AFTER statement trigger fires once: the trigger function can query old_table and new_table via SQL, process the entire batch, and return (the return value is ignored). 8. The transaction commits — the heap writes become visible. The entire process respects the isolation level (e.g., READ COMMITTED sees only committed rows at the start of the statement).
Q04 of 04SENIOR
How would you debug a production issue where an audit trigger is not logging any changes, but table modifications are happening?
ANSWER
First, check if the trigger is enabled: SELECT tgenabled, tgname FROM pg_trigger WHERE tgrelid = 'target_table'::regclass;. If tgenabled is 'D' (disabled) or 'R' (replica only), re-enable it. Second, check if session_replication_role was set to 'replica' — this disables normal triggers ('O' status). If the trigger is marked 'O' and session_replication_role is 'replica', the trigger is skipped. Third, verify the trigger function exists and has the correct signature: SELECT proname FROM pg_proc WHERE proname = 'your_trigger_function'. Fourth, inspect the function definition for errors — use \sf+ function_name. Fifth, check if the trigger fires only on certain events (e.g., missing DELETE event). Finally, set a log_statement to 'ddl' (or enable full logging) and observe whether trigger function execution appears in the logs. If all else fails, run a simple test INSERT with isolation level serializable to ensure the trigger fires inside the test transaction.
01
What's the difference between a BEFORE trigger returning NULL versus raising an exception — and when would you choose each approach?
SENIOR
02
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?
SENIOR
03
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.
SENIOR
04
How would you debug a production issue where an audit trigger is not logging any changes, but table modifications are happening?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
Can a PostgreSQL trigger call another trigger?
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.
Was this helpful?
02
What language can I write PostgreSQL trigger functions in?
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.
Was this helpful?
03
What's the difference between a PostgreSQL trigger and a rule?
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.
Was this helpful?
04
Can I create a trigger on a system catalog table like pg_class?
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.
Was this helpful?
05
How do I pass application-specific context to a trigger (e.g., current user ID)?
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.