Topic 15

Functions, Triggers, and PL/pgSQL

In-Database Code

Postgres will run your code inside the database, in a choice of languages, inside the transaction that called it. For an invariant that has to hold no matter which client is writing, that is the correct place for it and nowhere else will do. It is also how a team ends up with a quarter of its business logic somewhere with no version control, no test harness and no stack trace.

Cartwheel needs three things from this topic, and they sit in different places on that line. The status history in delivery_events has to record every status change including the ones made by hand, which the application cannot promise. The dashboard's materialized view has to be refreshable by a role that does not own it. And the API wants a helper that turns a customer-facing public_id into an internal orders.id without a round trip. Two of the three belong in the database; the third only if it is labelled correctly.

Volatility Is a Planner Contract

Every function carries a volatility label, and the label is a promise the planner acts on. IMMUTABLE means the function always returns the same result for the same arguments and never consults the database. STABLE means the result is fixed for the duration of one statement but may differ between statements, which is what any function that reads a table must be. VOLATILE means anything at all, and it is what you get by saying nothing.

Two functions, two labels, and the reason for each
-- reads a table, so STABLE is the strongest label it may carry
CREATE FUNCTION order_id_for(p_public_id uuid) RETURNS bigint
  LANGUAGE sql STABLE AS
  $$ SELECT id FROM orders WHERE public_id = p_public_id $$;

-- pure computation over its argument: usable in an index expression
CREATE FUNCTION sku_prefix(sku text) RETURNS text
  LANGUAGE sql IMMUTABLE AS
  $$ SELECT upper(left(sku, 3)) $$;

The label controls what the planner may do with a call. An IMMUTABLE call with constant arguments can be evaluated once at plan time and replaced by its value. Only an immutable function may appear in an index expression, because the index stores the answers and nothing recomputes them afterwards. And a STABLE function may be used in an index scan's comparison value, where a VOLATILE one forces re-evaluation per row. Getting it wrong in the safe direction costs performance. Getting it wrong in the other — labelling order_id_for as IMMUTABLE because it "basically" returns the same thing, then indexing on it — produces an index full of answers computed against data that has since changed, and the planner has no reason to doubt it.

Three volatility labels, and what each one permits the planner to do
IMMUTABLEsame result, same arguments
Never consults the database. A call with constant arguments can be evaluated once at plan time, and only an immutable function may appear in an index expression — because the index stores the answers and nothing recomputes them.
STABLEfixed for one statement
The strongest label a function that reads a table may carry. It may be used as an index scan's comparison value, where a volatile function forces re-evaluation per row.
VOLATILEwhat you get by saying nothing
Anything at all. Getting the label wrong in this direction costs performance; getting it wrong the other way builds an index full of answers computed against data that has since changed.

SQL Bodies and PL/pgSQL Bodies

A function written in plain SQL whose body is a single query can be inlined into the calling statement: the planner replaces the call with the query, and then optimizes the whole thing as one, with real statistics on both sides of the seam. That is the reason to prefer SQL for anything that is really just an expression.

A PL/pgSQL function is a black box by comparison. It has variables, loops, exception blocks and the ability to do several things in order, and the planner cannot see through any of it. The costs are concrete rather than philosophical: a procedural-language function is assumed to cost 100 units where a built-in costs 1, and a set-returning function with no ROWS estimate is planned as though it returns 1,000 rows — a number that is wrong by three orders of magnitude in either direction often enough to flip a join.

Standard Bodies and Dependency Tracking

The classic function body is a string literal. Postgres stores the text and parses it when the function runs, which means the body can name objects that do not exist yet, and it keeps working right up to the moment the table it reads is dropped. Since 14, a SQL function can be written in the standard BEGIN ATOMIC … END form instead, which is parsed at definition time.

The same function, before and after the dependency is real
-- string body: parsed when it runs; orders can be dropped underneath it
CREATE FUNCTION order_id_for(p_public_id uuid) RETURNS bigint
  LANGUAGE sql STABLE AS
  $$ SELECT id FROM orders WHERE public_id = p_public_id $$;

-- standard body: parsed and dependency-tracked at creation time
CREATE OR REPLACE FUNCTION order_id_for(p_public_id uuid) RETURNS bigint
  LANGUAGE sql STABLE
  BEGIN ATOMIC
    SELECT id FROM orders WHERE public_id = p_public_id;
  END;

