User-Defined Functions in SQL Server
Three types of UDFs:
- Scalar function -> returns ONE value
- Inline Table-Valued Function (iTVF) -> returns a table from ONE SELECT (fastest, like a parameterized view)
- Multi-Statement TVF (mTVF) -> builds a @table with many statements (flexible, usually slower)
- Rules: functions CANNOT change data in tables (no INSERT/UPDATE/DELETE on real tables), cannot use TRY...CATCH, cannot call stored procedures, no NEWID()/RAND() inside.
Run the sample database script first. Every query below was run on that data, and the Output under each query is the real result.
USE SqlLearningDb;
GO
Topic table: TaxSlabs (Indian style income tax slabs - simplified, for practice only)
DROP TABLE IF EXISTS dbo.TaxSlabs;
CREATE TABLE dbo.TaxSlabs
(
SlabID INT PRIMARY KEY,
FromAmount DECIMAL(12,2) NOT NULL,
ToAmount DECIMAL(12,2) NULL, -- NULL = no upper limit
RatePct DECIMAL(5,2) NOT NULL
);
INSERT INTO dbo.TaxSlabs (SlabID, FromAmount, ToAmount, RatePct) VALUES
(1, 0, 400000, 0),
(2, 400000, 800000, 5),
(3, 800000, 1200000, 10),
(4, 1200000, 1600000, 15),
(5, 1600000, 2000000, 20),
(6, 2000000, 2400000, 25),
(7, 2400000, NULL, 30);
GO
Messages(7 rows affected)
Part A: Scalar Functions
Simple scalar: full name with title
CREATE OR ALTER FUNCTION dbo.fn_FullName (@First VARCHAR(50), @Last VARCHAR(50), @Gender CHAR(1))
RETURNS VARCHAR(110)
WITH SCHEMABINDING -- good practice: deterministic + protects referenced objects
AS
BEGIN
RETURN CASE @Gender WHEN 'M' THEN 'Mr. ' WHEN 'F' THEN 'Ms. ' ELSE '' END
+ @First + ' ' + @Last;
END;
GO
Scalar with logic: years of service with label
CREATE OR ALTER FUNCTION dbo.fn_ServiceBand (@HireDate DATE, @AsOf DATE)
RETURNS VARCHAR(20)
AS
BEGIN
DECLARE @Years INT = DATEDIFF(YEAR, @HireDate, @AsOf)
- CASE WHEN DATEADD(YEAR, DATEDIFF(YEAR, @HireDate, @AsOf), @HireDate) > @AsOf THEN 1 ELSE 0 END;
RETURN CASE WHEN @Years >= 8 THEN 'Veteran (8+ yrs)'
WHEN @Years >= 4 THEN 'Senior (4-7 yrs)'
WHEN @Years >= 1 THEN 'Regular (1-3 yrs)'
ELSE 'New Joiner' END;
END;
GO
Scalar reading a table: annual tax using slabs
CREATE OR ALTER FUNCTION dbo.fn_AnnualTax (@AnnualIncome DECIMAL(12,2))
RETURNS DECIMAL(12,2)
AS
BEGIN
DECLARE @Tax DECIMAL(12,2);
SELECT @Tax = SUM(
(CASE WHEN @AnnualIncome < ISNULL(ToAmount, @AnnualIncome) THEN @AnnualIncome
ELSE ISNULL(ToAmount, @AnnualIncome) END - FromAmount) * RatePct / 100.0)
FROM dbo.TaxSlabs
WHERE @AnnualIncome > FromAmount;
RETURN ISNULL(@Tax, 0);
END;
GO
Using scalar functions
SELECT dbo.fn_FullName(FirstName, LastName, Gender) AS EmployeeName,
HireDate,
dbo.fn_ServiceBand(HireDate, '2025-06-30') AS ServiceBand,
Salary * 12 AS AnnualSalary,
dbo.fn_AnnualTax(Salary * 12) AS AnnualTax
FROM dbo.Employees
ORDER BY AnnualSalary DESC;
Output
| EmployeeName | HireDate | ServiceBand | AnnualSalary | AnnualTax |
| Mr. Rajesh Kumar | 2015-01-10 | Veteran (8+ yrs) | 3000000.00 | 480000.00 |
| Mr. Vikram Reddy | 2016-06-01 | Veteran (8+ yrs) | 2280000.00 | 270000.00 |
| Ms. Anita Sharma | 2016-03-15 | Veteran (8+ yrs) | 2160000.00 | 240000.00 |
| Mr. Suresh Patel | 2017-08-11 | Senior (4-7 yrs) | 1560000.00 | 114000.00 |
| Ms. Meena Iyer | 2017-02-20 | Veteran (8+ yrs) | 1440000.00 | 96000.00 |
| Ms. Kavya Nair | 2018-01-05 | Senior (4-7 yrs) | 1320000.00 | 78000.00 |
| Mr. Deepak Chauhan | 2017-11-03 | Senior (4-7 yrs) | 1260000.00 | 69000.00 |
| Mr. Arjun Mehta | 2018-04-17 | Senior (4-7 yrs) | 1200000.00 | 60000.00 |
| Mr. Kiran Rao | 2018-11-30 | Senior (4-7 yrs) | 1140000.00 | 54000.00 |
| Mr. Sanjay Das | 2019-10-10 | Senior (4-7 yrs) | 1080000.00 | 48000.00 |
| Ms. Neha Kapoor | 2022-03-01 | Regular (1-3 yrs) | 900000.00 | 30000.00 |
| Ms. Divya Menon | 2021-02-08 | Senior (4-7 yrs) | 840000.00 | 24000.00 |
| Ms. Geeta Bhat | 2020-08-17 | Senior (4-7 yrs) | 840000.00 | 24000.00 |
| Mr. Amit Joshi | 2021-06-14 | Senior (4-7 yrs) | 816000.00 | 21600.00 |
| Ms. Lakshmi Rao | 2021-12-01 | Regular (1-3 yrs) | 744000.00 | 17200.00 |
... showing 15 of 25 rows
Scalar function in a CHECK constraint / computed column is also possible
SELECT dbo.fn_AnnualTax(1000000) AS TaxOn10Lakh, dbo.fn_AnnualTax(3000000) AS TaxOn30Lakh;
GO
Output
| TaxOn10Lakh | TaxOn30Lakh |
| 40000.00 | 480000.00 |
Part B: Inline Table-valued Functions (iTVF) -> preferred for performance
Orders between two dates for a customer (parameterized view)
CREATE OR ALTER FUNCTION dbo.fn_CustomerOrders (@CustomerID INT, @From DATE, @To DATE)
RETURNS TABLE
AS
RETURN
(
SELECT o.OrderID, o.OrderDate, o.Status,
SUM(oi.LineTotal) AS OrderTotal,
COUNT(*) AS Lines
FROM dbo.Orders o
JOIN dbo.OrderItems oi ON oi.OrderID = o.OrderID
WHERE o.CustomerID = @CustomerID
AND o.OrderDate >= @From AND o.OrderDate < DATEADD(DAY, 1, @To)
GROUP BY o.OrderID, o.OrderDate, o.Status
);
GO
SELECT * FROM dbo.fn_CustomerOrders(1, '2024-01-01', '2025-12-31') ORDER BY OrderDate;
-- iTVF can be joined, filtered and aggregated like a table
SELECT Status, COUNT(*) AS Orders, SUM(OrderTotal) AS Revenue
FROM dbo.fn_CustomerOrders(2, '2024-01-01', '2025-12-31')
GROUP BY Status;
GO
Output
| OrderID | OrderDate | Status | OrderTotal | Lines |
| 1 | 2024-01-09 | Delivered | 285000.00 | 2 |
| 10 | 2024-03-12 | Delivered | 223500.00 | 3 |
| 13 | 2024-04-02 | Delivered | 142560.00 | 4 |
| 26 | 2024-06-28 | Delivered | 108000.00 | 1 |
| 39 | 2024-09-23 | Delivered | 88600.00 | 4 |
| 46 | 2024-11-06 | Delivered | 87750.00 | 2 |
| 52 | 2024-12-18 | Delivered | 42750.00 | 1 |
| 64 | 2025-03-08 | Delivered | 1200.00 | 1 |
| 71 | 2025-04-25 | Delivered | 293775.00 | 4 |
| 73 | 2025-05-09 | Delivered | 240750.00 | 2 |
| 75 | 2025-05-21 | Delivered | 14025.00 | 3 |
Output
| Status | Orders | Revenue |
| Cancelled | 1 | 324000.00 |
| Delivered | 5 | 839225.00 |
ITVF: product sales summary for a date range (used with CROSS APPLY in file 13 style)
CREATE OR ALTER FUNCTION dbo.fn_ProductSales (@ProductID INT, @From DATE, @To DATE)
RETURNS TABLE
AS
RETURN
(
SELECT COUNT(*) AS Transactions, ISNULL(SUM(Quantity), 0) AS Units, ISNULL(SUM(TotalAmount), 0) AS Revenue
FROM dbo.Sales
WHERE ProductID = @ProductID AND SaleDate BETWEEN @From AND @To
);
GO
SELECT p.ProductName, s.Transactions, s.Units, s.Revenue
FROM dbo.Products p
CROSS APPLY dbo.fn_ProductSales(p.ProductID, '2025-01-01', '2025-03-31') s
ORDER BY s.Revenue DESC;
GO
Output
| ProductName | Transactions | Units | Revenue |
| MacBook Air M3 | 7 | 16 | 1920000.00 |
| HP Pavilion 14 | 5 | 13 | 1105000.00 |
| Dell Inspiron 15 | 5 | 15 | 975000.00 |
| iPhone 15 | 4 | 11 | 770000.00 |
| Acer Aspire Desktop | 4 | 13 | 585000.00 |
| Lenovo ThinkCentre | 4 | 10 | 550000.00 |
| USB-C Hub | 8 | 88 | 308000.00 |
| Standing Desk | 6 | 11 | 242000.00 |
| Basic Office Chair | 9 | 22 | 187000.00 |
| Samsung Galaxy A55 | 3 | 7 | 175000.00 |
| Ergonomic Office Chair | 4 | 14 | 168000.00 |
| Redmi Note 13 | 4 | 10 | 150000.00 |
| 27 inch Monitor | 5 | 8 | 144000.00 |
| Mechanical Keyboard | 4 | 18 | 45000.00 |
| Wireless Mouse | 4 | 31 | 37200.00 |
... showing 15 of 20 rows
ITVF: numbers table generator (very handy utility)
CREATE OR ALTER FUNCTION dbo.fn_Numbers (@N INT)
RETURNS TABLE
AS
RETURN
(
SELECT TOP (@N) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
FROM sys.all_objects a CROSS JOIN sys.all_objects b
);
GO
-- Generate a date list using the numbers function
SELECT DATEADD(DAY, n - 1, '2025-02-01') AS CalendarDate
FROM dbo.fn_Numbers(28);
GO
Output
| CalendarDate |
| 2025-02-01 00:00:00 |
| 2025-02-02 00:00:00 |
| 2025-02-03 00:00:00 |
| 2025-02-04 00:00:00 |
| 2025-02-05 00:00:00 |
| 2025-02-06 00:00:00 |
| 2025-02-07 00:00:00 |
| 2025-02-08 00:00:00 |
| 2025-02-09 00:00:00 |
| 2025-02-10 00:00:00 |
| 2025-02-11 00:00:00 |
| 2025-02-12 00:00:00 |
| 2025-02-13 00:00:00 |
| 2025-02-14 00:00:00 |
| 2025-02-15 00:00:00 |
... showing 15 of 28 rows
Part C: Multi-statement Table-valued Function (mTVF)
Org chart below a manager, with level (needs several steps -> mTVF)
CREATE OR ALTER FUNCTION dbo.fn_TeamOf (@ManagerID INT)
RETURNS @Team TABLE
(
EmployeeID INT PRIMARY KEY,
FullName VARCHAR(110),
JobTitle VARCHAR(50),
Lvl INT,
Salary DECIMAL(10,2)
)
AS
BEGIN
DECLARE @Level INT = 1;
INSERT INTO @Team (EmployeeID, FullName, JobTitle, Lvl, Salary)
SELECT EmployeeID, FirstName + ' ' + LastName, JobTitle, @Level, Salary
FROM dbo.Employees WHERE ManagerID = @ManagerID;
WHILE @@ROWCOUNT > 0
BEGIN
SET @Level += 1;
INSERT INTO @Team (EmployeeID, FullName, JobTitle, Lvl, Salary)
SELECT e.EmployeeID, e.FirstName + ' ' + e.LastName, e.JobTitle, @Level, e.Salary
FROM dbo.Employees e
JOIN @Team t ON t.EmployeeID = e.ManagerID AND t.Lvl = @Level - 1;
END;
RETURN;
END;
GO
SELECT * FROM dbo.fn_TeamOf(3) ORDER BY Lvl, EmployeeID; -- everyone under the CTO
SELECT COUNT(*) AS TeamSize, SUM(Salary) AS TeamCost FROM dbo.fn_TeamOf(2); -- under VP Sales
GO
Output
| EmployeeID | FullName | JobTitle | Lvl | Salary |
| 11 | Kiran Rao | Senior Developer | 1 | 95000.00 |
| 14 | Neha Kapoor | Data Analyst | 1 | 75000.00 |
| 15 | Sanjay Das | DBA | 1 | 90000.00 |
| 12 | Divya Menon | Developer | 2 | 70000.00 |
| 13 | Amit Joshi | Developer | 2 | 68000.00 |
| 24 | Naveen Kumar | Intern | 2 | 20000.00 |
| 25 | Geeta Bhat | Developer | 2 | 70000.00 |
Output
| TeamSize | TeamCost |
| 8 | 522000.00 |
MTVF: customer scorecard with derived fields computed step by step
CREATE OR ALTER FUNCTION dbo.fn_CustomerScorecard (@AsOf DATE)
RETURNS @Card TABLE
(
CustomerID INT PRIMARY KEY,
CustomerName VARCHAR(100),
Orders INT,
Revenue DECIMAL(14,2),
DaysSinceLast INT,
Segment VARCHAR(20)
)
AS
BEGIN
INSERT INTO @Card (CustomerID, CustomerName, Orders, Revenue, DaysSinceLast)
SELECT c.CustomerID, c.CustomerName,
COUNT(DISTINCT o.OrderID),
ISNULL(SUM(oi.LineTotal), 0),
DATEDIFF(DAY, MAX(o.OrderDate), @AsOf)
FROM dbo.Customers c
LEFT JOIN dbo.Orders o ON o.CustomerID = c.CustomerID AND o.Status <> 'Cancelled'
LEFT JOIN dbo.OrderItems oi ON oi.OrderID = o.OrderID
GROUP BY c.CustomerID, c.CustomerName;
UPDATE @Card
SET Segment = CASE WHEN Orders = 0 THEN 'Never Bought'
WHEN DaysSinceLast <= 90 AND Revenue >= 500000 THEN 'Champion'
WHEN DaysSinceLast <= 180 THEN 'Active'
WHEN DaysSinceLast <= 365 THEN 'At Risk'
ELSE 'Lost' END;
RETURN;
END;
GO
SELECT * FROM dbo.fn_CustomerScorecard('2025-07-01') ORDER BY Revenue DESC;
SELECT Segment, COUNT(*) AS Customers FROM dbo.fn_CustomerScorecard('2025-07-01') GROUP BY Segment;
GO
Output
| CustomerID | CustomerName | Orders | Revenue | DaysSinceLast | Segment |
| 3 | Lakshmi Stores | 13 | 1925592.50 | 16 | Champion |
| 5 | Pune Tech Park | 8 | 1649975.00 | 7 | Champion |
| 1 | Ravi Traders | 11 | 1527910.00 | 41 | Champion |
| 15 | Marina Digital | 6 | 1326609.00 | 21 | Champion |
| 2 | Infosoft Solutions | 5 | 839225.00 | 156 | Active |
| 11 | Kerala Kart | 3 | 644925.00 | 176 | Active |
| 12 | Awadh Enterprises | 4 | 623885.00 | 217 | At Risk |
| 13 | Silicon Valley Pvt Ltd | 3 | 574857.00 | 60 | Champion |
| 17 | Shivaji Stationers | 4 | 548650.00 | 107 | Active |
| 10 | Pink City Furnishers | 4 | 536100.00 | 134 | Active |
| 14 | Gateway Retail | 1 | 476850.00 | 329 | At Risk |
| 4 | Mumbai Mart | 4 | 365735.00 | 80 | Active |
| 16 | Northern Distributors | 2 | 275200.00 | 189 | At Risk |
| 7 | Bengal Book House | 2 | 259075.00 | 303 | At Risk |
| 9 | Gujarat Office Supply | 3 | 148800.00 | 276 | At Risk |
... showing 15 of 20 rows
Output
| Segment | Customers |
| Active | 6 |
| At Risk | 6 |
| Champion | 5 |
| Never Bought | 3 |
Part D: Performance Notes & Metadata
Scalar UDF inlining (SQL Server 2019+): check if a function can be inlined
SELECT OBJECT_NAME(object_id) AS FunctionName, is_inlineable
FROM sys.sql_modules
WHERE OBJECT_NAME(object_id) LIKE 'fn[_]%';
Output
| FunctionName | is_inlineable |
| fn_LastOrders | 1 |
| fn_FullName | 1 |
| fn_ServiceBand | 1 |
| fn_AnnualTax | 1 |
| fn_CustomerOrders | 1 |
| fn_ProductSales | 1 |
| fn_Numbers | 1 |
| fn_TeamOf | 0 |
| fn_CustomerScorecard | 0 |
List all functions in the database with their type
SELECT name, type_desc, create_date
FROM sys.objects
WHERE type IN ('FN', 'IF', 'TF') -- FN = scalar, IF = inline TVF, TF = multi-statement TVF
ORDER BY type_desc, name;
Output
| name | type_desc | create_date |
| fn_CustomerOrders | SQL_INLINE_TABLE_VALUED_FUNCTION | 2026-10-18 18:05:16.65 |
| fn_LastOrders | SQL_INLINE_TABLE_VALUED_FUNCTION | 2026-10-18 18:05:15.757 |
| fn_Numbers | SQL_INLINE_TABLE_VALUED_FUNCTION | 2026-10-18 18:05:16.69 |
| fn_ProductSales | SQL_INLINE_TABLE_VALUED_FUNCTION | 2026-10-18 18:05:16.683 |
| fn_AnnualTax | SQL_SCALAR_FUNCTION | 2026-10-18 18:05:16.613 |
| fn_FullName | SQL_SCALAR_FUNCTION | 2026-10-18 18:05:16.61 |
| fn_ServiceBand | SQL_SCALAR_FUNCTION | 2026-10-18 18:05:16.613 |
| fn_CustomerScorecard | SQL_TABLE_VALUED_FUNCTION | 2026-10-18 18:05:16.71 |
| fn_TeamOf | SQL_TABLE_VALUED_FUNCTION | 2026-10-18 18:05:16.7 |
FUNCTION vs STORED PROCEDURE
| Function | Stored Procedure |
|-----------------------------------|-----------------------------------|
| Must return a value / table | Return is optional (int only) |
| Usable in SELECT, WHERE, JOIN | Called with EXEC only |
| Cannot modify table data | Can INSERT/UPDATE/DELETE |
| No TRY...CATCH, no transactions | Supports both |
PRACTICE QUESTIONS 1. Write a scalar function fn_GetDiscountedPrice(@Price, @DiscountPct) and use it on Products. 2. Write an iTVF fn_EmployeesByCity(@City) and join it with Departments. 3. Write fn_CategoryPath(@CategoryID) that returns 'Electronics > Computers > Laptops' (scalar, using a loop). 4. Write an mTVF that returns monthly revenue for a given year with a "Growth %" column. 5. Compare the execution time of a scalar UDF vs the same logic written inline in a SELECT.