MS SQL Lesson 34 – User-Defined Functions | Dataplexa

User-Defined Functions

What Is a User-Defined Function?

A user-defined function (UDF) is a named, reusable block of T-SQL logic that accepts input parameters, performs a calculation or lookup, and returns a result. Once created, a UDF can be called anywhere an expression is valid — inside SELECT, WHERE, GROUP BY, JOIN conditions, and computed column definitions. The function encapsulates logic that would otherwise be repeated across many queries, making it easier to maintain, test, and update in one place.

SQL Server supports three categories of UDF. A scalar function accepts one or more parameters and returns a single value of any data type — a number, a string, a date. An inline table-valued function (iTVF) returns a table result from a single SELECT statement and behaves like a parameterised view. A multi-statement table-valued function (msTVF) returns a table result built up across multiple T-SQL statements, declared with an explicit return table variable. Each type has different performance characteristics and appropriate use cases.

  • A scalar UDF returns one value — number, string, date, or any other SQL Server data type
  • An inline TVF returns a table from a single SELECT — it is the most performant TVF type
  • A multi-statement TVF builds its result table row by row — flexible but slower than inline TVF
  • UDFs cannot modify data — no INSERT, UPDATE, DELETE, or DDL statements inside a UDF
  • UDFs cannot call stored procedures or use non-deterministic functions like GETDATE() inside deterministic contexts
  • Schema-bind a UDF with WITH SCHEMABINDING to prevent accidental changes to the underlying objects
-- Scalar UDF: calculate the discounted price of a product.
-- Takes the original price and a discount percentage, returns the final price.
-- Once created, call it anywhere a value expression is valid.
CREATE FUNCTION dbo.CalculateDiscountedPrice
(
    @OriginalPrice  DECIMAL(10,2),    -- input: the price before discount
    @DiscountPct    DECIMAL(5,2)      -- input: discount as a percentage (e.g. 15 for 15%)
)
RETURNS DECIMAL(10,2)                 -- the return type: a decimal number
WITH SCHEMABINDING                    -- prevent changes to referenced objects
AS
BEGIN
    DECLARE @DiscountedPrice DECIMAL(10,2);
    -- guard against invalid inputs
    IF @DiscountPct < 0 OR @DiscountPct > 100
        RETURN @OriginalPrice;        -- return unchanged price for invalid discount
    SET @DiscountedPrice = @OriginalPrice * (1 - @DiscountPct / 100.0);
    RETURN ROUND(@DiscountedPrice, 2);
END;
-- Calling the scalar UDF inside a SELECT:
SELECT
    Name,
    Price                                          AS OriginalPrice,
    dbo.CalculateDiscountedPrice(Price, 15)        AS Price_15PctOff,
    dbo.CalculateDiscountedPrice(Price, 25)        AS Price_25PctOff
FROM Products
WHERE Category = 'Electronics'
ORDER BY Price DESC;
Name | OriginalPrice | Price_15PctOff | Price_25PctOff
---------------------|---------------|----------------|---------------
Monitor 27-inch | 299.99 | 254.99 | 224.99
Mechanical Keyboard | 89.99 | 76.49 | 67.49
USB-C Hub | 24.99 | 21.24 | 18.74
Wireless Mouse | 29.99 | 25.49 | 22.49
(4 rows affected)
  • The function is called with two arguments — the column value and the discount percentage
  • The same function is called twice in the same SELECT with different discount values — reuse without repetition
  • WITH SCHEMABINDING prevents the Products table or its Price column from being dropped or altered while this function depends on them
  • Always prefix UDF calls with the schema name (dbo.) — SQL Server requires it for user-defined functions, unlike built-in functions

Scalar UDF — String and Date Logic

Scalar UDFs are particularly useful for encapsulating string formatting and date calculations that appear repeatedly across queries. A function that formats a full name, parses a product code, or calculates the number of business days between two dates is exactly the kind of logic that belongs in a UDF — it is non-trivial to write each time, it must behave consistently everywhere, and it needs to be fixed in only one place if the rules change.

-- Scalar UDF: format a full name consistently.
-- Handles NULL first or last name gracefully.
CREATE FUNCTION dbo.FormatFullName
(
    @FirstName  NVARCHAR(50),
    @LastName   NVARCHAR(50)
)
RETURNS NVARCHAR(101)
WITH SCHEMABINDING
AS
BEGIN
    RETURN
        CASE
            WHEN @FirstName IS NULL AND @LastName IS NULL THEN 'Unknown'
            WHEN @FirstName IS NULL THEN @LastName
            WHEN @LastName  IS NULL THEN @FirstName
            ELSE @LastName + ', ' + @FirstName   -- format: "Chen, Sarah"
        END;
