Senior 4 min · March 06, 2026

PostgreSQL Triggers - Recursion Guard pg_trigger_depth()

Batch job fails with stack depth limit exceeded due to recursive triggers.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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 table
CREATE TABLE orders (
    order_id      SERIAL PRIMARY KEY,
    customer_name TEXT        NOT NULL,
    total_amount  NUMERIC(10,2) NOT NULL,
    status        TEXT        NOT NULL DEFAULT 'pending',
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at    TIMESTAMPTZ
);

-- 2. Create an audit log table to capture every change
CREATE TABLE order_audit_log (
    log_id        SERIAL PRIMARY KEY,
    order_id      INT,
    event_type    TEXT,         -- INSERT / UPDATE / DELETE
    old_status    TEXT,         -- NULL on INSERT
    new_status    TEXT,         -- NULL on DELETE
    changed_at    TIMESTAMPTZ  NOT NULL DEFAULT now(),
    changed_by    TEXT         NOT NULL  -- the DB role that made the change
);

-- ===========================================================
-- TRIGGER FUNCTION 1: Stamp updated_at before the row is written
-- This is a BEFORE trigger — we mutate NEW directly
-- ===========================================================
CREATE OR REPLACE FUNCTION stamp_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    -- NEW is the incoming row record. Mutating it here changes
    -- what actually gets written to the heap.
    NEW.updated_at := now();

    -- Returning NEW tells PostgreSQL: "use this (possibly modified)
    -- row as the actual row to insert/update".
    -- Returning NULL would cancel the operation entirely.
    RETURN NEW;
END;
$$;

-- ===========================================================
-- TRIGGER FUNCTION 2: Write an audit log entry AFTER the row lands
-- This is an AFTER trigger — OLD and NEW are both fully visible
-- ===========================================================
CREATE OR REPLACE FUNCTION log_order_status_change()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    -- On INSERT, OLD is NULL — use COALESCE to handle it safely
    INSERT INTO order_audit_log
        (order_id, event_type, old_status, new_status, changed_by)
    VALUES (
        COALESCE(NEW.order_id, OLD.order_id),
        TG_OP,                    -- built-in variable: 'INSERT','UPDATE','DELETE'
        OLD.status,               -- NULL for INSERT operations
        NEW.status,               -- NULL for DELETE operations
        current_user              -- the authenticated PostgreSQL role
    );

    -- AFTER row triggers must still return NEW (or OLD for DELETE)
    -- The return value is ignored, but NULL would suppress further
    -- triggers in the chain — always return the right record.
    RETURN NEW;
END;
$$;

-- ===========================================================
-- Attach triggers — note the naming convention: numeric prefix
-- controls alphabetical (therefore execution) order
-- ===========================================================

-- Fires BEFORE insert or update — stamps the timestamp
CREATE TRIGGER orders_10_stamp_updated_at
    BEFORE INSERT OR UPDATE
    ON orders
    FOR EACH ROW
    EXECUTE FUNCTION stamp_updated_at();

-- Fires AFTER insert, update, or delete — writes the audit log
CREATE TRIGGER orders_20_audit_status_change
    AFTER INSERT OR UPDATE OR DELETE
    ON orders
    FOR EACH ROW
    EXECUTE FUNCTION log_order_status_change();

-- ===========================================================
-- TEST IT
-- ===========================================================

-- Insert a new order
INSERT INTO orders (customer_name, total_amount)
VALUES ('Alice Johnson', 149.99);

-- Update the status
UPDATE orders
SET status = 'shipped'
WHERE customer_name = 'Alice Johnson';

-- Review the audit trail
SELECT
    log_id,
    order_id,
    event_type,
    old_status,
    new_status,
    changed_at,
    changed_by
FROM order_audit_log
ORDER BY log_id;
Output
log_id | order_id | event_type | old_status | new_status | changed_at | changed_by
--------+----------+------------+------------+------------+-------------------------------+------------
1 | 1 | INSERT | NULL | pending | 2024-03-15 10:22:14.381+00 | postgres
2 | 1 | UPDATE | pending | shipped | 2024-03-15 10:22:14.392+00 | postgres
(2 rows)
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
-- ===========================================================

