PL/pgSQL Fundamentals — Functions, Variables, and Control Flow

PL/pgSQL Fundamentals — Functions, Variables, and Control Flow

PL/pgSQL is PostgreSQL's procedural extension to SQL. It adds variables, loops, conditionals, exception handling, and the ability to execute dynamic SQL — everything needed for logic that pure SQL cannot express.

When to use PL/pgSQL

  • Reusable logic: a calculation used in 20 different queries, centralised as a function
  • Multi-step transactions: operations that need to query, compute, then write back, atomically
  • Trigger logic: automatically maintaining denormalised fields or audit logs
  • Dynamic SQL: queries where table or column names are not known until runtime

The golden rule: push computation to the database only when it reduces network round trips or enables set-based operations. Don't rewrite application logic in PL/pgSQL just because you can.


Example 1: Basic function structure

-- A simple function that returns the current collection rate for a client
CREATE OR REPLACE FUNCTION client_collection_rate(p_client_id INT)
RETURNS NUMERIC AS $$
DECLARE
    v_total_billed    NUMERIC := 0;
    v_total_collected NUMERIC := 0;
    v_rate            NUMERIC;
BEGIN
    -- Calculate total billed
    SELECT COALESCE(SUM(ii.quantity * ii.unit_price), 0)
    INTO v_total_billed
    FROM invoices i
    JOIN invoice_items ii ON ii.invoice_id = i.invoice_id
    WHERE i.client_id = p_client_id;

    -- Calculate total collected
    SELECT COALESCE(SUM(p.amount), 0)
    INTO v_total_collected
    FROM invoices i
    JOIN payments p ON p.invoice_id = i.invoice_id
    WHERE i.client_id = p_client_id;

    -- Compute rate
    IF v_total_billed = 0 THEN
        RETURN NULL;
    END IF;

    v_rate := ROUND(100.0 * v_total_collected / v_total_billed, 1);
    RETURN v_rate;
END;
$$ LANGUAGE plpgsql STABLE;

-- Usage:
SELECT company_name, client_collection_rate(client_id) AS collection_rate_pct
FROM clients ORDER BY collection_rate_pct NULLS LAST;

Example 2: Control flow — IF/ELSIF, CASE, LOOP

-- Function to categorise a customer based on their order history
CREATE OR REPLACE FUNCTION classify_customer(p_customer_id INT)
RETURNS TEXT AS $$
DECLARE
    v_order_count   INT;
    v_last_order    DATE;
    v_days_since    INT;
BEGIN
    SELECT COUNT(*) AS v_order_count, MAX(created_at::DATE) AS v_last_order
    INTO v_order_count, v_last_order
    FROM orders
    WHERE customer_id = p_customer_id AND status = 'completed';

    -- Handle customers with no orders
    IF v_order_count IS NULL OR v_order_count = 0 THEN
        RETURN 'Never Purchased';
    END IF;

    v_days_since := CURRENT_DATE - v_last_order;

    -- Multi-condition classification (using order count as frequency threshold)
    IF v_order_count >= 10 AND v_days_since <= 90 THEN
        RETURN 'Champion';
    ELSIF v_order_count >= 5 AND v_days_since <= 180 THEN
        RETURN 'Loyal Customer';
    ELSIF v_order_count >= 3 AND v_days_since <= 90 THEN
        RETURN 'Potential Loyalist';
    ELSIF v_days_since <= 30 THEN
        RETURN 'Recent Customer';
    ELSIF v_days_since <= 365 THEN
        RETURN 'At Risk';
    ELSE
        RETURN 'Lost';
    END IF;
END;
$$ LANGUAGE plpgsql STABLE;

-- Usage:
SELECT customer_id, email, classify_customer(customer_id) AS segment
FROM customers ORDER BY customer_id LIMIT 20;

Example 3: Functions returning sets (SETOF and TABLE)

