PostgreSQL Stored Procedures and PL/pgSQL
1. What You'll Learn
- CREATE FUNCTION / CREATE PROCEDURE (PG distinguishes functions from stored procedures)
- PL/pgSQL syntax: variables, assignment, IF/CASE/LOOP/WHILE/FOR
- Parameter modes: IN / OUT / INOUT / VARIADIC
- RETURN and RETURN QUERY
- Cursors: CURSOR / REFCURSOR
- Exception handling: EXCEPTION / RAISE
- Trigger functions
- Dynamic SQL: EXECUTE
2. The Story
Bob is a back-end engineer at an e-commerce platform. Every morning he needs to automatically run a series of data tasks:
- Compute the previous day's sales summary
- Refresh the materialized view
mv_daily_sales - If the calculation fails, notify the operations team
Bob decided to write a stored procedure sp_daily_sales_refresh() in PL/pgSQL, encapsulating all the logic on the database side for "one-click execution."
3. Concept: FUNCTION vs PROCEDURE
(1) Difference Between Functions and Procedures in PG
| Feature | FUNCTION | PROCEDURE |
|---|---|---|
| Return value | Must have RETURNS | No RETURNS (may return nothing) |
| Call style | SELECT func() |
CALL proc() |
| Transaction control | Cannot COMMIT/ROLLBACK internally | Can COMMIT/ROLLBACK internally |
| Use in SQL | In SELECT/WHERE | Cannot be embedded in SQL |
| INOUT parameters | Supported, auto-become return columns | Supported, passed back via CALL |
▶ Example: CREATE FUNCTION Basics
CREATE OR REPLACE FUNCTION fn_get_order_count(p_customer_id INT)
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE
v_count INT;
BEGIN
SELECT COUNT(*) INTO v_count
FROM orders
WHERE customer_id = p_customer_id;
RETURN v_count;
END;
$$;
-- Call as a scalar in SELECT
SELECT fn_get_order_count(1001);
Output:
count
-------
5
(1 row)
▶ Example: CREATE PROCEDURE Basics
CREATE OR REPLACE PROCEDURE sp_reset_daily_stats()
LANGUAGE plpgsql
AS $$
BEGIN
TRUNCATE TABLE daily_stats;
INSERT INTO daily_stats (stat_date, total_orders, total_revenue)
VALUES (CURRENT_DATE, 0, 0.00);
COMMIT;
END;
$$;
-- Call with CALL
CALL sp_reset_daily_stats();
Output:
INSERT 0 1
(2) When to Use FUNCTION vs PROCEDURE
| Scenario | Recommended | Reason |
|---|---|---|
| Compute and return a value | FUNCTION | Can be embedded in SQL |
| Batch ETL operations | PROCEDURE | Supports internal transaction control |
| Trigger callback | FUNCTION | Triggers only accept functions |
| Scheduled task | PROCEDURE | Can commit step by step |
▶ Example: Function with Default Values
CREATE OR REPLACE FUNCTION fn_calc_tax(
p_amount NUMERIC,
p_rate NUMERIC DEFAULT 0.08
)
RETURNS NUMERIC
LANGUAGE plpgsql
AS $$
BEGIN
RETURN ROUND(p_amount * p_rate, 2);
END;
$$;
SELECT fn_calc_tax(1000); -- use default 8%
SELECT fn_calc_tax(1000, 0.10); -- custom 10%
Output:
CREATE TABLE
4. Concept: PL/pgSQL Basics
(1) Variables and Assignment
DECLARE
v_name TEXT;
v_price NUMERIC(10,2) := 0.00;
v_count INT;
v_row orders%ROWTYPE; -- row type from table
v_status TEXT NOT NULL := 'pending';
BEGIN
v_name := 'Bob';
SELECT unit_price INTO v_price FROM products WHERE product_id = 1;
END;
| Declaration style | Syntax | Description |
|---|---|---|
| Basic type | v_name TEXT; |
Declare directly |
| Default value | v_price NUMERIC := 0; |
:= or DEFAULT |
| Row type | v_row orders%ROWTYPE; |
Matches table structure |
| NOT NULL | v_status TEXT NOT NULL := 'x'; |
Must assign an initial value |
▶ Example: Variables and SELECT INTO
CREATE OR REPLACE FUNCTION fn_customer_summary(p_id INT)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
DECLARE
v_name TEXT;
v_total NUMERIC(12,2);
v_msg TEXT;
BEGIN
SELECT first_name || ' ' || last_name, SUM(total_amount)
INTO v_name, v_total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE c.customer_id = p_id
GROUP BY c.customer_id, c.first_name, c.last_name;
v_msg := v_name || ': $' || COALESCE(v_total::TEXT, '0');
RETURN v_msg;
END;
$$;
Output:
result
----------
42.50
(1 row)
(2) Conditional Statements
▶ Example: IF/ELSIF/ELSE
CREATE OR REPLACE FUNCTION fn_discount_tier(p_total NUMERIC)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
BEGIN
IF p_total >= 10000 THEN
RETURN 'PLATINUM';
ELSIF p_total >= 5000 THEN
RETURN 'GOLD';
ELSIF p_total >= 1000 THEN
RETURN 'SILVER';
ELSE
RETURN 'BRONZE';
END IF;
END;
$$;
Output:
CREATE TABLE
▶ Example: CASE Statement
CREATE OR REPLACE FUNCTION fn_order_priority(p_amount NUMERIC)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
BEGIN
CASE
WHEN p_amount >= 5000 THEN RETURN 'URGENT';
WHEN p_amount >= 1000 THEN RETURN 'HIGH';
WHEN p_amount >= 100 THEN RETURN 'NORMAL';
ELSE RETURN 'LOW';
END CASE;
END;
$$;
Output:
CREATE TABLE
(3) Loop Statements
| Loop type | Syntax | Best for |
|---|---|---|
| LOOP | LOOP ... END LOOP; |
Needs manual EXIT |
| WHILE | WHILE cond LOOP ... END LOOP; |
Condition checked first |
| FOR (integer) | FOR i IN 1..10 LOOP ... END LOOP; |
Fixed number of iterations |
| FOR (query) | FOR rec IN SELECT ... LOOP ... END LOOP; |
Iterate over query results |
▶ Example: LOOP and EXIT
CREATE OR REPLACE FUNCTION fn_find_price_threshold(
p_target NUMERIC
)
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE
v_limit INT := 10;
v_sum NUMERIC := 0;
v_id INT;
BEGIN
LOOP
SELECT unit_price INTO v_sum FROM products
WHERE product_id = v_limit;
v_sum := v_sum + p_target;
EXIT WHEN v_limit > 100 OR v_sum > p_target * 10;
v_limit := v_limit + 10;
END LOOP;
RETURN v_limit;
END;
$$;
Output:
result
----------
42.50
(1 row)
▶ Example: FOR Query Loop
CREATE OR REPLACE FUNCTION fn_sum_category_revenue()
RETURNS NUMERIC
LANGUAGE plpgsql
AS $$
DECLARE
v_rec RECORD;
v_total NUMERIC(14,2) := 0;
BEGIN
FOR v_rec IN
SELECT category, SUM(unit_price * stock_qty) AS cat_rev
FROM products
GROUP BY category
LOOP
v_total := v_total + v_rec.cat_rev;
RAISE NOTICE 'Category %: $%', v_rec.category, v_rec.cat_rev;
END LOOP;
RETURN v_total;
END;
$$;
Output:
result
----------
42.50
(1 row)
5. Concept: Parameter Modes
(1) IN / OUT / INOUT / VARIADIC
| Mode | Input | Output | Description |
|---|---|---|---|
| IN (default) | Yes | No | Read-only parameter |
| OUT | No | Yes | Output parameter, auto-becomes a return column |
| INOUT | Yes | Yes | Bidirectional parameter |
| VARIADIC | Yes | No | Variable-length argument list (array) |
▶ Example: Function with OUT Parameters
CREATE OR REPLACE FUNCTION fn_order_stats(
p_customer_id INT,
OUT o_count INT,
OUT o_total NUMERIC
)
LANGUAGE plpgsql
AS $$
BEGIN
SELECT COUNT(*), COALESCE(SUM(total_amount), 0)
INTO o_count, o_total
FROM orders
WHERE customer_id = p_customer_id;
END;
$$;
SELECT * FROM fn_order_stats(1001);
Output:
count
-------
5
(1 row)
▶ Example: INOUT Parameter
CREATE OR REPLACE FUNCTION fn_apply_discount(
p_price INOUT NUMERIC,
p_rate NUMERIC
)
LANGUAGE plpgsql
AS $$
BEGIN
p_price := ROUND(p_price * (1 - p_rate), 2);
END;
$$;
-- Returns modified p_price as result
SELECT fn_apply_discount(99.99, 0.15);
Output:
count
-------
5
(1 row)
(2) RETURN vs RETURN QUERY
| Statement | Purpose | Return type |
|---|---|---|
RETURN value; |
Return a single value | Matches RETURNS type |
RETURN NEXT row; |
Append one row to the result set | RETURNS SETOF |
RETURN QUERY SELECT ...; |
Return an entire query result | RETURNS SETOF/TABLE |
▶ Example: RETURN QUERY to Return a Set
CREATE OR REPLACE FUNCTION fn_orders_by_date(p_date DATE)
RETURNS SETOF orders
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT * FROM orders
WHERE order_date = p_date
ORDER BY order_id;
END;
$$;
SELECT * FROM fn_orders_by_date('2025-06-01');
Output:
CREATE TABLE
▶ Example: RETURN TABLE Custom Return Shape
CREATE OR REPLACE FUNCTION fn_top_customers(p_limit INT)
RETURNS TABLE(
customer_name TEXT,
total_spent NUMERIC,
order_count INT
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
c.first_name || ' ' || c.last_name,
SUM(o.total_amount)::NUMERIC,
COUNT(*)::INT
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.first_name, c.last_name
ORDER BY total_spent DESC
LIMIT p_limit;
END;
$$;
Output:
count
-------
5
(1 row)
6. Concept: Cursors
(1) Explicit Cursors and REFCURSOR
| Cursor type | Declaration | Best for |
|---|---|---|
| Bound cursor | CURSOR (query) FOR |
Fixed query |
| REFCURSOR | REFCURSOR |
Dynamic query, can be returned to caller |
| Implicit cursor | FOR rec IN SELECT | Simple traversal |
▶ Example: Explicit Cursor Traversal
CREATE OR REPLACE FUNCTION fn_process_overdue_orders()
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE
v_cur CURSOR(p_date DATE) FOR
SELECT order_id, customer_id, total_amount
FROM orders
WHERE order_date < p_date
AND order_status = 'pending';
v_rec RECORD;
v_count INT := 0;
BEGIN
OPEN v_cur(CURRENT_DATE - INTERVAL '7 days');
LOOP
FETCH v_cur INTO v_rec;
EXIT WHEN NOT FOUND;
UPDATE orders SET order_status = 'cancelled'
WHERE order_id = v_rec.order_id;
v_count := v_count + 1;
END LOOP;
CLOSE v_cur;
RETURN v_count;
END;
$$;
Output:
count
-------
5
(1 row)
▶ Example: Returning a Cursor via REFCURSOR
CREATE OR REPLACE FUNCTION fn_open_category_cursor(
p_category TEXT
)
RETURNS REFCURSOR
LANGUAGE plpgsql
AS $$
DECLARE
v_ref REFCURSOR;
BEGIN
OPEN v_ref FOR
SELECT product_id, product_name, unit_price
FROM products
WHERE category = p_category
ORDER BY unit_price DESC;
RETURN v_ref;
END;
$$;
BEGIN;
SELECT fn_open_category_cursor('Electronics');
-- Returns cursor name like "<unnamed portal 1>"
FETCH ALL FROM "<unnamed portal 1>";
COMMIT;
Output:
CREATE TABLE
7. Concept: Exception Handling
(1) EXCEPTION Block
BEGIN
-- normal logic
EXCEPTION
WHEN OTHERS THEN
-- handle error
END;
| Condition | Description |
|---|---|
NO_DATA_FOUND |
SELECT INTO returned no rows |
TOO_MANY_ROWS |
SELECT INTO returned more than one row |
UNIQUE_VIOLATION |
Unique constraint violated |
FOREIGN_KEY_VIOLATION |
Foreign key violated |
DIVISION_BY_ZERO |
Division by zero |
OTHERS |
Catch all exceptions |
▶ Example: EXCEPTION Catching a Unique Violation
CREATE OR REPLACE FUNCTION fn_safe_insert_product(
p_name TEXT, p_price NUMERIC, p_cat TEXT
)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO products (product_name, unit_price, category)
VALUES (p_name, p_price, p_cat);
RETURN 'OK';
EXCEPTION
WHEN unique_violation THEN
RETURN 'DUPLICATE: ' || p_name;
END;
$$;
Output:
INSERT 0 1
(2) RAISE Notice and Error
| RAISE level | Behavior |
|---|---|
DEBUG |
Development log only |
LOG |
Written to server log |
NOTICE |
Shown to client |
WARNING |
Shown to client + log |
EXCEPTION |
Throws an error, rolls back the transaction |
▶ Example: RAISE NOTICE and RAISE EXCEPTION
CREATE OR REPLACE FUNCTION fn_validate_order(p_amount NUMERIC)
RETURNS BOOLEAN
LANGUAGE plpgsql
AS $$
BEGIN
IF p_amount <= 0 THEN
RAISE EXCEPTION 'Invalid order amount: $%', p_amount;
ELSIF p_amount > 100000 THEN
RAISE WARNING 'Large order: $% - requires approval', p_amount;
ELSE
RAISE NOTICE 'Order validated: $%', p_amount;
END IF;
RETURN true;
END;
$$;
Output:
CREATE TABLE
8. Concept: Dynamic SQL
(1) Using EXECUTE
| Scenario | Syntax | Description |
|---|---|---|
| Dynamic DDL | EXECUTE 'CREATE TABLE ...'; |
Build SQL at runtime |
| Parameterized | EXECUTE fmt USING v1, v2; |
Parameterized, injection-safe |
| Get result | EXECUTE sql INTO v_var; |
Store into a variable |
| Get multiple rows | FOR rec IN EXECUTE sql LOOP |
Iterate over dynamic query |
▶ Example: Dynamic SQL for Monthly Partitioning
CREATE OR REPLACE FUNCTION fn_create_monthly_partition(
p_year INT, p_month INT
)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
DECLARE
v_sql TEXT;
v_table TEXT;
v_start DATE;
v_end DATE;
BEGIN
v_table := 'orders_y' || p_year || 'm' || LPAD(p_month::TEXT, 2, '0');
v_start := make_date(p_year, p_month, 1);
v_end := v_start + INTERVAL '1 month';
v_sql := format(
'CREATE TABLE IF NOT EXISTS %I PARTITION OF orders
FOR VALUES FROM (%L) TO (%L)',
v_table, v_start, v_end
);
EXECUTE v_sql;
RETURN v_table || ' created';
END;
$$;
Output:
CREATE TABLE
▶ Example: Dynamic Query with USING
CREATE OR REPLACE FUNCTION fn_search_products(
p_col TEXT, p_val TEXT
)
RETURNS SETOF products
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY EXECUTE format(
'SELECT * FROM products WHERE %I = $1',
p_col
) USING p_val;
END;
$$;
SELECT * FROM fn_search_products('category', 'Electronics');
Output:
CREATE TABLE
9. Flowchart: PL/pgSQL Structure Decisions
flowchart TD
A[Need to encapsulate logic?] --> B{Need a return value?}
B -->|Yes| C[CREATE FUNCTION]
B -->|No| D{Need transaction control?}
D -->|Yes| E[CREATE PROCEDURE]
D -->|No| C
C --> F{Return one row / many rows?}
F -->|Single value| G[RETURN value]
F -->|Many rows| H{Is query fixed?}
H -->|Yes| I[RETURN QUERY SELECT]
H -->|No| J[FOR rec IN EXECUTE ... LOOP]
E --> K{Need dynamic SQL?}
K -->|Yes| L[EXECUTE format(...) USING]
K -->|No| M[Static SQL statements]
G --> N{Can it error?}
N -->|Yes| O[EXCEPTION block]
N -->|No| P[Direct logic]
I --> N
style A fill:#e1f5fe
style O fill:#ffcdd2
style L fill:#c8e6c9
10. Comprehensive Example
Bob's daily sales-summary stored procedure—computes sales, refreshes the materialized view, and notifies on errors:
CREATE OR REPLACE PROCEDURE sp_daily_sales_refresh()
LANGUAGE plpgsql
AS $$
DECLARE
v_yesterday DATE := CURRENT_DATE - INTERVAL '1 day';
v_order_count INT;
v_total_revenue NUMERIC(14,2);
v_avg_order NUMERIC(10,2);
BEGIN
RAISE NOTICE 'Start daily refresh for %', v_yesterday;
SELECT COUNT(*), COALESCE(SUM(total_amount), 0),
COALESCE(AVG(total_amount), 0)
INTO v_order_count, v_total_revenue, v_avg_order
FROM orders
WHERE order_date = v_yesterday
AND order_status = 'completed';
INSERT INTO daily_sales_summary
(stat_date, order_count, total_revenue, avg_order_value, created_at)
VALUES
(v_yesterday, v_order_count, v_total_revenue, v_avg_order, NOW())
ON CONFLICT (stat_date) DO UPDATE
SET order_count = EXCLUDED.order_count,
total_revenue = EXCLUDED.total_revenue,
avg_order_value = EXCLUDED.avg_order_value;
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;
RAISE NOTICE 'Done: % orders, $% revenue, $% avg',
v_order_count, v_total_revenue, v_avg_order;
EXCEPTION
WHEN OTHERS THEN
INSERT INTO error_log (error_time, procedure_name, error_msg)
VALUES (NOW(), 'sp_daily_sales_refresh', SQLERRM);
RAISE NOTICE 'ERROR in daily refresh: %', SQLERRM;
-- Notify ops team via pg_notify
PERFORM pg_notify('ops_alerts',
'Daily sales refresh failed: ' || SQLERRM);
END;
$$;
❓ FAQ
IF NOT FOUND THEN or EXCEPTION WHEN NO_DATA_FOUND.%I auto-quotes identifiers and %L handles literals, while USING parameterizes execution to prevent SQL injection. Direct string concatenation carries injection risk and forces you to handle quote escaping manually.FOR rec IN SELECT loop perform?📖 Summary
- PG distinguishes FUNCTION (must return a value, can be embedded in SQL) from PROCEDURE (no return value, supports transaction control)
- PL/pgSQL assigns variables with
:=, fetches query values withSELECT INTO, and matches row types with%ROWTYPE - Conditionals: both IF/ELSIF/ELSE and CASE; CASE fits multi-branch logic better
- Loops: LOOP (needs EXIT), WHILE (condition upfront), FOR (fixed count or query traversal)
- Parameter modes: IN is read-only, OUT is an output column, INOUT is bidirectional, VARIADIC takes variable arguments
- RETURN returns a single value; RETURN NEXT/QUERY return a set
- Cursors: bound cursors suit fixed queries; REFCURSOR suits dynamic queries and deferred fetching
- EXCEPTION blocks catch errors but add subtransaction overhead; RAISE controls message level
- EXECUTE + format + USING runs dynamic SQL and prevents injection
📝 Exercises
-
⭐ Write a function
fn_get_product_price(p_id INT)that uses SELECT INTO to queryunit_pricefrom theproductstable and returns 0 if no row exists. -
⭐⭐ Write a function
fn_customer_tier(p_customer_id INT)that queries the customer's total spend and returns a PLATINUM/GOLD/SILVER/BRONZE tier using IF/ELSIF (thresholds: 10000 / 5000 / 1000 USD). -
⭐⭐⭐ Write a stored procedure
sp_monthly_revenue_report(p_year INT, p_month INT): dynamically query that month's order summary, INSERT it into themonthly_reportstable, catch errors with EXCEPTION and write them toerror_log, output progress with RAISE NOTICE, and build the table name dynamically with format (e.g.,orders_y2025m06).