With the second form Postgres records that the function depends on orders and on the column it reads, so a DROP TABLE is refused rather than accepted and discovered later by a failing query. The trade is that a body parsed at creation time cannot resolve things that are only knowable at call time, which rules out polymorphic argument types and a few related constructs. It is also LANGUAGE sql only: a PL/pgSQL body is still a string, and a DROP TABLE underneath one still succeeds.

Triggers: What Fires, and How Often

A BEFORE row trigger runs before the row is written and can do two things nothing else can: modify the row on its way in, or return NULL and cancel the operation for that row. An AFTER row trigger sees the final state, including the effects of other triggers, and cannot change it — which is exactly what an audit trail wants. The other axis is how often the function runs. FOR EACH ROW fires once per affected row, so a delete of 10 rows calls it 10 times. FOR EACH STATEMENT fires once per statement, even when the statement modified nothing at all, and an AFTER statement trigger can see the whole change set through transition tables.

One call per statement, with the before and after images as tables
CREATE FUNCTION orders_log_status_change() RETURNS trigger
  LANGUAGE plpgsql AS $$
BEGIN
  INSERT INTO delivery_events (order_id, event_type, occurred_at, payload)
  SELECT n.id, 'status_changed', now(),
         jsonb_build_object('from', o.status, 'to', n.status,
                            'by', current_user)
    FROM new_rows n JOIN old_rows o ON o.id = n.id
   WHERE n.status IS DISTINCT FROM o.status;
  RETURN NULL;
END $$;

CREATE TRIGGER orders_status_audit
  AFTER UPDATE ON orders
  REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows
  FOR EACH STATEMENT EXECUTE FUNCTION orders_log_status_change();

The REFERENCING clause hands the function two relations holding the before and after images of every row the statement touched, so a bulk update of 50,000 orders produces one function call and one set-based insert instead of 50,000 calls and 50,000 inserts. IS DISTINCT FROM does the null-safe comparison, so a row whose status was null before and is pending now is recorded rather than skipped. One call per statement is what makes an audit trigger affordable. Attach the same logic FOR EACH ROW and a COPY of 5 million rows into delivery_events runs the function 5 million times, turning a 30-second load into an hour.

Where Triggers Earn Their Place

A trigger earns its place in three jobs. An audit trail that must capture every change, including the ones that never went through the API — Cartwheel's negative stock arrived from a correction script, and an application-side audit log would have recorded nothing at all about the writes that mattered most. A denormalized counter that has to move in the same transaction as the thing it counts. And bookkeeping columns such as updated_at, where the alternative is trusting every writer to remember.

The disqualifiers are equally clear. A trigger that calls an external service holds its transaction's locks for the entire round trip, runs again on every retry, and cannot be undone when the transaction rolls back — the write can be taken back, the email cannot. A multi-step workflow in PL/pgSQL is code the test harness cannot reach, because the harness knows how to call the API and not how to call a trigger. And a trigger that writes a table which has triggers of its own builds a cascade that is invisible from the UPDATE that started it, with a depth usually discovered in production.

Which jobs justify a trigger, and which disqualify one
Every change must be recorded, including the writes that never went through the APIA trigger
A denormalized counter that has to move in the same transaction as the thing it countsA trigger
Bookkeeping columns such as updated_at, where the alternative is trusting every writerA trigger
A call to an external service — the write can be taken back, the email cannotNot a trigger
A multi-step workflow the test harness cannot reachNot a trigger

SECURITY DEFINER Done Safely

A SECURITY DEFINER function executes with the privileges of the role that owns it rather than the role that called it. That is the clean way to hand a restricted role exactly one privileged operation: cartwheel_analytics can be allowed to rebuild the dashboard's materialized view without being given any rights over the view or the tables underneath it.

One narrow privilege, granted through a function nobody else may run
CREATE FUNCTION analytics.refresh_daily_orders() RETURNS void
  LANGUAGE plpgsql
  SECURITY DEFINER
  SET search_path = pg_catalog, analytics, pg_temp
  AS $$
BEGIN
  REFRESH MATERIALIZED VIEW CONCURRENTLY analytics.daily_orders;
END $$;

REVOKE EXECUTE ON FUNCTION analytics.refresh_daily_orders() FROM PUBLIC;
GRANT  EXECUTE ON FUNCTION analytics.refresh_daily_orders()
    TO cartwheel_analytics;

The SET search_path clause pins name resolution for the duration of the call, with pg_temp written last, and it is here for the reason Chapter 1 gave when it took name resolution apart: an unpinned definer function lets its caller decide which code runs with the owner's rights. The REVOKE is the half that gets left out. EXECUTE on a newly created function is granted to PUBLIC automatically, so between the CREATE and that line every role in the cluster can call it, and a definer function nobody revoked is a privilege handed to everyone rather than to cartwheel_analytics.

