Guide/Advanced/Stored Procedures and Functions

Stored Procedures and Functions

Encapsulating logic in the database: when it makes sense and when it does not.

Most application code talks to the database at arm's length: the application constructs a query, sends it over a connection, waits for results, and does further processing in the application layer. Database functions and stored procedures move some of that logic into the database itself, where it can run closer to the data.

This has tradeoffs. Code that runs in the database eliminates round trips, can access data atomically within a transaction, and enforces rules regardless of how the data is written. It is also harder to test, harder to deploy, and harder to change than application code. Understanding when that tradeoff favors in-database logic is what this guide is about.

SQL functions

The simplest kind: a named query that takes parameters and returns a value.

CREATE OR REPLACE FUNCTION get_customer_lifetime_value(p_customer_id BIGINT)
RETURNS NUMERIC AS $$
  SELECT COALESCE(SUM(total), 0)
  FROM orders
  WHERE customer_id = p_customer_id
    AND status = 'completed';
$$ LANGUAGE SQL;

Call it anywhere a scalar value is valid:

SELECT id, name, get_customer_lifetime_value(id) AS ltv
FROM customers
ORDER BY ltv DESC;

SQL functions are inlined by the planner in many cases, which means the optimizer can see through the function and optimize the underlying query. They are a good fit for frequently used calculations.

PL/pgSQL: procedural logic

When your logic needs variables, conditionals, loops, or exception handling, PL/pgSQL provides a procedural extension to PostgreSQL:

CREATE OR REPLACE FUNCTION apply_discount(p_order_id BIGINT, p_pct NUMERIC)
RETURNS VOID AS $$
DECLARE
  v_total NUMERIC;
BEGIN
  SELECT total INTO v_total
  FROM orders
  WHERE id = p_order_id;

  IF v_total IS NULL THEN
    RAISE EXCEPTION 'Order % not found', p_order_id;
  END IF;

  IF p_pct <= 0 OR p_pct > 100 THEN
    RAISE EXCEPTION 'Discount percent must be between 1 and 100';
  END IF;

  UPDATE orders
  SET total = total * (1 - p_pct / 100.0)
  WHERE id = p_order_id;
END;
$$ LANGUAGE plpgsql;

PL/pgSQL functions run inside the database process. They have access to full SQL and can read and write any table they have permission to access.

Functions vs procedures

FunctionProcedure
Returns a value?YesNot required
Used in SELECT?YesNo (called with CALL)
Can manage transactions?NoYes (PostgreSQL 11+)
Call syntaxSELECT fn(...)CALL proc(...)

Procedures can begin and commit their own transactions, which is useful for batch jobs that process large volumes of data and commit in chunks. Functions cannot control transactions; they run within the caller's transaction.

Triggers

A trigger is a database object that calls a function automatically when a specified event occurs on a table: INSERT, UPDATE, or DELETE.

CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at := NOW();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_set_updated_at
BEFORE UPDATE ON orders
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();

Now updated_at is set automatically on every update, regardless of which application, script, or migration tool performs the write. The application does not have to remember.

Triggers are well-suited to auditing (logging who changed what), enforcing invariants that span multiple rows, and maintaining denormalized summary data. They can also become a source of hard-to-trace bugs if they have side effects that are not obvious to the person writing the triggering query.

When in-database logic makes sense

Good uses:

  • Calculations that are naturally expressed as SQL and called frequently
  • Logic that must enforce integrity regardless of the caller (triggers, check logic)
  • Batch operations that benefit from not round-tripping through the application layer
  • Audit logs that must capture every write, not just writes made through the application

When to keep logic in the application instead:

  • Business logic that changes frequently: database functions are harder to version, test, and roll back than application code
  • Logic that is not inherently data-intensive: the database is not a better execution environment for general computation
  • Any new system: start in the application, move to the database only when you have a specific, demonstrated need

The honest assessment is that database functions and triggers are powerful and often underused for enforcement and auditing, but overused as a substitute for good application architecture. The right division of responsibility keeps the database responsible for data integrity and the application responsible for business logic.