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

PL/pgSQL in depth: errors, dynamic SQL and set-returning functions

Level 6: Design and Security30 min

Beyond simple functions, production PL/pgSQL needs three skills: handling errors precisely, building dynamic SQL without injection holes, and returning sets of rows efficiently. Each has one safe pattern worth memorising.

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
  • Catch specific errors and read their details with GET STACKED DIAGNOSTICS
  • Build dynamic SQL safely with format() and EXECUTE ... USING
  • Write RETURNS TABLE functions and pick the right volatility

conceptThe concept

Errors: a BEGIN ... EXCEPTION WHEN condition THEN ... END block catches errors by name (unique_violation, foreign_key_violation) or SQLSTATE. Entering such a block starts a subtransaction, so the failed statement's changes are rolled back and the function can continue. GET STACKED DIAGNOSTICS reads the message, detail, constraint name and SQLSTATE.

Dynamic SQL: EXECUTE runs a string. Identifiers go through format('%I', name) (or quote_ident), literals through %L, and values preferably through EXECUTE ... USING parameters, which are never parsed as SQL.

Sets: RETURNS TABLE (...) with RETURN QUERY streams rows from a query; RETURN NEXT appends one row at a time. Simple wrappers are often better as LANGUAGE sql functions, which the planner can inline into the calling query.

Volatility: IMMUTABLE (same inputs, same output forever; required for index expressions), STABLE (constant within a statement, may read tables) and VOLATILE (the default). Mislabelling a function as IMMUTABLE produces wrong results from indexes and caches.

whyWhy it matters

Most PL/pgSQL incidents are one of three things: a broad WHEN OTHERS that swallows a real error, string concatenation that allows SQL injection, or a function marked IMMUTABLE that reads a table.

Use caseReal-world scenario

An admin function counts rows in any table named by the caller. Written with %I, a malicious table name becomes a harmless quoted identifier that simply does not exist.

Try itExamples

Run and edit these in the interactive lesson.

Catching a specific error and reading its details
DO $$
DECLARE msg text; state text; detail text; cname text;
BEGIN
  INSERT INTO reviews (product_id, customer_id, rating, title, body, created_at)
  VALUES (99999, 1, 5, 'Ghost', 'No such product', now());
EXCEPTION WHEN foreign_key_violation THEN
  GET STACKED DIAGNOSTICS msg = MESSAGE_TEXT, state = RETURNED_SQLSTATE, detail = PG_EXCEPTION_DETAIL, cname = CONSTRAINT_NAME;
  RAISE NOTICE 'caught % on %: %', state, cname, detail;
END $$;
Safe dynamic SQL with format(%I) and USING
BEGIN;
CREATE FUNCTION count_where(tbl text, col text, val text) RETURNS bigint LANGUAGE plpgsql AS $$
DECLARE n bigint;
BEGIN
  EXECUTE format('SELECT count(*) FROM %I WHERE %I = $1', tbl, col) INTO n USING val;
  RETURN n;
END $$;
SELECT count_where('orders', 'status', 'pending') AS pending_orders;
SELECT count_where('orders; DROP TABLE orders; --', 'status', 'x');
The second call fails with relation "orders; DROP TABLE orders; --" does not exist: the whole string was treated as one quoted name, and the failed transaction rolled back.
RETURNS TABLE with RETURN QUERY
BEGIN;
CREATE FUNCTION top_customers(p_country text, p_limit int)
RETURNS TABLE (customer text, orders bigint, spent numeric) LANGUAGE plpgsql STABLE AS $$
BEGIN
  RETURN QUERY
  SELECT c.name, count(*), sum(o.total)
  FROM customers c JOIN orders o ON o.customer_id = c.id
  WHERE c.country = p_country AND o.status <> 'cancelled'
  GROUP BY c.id ORDER BY 3 DESC LIMIT p_limit;
END $$;
SELECT * FROM top_customers('DE', 3);
ROLLBACK;
Volatility as the planner sees it
SELECT oid::regprocedure AS function, provolatile, proparallel
FROM pg_proc
WHERE oid IN ('now()'::regprocedure, 'random()'::regprocedure, 'lower(text)'::regprocedure, 'pgm.embed(text)'::regprocedure)
ORDER BY oid::regprocedure::text;
provolatile: i immutable, s stable, v volatile. now() is stable because it returns the transaction start time.

