Skip to main content
SQL advanced Lesson 16 of 22

Stored Procedures and Functions

Write reusable database logic with PL/pgSQL functions, triggers, and stored procedures.

PL/pgSQL is PostgreSQL’s built-in procedural language. It extends SQL with variables, conditionals, loops, and exception handling, letting you write reusable logic that runs inside the database engine — close to the data, with no network round-trips. The primary use cases are enforcing business rules that span multiple statements, automating maintenance tasks, and building triggers that react to data changes.

CREATE FUNCTION Basics

Functions are reusable units of logic that can be called from any SQL context — in SELECT, WHERE, or as standalone calls. CREATE OR REPLACE updates an existing function without dropping it, preserving any permissions granted on it.

CREATE OR REPLACE FUNCTION greet(name TEXT)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
BEGIN
  RETURN 'Hello, ' || name || '!';
END;
$$;

SELECT greet('world');  -- Hello, world!

The $$ dollar-quoting avoids conflicts with single quotes inside the function body. CREATE OR REPLACE updates an existing function or creates it if it doesn’t exist.

Parameters: IN, OUT, INOUT

Functions can accept input, produce output, or both. OUT parameters let a function return multiple named values without building a composite type, which is useful when a function naturally produces two related results.

-- IN is the default: value passed in, not returned
-- OUT: value returned to the caller as a named column
-- INOUT: passed in and returned (modified in place)

CREATE OR REPLACE FUNCTION divide(
  IN  numerator   NUMERIC,
  IN  denominator NUMERIC,
  OUT result      NUMERIC,
  OUT remainder   NUMERIC
)
LANGUAGE plpgsql
AS $$
BEGIN
  result    := numerator / denominator;
  remainder := MOD(numerator, denominator);
END;
$$;

SELECT * FROM divide(17, 5);
-- result: 3.4   remainder: 2

DECLARE Block and Variables

The DECLARE block defines local variables before BEGIN. Variables let you store intermediate query results, compute values step by step, and make the logic easier to follow than a single deeply nested expression.

CREATE OR REPLACE FUNCTION calculate_discount(
  customer_id INT,
  order_total NUMERIC
)
RETURNS NUMERIC
LANGUAGE plpgsql
AS $$
DECLARE
  purchase_count INT;
  discount_rate  NUMERIC := 0.0;  -- initialize to zero
  final_total    NUMERIC;
BEGIN
  -- Load the customer's completed order count into a variable
  SELECT COUNT(*) INTO purchase_count
  FROM orders
  WHERE customer_id = calculate_discount.customer_id  -- qualify to avoid ambiguity
    AND status = 'completed';

  -- Apply tiered discount based on loyalty
  IF purchase_count >= 10 THEN
    discount_rate := 0.15;
  ELSIF purchase_count >= 5 THEN
    discount_rate := 0.10;
  ELSIF purchase_count >= 2 THEN
    discount_rate := 0.05;
  END IF;

  final_total := order_total * (1 - discount_rate);
  RETURN final_total;
END;
$$;

Note how parameter names are qualified with the function name (calculate_discount.customer_id) to avoid ambiguity with column names in queries.

Loops

Loops are useful for batch processing — iterating over a result set row by row, or repeating an operation until a condition is met. They’re most appropriate when the operation can’t be expressed as a single set-based SQL statement.

-- FOR loop over a range of integers
CREATE OR REPLACE FUNCTION sum_to(n INT)
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE
  total INT := 0;
  i     INT;
BEGIN
  FOR i IN 1..n LOOP
    total := total + i;
  END LOOP;
  RETURN total;
END;
$$;

-- FOR loop over a query result set — process one row at a time
CREATE OR REPLACE FUNCTION update_overdue_orders()
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
  rec RECORD;
BEGIN
  FOR rec IN
    SELECT id FROM orders
    WHERE status = 'pending'
      AND created_at < NOW() - INTERVAL '7 days'
  LOOP
    UPDATE orders SET status = 'overdue' WHERE id = rec.id;
    RAISE NOTICE 'Marked order % as overdue', rec.id;
  END LOOP;
END;
$$;

RAISE and Exception Handling

RAISE NOTICE sends a message to the client (useful for debugging and progress reporting). RAISE EXCEPTION aborts the function with an error and rolls back any changes made in the current transaction block. The EXCEPTION section catches errors so you can handle them gracefully.

CREATE OR REPLACE FUNCTION transfer_funds(
  from_id INT,
  to_id   INT,
  amount  NUMERIC
)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
  src_balance NUMERIC;
