Use PL/pgSQL control flow, exceptions, diagnostics, safe dynamic SQL, and a hardened SECURITY DEFINER boundary with explicit search_path and least-privilege EXECUTE grants.

Control Flow, Exceptions, Diagnostics, Dynamic SQL, and SECURITY DEFINER Safety

Use PL/pgSQL control flow, exceptions, diagnostics, safe dynamic SQL, and a hardened SECURITY DEFINER boundary with explicit search_path and least-privilege EXECUTE grants.

Intermediate → Advanced180–240 minutesServer-side programming and deployment safetyPostgreSQL 18.6 baselineCore SQL + built-in PL/pgSQL; no third-party extension requiredServiceHub disposable objects: app.ch18_*Lesson 4 uses a disposable admin database and superuser-equivalent local lab accountLocal/free tooling; psql recommendedLast reviewed: August 18, 2026

Learning outcomes

ServiceHub needs a small privileged routine that cancels a work order, records why it changed, and allows application callers to request only a constrained operation. This is where PL/pgSQL's control flow, diagnostics, dynamic SQL, and privilege model become one security problem. Dynamic commands must distinguish identifiers from values, and SECURITY DEFINER must not inherit a caller-controlled object lookup path.

01

Use variables, IF/ELSIF, loops, GET DIAGNOSTICS, and explicit SQLSTATE errors in PL/pgSQL.

02

Capture exception metadata with GET STACKED DIAGNOSTICS instead of parsing localized error strings.

03

Build dynamic SQL with format(%I) for identifiers and EXECUTE ... USING for values.

04

Demonstrate why naive string concatenation is an injection and correctness risk.

05

Harden a SECURITY DEFINER function with a secure search_path, non-superuser ownership, and same-transaction privilege revocation/grant.

1. Add a controlled cancellation/audit model

sql · supporting tables
DROP TABLE IF EXISTS app.ch18_work_order_change CASCADE;SET ROLE servicehub_owner;CREATE TABLE app.ch18_work_order_change (  change_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,  work_order_id bigint NOT NULL REFERENCES app.ch18_work_order(work_order_id),  changed_by name NOT NULL,  old_status text NOT NULL,  new_status text NOT NULL,  reason text NOT NULL,  changed_at timestamptz NOT NULL DEFAULT clock_timestamp());ALTER TABLE app.ch18_work_orderDROP CONSTRAINT IF EXISTS ch18_work_order_status_check;ALTER TABLE app.ch18_work_orderADD CONSTRAINT ch18_work_order_status_checkCHECK (status IN ('queued','assigned','completed','cancelled'));RESET ROLE;

The audit table is ordinary relational state. The routine will perform one explicit status transition and insert the audit row in the same transaction. If either statement fails, the caller's transaction fails unless the function catches and intentionally handles the error.

2. Control flow plus exact row-count diagnostics

sql · procedure-like logic inside a function
CREATE OR REPLACE FUNCTION app.ch18_cancel_work_order(  p_id bigint,  p_reason text)RETURNS app.ch18_work_orderLANGUAGE plpgsqlVOLATILEPARALLEL UNSAFEAS $$DECLARE  v_before app.ch18_work_order;  v_after  app.ch18_work_order;  v_rows bigint;BEGIN  IF p_reason IS NULL OR length(btrim(p_reason)) < 5 THEN    RAISE EXCEPTION USING      ERRCODE = 'P1802',      MESSAGE = 'cancellation reason is too short',      HINT = 'Provide at least five non-space characters.';  END IF;  SELECT * INTO STRICT v_before  FROM app.ch18_work_order  WHERE work_order_id = p_id  FOR UPDATE;  IF v_before.status = 'completed' THEN    RAISE EXCEPTION USING      ERRCODE = 'P1803',      MESSAGE = format('completed work order %s cannot be cancelled', p_id);  ELSIF v_before.status = 'cancelled' THEN    RETURN v_before;  END IF;  UPDATE app.ch18_work_order  SET status = 'cancelled'  WHERE work_order_id = p_id  RETURNING * INTO v_after;  GET DIAGNOSTICS v_rows = ROW_COUNT;  ASSERT v_rows = 1, 'exactly one work order must be updated';  INSERT INTO app.ch18_work_order_change(    work_order_id, changed_by, old_status, new_status, reason  ) VALUES (    p_id, session_user, v_before.status, v_after.status, btrim(p_reason)  );  RETURN v_after;END$$;ALTER FUNCTION app.ch18_cancel_work_order(bigint,text)OWNER TO servicehub_owner;

