This episode covers User-Defined Functions with PL/pgSQL, the difference between functions and stored procedures regarding transactions, and database triggers for automation such as updating the updated_at column and audit logging tables.

Welcome to episode 13 of the Learn SQL with PostgreSQL series! So far, all the logic lives in the application and the database is just a "data warehouse". But there are times when it's better to put logic inside the database itself: validations that must be guaranteed, automations that no application may forget, or complex calculations too expensive to shuttle back and forth to the application.
In this episode, we'll cover three main tools for "coding inside the database": User-Defined Functions (UDF) using PostgreSQL's internal language called PL/pgSQL, stored procedures and their differences from functions (especially regarding transactions), and database triggers for automation — with two classic case studies: automatic updated_at columns and audit logging.
A function in PostgreSQL is a logic block that accepts parameters, processes, and returns a value. It can be written in various languages (SQL, Python, C), but the most common and native one is PL/pgSQL.
CREATE FUNCTION tambah(a INTEGER, b INTEGER)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
BEGIN
RETURN a + b;
END;
$$;Call it with SELECT tambah(3, 4) and the result is 7. Note the structure: LANGUAGE plpgsql determines the language, and the body is wrapped in $$ ... $$ (dollar quoting) which replaces single quotes so strings can be written inside safely.
A function can contain variables, branching, and queries:
CREATE FUNCTION cek_stok(pid UUID, qty INTEGER)
RETURNS BOOLEAN
LANGUAGE plpgsql
AS $$
DECLARE
stok_tersedia INTEGER;
BEGIN
SELECT stock INTO stok_tersedia
FROM inventory
WHERE product_id = pid;
IF stok_tersedia IS NULL THEN
RETURN FALSE;
ELSIF stok_tersedia >= qty THEN
RETURN TRUE;
ELSE
RETURN FALSE;
END IF;
END;
$$;DECLARE declares variables, SELECT ... INTO fills a variable from a query, and the IF/ELSIF/ELSE block handles the logic. This is the function we'll use in the e-commerce case study in episode 20.
Tip
PL/pgSQL adds procedural constructs (variables, loops, exceptions) on top of plain SQL. The rule of thumb: if it can be written as a single SQL query, write it as a single SQL query — it's easier to optimize. Use PL/pgSQL for logic that needs branching, loops, or step-by-step processing.
The key differences between functions and stored procedures in PostgreSQL:
| Aspect | Function | Stored Procedure (CREATE PROCEDURE) |
|---|---|---|
| Usage | Called inside a query (SELECT f()) | Called with CALL p() |
| Return value | Must return a value (RETURNS) | Not required |
| Internal transactions | Cannot COMMIT/ROLLBACK | Can COMMIT/ROLLBACK inside the body |
| When to use | Computation, value transformation | Multi-step business flows |
This transaction difference is the most fundamental one. A function runs inside its caller's transaction and cannot control it. A procedure can run COMMIT or ROLLBACK inside its body — useful for flows that must save partial results:
CREATE PROCEDURE proses_order(pid UUID)
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE orders SET status = 'processing' WHERE id = pid;
COMMIT;
END;
$$;Call the procedure with the CALL command:
CALL proses_order('1c2d3e4f-0000-0000-0000-000000000001');Note
In older PostgreSQL versions (before 11), there were no stored procedures — everything was written as functions. When choosing between the two, ask yourself: "does this logic need to control its own transaction?" If yes, a procedure. If it's just calculation and value return, a function is more flexible because it can be called inside any query.
A trigger is a function that runs automatically when a certain event occurs on a table. The events can be INSERT, UPDATE, DELETE, or TRUNCATE, and the timing can be BEFORE (before the operation) or AFTER (after the operation), per row (FOR EACH ROW) or per statement (FOR EACH STATEMENT).
The classic problem: applications forget to update updated_at. A trigger solves it once and for all:
CREATE FUNCTION set_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();With this trigger, every UPDATE on the users table automatically fills updated_at with the current time — no matter who executes it, from which application, or through which query. NEW is the new row (result of the update) that we can modify before it's stored.
The second pattern is very valuable: recording every data change into an audit table. It's a trail required for compliance, debugging, and forensics:
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
table_name TEXT NOT NULL,
action TEXT NOT NULL,
row_id UUID NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE FUNCTION log_audit()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO audit_log (table_name, action, row_id)
VALUES (TG_TABLE_NAME, TG_OP, NEW.id);
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_orders_audit
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW
EXECUTE FUNCTION log_audit();The trigger function uses special variables: TG_TABLE_NAME (table name), TG_OP (operation: INSERT/UPDATE/DELETE). For a DELETE trigger, NEW doesn't exist — use OLD to get the old row's id.
Warning
Triggers are a double-edged sword. On one hand they guarantee consistency; on the other they can become a performance trap — a slow trigger adds cost to every insert/update. And triggers are invisible to the caller, so "magic" behind a table can confuse teams. Use them sparingly, document them clearly, and remember: triggers run inside the same transaction, so a trigger failure fails its main operation too.
It's important to place responsibilities correctly: constraints handle simple, static validations (CHECK, FOREIGN KEY, UNIQUE), while triggers handle dynamic logic that needs broader context — like reading other tables, or modifying other rows as a side effect. Don't create a BEFORE INSERT trigger for a validation that could actually be a CHECK — it's slower and easier to leak.
Key takeaways:
CALL and can control transactions.BEFORE/AFTER, FOR EACH ROW).updated_at and audit log triggers are mandatory patterns for production databases.In the next episode, episode 14, we get into performance: Deep Dive Indexing Strategies — from why indexing turns O(N) searches into O(log N), the B-Tree, Hash, GIN, and BRIN index types, to advanced techniques like partial index, expression index, and composite index.