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.
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.
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.
● 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.