PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 30: Functions and triggers

Functions, procedures and triggers

Level 6: Design and Security30 min

PostgreSQL runs code inside the database in SQL or PL/pgSQL. Functions return values and can be used in queries, procedures can manage transactions, and triggers run code automatically when rows change. Volatility labels and security settings decide whether that code is fast and safe.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Write SQL and PL/pgSQL functions and choose IMMUTABLE, STABLE or VOLATILE
  • Write row-level triggers for audit and derived columns
  • Know when to use SECURITY DEFINER safely and when to keep logic out of the database

conceptThe concept

A SQL function is a parameterised query; simple ones are inlined into the calling query. Since 14, BEGIN ATOMIC ... END bodies are parsed at creation time and track dependencies. PL/pgSQL adds variables, IF, loops, exceptions and dynamic SQL with EXECUTE format(...).

Volatility tells the planner what it may assume: IMMUTABLE (same inputs, same output forever; usable in indexes), STABLE (same within a statement, such as lookups), VOLATILE (default; may change anything, evaluated every row). A wrong label gives wrong results or slow plans.

Triggers call a function BEFORE or AFTER INSERT, UPDATE or DELETE, FOR EACH ROW or FOR EACH STATEMENT. BEFORE row triggers can modify NEW; AFTER triggers see the final row and suit auditing. Statement triggers can use transition tables (REFERENCING NEW TABLE AS ...) to process all changed rows at once. Procedures (CALL) can COMMIT inside their body, which suits batch jobs.

whyWhy it matters

Rules enforced in the database apply to every client. Audit trails written by triggers cannot be bypassed by a forgotten code path. The flip side: hidden logic is hard to debug, and row-by-row triggers can make bulk loads slow.

Use caseReal-world scenario

Every change to product prices must be recorded with the old and new value and who made it, regardless of which service or script changed it. An AFTER UPDATE trigger writes the audit row.

Try itExamples

Run and edit these in the interactive lesson.

SQL function with BEGIN ATOMIC
BEGIN;
CREATE FUNCTION order_count(cust int) RETURNS bigint
LANGUAGE sql STABLE
BEGIN ATOMIC
  SELECT count(*) FROM orders WHERE customer_id = cust;
END;
SELECT id, name, order_count(id) FROM customers WHERE id <= 3 ORDER BY id;
ROLLBACK;
PL/pgSQL with control flow
BEGIN;
CREATE FUNCTION loyalty_tier(cust int) RETURNS text
LANGUAGE plpgsql STABLE AS $$
DECLARE
  spent numeric;
BEGIN
  SELECT coalesce(sum(total), 0) INTO spent
  FROM orders WHERE customer_id = cust AND status <> 'cancelled';
  IF spent >= 20000 THEN RETURN 'platinum';
  ELSIF spent >= 5000 THEN RETURN 'gold';
  ELSE RETURN 'standard';
  END IF;
END $$;
SELECT loyalty_tier(id) AS computed, count(*) FROM customers GROUP BY 1 ORDER BY 2 DESC;
ROLLBACK;
An audit trigger
BEGIN;
CREATE TABLE price_audit (
  product_id int, old_price numeric, new_price numeric,
  changed_by text DEFAULT current_user, changed_at timestamptz DEFAULT now()
);
CREATE FUNCTION audit_price() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  INSERT INTO price_audit (product_id, old_price, new_price) VALUES (OLD.id, OLD.price, NEW.price);
  RETURN NULL;
END $$;
CREATE TRIGGER products_price_audit
  AFTER UPDATE OF price ON products
  FOR EACH ROW WHEN (OLD.price IS DISTINCT FROM NEW.price)
  EXECUTE FUNCTION audit_price();

