Functions, procedures and triggers
SQL and PL/pgSQL functions, what IMMUTABLE, STABLE and VOLATILE promise, procedures that can commit, and triggers that fire only on a real change.
After this lesson you can
- Write a SQL function and a PL/pgSQL function and call them in a query
- Pick the right volatility category and explain what it allows the planner
- Explain the difference between a function and a procedure, including COMMIT
- Write a row trigger with a WHEN condition that handles NULL correctly
PostgreSQL runs server-side code as functions (called inside SQL,
return a value or a set of rows) and, since version 11, procedures
(run with CALL, return nothing or INOUT parameters, and may control
transactions). Both can be written in plain SQL or in PL/pgSQL, the
procedural language with variables, IF, loops and exception blocks.
Functions
A LANGUAGE sql function is a query with parameters. Since PostgreSQL 14
a single-expression one can use the standard RETURN expr body instead of
a quoted string. A plpgsql function has DECLARE / BEGIN ... END and
uses SELECT ... INTO to put a query result into a variable.
CREATE TABLE orders (id int PRIMARY KEY, customer_id int NOT NULL, amount numeric(10,2) NOT NULL);INSERT INTO orders VALUES (1, 7, 120.00), (2, 7, 30.00), (3, 8, 600.00);CREATE FUNCTION discount_pct(amount numeric) RETURNS numeric LANGUAGE sql IMMUTABLE RETURN CASE WHEN amount >= 500 THEN 10 WHEN amount >= 100 THEN 5 ELSE 0 END; CREATE FUNCTION customer_total(cust int) RETURNS numeric LANGUAGE plpgsql STABLE AS $$DECLARE total numeric;BEGIN SELECT coalesce(sum(amount), 0) INTO total FROM orders WHERE customer_id = cust; RETURN total;END $$; SELECT id, amount, discount_pct(amount) AS pct, customer_total(customer_id) AS customer_totalFROM ordersORDER BY id;Every function declares a volatility, which is a promise to the planner:
IMMUTABLE— same arguments, same result, forever; reads no tables. Required for a function used in an index expression.lower()is one.STABLE— same result within one statement; may read tables.now()is one: it returns the transaction's start time throughout.VOLATILE(the default) — may change every call or have side effects:random(),clock_timestamp(), anything that writes.
Marking a function more stable than it is does not raise an error; it
produces wrong results when the planner caches or pre-evaluates the call.
Marking a stable function VOLATILE only costs speed.
Procedures and transactions
A procedure runs in the caller's transaction, so a failure inside it rolls
back everything it did. A procedure may also COMMIT or ROLLBACK
itself, which is the point of having them (for example, a batch job that
commits every 10,000 rows), but only when it was CALLed outside an
explicit transaction block. The examples here always run inside one
(the sandbox wraps each run in a transaction), so you can see the error
that produces.
CREATE FUNCTION attempt(stmt text) RETURNS text LANGUAGE plpgsql AS $$BEGIN EXECUTE stmt; RETURN 'ok';EXCEPTION WHEN OTHERS THEN RETURN SQLSTATE || ': ' || SQLERRM;END $$;CREATE TABLE accounts (id int PRIMARY KEY, balance numeric NOT NULL CHECK (balance >= 0));INSERT INTO accounts VALUES (1, 100), (2, 50);CREATE PROCEDURE transfer(from_id int, to_id int, amt numeric) LANGUAGE plpgsql AS $$BEGIN UPDATE accounts SET balance = balance - amt WHERE id = from_id; UPDATE accounts SET balance = balance + amt WHERE id = to_id;END $$;CREATE PROCEDURE transfer_and_commit(from_id int, to_id int, amt numeric) LANGUAGE plpgsql AS $$BEGIN UPDATE accounts SET balance = balance - amt WHERE id = from_id; UPDATE accounts SET balance = balance + amt WHERE id = to_id; COMMIT;END $$;CALL transfer(1, 2, 30); CREATE TEMP TABLE outcome ASSELECT attempt('CALL transfer(1, 2, 500)') AS overdraw, attempt('CALL transfer_and_commit(1, 2, 10)') AS commit_inside, attempt('SELECT transfer(1, 2, 1)') AS select_a_procedure; SELECT o.*, (SELECT string_agg(id || '=' || balance, ', ' ORDER BY id) FROM accounts) AS balancesFROM outcome o;Triggers
A trigger runs a function automatically on INSERT, UPDATE, DELETE
or TRUNCATE. Row-level triggers see OLD and NEW; a BEFORE trigger
can change NEW or cancel the row by returning NULL, an AFTER trigger
sees the final row. UPDATE OF col limits the trigger to statements that
set that column, and WHEN (...) to rows where a condition holds. Use
IS DISTINCT FROM there: OLD.price <> NEW.price is NULL, not true, when
either side is NULL.
CREATE TABLE products (id int PRIMARY KEY, name text NOT NULL, price numeric(10,2));CREATE TABLE price_history ( id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY, product_id int NOT NULL, old_price numeric(10,2), new_price numeric(10,2));INSERT INTO products VALUES (1, 'tea', 4.00), (2, 'cup', 9.00), (3, 'jar', NULL);CREATE FUNCTION log_price_change() RETURNS trigger LANGUAGE plpgsql AS $$BEGIN INSERT INTO price_history (product_id, old_price, new_price) VALUES (OLD.id, OLD.price, NEW.price); RETURN NEW;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 log_price_change(); UPDATE products SET price = price WHERE id = 1; -- no real changeUPDATE products SET price = 10.00 WHERE id IN (2, 3); SELECT product_id, old_price, new_priceFROM price_historyORDER BY id;Triggers are invisible to whoever reads the calling code, so keep them for rules that must hold for every writer: audit trails, derived columns, cross-table invariants.
Common mistakes
- Declaring a function
IMMUTABLEalthough it reads a table, then getting stale results from an index or a cached plan. - Putting
COMMITin a procedure and calling it from inside a transaction (or from a function): error2D000. - Writing
WHEN (OLD.price <> NEW.price)and missing every change from or to NULL. - Calling a procedure with
SELECT: error42809, it must beCALLed.
What interviewers ask
- Function versus procedure in PostgreSQL? Functions return values and
are used in queries, without transaction control; procedures are
CALLed and mayCOMMITwhen not inside an outer transaction. - What do IMMUTABLE, STABLE and VOLATILE mean? What may change between calls, and therefore what the planner may cache or index.
- BEFORE or AFTER trigger?
BEFOREto modify or reject the row being written,AFTERto react to the final row (audit, cascades).
Exercise · Pro
Audit price changes with a trigger
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade