404 Not Found

404 Not Found


nginx

PostgreSQL Stored Procedures and PL/pgSQL

1. What You'll Learn


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:

  1. Compute the previous day's sales summary
  2. Refresh the materialized view mv_daily_sales
  3. 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

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

TEXT
 count 
-------
     5
(1 row)

▶ Example: CREATE PROCEDURE Basics

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

TEXT
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

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

TEXT
CREATE TABLE

4. Concept: PL/pgSQL Basics

(1) Variables and Assignment

SQL
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

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

TEXT
  result  
----------
   42.50
(1 row)

(2) Conditional Statements

▶ Example: IF/ELSIF/ELSE

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

TEXT
CREATE TABLE

▶ Example: CASE Statement

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

TEXT
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

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

TEXT
  result  
----------
   42.50
(1 row)

▶ Example: FOR Query Loop

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

TEXT
  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

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

TEXT
 count 
-------
     5
(1 row)

▶ Example: INOUT Parameter

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

TEXT
 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

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

TEXT
CREATE TABLE

▶ Example: RETURN TABLE Custom Return Shape

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

TEXT
 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

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

TEXT
 count 
-------
     5
(1 row)

▶ Example: Returning a Cursor via REFCURSOR

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

TEXT
CREATE TABLE

7. Concept: Exception Handling

(1) EXCEPTION Block

SQL
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

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

TEXT
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

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

TEXT
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

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

TEXT
CREATE TABLE

▶ Example: Dynamic Query with USING

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

TEXT
CREATE TABLE

9. Flowchart: PL/pgSQL Structure Decisions

100%
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:

SQL
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

Q What is the single most important difference between FUNCTION and PROCEDURE?
A A PROCEDURE can execute COMMIT/ROLLBACK internally (autonomous transactions), while a FUNCTION cannot. A FUNCTION must have a RETURNS clause and can be called inside SQL, whereas a PROCEDURE is invoked with CALL.
Q What happens when SELECT INTO finds no matching row?
A The variable keeps its original value (it is not set to NULL). To detect "no data", use IF NOT FOUND THEN or EXCEPTION WHEN NO_DATA_FOUND.
Q What's the difference between RETURN NEXT and RETURN QUERY?
A RETURN NEXT appends one row to the result set at a time and must be used with a loop; RETURN QUERY returns the entire SELECT result at once and is more concise. Both require RETURNS SETOF.
Q Why is format + USING recommended over string concatenation for dynamic SQL?
A format's %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.
Q Does an EXCEPTION block affect performance?
A Yes. An EXCEPTION block creates a subtransaction savepoint, which adds overhead. Hot-path functions called frequently should avoid unnecessary EXCEPTION blocks.
Q When is a REFCURSOR cursor useful?
A When you need to defer fetching the query result to the caller—for example, the application layer fetching a large dataset in batches, or a procedure returning a cursor so the caller decides how to iterate. Note that cursors must be used inside a transaction.
Q How does PL/pgSQL's FOR rec IN SELECT loop perform?
A It's equivalent to a server-side cursor traversal and is far more efficient than fetching rows one by one in the application layer. But for large data operations, prefer set-based SQL (INSERT/UPDATE ... SELECT); use loops only for logic that set-based SQL can't express.
Q Will clients always see RAISE NOTICE messages?
A It depends on the client configuration. psql shows NOTICE by default, but many ORMs and drivers ignore it by default. Use RAISE WARNING or adjust log_min_messages to control this.

📖 Summary


📝 Exercises

  1. ⭐ Write a function fn_get_product_price(p_id INT) that uses SELECT INTO to query unit_price from the products table and returns 0 if no row exists.

  2. ⭐⭐ 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).

  3. ⭐⭐⭐ Write a stored procedure sp_monthly_revenue_report(p_year INT, p_month INT): dynamically query that month's order summary, INSERT it into the monthly_reports table, catch errors with EXCEPTION and write them to error_log, output progress with RAISE NOTICE, and build the table name dynamically with format (e.g., orders_y2025m06).

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