Four interactive concepts and a hands-on lab. Everything here runs against a throwaway Docker Postgres — open a terminal next to this page and follow along.
PL/pgSQL is not a replacement for your application layer — it's a tool for logic that is inseparable from the data. Click any leaf node below to see the reasoning.
SECURITY DEFINER: expose a narrow privileged operationUpdating 20 rows from the app with per-row statements costs 20 network round-trips. A function (or a single set-based statement) costs one. Press run and watch the packets.
The point isn't that PL/pgSQL is fast — it's that the network is slow. Work done next to the data skips the tax entirely. (And note: a single set-based UPDATE skips it too, with no function at all.)
Total time for the app-loop approach is roughly rows × (latency + per-row work). For the in-database approaches it's latency + rows × per-row work. Drag the sliders — the log-scale chart shows why nobody notices in dev (localhost, 0.2 ms) and everybody notices in prod (cross-AZ, 1–10 ms).
Notice the third bar: a plain set-based UPDATE usually beats the PL/pgSQL row-by-row function as well. The hierarchy is set-based SQL → PL/pgSQL → app loop. Use the procedural language when the logic genuinely can't be expressed as a set operation.
Triggers are the one place with no app-layer alternative: they fire on every write path — your service, a migration, a DBA in psql at 2 a.m. Press the button to push an UPDATE through the pipeline.
Key facts to hold onto: BEFORE triggers can mutate or veto the row (RETURN NULL skips it); AFTER triggers see the final row and are where audit logs live; everything runs inside the same transaction — if the trigger raises, the whole statement rolls back, audit entry included.
Every line of PL/pgSQL lives outside your normal engineering pipeline. The code itself is rarely the problem — the operational gravity around it is. Click each row to see what you give up compared to keeping the logic in your application layer.
Your application tier scales horizontally — more pods, done. Your primary database scales vertically, painfully, and is usually the scarcest resource in the system. Every CPU cycle a function burns is a cycle stolen from the thing only the database can do: serving queries. Heavy procedural logic in the DB means you eventually buy a bigger database to run business logic — the most expensive compute you own.
updated_at, counters) — not pricing rules, eligibility checks, or workflow state machines.UPDATE becomes an unpredictable avalanche of side effects.IF/ELSIF implementing domain decisions, it's an application that happens to live in your database — untested, unversioned, unloved.FOR r IN SELECT … LOOP UPDATE …, a set-based statement almost certainly does it faster (section 03's amber vs green bars).PL/pgSQL is Postgres dialect — fine if you're committed to Postgres (a good commitment), but it's logic you rewrite if anything ever changes, and it's invisible to most static analysis. And on a team, it concentrates knowledge: the median backend engineer reads Kotlin fluently and PL/pgSQL haltingly. Code only one person can review is a liability regardless of how well it runs.
Seven steps, ~30 minutes. Each one has a goal, the code, and an experiment. Click a step to expand it; tick the checkbox when done. Copy buttons everywhere.
docker run -d --name pgplay -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17
docker exec -it pgplay psql -U postgres\? to see psql commands, and \timing on — you'll want timings later.DO.CREATE TABLE accounts (id serial PRIMARY KEY, owner text, balance numeric);
DO $$
DECLARE
i int;
BEGIN
FOR i IN 1..5 LOOP
RAISE NOTICE 'hello from iteration %', i;
END LOOP;
END $$;That's the whole language skeleton: DECLARE / BEGIN / END, RAISE NOTICE as your printf, and $$ dollar-quoting so you don't escape quotes inside the body.
NOTICE to EXCEPTION and watch the block abort.CREATE TABLE audit_log (
id bigserial PRIMARY KEY,
table_name text, op text,
old_row jsonb, new_row jsonb,
at timestamptz DEFAULT now()
);
CREATE OR REPLACE FUNCTION audit() RETURNS trigger AS $$
BEGIN
INSERT INTO audit_log (table_name, op, old_row, new_row)
VALUES (TG_TABLE_NAME, TG_OP, to_jsonb(OLD), to_jsonb(NEW));
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER accounts_audit
AFTER INSERT OR UPDATE OR DELETE ON accounts
FOR EACH ROW EXECUTE FUNCTION audit();SELECT * FROM audit_log;. OLD is NULL on INSERT, NEW is NULL on DELETE — see it in the jsonb.INSERT INTO accounts (owner, balance) VALUES ('alice', 100), ('bob', 50);
CREATE OR REPLACE FUNCTION transfer(from_id int, to_id int, amount numeric)
RETURNS void AS $$
DECLARE
from_balance numeric;
BEGIN
SELECT balance INTO from_balance FROM accounts WHERE id = from_id FOR UPDATE;
IF from_balance IS NULL THEN
RAISE EXCEPTION 'account % not found', from_id;
END IF;
IF from_balance < amount THEN
RAISE EXCEPTION 'insufficient funds: % < %', from_balance, amount;
END IF;
UPDATE accounts SET balance = balance - amount WHERE id = from_id;
UPDATE accounts SET balance = balance + amount WHERE id = to_id;
END;
$$ LANGUAGE plpgsql;SELECT transfer(1, 2, 999999); — it raises, and nothing appears in audit_log: the trigger's insert rolled back with the statement. Then open a second psql session, BEGIN; SELECT transfer(1,2,10); in one (don't commit), and run a transfer from account 1 in the other — watch it block on FOR UPDATE until you COMMIT.INSERT INTO accounts (owner, balance)
SELECT 'user_' || g, random() * 1000 FROM generate_series(1, 100000) g;CREATE OR REPLACE FUNCTION apply_fee_loop() RETURNS int AS $$
DECLARE
r record; n int := 0;
BEGIN
FOR r IN SELECT id FROM accounts WHERE balance > 500 LOOP
UPDATE accounts SET balance = balance * 0.99 WHERE id = r.id;
n := n + 1;
END LOOP;
RETURN n;
END;
$$ LANGUAGE plpgsql;
\timing on
SELECT apply_fee_loop();UPDATE accounts SET balance = balance * 0.99 WHERE balance > 500;ALTER TABLE accounts ADD COLUMN updated_at timestamptz;
CREATE OR REPLACE FUNCTION touch() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW; -- BEFORE triggers return the (possibly modified) row
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER accounts_touch
BEFORE UPDATE ON accounts
FOR EACH ROW EXECUTE FUNCTION touch();Introspection toolkit: \df lists functions, \sf transfer prints source, \d accounts shows attached triggers, and RETURN NULL in a BEFORE trigger silently skips the row — try it and be appropriately horrified.
EXPLAIN ANALYZE SELECT * FROM accounts WHERE balance > 500;
CREATE OR REPLACE FUNCTION rich_accounts() RETURNS SETOF accounts AS $$
BEGIN
RETURN QUERY SELECT * FROM accounts WHERE balance > 500;
END;
$$ LANGUAGE plpgsql;
EXPLAIN ANALYZE SELECT * FROM rich_accounts();auto_explain with auto_explain.log_nested_statements = on, or rewrite hot functions as LANGUAGE sql, which can be inlined into the caller's plan. That distinction (plpgsql = opaque, sql = inlinable) is a great interview nugget.docker rm -f pgplay