BEGIN
  -- Lock the row to prevent concurrent transfers from the same account
  SELECT balance INTO src_balance
  FROM accounts WHERE id = from_id FOR UPDATE;

  IF src_balance < amount THEN
    -- Abort with a descriptive error — the caller sees this as a SQL error
    RAISE EXCEPTION 'Insufficient funds: balance is %, need %',
      src_balance, amount;
  END IF;

  UPDATE accounts SET balance = balance - amount WHERE id = from_id;
  UPDATE accounts SET balance = balance + amount WHERE id = to_id;

  RAISE NOTICE 'Transferred % from account % to account %',
    amount, from_id, to_id;

EXCEPTION
  WHEN OTHERS THEN
    -- Re-raise with context so the error is traceable
    RAISE EXCEPTION 'Transfer failed: %', SQLERRM;
END;
$$;

RETURN QUERY

Functions can return a full result set using RETURN QUERY. This lets you wrap a complex query in a function with a clean interface — callers use it just like a table.

CREATE OR REPLACE FUNCTION get_customer_orders(cid INT)
RETURNS TABLE(order_id INT, total NUMERIC, status TEXT)
LANGUAGE plpgsql
AS $$
BEGIN
  RETURN QUERY
    SELECT o.id, o.total, o.status
    FROM orders o
    WHERE o.customer_id = cid
    ORDER BY o.created_at DESC;
END;
$$;

-- Call it like a table — works in joins, CTEs, and subqueries too
SELECT * FROM get_customer_orders(42);

CREATE PROCEDURE

Procedures don’t return values but can issue COMMIT and ROLLBACK internally — something functions cannot do. This makes them the right choice for batch jobs that process data in chunks and commit each chunk independently to avoid long-running transactions.

CREATE OR REPLACE PROCEDURE archive_old_orders(cutoff_date DATE)
LANGUAGE plpgsql
AS $$
DECLARE
  batch_size INT := 1000;
  deleted    INT;
BEGIN
  LOOP
    DELETE FROM orders
    WHERE id IN (
      SELECT id FROM orders
      WHERE created_at < cutoff_date
        AND status = 'completed'
      LIMIT batch_size
    );

    GET DIAGNOSTICS deleted = ROW_COUNT;
    COMMIT;  -- commit each batch so the table isn't locked the entire time
    EXIT WHEN deleted < batch_size;  -- stop when no more rows to delete
  END LOOP;
END;
$$;

CALL archive_old_orders('2023-01-01');

Triggers

A trigger fires a function automatically when a row is inserted, updated, or deleted. Triggers are the right tool for cross-cutting concerns — audit logging, maintaining derived columns, or enforcing invariants that can’t be expressed as a CHECK constraint.

Step 1: Write the trigger function. It must return TRIGGER and use the special variables NEW (the new row) and OLD (the old row):

-- Automatically keep updated_at current on every UPDATE
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
  NEW.updated_at := NOW();  -- modify the new row before it's written
  RETURN NEW;
END;
$$;

Step 2: Attach the trigger to a table:

CREATE TRIGGER trg_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- Now every UPDATE on users automatically sets updated_at
-- regardless of which client performs the update

Audit Log Trigger

A common pattern is recording every change to a sensitive table — who changed it, when, and what the old and new values were. Triggers make this automatic and tamper-resistant because the audit record is written inside the same transaction as the change.

CREATE TABLE audit_log (
  id         BIGSERIAL PRIMARY KEY,
  table_name TEXT,
  operation  TEXT,  -- INSERT, UPDATE, or DELETE
  old_data   JSONB,
  new_data   JSONB,
  changed_at TIMESTAMPTZ DEFAULT NOW(),
  changed_by TEXT DEFAULT current_user
);

CREATE OR REPLACE FUNCTION audit_changes()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
  INSERT INTO audit_log(table_name, operation, old_data, new_data)
  VALUES (
    TG_TABLE_NAME,  -- built-in: name of the table that fired the trigger
    TG_OP,          -- built-in: INSERT, UPDATE, or DELETE
    CASE WHEN TG_OP = 'DELETE' THEN row_to_json(OLD)::jsonb END,
    CASE WHEN TG_OP != 'DELETE'  THEN row_to_json(NEW)::jsonb END
  );
  RETURN NEW;
END;
$$;

CREATE TRIGGER trg_accounts_audit
AFTER INSERT OR UPDATE OR DELETE ON accounts
FOR EACH ROW EXECUTE FUNCTION audit_changes();

TG_TABLE_NAME and TG_OP are built-in trigger variables that hold the table name and operation (INSERT, UPDATE, or DELETE).

Frequently Asked Questions

What is the difference between a function and a procedure in PostgreSQL?
Functions return a value and can be used in SELECT. Procedures (created with CREATE PROCEDURE) don't return values but can manage transactions (COMMIT/ROLLBACK inside the procedure). Procedures were added in PostgreSQL 11.
When should I put logic in the database vs the application?
Put logic in the database when it enforces data integrity, is shared by multiple applications, or when moving data to the app layer would be expensive. Keep business logic in the app when it changes frequently or requires non-SQL capabilities.