END;
-- Scalar UDF: calculate age in years from a date of birth.
CREATE FUNCTION dbo.CalculateAge
(
    @DateOfBirth  DATE,
    @AsOfDate     DATE        -- pass the reference date explicitly -- no GETDATE() in UDF
)
RETURNS INT
AS
BEGIN
    RETURN
        DATEDIFF(YEAR, @DateOfBirth, @AsOfDate)
        - CASE
            WHEN MONTH(@DateOfBirth) > MONTH(@AsOfDate) THEN 1
            WHEN MONTH(@DateOfBirth) = MONTH(@AsOfDate)
             AND DAY(@DateOfBirth)   > DAY(@AsOfDate)  THEN 1
            ELSE 0
          END;
END;
-- Using both UDFs together in a query:
SELECT
    dbo.FormatFullName(u.FirstName, u.LastName)  AS CustomerName,
    u.Email,
    dbo.CalculateAge(u.DateOfBirth, CAST(GETDATE() AS DATE)) AS Age
FROM Users u
WHERE u.Country = 'United States'
ORDER BY Age DESC;
CustomerName | Email | Age
----------------|--------------------------|-----
Chen, Sarah | sarah.chen@email.com | 34
Patel, Nina | nina.patel@email.com | 28
Wallace, Tom | tom.wallace@email.com | 26
(3 rows affected)
  • dbo.FormatFullName handles all four NULL combinations — both NULL, first only, last only, or both present
  • dbo.CalculateAge subtracts 1 when the birthday has not yet occurred in the current year — pure DATEDIFF would overcount by one for pre-birthday rows
  • GETDATE() cannot be called inside a UDF declared with SCHEMABINDING but can be passed in as a parameter — the caller decides the reference date
  • The result of a scalar UDF call can be used anywhere a column expression is valid — ORDER BY, WHERE, computed columns, CASE expressions

Inline Table-Valued Function

An inline table-valued function (iTVF) returns a table result set and is written as a single SELECT statement with no BEGIN/END block. The SQL Server query optimiser can look inside an iTVF and merge its logic with the calling query — a process called inlining. This means it performs like a parameterised view and is far more efficient than a multi-statement TVF. Use an iTVF any time you need to filter or shape a result set based on a parameter and then join that result into a larger query.

-- Inline TVF: return all orders for a given customer within a date range.
-- The function acts like a parameterised view -- no BEGIN/END, no RETURN variable.
CREATE FUNCTION dbo.GetCustomerOrders
(
    @UserId    INT,
    @StartDate DATE,
    @EndDate   DATE
)
RETURNS TABLE                          -- no explicit column list needed
WITH SCHEMABINDING
AS
RETURN                                 -- single SELECT, no BEGIN/END
(
    SELECT
        o.OrderId,
        o.OrderDate,
        o.Total,
        o.Status,
        COUNT(oi.OrderItemId)               AS ItemCount,
        SUM(oi.Quantity * oi.Price)         AS CalculatedTotal
    FROM dbo.Orders     o
    JOIN dbo.OrderItems oi ON o.OrderId = oi.OrderId
    WHERE o.UserId    = @UserId
      AND o.OrderDate BETWEEN @StartDate AND @EndDate
    GROUP BY o.OrderId, o.OrderDate, o.Total, o.Status
);
-- Calling an inline TVF using CROSS APPLY or direct FROM:
SELECT
    u.FirstName + ' ' + u.LastName  AS CustomerName,
    co.OrderId,
    co.OrderDate,
    co.ItemCount,
    co.CalculatedTotal
FROM dbo.Users u
CROSS APPLY dbo.GetCustomerOrders(u.UserId, '2024-01-01', '2024-06-30') co
WHERE u.Country = 'United States'
ORDER BY co.OrderDate DESC;
CustomerName | OrderId | OrderDate | ItemCount | CalculatedTotal
--------------|---------|------------|-----------|----------------
Sarah Chen | 1042 | 2024-06-18 | 4 | 476.93
Sarah Chen | 1019 | 2024-04-22 | 2 | 98.50
Nina Patel | 1031 | 2024-05-22 | 3 | 189.40
(3 rows affected)
  • The iTVF has no BEGIN/END — just RETURNS TABLE ... AS RETURN (SELECT ...)
  • CROSS APPLY calls the function once per row of the outer query (once per user) and merges the results — equivalent to a correlated JOIN
  • The optimiser can inline the function's SELECT into the outer query's execution plan — the entire query compiles as if the function's logic were written directly in the FROM clause
  • iTVFs can be joined, filtered with WHERE, and aggregated just like regular tables or views

