Advanced SQL
Advanced SQL
Section titled “Advanced SQL”PL/pgSQL
Section titled “PL/pgSQL”DB9 supports PL/pgSQL functions and procedures with variable declarations and standard control
flow. CALL runs a LANGUAGE plpgsql procedure through the same interpreter as a function body —
IF, loops, CASE, EXECUTE, RAISE and exception handlers included — on both the wire protocol
and the HTTP SQL API:
CREATE PROCEDURE add_user(n TEXT) LANGUAGE plpgsql AS $$BEGIN INSERT INTO users (name) VALUES (n); END;$$; -- CREATE PROCEDURE
CALL add_user('alice'); -- CALL; the row is insertedCREATE PROCEDURE registers the procedure in pg_proc with prokind = 'p', and a LANGUAGE sql
procedure runs its plain SQL statements with the arguments substituted. A LANGUAGE plpgsql body
must be wrapped in BEGIN ... END: bare SQL is refused at create time with 42601
(PL/pgSQL function must have BEGIN block), as PostgreSQL also refuses it. A CREATE PROCEDURE
with no LANGUAGE clause at all is rejected with a bare internal error (XX000), where
PostgreSQL reports 42P13 (no language specified).
Multi-line DECLARE entries parse correctly — a declaration whose type or value spans several
lines produces the same value as PostgreSQL, in both a DO block and a CREATE FUNCTION body:
DECLARE v int := 1 + 2; -- 3DECLARE s text := 'abc' || 'def'; -- 'abcdef'DECLARE v int := 42; -- 42Dynamic SQL and exception handling
Section titled “Dynamic SQL and exception handling”EXECUTE works in a CREATE FUNCTION body as well as a DO block. The command string may be a
literal, a variable, or an expression, and INTO and USING behave as in PostgreSQL:
CREATE FUNCTION count_rows(t TEXT) RETURNS INT AS $$DECLARE n INT;BEGIN EXECUTE 'SELECT count(*) FROM ' || quote_ident(t) INTO n; RETURN n;END;$$ LANGUAGE plpgsql;
CREATE FUNCTION double_it(p INT) RETURNS INT AS $$DECLARE n INT;BEGIN EXECUTE 'SELECT $1 * 2' INTO n USING p; RETURN n;END;$$ LANGUAGE plpgsql;Exception handling (BEGIN ... EXCEPTION) works in a CREATE FUNCTION body as well as a DO
block. Handlers are selected by condition name, and SQLSTATE and SQLERRM are populated:
CREATE FUNCTION insert_user(p_name TEXT) RETURNS TEXT AS $$BEGIN INSERT INTO users (name) VALUES (p_name); RETURN 'inserted';EXCEPTION WHEN unique_violation THEN RETURN 'duplicate: ' || SQLSTATE; -- 'duplicate: 23505'END;$$ LANGUAGE plpgsql;
DO $$BEGIN EXECUTE 'CREATE TABLE audit_snapshot(x int)';EXCEPTION WHEN others THEN RAISE NOTICE 'setup skipped';END;$$;Exception handlers follow PostgreSQL rollback semantics: statements that ran before the exception are rolled back, and only the handler’s effects persist.
Nested BEGIN ... END blocks work in a function body too, and see the enclosing block’s variables.
CASE statements
Section titled “CASE statements”CASE works in both function bodies and DO blocks, in simple and searched form:
CREATE FUNCTION size_label(n INT) RETURNS TEXT AS $$BEGIN CASE WHEN n > 100 THEN RETURN 'large'; WHEN n > 10 THEN RETURN 'medium'; ELSE RETURN 'small'; END CASE;END;$$ LANGUAGE plpgsql;CREATE FUNCTION increment(val INT) RETURNS INT AS $$BEGIN RETURN val + 1;END;$$ LANGUAGE plpgsql;
CREATE FUNCTION safe_divide(a NUMERIC, b NUMERIC) RETURNS NUMERIC AS $$BEGIN RETURN a / NULLIF(b, 0);END;$$ LANGUAGE plpgsql;SELECT … INTO
Section titled “SELECT … INTO”SELECT ... INTO variable assigns a query result to a declared variable.
CREATE FUNCTION user_count() RETURNS INT AS $$DECLARE n INT;BEGIN SELECT count(*) INTO n FROM users; RETURN n;END;$$ LANGUAGE plpgsql;RETURNING … INTO
Section titled “RETURNING … INTO”DML statements inside PL/pgSQL functions can capture returned values into variables using RETURNING ... INTO. Works with INSERT, UPDATE, and DELETE.
CREATE FUNCTION create_order(p_item TEXT) RETURNS INT AS $$DECLARE new_id INT;BEGIN INSERT INTO orders (item) VALUES (p_item) RETURNING id INTO new_id; RETURN new_id;END;$$ LANGUAGE plpgsql;
CREATE FUNCTION archive_user(p_id INT) RETURNS TEXT AS $$DECLARE removed_name TEXT;BEGIN DELETE FROM users WHERE id = p_id RETURNING name INTO removed_name; RETURN COALESCE(removed_name, 'not found');END;$$ LANGUAGE plpgsql;Multiple columns can be captured into separate variables:
RETURNING id, name INTO v_id, v_name;If the DML statement affects zero rows, the target variables are set to NULL. Expressions (not just
column names) are supported in the RETURNING list.
More than one returned row is an error, not a silent truncation — matching PostgreSQL, which raises
the same P0003 (adding HINT: Make sure the query returns a single row, or use LIMIT 1.):
DO $$ DECLARE v int; BEGIN DELETE FROM orders RETURNING id INTO v; -- 3 matching rowsEND $$;-- ERROR: query returned more than one row (P0003)INSERT and UPDATE behave the same way, and the whole statement is rolled back — nothing it
would have written or removed survives, so the three rows above are still present after the error.
Narrow the WHERE clause until it matches one row, or iterate with
FOR row IN DELETE FROM ... RETURNING ... if you need every row.
Plain SELECT ... INTO is the looser case, on both engines: it takes the first row and raises
nothing, so add an ORDER BY when which row you get matters.
DO $$ DECLARE v int; BEGIN SELECT id INTO v FROM orders ORDER BY id; -- 3 matching rows RAISE NOTICE 'got %', v; -- got 1 — first row, no errorEND $$;Triggers
Section titled “Triggers”Row-level and statement-level triggers on INSERT, UPDATE, and DELETE. A row trigger may carry a
WHEN (...) condition and an UPDATE OF column, ... list — see
what CREATE TRIGGER accepts below.
CREATE FUNCTION audit_trigger() RETURNS TRIGGER AS $$BEGIN INSERT INTO audit_log (table_name, action, changed_at) VALUES (TG_TABLE_NAME, TG_OP, NOW()); RETURN NEW;END;$$ LANGUAGE plpgsql;
CREATE TRIGGER users_audit AFTER INSERT OR UPDATE ON users FOR EACH ROW EXECUTE FUNCTION audit_trigger();Supported trigger timing: BEFORE, AFTER. Supported events: INSERT, UPDATE, DELETE. A
BEFORE trigger function runs as PL/pgSQL: it can assign to NEW (NEW.name := upper(NEW.name);)
and the modified row is what gets stored, skip the row with RETURN NULL, or reject it with
RAISE EXCEPTION — conditionally, inside IF … END IF, as well as unconditionally:
CREATE FUNCTION reject_bad() RETURNS TRIGGER AS $$BEGIN IF NEW.name = 'bad' THEN RAISE EXCEPTION 'rejected'; END IF; RETURN NEW;END;$$ LANGUAGE plpgsql;
CREATE TRIGGER guard BEFORE INSERT ON users FOR EACH ROW EXECUTE FUNCTION reject_bad();
INSERT INTO users VALUES (1, 'bad'); -- ERROR: rejected (P0001) — nothing is writtenAn AFTER trigger function runs the full interpreter, including the constructs a BEFORE trigger
refuses — counted FOR loops, CASE statements, EXECUTE, nested blocks and EXCEPTION
handlers. A compact one-line body (BEGIN INSERT INTO … ; RETURN NEW; END;), as formatters and
ORMs emit it, is handled correctly for both RETURN NEW; and RETURN OLD;.
What CREATE TRIGGER accepts
Section titled “What CREATE TRIGGER accepts”FOR EACH STATEMENT has statement-level semantics: the trigger runs once per statement however
many rows it touches, including a statement that matches no rows at all.
CREATE TRIGGER audit_stmt AFTER UPDATE ON orders FOR EACH STATEMENT EXECUTE FUNCTION audit_trigger();
UPDATE orders SET status = 'shipped'; -- every row → 1 executionUPDATE orders SET status = 'x' WHERE id = -1; -- no rows → 1 executionA row-level trigger can be narrowed with a WHEN condition over OLD and NEW columns, and an
UPDATE trigger with a column list:
CREATE TRIGGER price_changed AFTER UPDATE ON products FOR EACH ROW WHEN (OLD.price IS DISTINCT FROM NEW.price) EXECUTE FUNCTION audit_trigger();
CREATE TRIGGER stock_touched BEFORE UPDATE OF price, in_stock ON products FOR EACH ROW EXECUTE FUNCTION audit_trigger();price_changed fires only when the price actually changes; stock_touched fires whenever
price or in_stock appears in the SET list, changed or not, and never for an update that
names only other columns.
Trigger arguments work on row and statement triggers alike: EXECUTE FUNCTION f('a', 'b') passes
them to the function in TG_NARGS and TG_ARGV[], records them in pg_trigger.tgnargs and
tgargs, and pg_get_triggerdef() reproduces them.
These forms are rejected at CREATE TRIGGER, each with 0A000 and a message that names the
feature:
| Form | Message |
|---|---|
WHEN on a FOR EACH STATEMENT trigger | statement trigger WHEN predicates are not supported |
WHEN (OLD.* IS DISTINCT FROM NEW.*) | whole-record trigger WHEN predicates are not supported |
BEFORE / AFTER TRUNCATE | TRUNCATE triggers are not supported |
CREATE CONSTRAINT TRIGGER | constraint triggers are not supported |
REFERENCING NEW TABLE AS ... | trigger transition relations are not supported |
INSTEAD OF on a view | INSTEAD OF triggers are not supported |
Compare individual columns in place of the whole-record WHEN form, as in price_changed above.
A WHEN condition is also limited in what it may reference. Integer, numeric, text, boolean and
JSONB columns work, with comparison operators, LIKE, IS DISTINCT FROM and functions such as
lower(), upper(), abs() and coalesce(). A condition is refused at CREATE TRIGGER if it
touches any temporal column (timestamp, timestamptz, date, time or interval), calls a
session-dependent or volatile function (now(), current_date, current_user, session_user,
random()), casts to a date, or calls a user-defined function:
ERROR: trigger WHEN supports the documented context-independent scalar subset; temporal values, SQL functions, unclassified builtins, and session-dependent casts are not supported (0A000)So the common WHEN (OLD.updated_at IS DISTINCT FROM NEW.updated_at) guard cannot be written —
even WHEN (NEW.updated_at IS NOT NULL) is rejected. Put that test inside the trigger function
with an IF, or key the condition on a non-temporal column. (A user-defined function called
without its schema is refused with a different message,
catalog function '<name>' in a stored policy must be schema-qualified; qualifying it does not
help — it then gets the message above.)
DROP TRIGGER on a trigger that does not exist fails with 42704
(trigger "…" for table "…" does not exist), as in PostgreSQL. Use DROP TRIGGER IF EXISTS in
migration scripts.
Event triggers
Section titled “Event triggers”CREATE EVENT TRIGGER is supported for the ddl_command_start, ddl_command_end and sql_drop
events, with an optional WHEN TAG IN (...) filter. The function receives TG_EVENT and
TG_TAG and runs in the same transaction as the DDL — its writes roll back with it — and it
aborts the statement by raising:
CREATE FUNCTION no_new_tables() RETURNS event_trigger LANGUAGE plpgsql AS $$BEGIN RAISE EXCEPTION 'DDL blocked by policy';END $$;
CREATE EVENT TRIGGER block_create ON ddl_command_start WHEN TAG IN ('CREATE TABLE') EXECUTE FUNCTION no_new_tables();
CREATE TABLE t (id int); -- ERROR: DDL blocked by policy (P0001); no table is created
ALTER EVENT TRIGGER block_create DISABLE;DROP EVENT TRIGGER block_create;Event triggers are listed in pg_event_trigger. The table_rewrite and login events are
rejected (0A000 event table_rewrite is not supported).
Sequences
Section titled “Sequences”CREATE SEQUENCE order_seq START WITH 1000 INCREMENT BY 1;SELECT NEXTVAL('order_seq'); -- 1000SELECT NEXTVAL('order_seq'); -- 1001SELECT SETVAL('order_seq', 2000);SELECT NEXTVAL('order_seq'); -- 2001DROP SEQUENCE order_seq;CURRVAL('order_seq') and LASTVAL() return the value the current session last drew, so they
need a NEXTVAL earlier on the same connection. Without one they raise 55000
(currval of sequence "order_seq" is not yet defined in this session, or
lastval is not yet defined in this session). Over the HTTP SQL API every
request is a new session, so send them in the same request as the NEXTVAL:
SELECT NEXTVAL('order_seq'); SELECT CURRVAL('order_seq'); SELECT LASTVAL();Sequence options: START, INCREMENT, MINVALUE, MAXVALUE, CACHE, CYCLE.
ALTER SEQUENCE changes any of them after creation (RESTART [WITH], INCREMENT BY, the bounds,
CACHE, CYCLE), as well as OWNER TO, OWNED BY and RENAME TO; only SET SCHEMA,
SET LOGGED and SET UNLOGGED raise a bare ERROR: internal error carrying 0A000. SETVAL()
repositions a sequence too. DROP SEQUENCE refuses (2BP01) while a column default or identity
column still depends on the sequence — see DDL — Other DDL for both.
See also: DDL — Identity columns for GENERATED ALWAYS AS IDENTITY and GENERATED BY DEFAULT AS IDENTITY.
Custom Types
Section titled “Custom Types”-- Enum typeCREATE TYPE mood AS ENUM ('happy', 'sad', 'neutral');ALTER TYPE mood ADD VALUE 'excited';
-- Composite typeCREATE TYPE address AS (street TEXT, city TEXT, zip TEXT);Collations
Section titled “Collations”CREATE COLLATION my_collation (LOCALE = 'en_US.utf8');DROP COLLATION my_collation;A collation you create is real: it persists across sessions, and declaring it on a column changes how that column sorts and compares.
CREATE COLLATION my_collation (LOCALE = 'en_US.utf8');CREATE TABLE coll_demo (t TEXT COLLATE my_collation);INSERT INTO coll_demo VALUES ('B'), ('a');
SELECT t FROM coll_demo ORDER BY t;-- a-- BA TEXT column declared without a collation sorts the same way as COLLATE "C" — by byte value —
so the same two rows come back in the opposite order (B, then a). The same applies to
comparisons: ('a' COLLATE my_collation) < 'B' is true, while an uncollated 'a' < 'B' is false.
The catalog, however, does not reflect any of this. Two lookups you might reach for are misleading:
| Lookup | What it returns | Reality |
|---|---|---|
SELECT collname FROM pg_collation | only C, POSIX, default | your collation exists |
pg_attribute.attcollation for a COLLATE column | 100, the default OID — identical to a column declared with no collation | the column does carry the declared collation |
So do not use either one to check whether a collation exists. Create it again instead: if it is
already there, that fails with ERROR: collation "my_collation" already exists (42710) — and if it
was not, remember to DROP COLLATION the one you just made.