Scalar Functions in SQL Server: Reusable Logic With a Real Performance Cost

Written by

in

Scalar Functions in SQL Server: Reusable Logic With a Real Performance Cost

Chapter 4 of the Fundamentals course previewed that user-defined functions exist. This is that promise kept — starting with the simplest kind. A scalar function takes parameters and returns a single value, usable anywhere an expression is valid, exactly like a built-in function such as DATEDIFF. Simple to write, but genuinely not free at scale, which is the entire reason this chapter exists before stored procedures.

Called once… or once per row?row 1row 2row 3…thousands moreCalculateAge()called once PER ROWoptimizercan’t see inside the fn2019+: UDF inlining sometimes rescues thisDATEDIFF(…) inlineevaluated directly — optimizer sees itFew hundred rows? Fine.Millions of rows? PROFILE IT — aWHERE-clause fn call can be the realbottleneck. No index will fix it. ⚠️

Writing One

CREATE FUNCTION dbo.CalculateAge (@birthDate DATE)
RETURNS INT
AS
BEGIN
    RETURN DATEDIFF(YEAR, @birthDate, GETDATE())
         - CASE WHEN (MONTH(@birthDate) > MONTH(GETDATE()))
                  OR (MONTH(@birthDate) = MONTH(GETDATE()) AND DAY(@birthDate) > DAY(GETDATE()))
                THEN 1 ELSE 0 END;
END;
GO

SELECT first_name, dbo.CalculateAge(birth_date) AS age FROM dbo.Employee;

Notice this fixes the exact DATEDIFF boundary-crossing gotcha from the Fundamentals course (Chapter 4) — a naive DATEDIFF(YEAR, birth_date, GETDATE()) overcounts by one until the birthday actually passes this year. Wrapping the correct logic in a function means every caller gets it right automatically, instead of everyone re-deriving (and possibly getting wrong) the same CASE expression.

The Performance Trap

WHERE dbo.CalculateAge(birth_date) > 30 Called once PER ROW — can block query parallelization on large tables (pre-2019, and sometimes still)

Scalar UDFs referenced in a WHERE clause or SELECT list against every row of a large table historically prevent SQL Server from parallelizing the query and get called once per row — the query optimizer has no visibility into what’s happening inside the function body, so it can’t reason about it the way it reasons about ordinary SQL. SQL Server 2019+ improved this significantly with “scalar UDF inlining,” which can automatically rewrite qualifying simple functions into inline expressions — but it’s still something to profile, not assume is free, since not every function qualifies (ones using TRY/CATCH, temp tables, or certain other constructs opt out of inlining).

-- Seeing the difference directly: compare actual execution plans
SET STATISTICS TIME ON;
SELECT COUNT(*) FROM dbo.Employee WHERE dbo.CalculateAge(birth_date) > 30;
SELECT COUNT(*) FROM dbo.Employee WHERE DATEDIFF(YEAR, birth_date, GETDATE()) > 30; -- inline equivalent
SET STATISTICS TIME OFF;
Common mistake: Assuming a scalar function is “basically the same speed” as inline logic because it looks similarly simple in the CREATE FUNCTION body. On a table with a few hundred rows, you likely won’t notice a difference. On a table with millions of rows, a row-by-row function call in the WHERE clause can be the single biggest bottleneck in an otherwise well-indexed query — and it won’t show up as an obvious “missing index” warning, since the problem isn’t the index, it’s the function call itself.

When Scalar Functions Are Genuinely the Right Call

Despite the caveat above, scalar functions are still a good tool for reusable logic that’s called on modest data volumes, in a SELECT list rather than a WHERE clause on a huge table, or inside a stored procedure operating on a handful of rows at a time. A formatting function for phone numbers, a business-rule lookup used in a report generated nightly — these are fine. The concern scales specifically with row count and where in the query the function appears, not with the mere existence of a scalar function in your codebase.

DETERMINISTIC vs Not: A Subtle Constraint

-- This function CANNOT be used in a computed persisted column or an index,
-- because GETDATE() makes it non-deterministic (a different result each call):
CREATE FUNCTION dbo.DaysUntilToday (@date DATE) RETURNS INT
AS BEGIN RETURN DATEDIFF(DAY, @date, GETDATE()); END;

A function is deterministic only if it always returns the same output for the same input, with no dependency on the current date/time, random values, or external state. This matters beyond just style — SQL Server won’t let you use a non-deterministic function in a persisted computed column or as part of an index key, because the stored value would go stale the moment time passes.

Practice tip: Write dbo.CalculateAge yourself from the example above, then run it against a table with a few thousand synthetic rows (a numbers-table trick like the ones used in later capstones works well) in a WHERE clause, timing it with SET STATISTICS TIME ON. Compare against the inline DATEDIFF equivalent. Seeing an actual millisecond difference on your own machine is far more convincing than any explanation of why it happens.

Enjoyed this?

Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.

📡 Subscribe via RSS  | 
Share on X  | 
Share on LinkedIn  | 
Share on Facebook

Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server for Developers & DBAs, coming soon on this site.