CREATE TABLE products (
    product_id   SERIAL PRIMARY KEY,
    sku          TEXT   NOT NULL UNIQUE,
    product_name TEXT   NOT NULL,
    unit_price   NUMERIC(10,2) NOT NULL
);

CREATE TABLE inventory (
    inventory_id  SERIAL PRIMARY KEY,
    product_id    INT    NOT NULL REFERENCES products(product_id),
    warehouse     TEXT   NOT NULL DEFAULT 'main',
    stock_qty     INT    NOT NULL DEFAULT 0,
    UNIQUE (product_id, warehouse)
);

-- The view the application sees — a clean, flat product+stock surface
CREATE VIEW product_catalog AS
    SELECT
        p.product_id,
        p.sku,
        p.product_name,
        p.unit_price,
        i.stock_qty,
        i.warehouse
    FROM products p
    JOIN inventory i ON i.product_id = p.product_id;

-- ===========================================================
-- INSTEAD OF INSERT: fan the write out to both base tables
-- ===========================================================
CREATE OR REPLACE FUNCTION insert_into_product_catalog()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
    new_product_id INT;
BEGIN
    -- Step 1: insert the core product record and capture its generated PK
    INSERT INTO products (sku, product_name, unit_price)
    VALUES (NEW.sku, NEW.product_name, NEW.unit_price)
    RETURNING product_id INTO new_product_id;

    -- Step 2: create the corresponding inventory row
    INSERT INTO inventory (product_id, warehouse, stock_qty)
    VALUES (
        new_product_id,
        COALESCE(NEW.warehouse, 'main'),  -- default to main warehouse
        COALESCE(NEW.stock_qty, 0)
    );

    -- Return NEW so PostgreSQL knows the operation "succeeded"
    RETURN NEW;
END;
$$;

-- ===========================================================
-- INSTEAD OF UPDATE: update each base table independently
-- ===========================================================
CREATE OR REPLACE FUNCTION update_product_catalog()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    -- Only update columns that actually changed (compare OLD vs NEW)
    UPDATE products
    SET
        product_name = NEW.product_name,
        unit_price   = NEW.unit_price
    WHERE product_id = OLD.product_id;

    UPDATE inventory
    SET stock_qty = NEW.stock_qty
    WHERE product_id = OLD.product_id
      AND warehouse  = OLD.warehouse;

    RETURN NEW;
END;
$$;

-- Attach both INSTEAD OF triggers to the view
CREATE TRIGGER product_catalog_insert
    INSTEAD OF INSERT ON product_catalog
    FOR EACH ROW
    EXECUTE FUNCTION insert_into_product_catalog();

CREATE TRIGGER product_catalog_update
    INSTEAD OF UPDATE ON product_catalog
    FOR EACH ROW
    EXECUTE FUNCTION update_product_catalog();

-- ===========================================================
-- TEST: application writes to the view as if it's a table
-- ===========================================================
INSERT INTO product_catalog (sku, product_name, unit_price, stock_qty, warehouse)
VALUES ('SKU-9001', 'Wireless Keyboard', 79.99, 250, 'main');

UPDATE product_catalog
SET unit_price = 69.99, stock_qty = 230
WHERE sku = 'SKU-9001';

-- Verify data landed in the correct base tables
SELECT p.sku, p.unit_price, i.stock_qty
FROM products p JOIN inventory i ON i.product_id = p.product_id
WHERE p.sku = 'SKU-9001';
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
-- ===========================================================

