postgres procedural language · field manual

PL/pgSQL,
or: moving the code
to the data

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.

postgres=# \df
postgres=# \sf transfer
NOTICE:  debug: 42
COMMIT
01 / CONCEPT

Where should this logic live?

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.

✓ Reach for PL/pgSQL when

  • Triggers: audit logs, denormalized columns, invariants on every write path
  • Multi-statement atomic operations with branching
  • Set-heavy logic that would otherwise round-trip rows to the app
  • SECURITY DEFINER: expose a narrow privileged operation

✗ Keep it out when

  • Plain SQL already expresses it (view, CTE, generated column)
  • It's domain/business logic that belongs in your hexagon's core
  • Complex algorithms, external calls, anything needing real tooling
  • The team can't review or test it in the normal pipeline
02 / CONCEPT

The round-trip tax

Updating 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.

latency 8 ms
app loop — one UPDATE per row one call — SELECT apply_fee()

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.)

03 / CONCEPT

Latency math: when the loop becomes a crime

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).

rows 100k
network latency ≈ 8 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.

04 / CONCEPT

The trigger pipeline

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.

05 / CONCEPT

The case against: why & when not to use it

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.

The scaling argument (the big one)

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.

Smells that mean you've gone too far

The portability & people costs

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.

✓ A fair summary

  • Use it where the DB is the only reliable enforcement point (triggers)
  • Use it where data locality wins by orders of magnitude (sections 02–03)
  • Keep each function small, mechanical, and boring

✗ The litmus test

  • Would a new teammate look for this logic here? If no → app layer
  • Does it need unit tests with mocks/fixtures? → app layer
  • Could a view or one UPDATE do it? → plain SQL
  • Is it growing? PL/pgSQL should never be where code accumulates
06 / HANDS-ON

The lab — follow along in a terminal

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.

01Boot a throwaway Postgres
Goal · a Postgres 17 you can destroy without guilt.
shell
docker run -d --name pgplay -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17
docker exec -it pgplay psql -U postgres
Try · type \? to see psql commands, and \timing on — you'll want timings later.
02Schema + your first anonymous block
Goal · run PL/pgSQL without creating anything, using DO.
psql
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.

Try · change NOTICE to EXCEPTION and watch the block abort.
03The audit trigger (the killer use case)
Goal · an audit log that no write path can bypass — exactly what section 04 animated.
psql
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();
Try · insert, update, and delete a row, then SELECT * FROM audit_log;. OLD is NULL on INSERT, NEW is NULL on DELETE — see it in the jsonb.
04Atomic transfer with locking
Goal · multi-statement logic that succeeds or fails as one unit, with pessimistic locking.
psql
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;
Try · 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.
05The benchmark — feel the round-trip tax
Goal · measure sections 02–03 for real: loop-in-function vs set-based UPDATE.
psql · seed 100k rows
INSERT INTO accounts (owner, balance)
SELECT 'user_' || g, random() * 1000 FROM generate_series(1, 100000) g;
psql · version A: row-by-row in PL/pgSQL
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();
psql · version B: one set-based statement
UPDATE accounts SET balance = balance * 0.99 WHERE balance > 500;
Try · compare the timings. Then, for the full lesson, write a 15-line script in your language of choice that fetches the ids and issues one UPDATE per row over the wire. Rank the three. That ranking — set SQL > PL/pgSQL loop > app loop — is the core intuition of this whole page. (Tip: drop the audit trigger first or you'll also be benchmarking 50k audit inserts… which is its own interesting lesson.)
06updated_at via BEFORE trigger + introspection
Goal · see a BEFORE trigger mutate the row in flight, and learn to inspect what exists.
psql
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.

07Boss level: planner opacity
Goal · understand the most common PL/pgSQL performance complaint.
psql
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();
Try · the second plan shows just a Function Scan — the planner can't see inside. To peek inside, enable 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.

Cleanup

shell
docker rm -f pgplay