MS SQL Server
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 SCHEMABINDINGto 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;---------------------|---------------|----------------|---------------
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 SCHEMABINDINGprevents 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;----------------|--------------------------|-----
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.FormatFullNamehandles all four NULL combinations — both NULL, first only, last only, or both presentdbo.CalculateAgesubtracts 1 when the birthday has not yet occurred in the current year — pure DATEDIFF would overcount by one for pre-birthday rowsGETDATE()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;--------------|---------|------------|-----------|----------------
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 APPLYcalls 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;----------|-----------|------------
Platinum | 2 | 3182.50
Gold | 5 | 4920.75
Silver | 8 | 3841.20
Bronze | 11 | 2140.60
(4 rows affected)
- The return table
@Resultsis 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'sRETURN @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;----------------------------|-------------------------|-------------------------|------------------------
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_dateon CalculateDiscountedPrice is later than itscreate_date— confirming the ALTER was applied- In SQL Server 2016 and later you can use
DROP FUNCTION IF EXISTS dbo.FunctionNameas 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.