Multi-Statement Table-Valued Function

A multi-statement TVF (msTVF) explicitly declares a table variable as its return type, then builds that table row by row across multiple T-SQL statements inside a BEGIN/END block. This allows complex conditional logic, loops, and intermediate calculations that a single SELECT cannot express. The trade-off is performance — the query optimiser cannot look inside an msTVF the way it can with an iTVF, so it treats the return table as a black box with estimated row counts that are often wrong. Use msTVFs only when inline logic genuinely cannot express the requirement.

-- Multi-statement TVF: classify each customer by spending tier.
-- The conditional classification logic cannot be written as a single SELECT
-- without repetition, making an msTVF a reasonable choice here.
CREATE FUNCTION dbo.GetCustomerTiers
(
    @MinOrders  INT   -- only return customers with at least this many orders
)
RETURNS @Results TABLE               -- explicitly declare the return table structure
(
    UserId       INT,
    CustomerName NVARCHAR(101),
    TotalOrders  INT,
    TotalSpent   DECIMAL(10,2),
    SpendTier    NVARCHAR(20)
)
AS
BEGIN
    -- first populate base spend data into the return table
    INSERT INTO @Results (UserId, CustomerName, TotalOrders, TotalSpent, SpendTier)
    SELECT
        u.UserId,
        u.FirstName + ' ' + u.LastName,
        COUNT(o.OrderId),
        SUM(o.Total),
        'Unclassified'              -- placeholder -- updated in the next step
    FROM dbo.Users  u
    JOIN dbo.Orders o ON u.UserId = o.UserId
    GROUP BY u.UserId, u.FirstName, u.LastName
    HAVING COUNT(o.OrderId) >= @MinOrders;
    -- then apply the tier classification based on total spend
    UPDATE @Results
    SET SpendTier =
        CASE
            WHEN TotalSpent >= 1500 THEN 'Platinum'
            WHEN TotalSpent >= 750  THEN 'Gold'
            WHEN TotalSpent >= 300  THEN 'Silver'
            ELSE                         'Bronze'
        END;
    RETURN;         -- return the fully populated @Results table
