Skip to main content
ANVISoftware Solutions
Lesson 15 of 22Intermediate16 min

Functions

By the end of this lesson

Write reusable scalar and table-valued functions.

A function takes inputs and returns a value. Unlike a procedure, it is used inside a query — in a SELECT list, a WHERE clause, or a FROM clause — which is what makes it composable.

That difference decides which one you want. A procedure performs an action. A function produces a value that a query consumes.

Function or procedure:

 FunctionStored procedure
UsedInside a query, as part of an expression or a FROM clauseCalled on its own with EXEC
ReturnsOne value, or one tableAny number of result sets, plus output parameters
Can change dataNo — writes to tables are not allowedYes
Can contain a transactionNoYes
Typical useA calculation or a filtered set you reuse in queriesAn operation: cancel an order, run an import

Scalar functions return one value

A scalar function, and using it
SQL
CREATE OR ALTER FUNCTION fn_line_total
(
    @quantity   INT,
    @unit_price DECIMAL(10,2)
)
RETURNS DECIMAL(12,2)
WITH SCHEMABINDING
AS
BEGIN
    RETURN ISNULL(@quantity, 0) * ISNULL(@unit_price, 0);
END;

-- Used like a built-in function
SELECT
    oi.order_id,
    oi.product_id,
    dbo.fn_line_total(oi.quantity, oi.unit_price) AS line_total
FROM order_items AS oi
WHERE oi.order_id = 1043;
  • RETURNS declares the type of the single value that comes back. The body must return that type on every path.
  • In SQL Server a scalar function is called with its schema prefix — dbo.fn_line_total, not fn_line_total.
  • WITH SCHEMABINDING stops anyone dropping or altering the objects the function depends on while it exists. It also lets the optimiser reason about the function better, which matters for the performance point below.
  • ISNULL guards against a null input producing a null total. Whether that is right depends on the business question: treating a missing quantity as zero is a decision, and one worth making explicitly rather than inheriting from NULL arithmetic.

Table-valued functions return a result set

A table-valued function returns rows, so you use it in a FROM clause. It is close to a view that takes parameters, which is exactly the gap it fills — a view cannot be parameterised.

There are two kinds, and the difference matters more than the syntax suggests.

An inline table-valued function: a single RETURN with one SELECT
SQL
CREATE OR ALTER FUNCTION fn_orders_in_period
(
    @from_date DATE,
    @to_date   DATE
)
RETURNS TABLE
AS
RETURN
(
    SELECT
        o.order_id,
        o.customer_id,
        o.order_date,
        o.status,
        SUM(oi.quantity * oi.unit_price) + o.shipping_fee AS order_total
    FROM orders AS o
    JOIN order_items AS oi ON oi.order_id = o.order_id
    WHERE o.order_date >= @from_date
      AND o.order_date <  @to_date
      AND o.status <> 'cancelled'
    GROUP BY o.order_id, o.customer_id, o.order_date, o.status, o.shipping_fee
);

-- Used in FROM, like a parameterised view
SELECT c.company_name, COUNT(*) AS orders, SUM(f.order_total) AS total
FROM fn_orders_in_period('2026-01-01', '2026-04-01') AS f
JOIN customers AS c ON c.customer_id = f.customer_id
GROUP BY c.company_name
ORDER BY total DESC;
  • There is no BEGIN and END, and no variable declarations. The whole body is one RETURN with one SELECT, which is what makes it inline.
  • That form lets the optimiser expand the function's definition into the calling query, so the outer WHERE and JOIN can influence how the inner query runs. It behaves much like a view with parameters.
  • Called without a schema prefix in FROM, unlike a scalar function. The inconsistency is a quirk of T-SQL rather than a rule with a reason.
A multi-statement table-valued function — more capable, harder to optimise
SQL
CREATE OR ALTER FUNCTION fn_customer_ranking (@from_date DATE)
RETURNS @result TABLE
(
    customer_id  INT,
    total_spend  DECIMAL(14,2),
    spend_band   NVARCHAR(10)
)
AS
BEGIN
    INSERT INTO @result (customer_id, total_spend, spend_band)
    SELECT
        o.customer_id,
        SUM(oi.quantity * oi.unit_price),
        CASE
            WHEN SUM(oi.quantity * oi.unit_price) >= 50000 THEN 'high'
            WHEN SUM(oi.quantity * oi.unit_price) >= 10000 THEN 'medium'
            ELSE 'low'
        END
    FROM orders AS o
    JOIN order_items AS oi ON oi.order_id = o.order_id
    WHERE o.order_date >= @from_date
      AND o.status <> 'cancelled'
    GROUP BY o.customer_id;

    RETURN;
END;
  • This form declares the shape of the returned table, fills it with one or more statements, then returns it. You can do more: several inserts, conditions, intermediate variables.
  • The cost is that the optimiser cannot see inside. It treats the function as a black box producing rows, and historically estimated a fixed row count, which produced poor plans when the real count was very different. SQL Server 2017 and later can interleave execution to get a real count first, which helps but does not make it free.
  • Prefer the inline form when one SELECT can do the job. Reach for the multi-statement form when it genuinely cannot, and measure the result.

Summary

  • A function returns a value or a table and is used inside a query; a procedure performs an action
  • Functions cannot modify data, which is what makes them safe to call in a SELECT
  • Scalar functions can be evaluated per row — modern servers inline many of them, but not all
  • Wrapping a column in a function in WHERE usually prevents an index being used on that column
  • Prefer inline table-valued functions, which behave like parameterised views, over the multi-statement form

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Try it yourself

Write an inline table-valued function that returns every order item on a given order, with the product name and the line total. Then use it for order 1043.

Show solution

Inline is the right form: one SELECT does the whole job, so the optimiser can expand it into the caller and use the index on order_items.order_id.

This could also be a view with a WHERE clause applied by the caller. The function is preferable when you want the parameter to be part of the contract, so nobody accidentally queries it unfiltered across every order.

The line total is computed as an expression rather than through the scalar function from earlier in the lesson. That is deliberate: one multiplication does not need a function, and keeping it inline avoids a per-row call.

SQL
CREATE OR ALTER FUNCTION fn_order_lines (@order_id INT)
RETURNS TABLE
AS
RETURN
(
    SELECT
        oi.order_item_id,
        p.product_name,
        p.category,
        oi.quantity,
        oi.unit_price,
        oi.quantity * oi.unit_price AS line_total
    FROM order_items AS oi
    JOIN products AS p ON p.product_id = oi.product_id
    WHERE oi.order_id = @order_id
);

SELECT * FROM fn_order_lines(1043) ORDER BY product_name;

Think about it

Think about it

A report filters on a scalar function that formats a customer reference from the customer id. It returns correct results and takes 40 seconds on 900,000 rows. Removing the function and comparing on the raw column takes under a second. Explain both observations.

Show solution

Two separate costs are combining. First, the function may be evaluated once per row, and 900,000 calls add up even if each is fast.

Second, and usually larger: wrapping the column in a function makes the condition non-searchable. The database cannot use an index on customer_id to find matching rows, because the index stores customer_id values and not the function's output. So it reads every row and tests each one.

Comparing on the raw column restores both: no per-row call, and the index can be used to seek directly to the matching rows.

If the formatted reference genuinely needs to be filtered on, the options are to store it as a computed column and index that, or to translate the filter value once in the application and compare against the raw column. Both move the work out of the per-row path.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

Why is an inline table-valued function usually preferred over a multi-statement one?

Saved in this browser only.