Postgres by Example: Functions and PL/pgSQL
June 22, 2026 · View on GitHub
PostgreSQL lets you define your own functions. The simplest are SQL functions — a function body that is one or more SQL statements; PostgreSQL can often inline them. The more powerful are PL/pgSQL functions — written in a procedural language built into the server, with variables, loops, conditions, and exceptions. PL/pgSQL is what you use for triggers, complex business logic, and one-off maintenance routines. There are also PL/Python, PL/Perl, and others, but PL/pgSQL is always available and is what you should learn first.
What you'll learn:
- The
CREATE FUNCTIONsyntax - The difference between
LANGUAGE sqlandLANGUAGE plpgsql - Volatility categories:
IMMUTABLE,STABLE,VOLATILE - Variables,
IF,LOOP, andRAISE NOTICEin PL/pgSQL SECURITY DEFINERand thesearch_pathwarning
-- A simple SQL function
CREATE OR REPLACE FUNCTION square(x integer)
RETURNS integer
LANGUAGE sql
IMMUTABLE
AS $$
SELECT x * x;
$$;
SELECT square(7) AS result;
-- A SQL function that returns a set (multiple rows)
CREATE OR REPLACE FUNCTION recent_orders(min_total numeric)
RETURNS TABLE (id integer, customer_id integer, total numeric)
LANGUAGE sql
STABLE
AS $$
SELECT id, customer_id, total
FROM orders_example
WHERE total >= min_total
ORDER BY id;
$$;
SELECT * FROM recent_orders(20);
-- A PL/pgSQL function with control flow
CREATE OR REPLACE FUNCTION classify_total(total numeric)
RETURNS text
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE
result text;
BEGIN
IF total IS NULL THEN
result := 'unknown';
ELSIF total < 20 THEN
result := 'small';
ELSIF total < 100 THEN
result := 'medium';
ELSE
result := 'large';
END IF;
RETURN result;
END;
$$;
SELECT id, total, classify_total(total) AS bucket
FROM orders_example
ORDER BY id;
-- A function that loops and uses RAISE NOTICE (a print statement)
CREATE OR REPLACE FUNCTION ping(n integer)
RETURNS integer
LANGUAGE plpgsql
AS $$
DECLARE
i integer;
BEGIN
FOR i IN 1..n LOOP
RAISE NOTICE 'ping %', i;
END LOOP;
RETURN n;
END;
$$;
SELECT ping(3);
-- Cleanup
DROP FUNCTION IF EXISTS square(integer);
DROP FUNCTION IF EXISTS recent_orders(numeric);
DROP FUNCTION IF EXISTS classify_total(numeric);
DROP FUNCTION IF EXISTS ping(integer);
A function declaration includes a name, argument list, return type, language, optional volatility, and body. The body is bracketed by dollar quotes ($$ ... $$) — these avoid the need to escape quotes inside the body. You can use $tag$ ... $tag$ if you ever need nested dollar quotes.
The volatility categories are critical for performance:
IMMUTABLE: same inputs always produce the same output, no database state involved. The planner can constant-fold these into expressions and even pre-evaluate them at planning time. Use for math, string formatting, deterministic transforms.STABLE: same inputs produce the same output within a single statement, but may differ across statements (e.g. functions that read tables). The planner can cache the result for the statement.VOLATILE(default): may return different results even within one statement (e.g.random(),now(), anything that modifies data). The planner re-evaluates every time.
Wrong volatility leads to wrong query plans — most beginners leave everything VOLATILE and pay the cost. Mark functions as IMMUTABLE / STABLE whenever true.
To run:
$ psql -f source/functions-and-plpgsql.sql postgres
CREATE FUNCTION
result
--------
49
(1 row)
...
NOTICE: ping 1
NOTICE: ping 2
NOTICE: ping 3
ping
------
3
(1 row)
Common pitfalls:
- Defining a
SECURITY DEFINERfunction (runs as the function owner) without settingsearch_pathis a known privilege-escalation pattern. Always includeSET search_path = pg_catalog, publicin such functions. - Marking a function
IMMUTABLEwhen it actually reads tables — the planner caches a wrong value. ReserveIMMUTABLEfor true math/string functions. - Overloading by argument types is allowed but easy to abuse.
recent_orders(numeric)andrecent_orders(integer)are different functions; small calls may unexpectedly target the wrong one. - PL/pgSQL is line-oriented and case-insensitive. Be careful with reserved words inside the function body.
Tip: Stored procedures (CREATE PROCEDURE, PostgreSQL 11+) are like functions but can manage transactions (COMMIT, ROLLBACK inside the body). Use functions for things that return values; procedures for batch operations that need transaction control.
Try it: Write an IMMUTABLE SQL function discount(price, pct) that returns the discounted price. Apply it in a query: SELECT id, total, discount(total, 10) FROM orders_example;. Then write a PL/pgSQL function that returns the largest order amount per customer using a loop or a single SQL query (the SQL form is much faster — try both to see).
Source: functions-and-plpgsql.sql
Next: Triggers
Home: Postgres by Example