T-SQL User-Defined Function
Master T-SQL user-defined functions — scalar functions, inline table-valued functions (iTVFs), and multi-statement table-valued functions (MTVFs). Covers performance differences, scalar UDF inlining (SQL Server 2019), WITH SCHEMABINDING, comparison with stored procedures, and when to choose each type.
A user-defined function (UDF) is a named T-SQL routine that takes parameters and returns a value — like a stored procedure, but designed to be used inside expressions: in a SELECT list, a WHERE clause, a computed column, or wherever a value (or a table) can appear.
T-SQL has three flavors of UDF, distinguished by what they return and how they're written:
| Type | Returns | Body | Performance |
|---|---|---|---|
| Scalar function | A single value | Multi-statement | Slow before SQL Server 2019; faster with inlining since 2019 |
| Inline TVF (iTVF) | A table | Single SELECT statement | Fast — optimizer treats it like a parameterized view |
| Multi-statement TVF (MTVF) | A table | Multi-statement, populates a table variable | Slower — optimizer can't see inside |
| Aspect | Function | Procedure |
|---|---|---|
| Called via | Inside an expression: SELECT dbo.fn(...) | EXEC proc_name ... |
| Returns | A scalar value or a table — must return something | Optional result set + integer status code + OUTPUT parameters |
| Side effects (INSERT/UPDATE/DELETE) | Not allowed — functions must be read-only | Allowed |
| Use in SELECT/WHERE/JOIN | Yes | No |
| Try/catch errors inside | Limited (no TRY/CATCH for many error types) | Full TRY/CATCH support |
| Default parameter values | Yes (must pass DEFAULT keyword to use them) | Yes (just omit the parameter) |
A scalar function returns a single value of a specified type. Use them to wrap business logic that produces one value — calculations, formatting, lookups.
Syntax
CREATE OR ALTER FUNCTION dbo.GetSquare (@number INT)
RETURNS INT
AS
BEGIN
DECLARE @square INT;
SET @square = @number * @number;
RETURN @square;
END;
GO
SELECT dbo.GetSquare(5) AS SquareOf5;
dbo.GetSquare(...), not just GetSquare(...). Without the schema, T-SQL treats unqualified function names as built-ins and won't find your UDF.A more realistic scalar function — computes age from a date of birth, accounting for whether the birthday has happened this year.
CREATE TABLE Employees (
id INT PRIMARY KEY,
emp_name VARCHAR(30),
salary INT,
department_id INT,
dob DATE
);
INSERT INTO Employees VALUES
(1, 'John Doe', 50000, 1, '1985-03-15'),
(2, 'Jane Smith', 60000, 2, '1979-07-22'),
(3, 'Alice Johnson', 55000, 1, '1990-05-10'),
(4, 'Bob Brown', 52000, 3, '1988-11-30'),
(5, 'Eve Brown', 48000, 2, '1995-02-18');
CREATE OR ALTER FUNCTION dbo.GetEmployeeAge (@DOB DATE)
RETURNS INT
AS
BEGIN
DECLARE @age INT;
-- Years between today and DOB
SET @age = DATEDIFF(YEAR, @DOB, GETDATE());
-- If birthday hasn't happened yet this year, subtract 1
IF (MONTH(@DOB) > MONTH(GETDATE()))
OR (MONTH(@DOB) = MONTH(GETDATE()) AND DAY(@DOB) > DAY(GETDATE()))
BEGIN
SET @age = @age - 1;
END
RETURN @age;
END;
GO
SELECT
emp_name,
dob,
dbo.GetEmployeeAge(dob) AS age
FROM Employees;
An iTVF returns a table. The body is a single SELECT statement. The optimizer treats it just like a parameterized view — fully inlines it into the calling query, applies indexes and statistics, picks the best plan. iTVFs are the fastest way to encapsulate a parameterized query.
Syntax
Note the lack of BEGIN/END — an iTVF is just RETURN (single_select).
CREATE TABLE Orders (
OrderID INT PRIMARY KEY,
CustomerID INT,
OrderDate DATE,
Amount DECIMAL(10, 2)
);
INSERT INTO Orders VALUES
(1, 1, '2024-01-01', 100.00),
(2, 2, '2024-02-01', 200.00),
(3, 1, '2024-03-01', 150.00);
CREATE OR ALTER FUNCTION dbo.GetOrdersByCustomer (@CustomerID INT)
RETURNS TABLE
AS
RETURN
(
SELECT OrderID, OrderDate, Amount
FROM Orders
WHERE CustomerID = @CustomerID
);
GO
Use it just like a table — including in JOINs:
SELECT * FROM dbo.GetOrdersByCustomer(1);
CROSS APPLY / OUTER APPLY is the canonical way to do "for each row in table A, return rows from a function call". Much faster than scalar functions in a SELECT list when the function does any non-trivial work.An MTVF also returns a table — but the body is a multi-statement block that populates a declared TABLE variable. Use it when the result needs multiple steps that can't fit in a single SELECT.
Syntax
DROP TABLE IF EXISTS Employees;
CREATE TABLE Employees (
EmployeeID INT PRIMARY KEY,
FirstName NVARCHAR(50),
LastName NVARCHAR(50),
Salary MONEY,
DepartmentID INT
);
INSERT INTO Employees VALUES
(1, 'John', 'Doe', 45000, 1),
(2, 'Jane', 'Smith', 55000, 1),
(3, 'Jim', 'Brown', 30000, 2),
(4, 'Jake', 'White', 70000, 2),
(5, 'Jill', 'Green', 48000, 1);
CREATE OR ALTER FUNCTION dbo.GetEmployeesByDepartment (@DepartmentID INT)
RETURNS @EmployeeTable TABLE (
EmployeeID INT,
FirstName NVARCHAR(50),
LastName NVARCHAR(50),
Salary MONEY
)
AS
BEGIN
-- Step 1: Pull the matching employees
INSERT INTO @EmployeeTable (EmployeeID, FirstName, LastName, Salary)
SELECT EmployeeID, FirstName, LastName, Salary
FROM Employees
WHERE DepartmentID = @DepartmentID;
-- Step 2: Give a 10% bonus to anyone earning under 50,000
UPDATE @EmployeeTable
SET Salary = Salary * 1.10
WHERE Salary < 50000;
RETURN;
END;
GO
SELECT * FROM dbo.GetEmployeesByDepartment(1);
Notice rows 1 and 5 got the 10% bump (their original salaries 45000 and 48000 were below 50000); Jane's stayed at 55000.
CASE:
CREATE OR ALTER FUNCTION dbo.GetEmployeesByDepartment_inline (@DepartmentID INT)
RETURNS TABLE
AS
RETURN
(
SELECT EmployeeID, FirstName, LastName,
CASE WHEN Salary < 50000 THEN Salary * 1.10 ELSE Salary END AS Salary
FROM Employees
WHERE DepartmentID = @DepartmentID
);
The iTVF version is faster and the optimizer can use it freely. Whenever you can rewrite an MTVF as an iTVF, do it.| Option | Effect |
|---|---|
WITH SCHEMABINDING | Locks dependencies — referenced tables can't be altered out from under the function. Required for some uses (indexed views, computed-column persistence). Strongly recommended for scalar UDFs to enable inlining in 2019+. |
RETURNS NULL ON NULL INPUT | Function automatically returns NULL if any parameter is NULL — saves a check at the top of the body. |
WITH ENCRYPTION | Stores the body encrypted. (Not strong security — many tools can still decrypt.) |
| Default parameter value | @p INT = 0 — caller must pass the keyword DEFAULT to use it (functions are stricter than procedures here). |
DROP FUNCTION IF EXISTS dbo.GetSquare;
DROP FUNCTION IF EXISTS dbo.GetOrdersByCustomer;
DROP FUNCTION IF EXISTS dbo.GetEmployeesByDepartment;
- Prefer iTVFs. Whenever you need to encapsulate a parameterized query, write it as an iTVF — fastest, most flexible, optimizer-friendly.
- Use scalar UDFs sparingly, especially on SQL Server pre-2019. Replace with inline expressions where possible.
- Always include the schema —
dbo.fn_name(...), never justfn_name(...). - Use
WITH SCHEMABINDINGon scalar UDFs that don't need underlying-table flexibility — enables 2019+ inlining and stops accidental table changes that would break the function. - Use
CREATE OR ALTER FUNCTIONfor idempotent deployment. - Don't reach for an MTVF just because you have multiple lines of work — almost every MTVF can be rewritten as an iTVF with CASE expressions and CTEs.
- T-SQL has three function flavors: scalar (returns one value), inline TVF (single SELECT, optimizer-friendly), and multi-statement TVF (table variable + multiple steps).
- Functions go inside expressions (SELECT, WHERE, JOIN); procedures are called via
EXEC. - Functions cannot modify data — no INSERT/UPDATE/DELETE inside. If you need that, write a procedure.
- Always qualify with the schema (
dbo.fn_name) when calling — unqualified names are looked up as built-ins. - Performance: iTVF > inline expression > scalar UDF (with 2019+ inlining) > MTVF > scalar UDF (pre-2019). Pick the lightest tool for the job.
- Use
WITH SCHEMABINDINGfor scalar UDFs to enable inlining; useCREATE OR ALTER FUNCTIONfor clean deployments.