END;
-- Calling the msTVF:
SELECT SpendTier, COUNT(*) AS Customers, SUM(TotalSpent) AS TierRevenue
FROM dbo.GetCustomerTiers(3)       -- only customers with 3 or more orders
GROUP BY SpendTier
ORDER BY TierRevenue DESC;
SpendTier | Customers | TierRevenue
----------|-----------|------------
Platinum | 2 | 3182.50
Gold | 5 | 4920.75
Silver | 8 | 3841.20
Bronze | 11 | 2140.60
(4 rows affected)
  • The return table @Results is populated in two passes — an INSERT first, then an UPDATE to set the tier — logic that cannot be expressed as a single SELECT
  • The msTVF is called exactly like the iTVF — FROM dbo.GetCustomerTiers(3) — the caller does not need to know which type it is
  • The optimiser cannot see inside the msTVF and assumes a fixed row count estimate, which can lead to poor query plans on large data — always test performance with real data volumes
  • RETURN; at the end of an msTVF signals the end of the function — it does not take an argument (unlike a scalar UDF's RETURN @value)

Altering and Dropping UDFs

UDFs are database objects and follow the same DDL lifecycle as tables and views. Use ALTER FUNCTION to modify an existing function without dropping it — this preserves any permissions that have been granted on the function. Use DROP FUNCTION to remove it entirely. If a function is schema-bound, you must drop the schema binding or the dependent object before you can alter or drop the underlying tables the function references. The system catalogue view sys.objects lets you check whether a function exists before attempting to create or drop it.

-- Safe create-or-replace pattern for UDFs.
-- Drop if exists, then recreate -- preserves a clean definition each deployment.
-- Check if the function exists and drop it if so
IF OBJECT_ID(N'dbo.CalculateDiscountedPrice', N'FN') IS NOT NULL
    DROP FUNCTION dbo.CalculateDiscountedPrice;
GO
-- Recreate with updated logic (now applies a minimum floor price)
CREATE FUNCTION dbo.CalculateDiscountedPrice
(
    @OriginalPrice  DECIMAL(10,2),
    @DiscountPct    DECIMAL(5,2)
)
RETURNS DECIMAL(10,2)
WITH SCHEMABINDING
AS
BEGIN
    IF @DiscountPct < 0 OR @DiscountPct > 100
        RETURN @OriginalPrice;
    DECLARE @Discounted DECIMAL(10,2) =
        ROUND(@OriginalPrice * (1 - @DiscountPct / 100.0), 2);
    -- new rule: never go below $1.00 regardless of discount
    RETURN CASE WHEN @Discounted < 1.00 THEN 1.00 ELSE @Discounted END;
END;
GO
-- List all UDFs in the current database with their type and creation date
SELECT
    name                AS FunctionName,
    type_desc           AS FunctionType,      -- SCALAR_FUNCTION or TABLE_VALUED_FUNCTION
    create_date         AS Created,
    modify_date         AS LastModified
FROM sys.objects
WHERE type IN ('FN', 'IF', 'TF')              -- FN=scalar, IF=inline TVF, TF=multi-stmt TVF
ORDER BY name;
FunctionName | FunctionType | Created | LastModified
----------------------------|-------------------------|-------------------------|------------------------
CalculateAge | SCALAR_FUNCTION | 2024-02-10 09:14:22.000 | 2024-02-10 09:14:22.000
CalculateDiscountedPrice | SCALAR_FUNCTION | 2024-01-08 14:30:10.000 | 2024-06-20 11:05:44.000
FormatFullName | SCALAR_FUNCTION | 2024-01-08 14:32:05.000 | 2024-01-08 14:32:05.000
GetCustomerOrders | TABLE_VALUED_FUNCTION | 2024-02-14 10:22:18.000 | 2024-02-14 10:22:18.000
GetCustomerTiers | TABLE_VALUED_FUNCTION | 2024-03-01 15:40:33.000 | 2024-03-01 15:40:33.000
(5 rows affected)
  • OBJECT_ID(N'dbo.CalculateDiscountedPrice', N'FN') returns NULL if the function does not exist — the IF guard makes the script safe to run repeatedly
  • The type codes are: FN = scalar function, IF = inline TVF, TF = multi-statement TVF
  • modify_date on CalculateDiscountedPrice is later than its create_date — confirming the ALTER was applied
  • In SQL Server 2016 and later you can use DROP FUNCTION IF EXISTS dbo.FunctionName as a shorter alternative to the OBJECT_ID check pattern

Lesson Summary

Concept What It Means Example
Scalar UDF Returns a single value — usable anywhere an expression is valid dbo.CalculateDiscountedPrice(Price, 15)
Inline TVF Returns a table from a single SELECT — optimiser can inline it for best performance FROM dbo.GetCustomerOrders(14, '2024-01-01', '2024-06-30')
Multi-stmt TVF Returns a declared table variable populated across multiple statements RETURNS @Results TABLE (...) with INSERT + UPDATE
WITH SCHEMABINDING Prevents referenced tables/columns from being altered while the function depends on them CREATE FUNCTION ... WITH SCHEMABINDING
dbo. prefix Required when calling a UDF — SQL Server needs the schema to resolve the name dbo.FormatFullName(...) not FormatFullName(...)
CROSS APPLY Calls a TVF once per row of the outer query — like a correlated JOIN CROSS APPLY dbo.GetCustomerOrders(u.UserId, ...)
No data modification UDFs cannot INSERT, UPDATE, DELETE, or execute DDL — read-only logic only Use a stored procedure instead when writes are needed
DROP IF EXISTS Safe drop syntax available from SQL Server 2016 onward DROP FUNCTION IF EXISTS dbo.FunctionName
sys.objects System catalogue listing all functions — filter by type FN, IF, or TF WHERE type IN ('FN', 'IF', 'TF')

Practice Questions

Practice 1. What are the three types of user-defined function in SQL Server?



Practice 2. Why must you prefix a UDF call with the schema name (e.g. dbo.) when calling it in a query?



Practice 3. What is the performance advantage of an inline TVF over a multi-statement TVF?



Practice 4. Can a UDF execute an INSERT or UPDATE statement inside its body? Why or why not?



Practice 5. What does WITH SCHEMABINDING do when applied to a UDF?



Quiz

Quiz 1. You need a reusable calculation that takes two numbers and returns one number. Which UDF type is most appropriate?






Quiz 2. What T-SQL operator calls a TVF once per row of the outer query, passing each row's columns as arguments?






Quiz 3. Which type code in sys.objects identifies a scalar function?






Quiz 4. Why is a multi-statement TVF often slower than an inline TVF for the same query?






Quiz 5. You try to drop a table that is referenced by a schema-bound UDF. What happens?






Next up — Triggers — How to automatically execute T-SQL logic in response to INSERT, UPDATE, or DELETE events on a table.