Scalar UDFs vs Inline TVFs: Measuring the Damage

Scalar user-defined functions are the most natural thing in the world to write. You have a bit of logic, you wrap it in a function, you call it in your SELECT list, and your query is suddenly clean and readable and forty times slower. That last part tends to arrive later, on someone else’s on-call shift.

Split illustration comparing thousands of tiny workers carrying single data cubes through a narrow door versus one wide conveyor belt carrying all the cubes at once

This post takes one piece of logic and implements it three ways: as a classic scalar UDF, as the same scalar UDF with SQL Server 2019’s automatic inlining allowed to work, and as an inline table-valued function. Then it measures all three, on SQL Server 2019 (CU32) and SQL Server 2025 (RTM), with the full demo scripts so you can reproduce every number.

The setup

One table, a million rows, and three functions that all compute the same thing: the age of an order in days, floored at zero.

The three contestants. First, the classic scalar UDF. I am explicitly disabling inlining with WITH INLINE = OFF so it behaves the way every scalar UDF behaved before SQL Server 2019:

Second, the identical function with the default inlining behavior (no INLINE clause, so SQL Server decides):

Third, the same logic as an inline table-valued function. Note there is no BEGIN...END body at all; an iTVF is a single RETURN of a query, which is precisely why the optimizer can treat it like a view and fold it into the calling query:

The race

Each variant aggregates over the full million rows, so the function logic runs a million times (or, spoiler, gets rewritten so that it does not):

Warm-cache results, second run, on both instances:

Same logic, same rows, same instance: the pre-2019-style scalar UDF takes about 4 seconds, and both modern shapes take well under 100 milliseconds. The gap is not the arithmetic; DATEDIFF costs next to nothing. The gap is a million function invocations, each with its own T-SQL execution context, each a black box the optimizer cannot cost, running on a plan that scalar UDFs force to be serial. The inlined variants get rewritten into relational expressions inside the calling query, where the optimizer can vectorize the work across the scan and, when useful, parallelize it.

The gotcha I hit so you don’t have to

My first benchmark run on the 2019 instance produced a surprise: the “inlineable” scalar UDF ran just as slowly as the INLINE = OFF one. Four seconds, serial plan, no inlining.

The cause: my demo database had inherited compatibility level 130 from the instance’s model database. Scalar UDF inlining is part of the Intelligent Query Processing family and requires compatibility level 150 or higher[1]. The feature does not warn you; the function simply executes the old way. You can check whether SQL Server considers a function inlineable, and whether your database will actually do it:

inline_type = 1 plus compatibility level 150+ gets you inlining. Either one missing gets you 2017 performance in a 2025 instance. If you are carrying old compatibility levels around, I wrote about upgrading compatibility levels across a whole instance, and this feature is one of the better reasons to do it.

So which one should you write?

Inlining is impressive, and the numbers above show it working almost as well as the hand-written iTVF. But I still reach for the inline TVF when the logic is destined for set-based use, for three reasons:

  • Inlining has a long list of disqualifiers. GETDATE() and other time-dependent intrinsics, security functions, table variable access, recursion, WITH SCHEMABINDING in some shapes, and more[1]. Functions drift over time; one innocent edit can silently turn inlining off, and nothing tells you. An iTVF cannot lose a property it never depended on.
  • The iTVF is honest about being a query. The Froid framework behind inlining[2] works by translating imperative function bodies into relational expressions. An iTVF skips the translation because you wrote the relational expression yourself.
  • It works everywhere. Compatibility level 100 or 170, Standard or Enterprise, the iTVF behaves the same.

The CROSS APPLY calling pattern takes a day to get used to, and then it is simply how you write it. If the apply operator itself is unfamiliar territory, I recently dug into one of its more expensive side effects in finding eager index spools in the plan cache.

For a deeper practical treatment of both function types, Erik Darling has excellent dedicated lessons on scalar UDFs[3] and inline table-valued functions[4], both with video.

Clean up when you are done:

Have a scalar UDF horror story, or a function that mysteriously refuses to inline? I would love to hear about it in the comments, or find me on Bluesky or LinkedIn.

References

  1. Scalar UDF Inlining – Microsoft Learn. Requirements, the full list of inlining disqualifiers, and the inline_type / WITH INLINE controls.
  2. Froid: Optimization of Imperative Programs in a Relational Database – Ramachandra et al., PVLDB Vol. 11. The research behind scalar UDF inlining: how imperative function bodies are translated into relational algebra.
  3. Learn T-SQL With Erik: Scalar UDFs – Erik Darling, erikdarling.com. Why scalar UDFs hurt, demonstrated, with video.
  4. Learn T-SQL With Erik: Inline Table Valued Functions – Erik Darling, erikdarling.com. The iTVF pattern done properly.