SELECT ... INTO STRICT raises NO_DATA_FOUND or TOO_MANY_ROWS instead of silently assigning null/one arbitrary row. GET DIAGNOSTICS ROW_COUNT reports the immediately preceding SQL command. ASSERT is for programmer invariants and can be disabled with plpgsql.check_asserts; business errors belong in RAISE with stable SQLSTATE codes.

3. Loops are useful when each item genuinely needs procedural handling

sql · bounded FOR loop with per-status summary
CREATE OR REPLACE FUNCTION app.ch18_status_summary()RETURNS TABLE(status text, order_count bigint)LANGUAGE plpgsqlSTABLEAS $$DECLARE  v_status text;BEGIN  FOREACH v_status IN ARRAY ARRAY['queued','assigned','completed','cancelled']::text[]  LOOP    RETURN QUERY    SELECT v_status, count(*)    FROM app.ch18_work_order AS w    WHERE w.status = v_status;  END LOOP;END$$;SELECT * FROM app.ch18_status_summary();

This loop is deliberately small and bounded. In production, prefer one set-based GROUP BY when it expresses the operation directly; use procedural loops when each iteration has distinct control flow, diagnostics, or external routine calls that cannot be represented cleanly as one SQL statement.

4. Exception handlers should classify, not hide, failures

sql · capture structured error diagnostics
CREATE OR REPLACE FUNCTION app.ch18_cancel_with_diagnostics(  p_id bigint,  p_reason text)RETURNS TABLE(sqlstate text, message text, detail text, constraint_name text)LANGUAGE plpgsqlVOLATILEAS $$DECLARE  v_state text;  v_message text;  v_detail text;  v_constraint text;BEGIN  PERFORM app.ch18_cancel_work_order(p_id, p_reason);  RETURN QUERY SELECT '00000', 'success', NULL::text, NULL::text;EXCEPTION WHEN OTHERS THEN  GET STACKED DIAGNOSTICS    v_state = RETURNED_SQLSTATE,    v_message = MESSAGE_TEXT,    v_detail = PG_EXCEPTION_DETAIL,    v_constraint = CONSTRAINT_NAME;  RETURN QUERY SELECT v_state, v_message, v_detail, v_constraint;END$$;SELECT * FROM app.ch18_cancel_with_diagnostics(999999,'operator request');

This wrapper is pedagogical: production routines often should re-raise unexpected failures so transactions cannot continue under false success. The important point is that SQLSTATE, constraint name, detail, hint, and exception context are structured fields. Applications should branch on SQLSTATE/contract codes rather than English message text.

5. Dynamic SQL: identifiers are syntax; values are parameters

PL/pgSQL normally caches plans for static SQL. Dynamic SQL is appropriate when the table/column identifier itself changes. Values should still be bound through USING; identifiers should be quoted with format('%I', ...).

sql · safe dynamic column filter with a whitelist
CREATE OR REPLACE FUNCTION app.ch18_count_orders_by(  p_column text,  p_value text)RETURNS bigintLANGUAGE plpgsqlSTABLEAS $$DECLARE  v_sql text;  v_count bigint;BEGIN  IF p_column NOT IN ('status') THEN    RAISE EXCEPTION USING      ERRCODE = 'P1804',      MESSAGE = 'unsupported filter column';  END IF;  v_sql := format(    'SELECT count(*) FROM app.ch18_work_order WHERE %I = $1',    p_column  );  EXECUTE v_sql INTO v_count USING p_value;  RETURN v_count;END$$;SELECT app.ch18_count_orders_by('status','completed');

USING keeps the value in its native parameter channel and avoids repeated text quoting/conversion. The whitelist controls which identifier shapes the routine is willing to expose. %I protects identifier syntax but does not decide authorization on its own.

6. Wrong dynamic SQL: concatenating values into executable text

sql · deliberately unsafe pattern — do not execute with untrusted input
CREATE OR REPLACE FUNCTION app.ch18_count_orders_unsafe(p_status text)RETURNS bigintLANGUAGE plpgsqlAS $$DECLARE  v_count bigint;BEGIN  EXECUTE 'SELECT count(*) FROM app.ch18_work_order WHERE status = ' || chr(39) || p_status || chr(39)  INTO v_count;  RETURN v_count;END$$;-- A value containing quote/comment syntax can alter the command text.-- Repair: EXECUTE '... status = $1' INTO v_count USING p_status;