internalsUnder the hood

Exception blocks cost a subtransaction each time they are entered, which consumes a subtransaction ID when the block writes. Calling such a function per row in a big UPDATE can create millions of subtransactions; more than 64 open at once in a transaction overflows the per-backend cache and slows every snapshot on the server. Catch errors at the outermost useful level, not per row.

Avoid / preferDo this, not that

Avoid
EXECUTE 'SELECT count(*) FROM ' || tbl
     || ' WHERE name = ''' || val || '''';
Prefer
EXECUTE format('SELECT count(*) FROM %I WHERE name = $1', tbl)
INTO n USING val;

Concatenation lets a crafted value close the quote and append its own SQL. %I quotes identifiers and USING passes values as parameters that are never parsed.

Avoid
EXCEPTION WHEN OTHERS THEN
  RETURN NULL;
Prefer
EXCEPTION WHEN unique_violation THEN
  RETURN existing_id;

WHEN OTHERS also catches out-of-memory, serialization failures and bugs, hiding them as a NULL. Catch the conditions you can actually handle and let everything else propagate.

PitfallsCommon mistakes

  • Marking a function IMMUTABLE when it reads a table or depends on settings such as TimeZone; indexes built on it silently go stale.
  • An exception block inside a per-row function, creating one subtransaction per row.
  • SELECT INTO a variable without STRICT and not noticing the query matched zero or several rows.

LimitsWhen not to use it

Do not move business logic into PL/pgSQL just because you can. It is the right place for data-integrity rules, set-based maintenance and logic that must be close to the data; it is a poor place for complex branching that changes weekly, needs unit testing with mocks or calls external services.

SecuritySecurity notes

Every dynamic statement is a potential injection point; review EXECUTE calls like you would review raw SQL in application code. SECURITY DEFINER functions run with the owner's privileges, so they must set SET search_path = pg_catalog, pg_temp (or a trusted schema) to stop a caller from shadowing functions and operators with their own objects.

ChallengeTry it yourself

Write a function safe_insert_review(p_product int, p_customer int, p_rating int) that inserts a review and returns 'ok', or returns the constraint name if a foreign key or check constraint fails. Test it with a valid product, a missing product and a rating of 9, inside a transaction.

Show one solution
One solution
BEGIN;
CREATE FUNCTION safe_insert_review(p_product int, p_customer int, p_rating int) RETURNS text LANGUAGE plpgsql AS $$
DECLARE cname text;
BEGIN
  INSERT INTO reviews (product_id, customer_id, rating, title, body, created_at)
  VALUES (p_product, p_customer, p_rating, 'Test', 'Test review', now());
  RETURN 'ok';
EXCEPTION WHEN foreign_key_violation OR check_violation THEN
  GET STACKED DIAGNOSTICS cname = CONSTRAINT_NAME;
  RETURN cname;
END $$;
SELECT safe_insert_review(1, 1, 5) AS valid,
       safe_insert_review(99999, 1, 5) AS missing_product,
       safe_insert_review(1, 1, 9) AS bad_rating;
ROLLBACK;

InterviewQuestions you should be able to answer

How do you prevent SQL injection in PL/pgSQL dynamic SQL?
Never concatenate untrusted strings. Use format() with %I for identifiers and %L for literals, and better still pass values with EXECUTE ... USING so they are bound as parameters. Validate identifiers against the catalog (for example by casting to regclass) when the caller may only pick from existing tables. For SECURITY DEFINER functions also pin the search_path.
What is the cost of an EXCEPTION block?
Entering the block creates a subtransaction (a savepoint) so the protected statements can be rolled back. It costs CPU, and if the block writes it consumes a subtransaction ID. Used per row in large operations it can create huge numbers of subtransactions, and more than 64 in one transaction overflows the subxid cache, making snapshots expensive for every session. Keep exception blocks coarse or check conditions explicitly instead.

QuizCheck your understanding

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

Go deeperOfficial documentation