7. Database-Side Programming
Database-Side Programming in PostgreSQL
Section titled “Database-Side Programming in PostgreSQL”In many projects, business logic is implemented in an application server (for example in Python, Java, or TypeScript). But relational database systems can also execute logic directly inside the database. This is called database-side programming.
In PostgreSQL, the three most important building blocks are:
- Functions (
CREATE FUNCTION) - Stored Procedures (
CREATE PROCEDURE) - Triggers (
CREATE TRIGGER+ trigger function)
These features help to centralize rules, enforce data quality, and reduce duplicated logic across multiple applications.
Why Use Database-Side Logic?
Section titled “Why Use Database-Side Logic?”Typical reasons:
- Consistency: Business rules are enforced at one central point.
- Security: Applications get restricted access and call approved routines instead of writing arbitrary SQL.
- Performance: Some operations are faster when they run close to the data.
- Reusability: The same logic can be used by web apps, batch jobs, and BI tools.
Potential downsides:
- More logic in SQL/PLpgSQL can increase complexity.
- Versioning and deployment must be managed carefully.
- Vendor-specific syntax may reduce portability.
- Limited debugging support: PostgreSQL lacks the mature debugging tools available in modern programming languages, making it harder to troubleshoot complex procedures and functions.
1. PostgreSQL Functions
Section titled “1. PostgreSQL Functions”A function returns a value (scalar, row, or table). Functions can be used in SQL expressions, SELECT lists, or WHERE clauses.
1.1 Basic Function Example
Section titled “1.1 Basic Function Example”CREATE OR REPLACE FUNCTION calculate_vat_net_to_gross( net_amount NUMERIC, vat_rate NUMERIC DEFAULT 0.20)RETURNS NUMERICLANGUAGE plpgsqlAS $$BEGIN RETURN ROUND(net_amount * (1 + vat_rate), 2);END;$$;Usage:
SELECT calculate_vat_net_to_gross(100.00); -- 120.00SELECT calculate_vat_net_to_gross(100.00, 0.10); -- 110.00Using a function in a real-world query:
Assume a products table with net prices:
SELECT product_id, name, net_price, calculate_vat_net_to_gross(net_price) AS gross_price, calculate_vat_net_to_gross(net_price, 0.07) AS reduced_vat_priceFROM productsWHERE net_price > 50.00ORDER BY gross_price DESC;This query calculates both standard (20%) and reduced (7%) VAT for all products over 50 EUR net price.
Why use a function here instead of inline calculation?
You could write the formula directly in the SELECT:
-- Without function (harder to maintain):SELECT product_id, name, net_price, ROUND(net_price * 1.20, 2) AS gross_price, ROUND(net_price * 1.07, 2) AS reduced_vat_priceFROM products;But using a function offers several advantages:
- Single source of truth: If VAT law changes (e.g., from 20% to 21%), you update only the function, not dozens of queries across the codebase.
- Consistency: All parts of the application use the same logic, preventing calculation errors.
- Readability:
calculate_vat_net_to_gross(price)is clearer thanROUND(price * 1.20, 2). - Reusability: The same function can be called from reports, APIs, batch jobs, and other routines.
- Flexibility: You can add more complex logic (rounding rules, special rates, historical rates) inside the function without changing all callers.
1.2 SQL Function Example (No PL/pgSQL Needed)
Section titled “1.2 SQL Function Example (No PL/pgSQL Needed)”If a function is just one query, LANGUAGE sql is often enough:
CREATE OR REPLACE FUNCTION full_name(first_name TEXT, last_name TEXT)RETURNS TEXTLANGUAGE sqlAS $$ SELECT CONCAT(first_name, ' ', last_name);$$;1.3 Returning a Table
Section titled “1.3 Returning a Table”CREATE OR REPLACE FUNCTION active_students()RETURNS TABLE(student_id INT, name TEXT)LANGUAGE sqlAS $$ SELECT id, full_name FROM students WHERE is_active = TRUE ORDER BY full_name;$$;Usage:
SELECT * FROM active_students();2. Stored Procedures in PostgreSQL
Section titled “2. Stored Procedures in PostgreSQL”Stored procedures were introduced in PostgreSQL 11 (CREATE PROCEDURE).
Main difference compared to functions:
- Functions are called with
SELECT ...and must return a value. - Procedures are called with
CALL ...and do not have to return a value.
Also important:
- Procedures can perform transaction control (
COMMIT,ROLLBACK) in valid call contexts.
2.1 Procedure Example: Transfer Funds
Section titled “2.1 Procedure Example: Transfer Funds”Assume this table structure:
CREATE TABLE accounts ( account_id INT PRIMARY KEY, owner_name TEXT NOT NULL, balance NUMERIC(12,2) NOT NULL CHECK (balance >= 0));
CREATE TABLE transfer_log ( transfer_id BIGSERIAL PRIMARY KEY, from_account INT NOT NULL, to_account INT NOT NULL, amount NUMERIC(12,2) NOT NULL CHECK (amount > 0), transferred_at TIMESTAMPTZ NOT NULL DEFAULT NOW());Procedure:
CREATE OR REPLACE PROCEDURE transfer_funds( p_from_account INT, p_to_account INT, p_amount NUMERIC(12,2))LANGUAGE plpgsqlAS $$DECLARE v_source_balance NUMERIC(12,2);BEGIN IF p_amount <= 0 THEN RAISE EXCEPTION 'Amount must be greater than zero'; END IF;
IF p_from_account = p_to_account THEN RAISE EXCEPTION 'Source and target account must differ'; END IF;
SELECT balance INTO v_source_balance FROM accounts WHERE account_id = p_from_account FOR UPDATE;
IF NOT FOUND THEN RAISE EXCEPTION 'Source account % does not exist', p_from_account; END IF;
IF v_source_balance < p_amount THEN RAISE EXCEPTION 'Insufficient funds in account %', p_from_account; END IF;
UPDATE accounts SET balance = balance - p_amount WHERE account_id = p_from_account;
UPDATE accounts SET balance = balance + p_amount WHERE account_id = p_to_account;
IF NOT FOUND THEN RAISE EXCEPTION 'Target account % does not exist', p_to_account; END IF;
INSERT INTO transfer_log(from_account, to_account, amount) VALUES (p_from_account, p_to_account, p_amount);END;$$;Call the procedure:
CALL transfer_funds(1, 2, 50.00);3. Triggers
Section titled “3. Triggers”A trigger is a mechanism that automatically executes logic when a table event occurs:
INSERTUPDATEDELETE- (and others such as
TRUNCATE)
In PostgreSQL, a trigger executes a trigger function (a special function that returns TRIGGER).
3.1 Typical Trigger Use Cases
Section titled “3.1 Typical Trigger Use Cases”- Automatic
updated_attimestamps - Audit logging (who changed what and when)
- Validation of complex business constraints
- Synchronization with summary tables
3.2 BEFORE UPDATE Trigger: Set updated_at
Section titled “3.2 BEFORE UPDATE Trigger: Set updated_at”This example shows how to automatically update a timestamp column whenever a row is modified, without requiring manual application logic.
CREATE TABLE courses ( course_id SERIAL PRIMARY KEY, title TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());Trigger function:
CREATE OR REPLACE FUNCTION set_updated_at()RETURNS TRIGGERLANGUAGE plpgsqlAS $$BEGIN NEW.updated_at := NOW(); RETURN NEW;END;$$;Trigger definition:
CREATE TRIGGER trg_courses_set_updated_atBEFORE UPDATE ON coursesFOR EACH ROWEXECUTE FUNCTION set_updated_at();- BEFORE UPDATE: The trigger fires before the update is actually written to disk, so we can modify
NEW.updated_atin time. - FOR EACH ROW: The trigger fires once per row that is updated.
- NEW: Within a BEFORE trigger,
NEWcontains the new values that are about to be inserted/updated. We modifyNEW.updated_atto the current time. - RETURN NEW: We return the modified
NEWrow, so the update proceeds with the updated timestamp.
Result: Every time the courses table is updated, the updated_at column is automatically set to the current time, with no manual intervention required in the application.
3.3 Audit Trigger Example
Section titled “3.3 Audit Trigger Example”This example demonstrates how to create a comprehensive audit trail by logging all INSERT, UPDATE, and DELETE operations on the students table, including the username, timestamp, and the old and new data values.
Audit table:
CREATE TABLE student_audit ( audit_id BIGSERIAL PRIMARY KEY, action TEXT NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), db_user TEXT NOT NULL, old_data JSONB, new_data JSONB);Trigger function:
CREATE OR REPLACE FUNCTION audit_students_changes()RETURNS TRIGGERLANGUAGE plpgsqlAS $$BEGIN IF TG_OP = 'INSERT' THEN INSERT INTO student_audit(action, db_user, new_data) VALUES ('INSERT', CURRENT_USER, to_jsonb(NEW)); RETURN NEW; ELSIF TG_OP = 'UPDATE' THEN INSERT INTO student_audit(action, db_user, old_data, new_data) VALUES ('UPDATE', CURRENT_USER, to_jsonb(OLD), to_jsonb(NEW)); RETURN NEW; ELSIF TG_OP = 'DELETE' THEN INSERT INTO student_audit(action, db_user, old_data) VALUES ('DELETE', CURRENT_USER, to_jsonb(OLD)); RETURN OLD; END IF;
RETURN NULL;END;$$;Attach trigger to table students:
CREATE TRIGGER trg_students_auditAFTER INSERT OR UPDATE OR DELETE ON studentsFOR EACH ROWEXECUTE FUNCTION audit_students_changes();- AFTER INSERT OR UPDATE OR DELETE: The trigger fires after the change is committed to the table (not before), so we are recording the final state.
- TG_OP: A special PostgreSQL variable that tells us which operation triggered the function (
'INSERT','UPDATE', or'DELETE'). - OLD and NEW: In AFTER triggers (and UPDATE operations),
OLDcontains the previous row,NEWcontains the updated row. - to_jsonb(): Converts the entire row to JSON, which is flexible storage for audit purposes.
- CURRENT_USER: A PostgreSQL function that returns the username of the database user who made the change.
- RETURN NULL: For AFTER triggers, the return value is ignored for INSERT/UPDATE; returning NULL is convention.
Why this matters:
This audit table now contains a complete history of who changed what and when. Such records are often required for compliance (GDPR, SOX, financial regulations) or for debugging unexpected data changes. You can query the audit log to answer questions like “Who deleted customer #42 and when?” or “What was the grade before the correction?“
3.4 BEFORE INSERT Trigger: Enforce Business Constraint
Section titled “3.4 BEFORE INSERT Trigger: Enforce Business Constraint”This example shows how to enforce a business rule: A student may not enroll in more than 5 courses per semester.
Assume these tables:
CREATE TABLE semesters ( semester_id SERIAL PRIMARY KEY, name TEXT NOT NULL, -- e.g., "WS 2025/26", "SS 2026" start_date DATE NOT NULL, end_date DATE NOT NULL);
CREATE TABLE students ( student_id SERIAL PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE courses ( course_id SERIAL PRIMARY KEY, name TEXT NOT NULL, semester_id INT NOT NULL REFERENCES semesters(semester_id));
CREATE TABLE enrollments ( enrollment_id SERIAL PRIMARY KEY, student_id INT NOT NULL REFERENCES students(student_id), course_id INT NOT NULL REFERENCES courses(course_id), enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW());Trigger function to enforce the constraint:
CREATE OR REPLACE FUNCTION check_enrollment_limit()RETURNS TRIGGERLANGUAGE plpgsqlAS $$DECLARE v_courses_count INT; v_semester_id INT;BEGIN -- Get the semester of the course being enrolled in SELECT semester_id INTO v_semester_id FROM courses WHERE course_id = NEW.course_id;
-- Count how many courses this student is already enrolled in this semester SELECT COUNT(*) INTO v_courses_count FROM enrollments e JOIN courses c ON e.course_id = c.course_id WHERE e.student_id = NEW.student_id AND c.semester_id = v_semester_id;
-- Enforce the limit IF v_courses_count >= 5 THEN RAISE EXCEPTION 'Student % cannot enroll in more than 5 courses per semester', NEW.student_id; END IF;
RETURN NEW;END;$$;Explanation of check_enrollment_limit():
-
DECLARE: Two local variables:
v_courses_count: Will hold the count of courses the student is already enrolled inv_semester_id: Will hold the semester ID of the course being enrolled in
-
NEW: A special PostgreSQL trigger variable that contains the row data about to be inserted.
NEW.course_idis the course ID from the new enrollment record, andNEW.student_idis the student ID. -
SELECT … INTO: Queries the
coursestable to find which semester this course belongs to, storing the result inv_semester_id. -
JOIN and WHERE logic: Counts how many enrollments this student already has in this semester by joining
enrollmentsandcoursesand filtering bystudent_idandsemester_id. -
IF … RAISE EXCEPTION: If the count is 5 or more, raises an exception with the student ID. This exception cancels the INSERT operation before it reaches the database.
-
RETURN NEW: If the constraint check passes (count < 5), return
NEWunchanged, allowing the INSERT to proceed.
Trigger definition:
CREATE TRIGGER trg_check_enrollment_limitBEFORE INSERT ON enrollmentsFOR EACH ROWEXECUTE FUNCTION check_enrollment_limit();How it works:
- When a student tries to enroll in a course, the trigger fires before the INSERT happens.
- The trigger queries how many courses the student is already enrolled in for that semester.
- If the count is 5 or more, the trigger raises an exception, preventing the enrollment.
- If the count is less than 5, the enrollment is allowed to proceed.
Testing:
-- Student 1 enrolls in 5 courses of semester 1INSERT INTO enrollments (student_id, course_id) VALUES (1, 1); -- OKINSERT INTO enrollments (student_id, course_id) VALUES (1, 2); -- OKINSERT INTO enrollments (student_id, course_id) VALUES (1, 3); -- OKINSERT INTO enrollments (student_id, course_id) VALUES (1, 4); -- OKINSERT INTO enrollments (student_id, course_id) VALUES (1, 5); -- OK
-- Attempt to enroll in a 6th courseINSERT INTO enrollments (student_id, course_id) VALUES (1, 6);-- ERROR: Student 1 cannot enroll in more than 5 courses per semesterThis is a classic example of a business constraint from an ER diagram that cannot be expressed with simple foreign keys or CHECK constraints, but is easily enforced at the database level using a trigger.
3.5 AFTER INSERT Trigger: Keep Order Totals in Sync
Section titled “3.5 AFTER INSERT Trigger: Keep Order Totals in Sync”This example demonstrates a typical AFTER trigger use case: after inserting an order item, the order header is updated with the new total amount.
Assume these tables:
CREATE TABLE orders ( order_id SERIAL PRIMARY KEY, customer_id INT NOT NULL, total_amount NUMERIC(12,2) NOT NULL DEFAULT 0);
CREATE TABLE order_items ( order_item_id SERIAL PRIMARY KEY, order_id INT NOT NULL REFERENCES orders(order_id), product_id INT NOT NULL, quantity INT NOT NULL CHECK (quantity > 0), unit_price NUMERIC(12,2) NOT NULL CHECK (unit_price >= 0));Trigger function:
CREATE OR REPLACE FUNCTION update_order_total_after_item_insert()RETURNS TRIGGERLANGUAGE plpgsqlAS $$BEGIN UPDATE orders SET total_amount = total_amount + (NEW.quantity * NEW.unit_price) WHERE order_id = NEW.order_id;
RETURN NULL;END;$$;Trigger definition:
CREATE TRIGGER trg_order_items_after_insertAFTER INSERT ON order_itemsFOR EACH ROWEXECUTE FUNCTION update_order_total_after_item_insert();How it works:
- A new line is inserted into
order_items. - The AFTER trigger fires once for that row.
- The function reads
NEW.quantity,NEW.unit_price, andNEW.order_id. - It updates
orders.total_amountby adding the item subtotal.
4. Functions vs Procedures vs Triggers
Section titled “4. Functions vs Procedures vs Triggers”| Feature | Function | Procedure | Trigger |
|---|---|---|---|
| How to start | SELECT my_function(...) | CALL my_procedure(...) | Automatically on table event |
| Must return a value | Yes | No | Returns TRIGGER value (NEW/OLD) |
| Typical use | Calculations, reusable query logic | Multi-step operations, workflows | Automatic reactions to data changes |
| Directly callable by app | Yes | Yes | No (indirect via DML event) |
5. Security and Maintenance Guidelines
Section titled “5. Security and Maintenance Guidelines”- Use least privilege: Grant
EXECUTEon approved routines, not broad table rights. - Schema-qualify object names: Use
public.studentsinstead ofstudentsin production code. - Keep trigger logic short: Heavy trigger code can slow down writes.
- Document side effects: Every trigger should be visible in project documentation.
- Test with transactions and concurrency: Especially for financial or inventory operations.
- Version routines in migrations: Never edit production objects manually without migration scripts.
Summary
Section titled “Summary”Database-side programming in PostgreSQL is a powerful way to keep business rules close to the data. Functions are ideal for reusable calculations and query logic, procedures are useful for controlled multi-step operations, and triggers automate reactions to data changes. Used carefully, these mechanisms improve consistency, security, and maintainability in real-world database systems.
Beyond these functions, stored procedures and triggers, there are additional database-side options you should know:
- Views and Materialized Views (see chapter: Views detailed)
- Constraints and Domains: They define and enforce valid data structures and business rules directly at the schema level.
- Row Level Security (RLS) Policies: They control which rows a user is allowed to read or modify, based on role-specific conditions.
- Event Triggers and Extensions: Event triggers react to DDL events, while extensions add reusable database features such as types, functions, or modules.