UPDATE products SET price = price * 1.10 WHERE category = 'garden';
UPDATE products SET stock = stock + 1 WHERE category = 'garden';
SELECT count(*) AS audited, round(avg(new_price / old_price), 3) AS avg_ratio FROM price_audit;
ROLLBACK;
The WHEN clause and UPDATE OF price mean the stock update did not fire the trigger at all.
Volatility matters
BEGIN;
CREATE FUNCTION slow_now() RETURNS timestamptz LANGUAGE sql VOLATILE AS 'SELECT clock_timestamp()';
CREATE FUNCTION stable_now() RETURNS timestamptz LANGUAGE sql STABLE AS 'SELECT now()';
EXPLAIN SELECT count(*) FROM orders WHERE created_at > slow_now() - interval '30 days';
EXPLAIN SELECT count(*) FROM orders WHERE created_at > stable_now() - interval '30 days';
ROLLBACK;
A VOLATILE function is re-evaluated for every row and cannot be used as an index condition; a STABLE one is evaluated once per scan.

internalsUnder the hood

SECURITY DEFINER functions run with the owner's privileges, like setuid programs. Always pin SET search_path = pg_catalog, pg_temp (and schema-qualify names) on them, or a caller can create a same-named function or table earlier in the search path and hijack them.

Avoid / preferDo this, not that

Avoid
CREATE TRIGGER notify AFTER INSERT ON orders FOR EACH ROW
EXECUTE FUNCTION call_payment_api();  -- network call inside the transaction
Prefer
-- insert an outbox row in the trigger; a worker calls the API

External calls inside a transaction hold locks while waiting on the network, and cannot be rolled back if the transaction aborts.

Avoid
CREATE FUNCTION price_with_tax(p numeric) RETURNS numeric
LANGUAGE sql IMMUTABLE AS $$ SELECT p * (SELECT rate FROM tax_rates WHERE current) $$;
Prefer
CREATE FUNCTION price_with_tax(p numeric) RETURNS numeric
LANGUAGE sql STABLE AS $$ SELECT p * (SELECT rate FROM tax_rates WHERE current) $$;

A function that reads tables is not IMMUTABLE. Marked so, it can be constant-folded or used in an index and return stale results after the rate changes.

PitfallsCommon mistakes

  • Marking a function IMMUTABLE when it reads tables or depends on settings such as TimeZone. Indexes built on it can silently become wrong.
  • Writing SECURITY DEFINER functions without a fixed search_path.
  • Using row-level triggers on tables that receive bulk loads, then wondering why COPY is slow. Consider statement-level triggers with transition tables.

LimitsWhen not to use it

Avoid putting core business workflows in triggers that developers do not expect: hidden side effects make bugs hard to trace. Use them for auditing, derived data and invariants.

SecuritySecurity notes

A SECURITY DEFINER function runs with its owner's privileges. Always add SET search_path = pg_catalog, pg_temp (and schema-qualify names), and REVOKE EXECUTE ... FROM PUBLIC before granting it to the roles that need it.

ChallengeTry it yourself

Write a BEFORE UPDATE trigger on products that rejects any price cut of more than 50%. Show a 10% cut succeeding and a 90% cut failing.

Show one solution
One solution
BEGIN;
CREATE FUNCTION guard_price() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  IF NEW.price < OLD.price * 0.5 THEN
    RAISE EXCEPTION 'price cut too large for %: % to %', OLD.sku, OLD.price, NEW.price;
  END IF;
  RETURN NEW;
END $$;
CREATE TRIGGER products_price_guard BEFORE UPDATE OF price ON products
  FOR EACH ROW EXECUTE FUNCTION guard_price();
UPDATE products SET price = round(price * 0.9, 2) WHERE id = 1 RETURNING sku, price;
UPDATE products SET price = round(price * 0.1, 2) WHERE id = 1;
ROLLBACK;

InterviewQuestions you should be able to answer

What are IMMUTABLE, STABLE and VOLATILE?
Promises to the planner. IMMUTABLE: result depends only on arguments, forever; it can be constant-folded and used in index expressions. STABLE: no modifications and same result for the same arguments within one statement (may read tables or now()); it can be evaluated once for an index condition. VOLATILE (default): may return different results per call or have side effects; evaluated per row and blocks some optimisations. Mislabelling causes wrong results (too strong) or slow plans (too weak).
BEFORE versus AFTER triggers?
BEFORE ROW triggers run before the row is written and can change NEW (or return NULL to skip the row), ideal for normalising data or setting derived columns. AFTER triggers run once the row change is done, see final values and are used for auditing, cascading changes and notifications; AFTER ROW triggers are queued until the end of the statement. Constraint triggers can be deferred to commit.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation