Skip to content
Discord Get Started

Advanced SQL

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:

SQL
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 inserted

CREATE 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:

SQL
DECLARE v int := 1
+ 2; -- 3
DECLARE s text := 'abc'
|| 'def'; -- 'abcdef'
DECLARE v int
:= 42; -- 42

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:

SQL
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:

SQL
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 works in both function bodies and DO blocks, in simple and searched form:

SQL
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;
SQL
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 variable assigns a query result to a declared variable.

SQL
CREATE FUNCTION user_count() RETURNS INT AS $$
DECLARE n INT;
BEGIN
SELECT count(*) INTO n FROM users;
RETURN n;
END;
$$ LANGUAGE plpgsql;

DML statements inside PL/pgSQL functions can capture returned values into variables using RETURNING ... INTO. Works with INSERT, UPDATE, and DELETE.

SQL
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:

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

SQL
DO $$ DECLARE v int; BEGIN
DELETE FROM orders RETURNING id INTO v; -- 3 matching rows
END $$;
-- 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.

SQL
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 error
END $$;

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.

SQL
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:

SQL
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 written

An 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;.

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.

SQL
CREATE TRIGGER audit_stmt AFTER UPDATE ON orders
FOR EACH STATEMENT EXECUTE FUNCTION audit_trigger();
UPDATE orders SET status = 'shipped'; -- every row → 1 execution
UPDATE orders SET status = 'x' WHERE id = -1; -- no rows → 1 execution

A row-level trigger can be narrowed with a WHEN condition over OLD and NEW columns, and an UPDATE trigger with a column list:

SQL
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:

FormMessage
WHEN on a FOR EACH STATEMENT triggerstatement trigger WHEN predicates are not supported
WHEN (OLD.* IS DISTINCT FROM NEW.*)whole-record trigger WHEN predicates are not supported
BEFORE / AFTER TRUNCATETRUNCATE triggers are not supported
CREATE CONSTRAINT TRIGGERconstraint triggers are not supported
REFERENCING NEW TABLE AS ...trigger transition relations are not supported
INSTEAD OF on a viewINSTEAD 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.

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:

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

SQL
CREATE SEQUENCE order_seq START WITH 1000 INCREMENT BY 1;
SELECT NEXTVAL('order_seq'); -- 1000
SELECT NEXTVAL('order_seq'); -- 1001
SELECT SETVAL('order_seq', 2000);
SELECT NEXTVAL('order_seq'); -- 2001
DROP 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:

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

SQL
-- Enum type
CREATE TYPE mood AS ENUM ('happy', 'sad', 'neutral');
ALTER TYPE mood ADD VALUE 'excited';
-- Composite type
CREATE TYPE address AS (street TEXT, city TEXT, zip TEXT);
SQL
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.

SQL
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
-- B

A 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:

LookupWhat it returnsReality
SELECT collname FROM pg_collationonly C, POSIX, defaultyour collation exists
pg_attribute.attcollation for a COLLATE column100, the default OID — identical to a column declared with no collationthe 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.