CREATE TABLE product_prices (
    product_id    INT  PRIMARY KEY,
    sku           TEXT NOT NULL,
    current_price NUMERIC(10,2) NOT NULL,
    updated_at    TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE price_change_audit (
    audit_id      SERIAL PRIMARY KEY,
    product_id    INT,
    sku           TEXT,
    old_price     NUMERIC(10,2),
    new_price     NUMERIC(10,2),
    price_delta   NUMERIC(10,2),   -- computed: new - old
    changed_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    batch_job_id  TEXT             -- passed via SET LOCAL
);

-- ===========================================================
-- Statement-level trigger function using transition tables
-- OLD TABLE contains all rows BEFORE the UPDATE
-- NEW TABLE contains all rows AFTER the UPDATE
-- Both are queryable with standard SQL inside this function
-- ===========================================================
CREATE OR REPLACE FUNCTION audit_price_changes_bulk()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    -- Single INSERT joining transition tables — processes ALL
    -- modified rows in one statement regardless of batch size.
    -- current_setting() reads a session-level variable set by
    -- the calling application for traceability.
    INSERT INTO price_change_audit
        (product_id, sku, old_price, new_price, price_delta, batch_job_id)
    SELECT
        old_rows.product_id,
        new_rows.sku,
        old_rows.current_price                       AS old_price,
        new_rows.current_price                       AS new_price,
        new_rows.current_price - old_rows.current_price AS price_delta,
        current_setting('app.batch_job_id', true)    -- true = return NULL if unset
    FROM old_table AS old_rows                       -- transition table: pre-update snapshot
    JOIN new_table AS new_rows                       -- transition table: post-update snapshot
      ON old_rows.product_id = new_rows.product_id
    WHERE old_rows.current_price <> new_rows.current_price;  -- only log actual changes

    -- Statement-level triggers: return value is always NULL / ignored
    RETURN NULL;
END;
$$;

-- ===========================================================
-- Attach as a statement-level AFTER UPDATE trigger
-- REFERENCING declares the transition table aliases
-- ===========================================================
CREATE TRIGGER product_prices_bulk_audit
    AFTER UPDATE ON product_prices
    REFERENCING OLD TABLE AS old_table NEW TABLE AS new_table
    FOR EACH STATEMENT
    EXECUTE FUNCTION audit_price_changes_bulk();

-- ===========================================================
-- Seed some test data
-- ===========================================================
INSERT INTO product_prices (product_id, sku, current_price) VALUES
    (1, 'SKU-001', 29.99),
    (2, 'SKU-002', 49.99),
    (3, 'SKU-003', 99.99);

-- ===========================================================
-- Simulate a nightly batch price update
-- The app sets a session variable for traceability
-- ===========================================================
SET LOCAL app.batch_job_id = 'NIGHTLY-PRICE-JOB-2024-03-15';

UPDATE product_prices
SET
    current_price = current_price * 0.90,  -- 10% discount run
    updated_at    = now()
WHERE product_id IN (1, 2, 3);

-- One trigger invocation processed all 3 rows
SELECT
    product_id,
    sku,
    old_price,
    new_price,
    price_delta,
    batch_job_id
FROM price_change_audit
ORDER BY product_id;
Output
product_id | sku | old_price | new_price | price_delta | batch_job_id
------------+---------+-----------+-----------+-------------+---------------------------------
1 | SKU-001 | 29.99 | 26.99 | -3.00 | NIGHTLY-PRICE-JOB-2024-03-15
2 | SKU-002 | 49.99 | 44.99 | -5.00 | NIGHTLY-PRICE-JOB-2024-03-15
3 | SKU-003 | 99.99 | 89.99 | -10.00 | NIGHTLY-PRICE-JOB-2024-03-15
(3 rows)
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
-- ===========================================================

CREATE TABLE categories (
    category_id   SERIAL PRIMARY KEY,
    parent_id     INT REFERENCES categories(category_id),
    category_name TEXT NOT NULL,
    full_path     TEXT  -- e.g. 'Electronics > Laptops > Gaming'
);

CREATE OR REPLACE FUNCTION rebuild_category_path()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    -- pg_trigger_depth() = 0 means we're the outermost trigger call.
    -- If > 0, we're inside a recursive trigger chain — skip execution
    -- to prevent infinite recursion without raising an error.
    IF pg_trigger_depth() > 1 THEN
        RETURN NEW;
    END IF;

    -- Build the full path by recursively walking parent_id
    WITH RECURSIVE ancestor_path AS (
        -- Base case: start from the current row's parent
        SELECT parent_id, category_name, 1 AS depth
        FROM categories
        WHERE category_id = NEW.parent_id

        UNION ALL

        -- Recursive case: walk up to the root
        SELECT c.parent_id, c.category_name, ap.depth + 1
        FROM categories c
        JOIN ancestor_path ap ON ap.parent_id = c.category_id
    )
    SELECT
        string_agg(category_name, ' > ' ORDER BY depth DESC) || ' > ' || NEW.category_name
    INTO NEW.full_path
    FROM ancestor_path;

    -- If no parent exists, the category IS the root
    NEW.full_path := COALESCE(NEW.full_path, NEW.category_name);

    RETURN NEW;
END;
$$;

CREATE TRIGGER categories_rebuild_path
    BEFORE INSERT OR UPDATE OF parent_id, category_name
    ON categories
    FOR EACH ROW
    EXECUTE FUNCTION rebuild_category_path();

-- ===========================================================
-- Pattern 2: Deferred constraint trigger for referential
-- integrity that needs to survive mid-transaction inconsistency
-- Scenario: batch import that inserts child rows before parents
-- ===========================================================

CREATE TABLE departments (
    dept_id   SERIAL PRIMARY KEY,
    dept_name TEXT NOT NULL
);

CREATE TABLE employees (
    employee_id SERIAL PRIMARY KEY,
    full_name   TEXT NOT NULL,
    dept_id     INT  -- intentionally no FK — trigger enforces it
);

CREATE OR REPLACE FUNCTION check_dept_exists()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    -- This check runs at COMMIT time, not statement time,
    -- so mid-transaction inconsistency is acceptable.
    IF NOT EXISTS (
        SELECT 1 FROM departments WHERE dept_id = NEW.dept_id
    ) THEN
        RAISE EXCEPTION
            'dept_id % does not exist in departments table', NEW.dept_id;
    END IF;
    RETURN NEW;
END;
$$;

-- CONSTRAINT TRIGGER + DEFERRABLE: validation fires at end of transaction
CREATE CONSTRAINT TRIGGER employees_check_dept_deferred
    AFTER INSERT OR UPDATE OF dept_id
    ON employees
    DEFERRABLE INITIALLY DEFERRED
    FOR EACH ROW
    EXECUTE FUNCTION check_dept_exists();

-- ===========================================================
-- Pattern 3: Query pg_trigger to audit every trigger in the DB
-- ===========================================================
SELECT
    t.tgname                                          AS trigger_name,
    c.relname                                         AS table_name,
    p.proname                                         AS function_name,
    CASE t.tgtype & 2  WHEN 2 THEN 'BEFORE' ELSE 'AFTER'  END AS timing,
    CASE t.tgtype & 1  WHEN 1 THEN 'ROW'    ELSE 'STATEMENT' END AS scope,
    CASE
        WHEN t.tgenabled = 'O' THEN 'ENABLED'
        WHEN t.tgenabled = 'D' THEN 'DISABLED'
        WHEN t.tgenabled = 'R' THEN 'REPLICA'
        WHEN t.tgenabled = 'A' THEN 'ALWAYS'
    END                                               AS status
FROM pg_trigger t
JOIN pg_class   c ON c.oid    = t.tgrelid
JOIN pg_proc    p ON p.oid    = t.tgfoid
WHERE NOT t.tgisinternal                             -- exclude FK constraint triggers
ORDER BY c.relname, t.tgname;
Output
trigger_name | table_name | function_name | timing | scope | status
-----------------------------------+------------------+---------------------------------+----------+-----------+---------
categories_rebuild_path | categories | rebuild_category_path | BEFORE | ROW | ENABLED
employees_check_dept_deferred | employees | check_dept_exists | AFTER | ROW | ENABLED
orders_10_stamp_updated_at | orders | stamp_updated_at | BEFORE | ROW | ENABLED
orders_20_audit_status_change | orders | log_order_status_change | AFTER | ROW | ENABLED
product_prices_bulk_audit | product_prices | audit_price_changes_bulk | AFTER | STATEMENT | ENABLED
(5 rows)
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 permissions
CREATE ROLE trigger_audit_role;
GRANT INSERT ON TABLE order_audit_log TO trigger_audit_role;

-- Create the trigger function with SECURITY DEFINER,
-- so it runs as the owner of the function (which is the audit role)
CREATE OR REPLACE FUNCTION log_order_status_change_secure()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'   -- avoid search_path attacks
AS $$
BEGIN
    INSERT INTO order_audit_log
        (order_id, event_type, old_status, new_status, changed_by)
    VALUES (
        COALESCE(NEW.order_id, OLD.order_id),
        TG_OP,
        OLD.status,
        NEW.status,
        current_user
    );
    RETURN NEW;
END;
$$;

-- Alter the function owner to the audit role
ALTER FUNCTION log_order_status_change_secure()
    OWNER TO trigger_audit_role;

-- Attach the trigger — now the calling user only needs INSERT on orders,
-- not on the audit table. The trigger function handles audit insertion
-- as the audit role.
CREATE TRIGGER orders_20_audit_secure
    AFTER INSERT OR UPDATE OR DELETE ON orders
    FOR EACH ROW
    EXECUTE FUNCTION log_order_status_change_secure();

-- ===========================================================
-- Test: user 'app_user' has INSERT on orders but not on order_audit_log
-- The trigger still works because it runs as trigger_audit_role.
-- ===========================================================

SET ROLE app_user;

INSERT INTO orders (customer_name, total_amount)
VALUES ('Bob Test', 59.99);

-- Verify audit entry exists
SELECT * FROM order_audit_log;
-- User can see the result because they have SELECT on the audit table
-- (granted separately for reporting)

RESET ROLE;
Output
log_id | order_id | event_type | old_status | new_status | changed_by
--------+----------+------------+------------+------------+------------
1 | 1 | INSERT | NULL | pending | app_user
(1 row)
Mental Model: Trigger Privilege = Table Owner's Permissions
  • 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;
\! psql -c "SELECT current_setting('session_replication_role');"
Fix now
ALTER TRIGGER audit_trigger ON your_table ENABLE ALWAYS; -- survives replica role
PostgreSQL Trigger Types Compared
DimensionFOR EACH ROWFOR EACH STATEMENT
Invocation count per 10K-row UPDATE10,000 separate calls1 single call
Access to individual OLD/NEW valuesYes — OLD.col, NEW.colNo — use transition tables instead
Transition table supportNot availableAvailable (PostgreSQL 10+)
Can modify the row being writtenYes — mutate NEW in BEFORE triggerNo — statement already determined
Performance on bulk DMLCatastrophic at scale (context switches)Excellent — single execution
Use case sweet spotValidation, auto-stamping, per-row logicBulk audit, cache invalidation, aggregates
INSTEAD OF trigger supportYes (views only)No — not supported
Fires when 0 rows are affectedNoYes — always fires once
Can suppress the DML (return NULL)Yes in BEFORE triggersNo — return value is ignored
Security modelRuns as table ownerRuns as table owner
Privilege escalation riskMedium — SECURITY DEFINER can open accessMedium — same as row level
Memory usageNegligible per callTransition 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'.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can a PostgreSQL trigger call another trigger?
02
What language can I write PostgreSQL trigger functions in?
03
What's the difference between a PostgreSQL trigger and a rule?
04
Can I create a trigger on a system catalog table like pg_class?
05
How do I pass application-specific context to a trigger (e.g., current user ID)?
🔥

That's MySQL & PostgreSQL. Mark it forged?

4 min read · try the examples if you haven't

Previous
PostgreSQL Extensions
9 / 13 · MySQL & PostgreSQL
Next
MySQL Replication Setup