Skip to content

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
EmployeeNameHireDateServiceBandAnnualSalaryAnnualTax
Mr. Rajesh Kumar2015-01-10Veteran (8+ yrs)3000000.00480000.00
Mr. Vikram Reddy2016-06-01Veteran (8+ yrs)2280000.00270000.00
Ms. Anita Sharma2016-03-15Veteran (8+ yrs)2160000.00240000.00
Mr. Suresh Patel2017-08-11Senior (4-7 yrs)1560000.00114000.00
Ms. Meena Iyer2017-02-20Veteran (8+ yrs)1440000.0096000.00
Ms. Kavya Nair2018-01-05Senior (4-7 yrs)1320000.0078000.00
Mr. Deepak Chauhan2017-11-03Senior (4-7 yrs)1260000.0069000.00
Mr. Arjun Mehta2018-04-17Senior (4-7 yrs)1200000.0060000.00
Mr. Kiran Rao2018-11-30Senior (4-7 yrs)1140000.0054000.00
Mr. Sanjay Das2019-10-10Senior (4-7 yrs)1080000.0048000.00
Ms. Neha Kapoor2022-03-01Regular (1-3 yrs)900000.0030000.00
Ms. Divya Menon2021-02-08Senior (4-7 yrs)840000.0024000.00
Ms. Geeta Bhat2020-08-17Senior (4-7 yrs)840000.0024000.00
Mr. Amit Joshi2021-06-14Senior (4-7 yrs)816000.0021600.00
Ms. Lakshmi Rao2021-12-01Regular (1-3 yrs)744000.0017200.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
TaxOn10LakhTaxOn30Lakh
40000.00480000.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
OrderIDOrderDateStatusOrderTotalLines
12024-01-09Delivered285000.002
102024-03-12Delivered223500.003
132024-04-02Delivered142560.004
262024-06-28Delivered108000.001
392024-09-23Delivered88600.004
462024-11-06Delivered87750.002
522024-12-18Delivered42750.001
642025-03-08Delivered1200.001
712025-04-25Delivered293775.004
732025-05-09Delivered240750.002
752025-05-21Delivered14025.003
Output
StatusOrdersRevenue
Cancelled1324000.00
Delivered5839225.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
ProductNameTransactionsUnitsRevenue
MacBook Air M37161920000.00
HP Pavilion 145131105000.00
Dell Inspiron 15515975000.00
iPhone 15411770000.00
Acer Aspire Desktop413585000.00
Lenovo ThinkCentre410550000.00
USB-C Hub888308000.00
Standing Desk611242000.00
Basic Office Chair922187000.00
Samsung Galaxy A5537175000.00
Ergonomic Office Chair414168000.00
Redmi Note 13410150000.00
27 inch Monitor58144000.00
Mechanical Keyboard41845000.00
Wireless Mouse43137200.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
EmployeeIDFullNameJobTitleLvlSalary
11Kiran RaoSenior Developer195000.00
14Neha KapoorData Analyst175000.00
15Sanjay DasDBA190000.00
12Divya MenonDeveloper270000.00
13Amit JoshiDeveloper268000.00
24Naveen KumarIntern220000.00
25Geeta BhatDeveloper270000.00
Output
TeamSizeTeamCost
8522000.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
CustomerIDCustomerNameOrdersRevenueDaysSinceLastSegment
3Lakshmi Stores131925592.5016Champion
5Pune Tech Park81649975.007Champion
1Ravi Traders111527910.0041Champion
15Marina Digital61326609.0021Champion
2Infosoft Solutions5839225.00156Active
11Kerala Kart3644925.00176Active
12Awadh Enterprises4623885.00217At Risk
13Silicon Valley Pvt Ltd3574857.0060Champion
17Shivaji Stationers4548650.00107Active
10Pink City Furnishers4536100.00134Active
14Gateway Retail1476850.00329At Risk
4Mumbai Mart4365735.0080Active
16Northern Distributors2275200.00189At Risk
7Bengal Book House2259075.00303At Risk
9Gujarat Office Supply3148800.00276At Risk
... showing 15 of 20 rows
Output
SegmentCustomers
Active6
At Risk6
Champion5
Never Bought3

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
FunctionNameis_inlineable
fn_LastOrders1
fn_FullName1
fn_ServiceBand1
fn_AnnualTax1
fn_CustomerOrders1
fn_ProductSales1
fn_Numbers1
fn_TeamOf0
fn_CustomerScorecard0

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
nametype_desccreate_date
fn_CustomerOrdersSQL_INLINE_TABLE_VALUED_FUNCTION2026-10-18 18:05:16.65
fn_LastOrdersSQL_INLINE_TABLE_VALUED_FUNCTION2026-10-18 18:05:15.757
fn_NumbersSQL_INLINE_TABLE_VALUED_FUNCTION2026-10-18 18:05:16.69
fn_ProductSalesSQL_INLINE_TABLE_VALUED_FUNCTION2026-10-18 18:05:16.683
fn_AnnualTaxSQL_SCALAR_FUNCTION2026-10-18 18:05:16.613
fn_FullNameSQL_SCALAR_FUNCTION2026-10-18 18:05:16.61
fn_ServiceBandSQL_SCALAR_FUNCTION2026-10-18 18:05:16.613
fn_CustomerScorecardSQL_TABLE_VALUED_FUNCTION2026-10-18 18:05:16.71
fn_TeamOfSQL_TABLE_VALUED_FUNCTION2026-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.