-- Function returning a table of invoice summaries for a date range
CREATE OR REPLACE FUNCTION invoice_summary(
    p_start_date DATE,
    p_end_date   DATE
)
RETURNS TABLE (
    industry         TEXT,
    invoice_count    BIGINT,
    total_billed     NUMERIC,
    total_collected  NUMERIC,
    collection_rate  NUMERIC
) AS $$
BEGIN
    RETURN QUERY
    SELECT
        c.industry,
        COUNT(DISTINCT i.invoice_id),
        ROUND(SUM(ii.quantity * ii.unit_price), 2),
        ROUND(SUM(ii.quantity * ii.unit_price)
            FILTER (WHERE i.status = 'paid'), 2),
        ROUND(100.0 * SUM(ii.quantity * ii.unit_price) FILTER (WHERE i.status = 'paid')
            / NULLIF(SUM(ii.quantity * ii.unit_price), 0), 1)
    FROM clients c
    JOIN invoices i ON i.client_id = c.client_id
    JOIN invoice_items ii ON ii.invoice_id = i.invoice_id
    WHERE i.issue_date BETWEEN p_start_date AND p_end_date
    GROUP BY c.industry
    ORDER BY total_billed DESC NULLS LAST;
END;
$$ LANGUAGE plpgsql STABLE;

-- Usage — call like a table:
SELECT * FROM invoice_summary('2024-01-01', '2024-12-31');
SELECT * FROM invoice_summary('2025-01-01', CURRENT_DATE);

-- Can be used in JOINs and CTEs:
WITH q1 AS (SELECT * FROM invoice_summary('2025-01-01', '2025-03-31')),
     q2 AS (SELECT * FROM invoice_summary('2024-01-01', '2024-03-31'))
SELECT q1.industry, q1.total_billed AS q1_billed, q2.total_billed AS q1_last_year,
    ROUND(100.0 * (q1.total_billed - q2.total_billed) / NULLIF(q2.total_billed, 0), 1) AS yoy_growth
FROM q1 JOIN q2 ON q1.industry = q2.industry;

Example 4: Exception handling

-- Robust data processing function with error handling
CREATE OR REPLACE FUNCTION safe_process_payment(
    p_invoice_id INT,
    p_amount     NUMERIC,
    p_method     TEXT
)
RETURNS JSONB AS $$
DECLARE
    v_invoice_total NUMERIC;
    v_already_paid  NUMERIC;
    v_balance       NUMERIC;
    v_payment_id    INT;
BEGIN
    -- Validate invoice exists and get total
    SELECT COALESCE(SUM(quantity * unit_price), 0)
    INTO v_invoice_total
    FROM invoice_items WHERE invoice_id = p_invoice_id;

    IF v_invoice_total = 0 THEN
        RETURN jsonb_build_object('success', false, 'error', 'Invoice not found or has no items');
    END IF;

    -- Check how much is already paid
    SELECT COALESCE(SUM(amount), 0) INTO v_already_paid
    FROM payments WHERE invoice_id = p_invoice_id;

    v_balance := v_invoice_total - v_already_paid;

    IF p_amount > v_balance * 1.01 THEN  -- 1% tolerance
        RETURN jsonb_build_object(
            'success', false,
            'error', FORMAT('Payment of %s exceeds balance of %s', p_amount, v_balance)
        );
    END IF;

    -- Insert the payment
    INSERT INTO payments (invoice_id, payment_date, amount, method)
    VALUES (p_invoice_id, CURRENT_DATE, p_amount, p_method)
    RETURNING payment_id INTO v_payment_id;

    -- Update invoice status if fully paid
    IF p_amount >= v_balance * 0.99 THEN
        UPDATE invoices SET status = 'paid' WHERE invoice_id = p_invoice_id;
    END IF;

    RETURN jsonb_build_object(
        'success', true,
        'payment_id', v_payment_id,
        'amount', p_amount,
        'remaining_balance', GREATEST(0, v_balance - p_amount)
    );

EXCEPTION
    WHEN OTHERS THEN
        RETURN jsonb_build_object(
            'success', false,
            'error', SQLERRM,
            'sqlstate', SQLSTATE
        );
END;
$$ LANGUAGE plpgsql;

Key Takeaway

PL/pgSQL follows a clear structure: DECLARE variables, BEGIN logic, EXCEPTION handlers. Use SELECT ... INTO to capture query results into variables. RETURN QUERY in table-returning functions streams results directly. Exception handlers with SQLERRM and SQLSTATE produce structured error responses safe to return to application callers.