Do not “repair” this with manual quote replacement. Parameter binding is the correct value channel. quote_literal/format('%L') are useful when a value genuinely must become literal text in generated DDL, but DML values should usually remain parameters.

7. SECURITY DEFINER is a privilege boundary

A normal function is SECURITY INVOKER: it uses the caller's privileges. A SECURITY DEFINER function uses the owner's privileges, so a caller can indirectly perform operations it could not perform directly. That is powerful enough to require threat modeling.

The course's app schema is owned by the non-login servicehub_owner role and application roles do not have CREATE there. We therefore treat app as a trusted schema for this lab and place pg_temp last in the function-specific search_path. Objects are still schema-qualified in the body.

sql · hardened definer creation and grants in one transaction
BEGIN;CREATE OR REPLACE FUNCTION app.ch18_app_cancel_work_order(  p_id bigint,  p_reason text)RETURNS bigintLANGUAGE plpgsqlSECURITY DEFINERSET search_path = pg_catalog, app, pg_tempAS $$DECLARE  v_id bigint;BEGIN  PERFORM app.ch18_cancel_work_order(p_id, p_reason);  v_id := p_id;  RETURN v_id;END$$;ALTER FUNCTION app.ch18_app_cancel_work_order(bigint,text)OWNER TO servicehub_owner;REVOKE ALL ON FUNCTION app.ch18_app_cancel_work_order(bigint,text) FROM PUBLIC;GRANT EXECUTE ON FUNCTION app.ch18_app_cancel_work_order(bigint,text)TO servicehub_app;COMMIT;

New functions normally receive EXECUTE for PUBLIC. Revoking and granting in the same transaction avoids a window where every role could call a freshly created privileged routine. The owner should be the least-privileged role that still has the required table privileges—not a superuser by convenience.

8. Prove caller identity versus effective routine privileges

sql · role-behavior verification
SELECT session_user, current_user;-- In a test session authenticated/SET ROLE as servicehub_app:SELECT app.ch18_app_cancel_work_order(18003,'customer withdrew request');SELECT change_id, work_order_id, changed_by, old_status, new_status, reasonFROM app.ch18_work_order_changeORDER BY change_id DESCLIMIT 5;

Inside a security-definer routine, object access executes with the function owner's effective privileges, while session_user remains useful for recording the original login identity. If your application uses role switching, decide whether session_user, current_user, or an explicit authenticated principal parameter represents the audit actor.

9. Security-definer review checklist

Question Required evidence
Who owns it? Non-superuser owner with only required privileges.
Who may execute? PUBLIC revoked; explicit narrow GRANT.
How are names resolved? Secure routine-level search_path; trusted schemas; pg_temp last; schema-qualified body.
Can input alter syntax? Values use USING; identifiers quoted and whitelisted.
How do callers classify errors? Stable SQLSTATE/contract codes, not localized text.
Production judgment

SECURITY DEFINER should expose a narrow capability, not become a generic “run privileged SQL” endpoint. If the routine needs arbitrary object names or commands, the privilege surface is probably too broad.

10. Checkpoint

Check your understanding

  1. What is the difference between GET DIAGNOSTICS and GET STACKED DIAGNOSTICS?
  2. Why should dynamic values use EXECUTE ... USING?
  3. Why is format(%I) necessary but insufficient for a user-supplied identifier?
  4. Why must pg_temp be placed after trusted schemas in a SECURITY DEFINER search_path?
  5. Why revoke PUBLIC EXECUTE in the same transaction as function creation?
Review the answers

GET DIAGNOSTICS reports current statement status while GET STACKED DIAGNOSTICS reports an active caught exception. USING binds values without turning them into executable text. %I quotes syntax but a whitelist still controls what the routine authorizes. pg_temp is normally searched early and writable by users, so putting it last prevents object shadowing. Same-transaction revoke/grant eliminates a temporary public privilege window.

Authoritative references

Routines, trigger timing, privileges, and planner promises are version-sensitive. These PostgreSQL 18 primary sources define the behavior used in this lesson.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.