Functions, procedures and triggers
Triggers
The idea in 30 seconds
A trigger runs a function automatically when rows are inserted, updated or deleted. It runs inside the same statement, so it can change the row on its way in, reject it, or record what happened, and if it fails the whole statement fails. That power is also the cost: every write pays for it, and nobody reading the application code can see it.
Helps to know first: SQL data modification, Constraints and data integrity
On this page
When, and how often
A trigger connects a table event to a function. Two choices define it:
| Fires once per row | Fires once per statement | |
|---|---|---|
| BEFORE the change | Can rewrite the row (NEW) or skip it | Runs before anything happens |
| AFTER the change | Sees the final row; can record it elsewhere | Can see every changed row at once through a transition table |
INSTEAD OF triggers exist too, on views, to make non-updatable views accept writes.
Changing the row on its way in
The classic use: keep an updated_at column honest, whatever code does the update.
ALTER TABLE orders ADD COLUMN updated_at timestamptz;
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END $$;
CREATE TRIGGER orders_updated_at
BEFORE UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
A row-level BEFORE trigger receives the new row as NEW. Whatever it returns is what gets written; returning NULL silently skips the row. For inserts, a column DEFAULT now() does the same job with no trigger at all.
Rejecting a change
A trigger that raises an error cancels the whole statement, not just the row:
CREATE OR REPLACE FUNCTION reject_big_orders() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.quantity > 10 THEN
RAISE EXCEPTION 'quantity % is over the limit of 10', NEW.quantity;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER order_items_limit
BEFORE INSERT ON order_items
FOR EACH ROW EXECUTE FUNCTION reject_big_orders();
INSERT INTO order_items VALUES (106, 1, 2, 4500.00), (106, 2, 50, 1200.00);
ERROR: quantity 50 is over the limit of 10
CONTEXT: PL/pgSQL function reject_big_orders() line 4 at RAISE
The first row was valid, but the statement failed as a unit: order 106 still has exactly the one item it had before. For a rule about a single row like this one, a CHECK constraint is simpler and faster; triggers earn their place when a rule needs logic a constraint cannot express.
Recording what happened
An audit trail is the other classic use: every change also writes a copy to an audit table.
CREATE TABLE audit (table_name text, op text, row_data jsonb, at timestamptz DEFAULT now());
-- once per row
CREATE OR REPLACE FUNCTION audit_row() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO audit (table_name, op, row_data) VALUES (TG_TABLE_NAME, TG_OP, to_jsonb(NEW));
RETURN NULL;
END $$;
-- once per statement, reading every new row from a transition table
CREATE OR REPLACE FUNCTION audit_statement() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO audit (table_name, op, row_data)
SELECT TG_TABLE_NAME, TG_OP, to_jsonb(n) FROM new_rows n;
RETURN NULL;
END $$;
CREATE TRIGGER order_items_audit AFTER INSERT ON order_items
REFERENCING NEW TABLE AS new_rows
FOR EACH STATEMENT EXECUTE FUNCTION audit_statement();
What triggers cost
Predict200,000 inserts
Inserting 200,000 rows into a table takes about 400 ms. Roughly how long does it take with a row-level audit trigger that writes one audit row per inserted row?
About three times as long. We inserted the same 200,000 rows into a fresh four-column table with each kind of trigger; medians of three runs, PostgreSQL 18.3 on a laptop:
| Trigger on the table | Time | Compared with none |
|---|---|---|
| None | 398 ms | — |
BEFORE row trigger setting updated_at | 503 ms | +26% |
AFTER row trigger writing an audit row | 1,218 ms | 3.1× |
AFTER statement trigger with a transition table | 965 ms | 2.4× |
Both audit versions wrote all 200,000 audit rows. The statement-level version is cheaper because it calls the function once and inserts all audit rows in one go, instead of 200,000 separate calls. Absolute timings on our laptop varied a lot between batches (one no-trigger run took 244 ms, another 399 ms), so trust the ratios from one batch, not the milliseconds.
Every bulk load, backfill and data fix pays that cost too, often without the person running it knowing the trigger exists.
Check yourself
A
BEFORE INSERTrow trigger returnsNULL. What happens to the row?Show answer
It is skipped silently: the insert does not happen for that row, and no error is raised.
A trigger rejects the second of two rows in one
INSERT. Is the first row saved?Show answer
No. The trigger’s error fails the whole statement, so neither row is inserted.
Why was the statement-level audit trigger faster than the row-level one?
Show answer
It ran once for the whole statement and inserted all audit rows from the transition table in one query, instead of calling the function and running a separate insert for each of the 200,000 rows.
Finished reading?
Saved in this browser only.
Sources
Where each claim on this page comes from.
- 1.PostgreSQL documentation: 37.1 Overview of Trigger BehaviorPrimary· §37.1 Overview of Trigger Behavior
Triggers fire before, after or instead of INSERT, UPDATE or DELETE, either once per row or once per statement.
- 2.PostgreSQL documentation: 41.10 Trigger FunctionsPrimary· §41.10 Trigger Functions
A row-level BEFORE trigger can return a modified NEW to change the row, or NULL to skip the operation for that row.
- 3.PostgreSQL documentation: CREATE TRIGGERPrimary· Parameters: REFERENCING
Transition relations let an AFTER trigger see all rows inserted, deleted or modified by the statement at once.
Go further
What to read next, and why it is worth the time.
- 1.PostgreSQL documentation: 37.1 Overview of Trigger BehaviorPrimary· §37.1
Lays out exactly when each kind of trigger fires relative to the row change and to each other.
Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.