Logic in the database vs logic in the application

In the database — enforced for every writer, transactional with the data it guards, and free of a network round trip. It is also harder to test, harder to review, harder to version, and invisible in the application's stack trace.

In the application — testable, versioned, observable, and enforced only on the writers that go through it. It is where a workflow belongs and where an invariant does not.

The split that works — invariants go in the database, as constraints first and triggers second; workflow goes in the application. A trigger that sends an email is on the wrong side of the line, because the transaction can roll back and the email cannot.

Common Mistakes
  • Labelling a function IMMUTABLE because it usually returns the same value, then building an index on it — the index keeps answers computed against data that has moved, and the planner trusts every one of them.
  • Attaching a FOR EACH ROW trigger to a bulk-loaded table when a statement-level one with transition tables would do — the per-row call is the difference between a load that finishes and a load that is still running.
  • Calling an external service from inside a trigger — the transaction holds its locks for the whole round trip, the call repeats on every retry, and a rollback cannot take it back.
  • Chaining triggers that write tables carrying triggers of their own — the cascade is invisible from the statement that started it, and its depth is measured in production.
  • Omitting SET search_path on a SECURITY DEFINER function — pg_temp is searched first and is writable by anyone, so a caller can plant an object the function then runs as its owner.
  • Leaving a set-returning PL/pgSQL function without a ROWS estimate — the planner assumes 1,000 rows and picks a join strategy for a number that was never measured.
Best Practices
  • Declare volatility on every function deliberately, and treat STABLE as the ceiling for anything that reads a table.
  • Write single-query helpers as LANGUAGE sql so the planner can inline them, and keep PL/pgSQL for work that genuinely needs control flow.
  • Use BEGIN ATOMIC bodies for SQL functions so dependencies are tracked and a bad DROP fails at DDL time instead of at query time.
  • Prefer AFTER … FOR EACH STATEMENT triggers with REFERENCING transition tables, so one set-based statement replaces one call per row.
  • Pin SET search_path with pg_temp last on every SECURITY DEFINER function, and revoke EXECUTE from PUBLIC before granting it to the role that needs it.
  • Keep triggers to invariants, audit and bookkeeping — small, fast, and with no call that leaves the database.
Comparable toolsOracle PL/SQL, the deepest in-database languageSQL Server T-SQL procedures and CLR assembliesMySQL procedures and triggers, no transactional DDLPL/Python, PL/v8, PL/Rust the other Postgres languages

Knowledge Check

A helper that looks up an order by public_id is declared IMMUTABLE and used in an index expression. What goes wrong?

  • The index keeps answers computed under older data and the planner trusts them
  • The server detects the table access at run time and raises a volatility error
  • The function is silently treated as STABLE, so the index is simply never used
  • The index build fails, because index expressions may not reference other tables

Why is a single-query LANGUAGE sql function usually planned better than the same logic in PL/pgSQL?

  • SQL functions are compiled to native code while PL/pgSQL is interpreted per call
  • Its body can be inlined into the caller and optimized as one query
  • Its plan is cached across sessions while PL/pgSQL plans are rebuilt each call
  • It is always parallel-safe, whereas PL/pgSQL bodies never run in parallel

A bulk UPDATE touches 50,000 orders. Which trigger form records the changes with one function call?

  • BEFORE UPDATE ... FOR EACH ROW, reading the NEW and OLD record variables
  • AFTER UPDATE ... FOR EACH STATEMENT with REFERENCING transition tables
  • AFTER UPDATE ... FOR EACH ROW, which batches its calls per statement
  • BEFORE UPDATE ... FOR EACH STATEMENT with REFERENCING transition tables

A SECURITY DEFINER function is created so cartwheel_analytics can refresh one materialized view. Who can call it before any GRANT or REVOKE is written?

  • Only the function's owner, until an explicit GRANT extends it to other roles
  • Every role in the cluster, because EXECUTE is granted to PUBLIC by default
  • Only roles holding privileges on the materialized view the function refreshes
  • Nobody, because a SECURITY DEFINER function must be granted before it is callable

Cartwheel wants an email sent whenever an order reaches 'delivered'. Why is a trigger the wrong place for it?

  • Triggers cannot make network calls at all, so the send would fail immediately
  • The call sits inside the transaction, holds locks while it waits, and cannot be undone
  • An AFTER trigger cannot see the new status value, only the row as it was before
  • A statement trigger would fire once per statement rather than once per order

You got correct