Skip to content
Discord Get Started

Advanced SQL

DB9 supports PL/pgSQL functions and procedures with variable declarations and standard control flow.

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) still requires a DO block:

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

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. Following PostgreSQL semantics, RETURNING ... INTO with a statement that returns more than one row will capture the first row.

Row-level triggers on INSERT, UPDATE, and DELETE. FOR EACH STATEMENT is accepted but does not get statement-level semantics — see the caution 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

DROP TRIGGER on a trigger that does not exist fails with a bare internal error (XX000), where PostgreSQL raises 42704. Use DROP TRIGGER IF EXISTS in migration scripts.

SQL
CREATE SEQUENCE order_seq START WITH 1000 INCREMENT BY 1;
SELECT NEXTVAL('order_seq');
SELECT CURRVAL('order_seq');
SELECT LASTVAL();
SELECT SETVAL('order_seq', 2000);
DROP SEQUENCE order_seq;

Sequence options: START, INCREMENT, MINVALUE, MAXVALUE, CACHE, CYCLE.

ALTER SEQUENCE cannot change any of them after creation — it supports only OWNER TO and OWNED BY, so RESTART, INCREMENT BY, RENAME TO and every other clause raise a bare ERROR: internal error carrying 0A000. Use SETVAL() to reposition a sequence. Note that DROP SEQUENCE does not check column defaults that depend on the sequence — see DDL — Other DDL for both caveats.

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.