PL/pgSQL UDFs
Write procedural database functions using PL/pgSQL for control flow, calculations, and data transformations without external dependencies.
Overview
PL/pgSQL is PostgreSQL’s built-in procedural language. Unlike PL/Python, it doesn’t require any external runtime—it’s always available and runs directly in the database engine.
Why use PL/pgSQL?
- No setup required: Works out of the box, no extensions to install
- Familiar SQL syntax: If you know SQL, you’re halfway there
- Control flow: Variables, loops, conditionals, exceptions
- Fast for simple logic: No cross-process overhead like with PL/Python
The current version does not support table access (SELECT, INSERT, UPDATE, DELETE) within PL/pgSQL functions. Use it for computational logic, validation, and data transformation only.
Basic Syntax
CREATE OR REPLACE FUNCTION function_name(param1 TYPE, param2 TYPE)
RETURNS return_type
AS $$
DECLARE
-- Variable declarations
my_var INTEGER := 0;
BEGIN
-- Your logic here
RETURN result;
END;
$$ LANGUAGE plpgsql;
Variables and Types
Declare variables in the DECLARE block:
DECLARE
-- Numbers
counter INTEGER := 0;
price NUMERIC(10, 2);
-- Strings
name VARCHAR(50) := 'guest';
message TEXT;
-- Boolean
is_valid BOOLEAN DEFAULT false;
-- Dates
created_at TIMESTAMP := NOW();
-- Arrays
scores INTEGER[] := ARRAY[90, 85, 78, 92];
tags TEXT[] := ARRAY['urgent', 'review'];
-- JSON
config JSONB;
Control Flow
If/Else
IF age >= 21 THEN
status := 'adult';
ELSIF age >= 13 THEN
status := 'teenager';
ELSE
status := 'child';
END IF;
Loops
FOR loop with range:
FOR i IN 1..10 LOOP
total := total + i;
END LOOP;
FOR loop with array:
FOREACH item IN ARRAY my_array LOOP
-- process item
END LOOP;
WHILE loop:
WHILE counter < 100 LOOP
counter := counter + 1;
END LOOP;
CASE Expression
result := CASE grade
WHEN 'A' THEN 'Excellent'
WHEN 'B' THEN 'Good'
WHEN 'C' THEN 'Average'
ELSE 'Needs improvement'
END;
Practical Examples
Password Strength Checker
CREATE OR REPLACE FUNCTION check_password_strength(password TEXT)
RETURNS TEXT
AS $$
DECLARE
score INTEGER := 0;
BEGIN
-- Length checks
IF LENGTH(password) >= 8 THEN score := score + 1; END IF;
IF LENGTH(password) >= 12 THEN score := score + 1; END IF;
-- Character type checks
IF password ~ '[A-Z]' THEN score := score + 1; END IF;
IF password ~ '[a-z]' THEN score := score + 1; END IF;
IF password ~ '[0-9]' THEN score := score + 1; END IF;
IF password ~ '[^A-Za-z0-9]' THEN score := score + 1; END IF;
RETURN CASE score
WHEN 0 THEN 'very weak'
WHEN 1 THEN 'weak'
WHEN 2 THEN 'fair'
WHEN 3 THEN 'fair'
WHEN 4 THEN 'good'
WHEN 5 THEN 'strong'
ELSE 'very strong'
END;
END;
$$ LANGUAGE plpgsql;
SELECT check_password_strength('MyP@ssw0rd!'); -- 'very strong'
Phone Number Formatter
CREATE OR REPLACE FUNCTION format_phone(raw TEXT)
RETURNS TEXT
AS $$
DECLARE
digits TEXT;
BEGIN
-- Strip everything except digits
digits := REGEXP_REPLACE(raw, '[^0-9]', '', 'g');
-- Format as XXX-XXXX-XXXX if 11 digits
IF LENGTH(digits) = 11 THEN
RETURN SUBSTRING(digits, 1, 3) || '-' ||
SUBSTRING(digits, 4, 4) || '-' ||
SUBSTRING(digits, 8, 4);
END IF;
-- Return as-is if different length
RETURN digits;
END;
$$ LANGUAGE plpgsql;
SELECT format_phone('138 1234 5678'); -- '138-1234-5678'
Business Days Calculator
CREATE OR REPLACE FUNCTION count_business_days(start_date DATE, end_date DATE)
RETURNS INTEGER
AS $$
DECLARE
current_date DATE;
count INTEGER := 0;
BEGIN
current_date := start_date;
WHILE current_date <= end_date LOOP
-- Skip weekends (0 = Sunday, 6 = Saturday)
IF EXTRACT(DOW FROM current_date) NOT IN (0, 6) THEN
count := count + 1;
END IF;
current_date := current_date + 1;
END LOOP;
RETURN count;
END;
$$ LANGUAGE plpgsql;
SELECT count_business_days('2024-01-01', '2024-01-31');
Input Validation
CREATE OR REPLACE FUNCTION validate_user(
username TEXT,
email TEXT,
age INTEGER
)
RETURNS JSONB
AS $$
DECLARE
errors TEXT[] := '{}';
BEGIN
-- Username checks
IF LENGTH(username) < 3 THEN
errors := array_append(errors, 'Username must be at least 3 characters');
END IF;
-- Email format check
IF email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
errors := array_append(errors, 'Invalid email format');
END IF;
-- Age check
IF age < 13 OR age > 120 THEN
errors := array_append(errors, 'Age must be between 13 and 120');
END IF;
IF array_length(errors, 1) > 0 THEN
RETURN jsonb_build_object('valid', false, 'errors', errors);
END IF;
RETURN jsonb_build_object('valid', true);
END;
$$ LANGUAGE plpgsql;
SELECT validate_user('jo', 'not-an-email', 150);
-- {"valid": false, "errors": ["Username must be at least 3 characters", "Invalid email format", "Age must be between 13 and 120"]}
Array Statistics
CREATE OR REPLACE FUNCTION array_stats(numbers NUMERIC[])
RETURNS JSONB
AS $$
DECLARE
total NUMERIC := 0;
min_val NUMERIC;
max_val NUMERIC;
i INTEGER;
BEGIN
IF array_length(numbers, 1) IS NULL THEN
RETURN '{"error": "empty array"}'::JSONB;
END IF;
min_val := numbers[1];
max_val := numbers[1];
FOR i IN 1..array_length(numbers, 1) LOOP
total := total + numbers[i];
IF numbers[i] < min_val THEN min_val := numbers[i]; END IF;
IF numbers[i] > max_val THEN max_val := numbers[i]; END IF;
END LOOP;
RETURN jsonb_build_object(
'count', array_length(numbers, 1),
'sum', total,
'avg', ROUND(total / array_length(numbers, 1), 2),
'min', min_val,
'max', max_val,
'range', max_val - min_val
);
END;
$$ LANGUAGE plpgsql;
SELECT array_stats(ARRAY[10, 20, 30, 40, 50]);
Loan Payment Calculator
CREATE OR REPLACE FUNCTION calculate_loan(
principal NUMERIC,
annual_rate NUMERIC,
years INTEGER
)
RETURNS JSONB
AS $$
DECLARE
monthly_rate NUMERIC;
num_payments INTEGER;
monthly_payment NUMERIC;
total_payment NUMERIC;
BEGIN
IF principal <= 0 OR annual_rate < 0 OR years <= 0 THEN
RETURN '{"error": "Invalid parameters"}'::JSONB;
END IF;
monthly_rate := annual_rate / 100 / 12;
num_payments := years * 12;
IF monthly_rate = 0 THEN
monthly_payment := principal / num_payments;
ELSE
monthly_payment := principal *
(monthly_rate * POWER(1 + monthly_rate, num_payments)) /
(POWER(1 + monthly_rate, num_payments) - 1);
END IF;
total_payment := monthly_payment * num_payments;
RETURN jsonb_build_object(
'monthly_payment', ROUND(monthly_payment, 2),
'total_payment', ROUND(total_payment, 2),
'total_interest', ROUND(total_payment - principal, 2)
);
END;
$$ LANGUAGE plpgsql;
SELECT calculate_loan(200000, 4.5, 30);
-- {"monthly_payment": 1013.37, "total_payment": 364813.42, "total_interest": 164813.42}
Config Parser
Parse key-value configuration strings into JSON:
CREATE OR REPLACE FUNCTION parse_config(config_text TEXT)
RETURNS JSONB
AS $$
DECLARE
result JSONB := '{}';
lines TEXT[];
line TEXT;
parts TEXT[];
BEGIN
lines := STRING_TO_ARRAY(config_text, E'\n');
FOREACH line IN ARRAY lines LOOP
-- Skip empty lines and comments
IF line = '' OR line LIKE '#%' THEN
CONTINUE;
END IF;
-- Parse key=value
IF POSITION('=' IN line) > 0 THEN
parts := STRING_TO_ARRAY(line, '=');
result := result || jsonb_build_object(
TRIM(parts[1]),
TRIM(parts[2])
);
END IF;
END LOOP;
RETURN result;
END;
$$ LANGUAGE plpgsql;
SELECT parse_config('
host=localhost
port=5432
# This is a comment
database=myapp
');
-- {"host": "localhost", "port": "5432", "database": "myapp"}
Random String Generator
CREATE OR REPLACE FUNCTION random_string(
length INTEGER DEFAULT 10,
charset TEXT DEFAULT 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
)
RETURNS TEXT
AS $$
DECLARE
result TEXT := '';
i INTEGER;
BEGIN
FOR i IN 1..length LOOP
result := result || SUBSTRING(charset, FLOOR(RANDOM() * LENGTH(charset) + 1)::INTEGER, 1);
END LOOP;
RETURN result;
END;
$$ LANGUAGE plpgsql;
SELECT random_string(8); -- 'kX7mP2nQ'
SELECT random_string(6, '0123456789'); -- '847291'
Error Handling
Use RAISE to signal errors and EXCEPTION blocks to catch them:
CREATE OR REPLACE FUNCTION safe_divide(a NUMERIC, b NUMERIC)
RETURNS NUMERIC
AS $$
BEGIN
IF b = 0 THEN
RAISE EXCEPTION 'Cannot divide by zero';
END IF;
RETURN a / b;
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'Error: %', SQLERRM;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
Severity levels for RAISE:
DEBUG— For development loggingLOG— Server log onlyNOTICE— Client notification (default)WARNING— Warning messageEXCEPTION— Abort the current transaction
Performance Tips
Mark Functions as IMMUTABLE When Possible
If a function always returns the same output for the same input, mark it IMMUTABLE. This lets the planner optimize queries:
CREATE OR REPLACE FUNCTION calculate_tax(amount NUMERIC)
RETURNS NUMERIC
AS $$
BEGIN
RETURN amount * 0.08;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
Volatility options:
VOLATILE— Default. Can return different results on each call.STABLE— Returns same result within a single query.IMMUTABLE— Always returns same result for same inputs. Never accesses the database.
Use STRICT for NULL Handling
If your function should return NULL when any input is NULL, use STRICT to avoid writing null checks:
CREATE OR REPLACE FUNCTION multiply(a INTEGER, b INTEGER)
RETURNS INTEGER
AS $$
BEGIN
RETURN a * b;
END;
$$ LANGUAGE plpgsql STRICT;
SELECT multiply(5, NULL); -- Returns NULL automatically