PL/pgSQL
PL/pgSQL is PostgreSQLs procedural language. Use it inside functions, triggers, and DO blocks for logic that benefits from running inside the database — batch updates that would be slow over the wire, integrity rules, and admin chores. Keep it where the data is; reach for app code when business logic needs versioning, tests, and observability.
Variables, control flow, exceptions, RETURN QUERY
EXAMPLE
-- 1) A function with parameters, locals, control flow
CREATE OR REPLACE FUNCTION place_order(p_customer_id bigint, p_total_cents bigint)
RETURNS bigint
LANGUAGE plpgsql
AS $$
DECLARE
v_order_id bigint;
v_status text := 'new';
BEGIN
IF p_total_cents < 0 THEN
RAISE EXCEPTION 'total_cents must be >= 0';
END IF;
INSERT INTO orders (customer_id, total_cents, status, created_at)
VALUES (p_customer_id, p_total_cents, v_status, now())
RETURNING id INTO v_order_id;
UPDATE customers SET orders_count = orders_count + 1
WHERE id = p_customer_id;
RETURN v_order_id;
END;
$$;
-- 2) Set-returning function — looks like a table
CREATE OR REPLACE FUNCTION top_customers(n int)
RETURNS TABLE(customer_id bigint, name text, ltv numeric)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT c.id, c.name,
COALESCE(SUM(o.total_cents) / 100.0, 0) AS ltv
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status IN ('paid', 'shipped')
GROUP BY c.id
ORDER BY ltv DESC
LIMIT n;
END;
$$;
SELECT * FROM top_customers(10);
-- 3) Loops + cursors — when you really need row-at-a-time
CREATE OR REPLACE FUNCTION backfill_slugs()
RETURNS int LANGUAGE plpgsql AS $$
DECLARE
r RECORD;
c int := 0;
BEGIN
FOR r IN SELECT id, name FROM products WHERE slug IS NULL LOOP
UPDATE products
SET slug = lower(regexp_replace(r.name, '[^a-zA-Z0-9]+', '-', 'g'))
WHERE id = r.id;
c := c + 1;
END LOOP;
RETURN c;
END;
$$;
-- 4) Exception handling — trap a specific SQLSTATE
CREATE OR REPLACE FUNCTION safe_divide(a numeric, b numeric)
RETURNS numeric LANGUAGE plpgsql AS $$
BEGIN
RETURN a / b;
EXCEPTION
WHEN division_by_zero THEN
RAISE WARNING 'divide-by-zero at %', clock_timestamp();
RETURN NULL;
END;
$$;
-- 5) Dynamic SQL — parameterised inside EXECUTE
CREATE OR REPLACE FUNCTION find_by(uname text)
RETURNS SETOF users LANGUAGE plpgsql AS $$
BEGIN
RETURN QUERY EXECUTE
'SELECT * FROM users WHERE username = $1'
USING uname;
END;
$$;
-- 6) Trigger function — keep an audit log on writes
CREATE OR REPLACE FUNCTION orders_audit_trg()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO orders_audit (order_id, op, before, after, changed_at)
VALUES (
COALESCE(NEW.id, OLD.id),
TG_OP,
to_jsonb(OLD), to_jsonb(NEW),
now()
);
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_orders_audit
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION orders_audit_trg();
-- 7) Performance + observability
-- VOLATILE / STABLE / IMMUTABLE markers help the planner inline calls.
-- RAISE NOTICE prints into psql; RAISE LOG goes to the server log.
-- EXPLAIN ANALYZE works on functions via auto_explain.
-- 8) Permissions
GRANT EXECUTE ON FUNCTION place_order(bigint, bigint) TO app_user;
REVOKE INSERT, UPDATE, DELETE ON orders FROM app_user;
Why it matters
Use PL/pgSQL for code that lives close to the data — bulk backfills, integrity triggers, set-returning helpers. Resist the temptation to put business logic in the DB: PRs, code review, observability, and rollback are all harder there. The right test: "would I happily debug this at 2am over a SQL prompt?".
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
CREATE OR REPLACE FUNCTION inc(uid bigint)
RETURNS void AS $$
BEGIN
UPDATE counters SET n = n + 1 WHERE user_id = uid;
END;
$$ LANGUAGE plpgsql;
Try it Yourself »
Discussion
Loading…