skip to content

A query that joins against a set-returning (table-valued) function performs far worse than the equivalent query written against base tables. What is going on inside the optimizer, and what are your options?

level: seniorimportance: should knowfreq 34%

answer

  1. black box: no histograms, fixed default row guess
  2. 1000 rows (PG) / 1 or 100 (SQL Server MSTVF)
  3. inline single-statement = planner sees real tables
  4. multi-statement = materialize, no pushdown
  5. fix: inline, declare rows, or temp table + analyze

basics

~20 s

The optimizer usually cannot see inside the function, so it uses a fixed default row estimate instead of statistics. A wrong cardinality picks the wrong join order and join method — typically nested loops over millions of rows. Fixes: get the function inlined, declare a row estimate, or materialize its output into a temp table with statistics.

solid answer

~60 s

The function is a **black box** to the planner. It has no histograms, no distinct counts, no correlation with the join key. Most engines therefore substitute a hard-coded guess — historically 1000 rows in PostgreSQL, 1 or 100 in older SQL Server multi-statement table-valued functions. If reality is 2 million rows, the plan built on that guess is catastrophically wrong: nested loops where a hash join belonged, no parallelism, memory grants sized for nothing. A second effect compounds it: a **multi-statement** function must run to completion and materialize, so it cannot be merged into the surrounding query, and predicates cannot be pushed into it. A **single-statement/inline** function can often be *inlined* — textually substituted — after which the planner sees real tables and real statistics and the problem disappears. Options, in order: rewrite the function so it is inlinable (one SQL statement, no procedural body); declare an explicit row estimate or cost if the engine supports it; materialize into a temporary table, analyze it, then join; or drop the function and inline the query.

code

text · 6 lines
text
Nested Loop  (cost=0.29..8451.02 rows=1000 width=48)
              (actual time=0.05..91442.7 rows=2118904 loops=1)
  ->  Function Scan on expand_ids  (cost=0.25..10.25 rows=1000 width=8)
                                   (actual time=0.02..812.4 rows=2118904 loops=1)
  ->  Index Scan using orders_pkey on orders
        (cost=0.29..8.41 rows=1 width=40) (actual rows=1 loops=2118904)

go deeper

for a junior

Know that the database does not have statistics for a function's output and therefore guesses, which can produce a bad plan.

for a middle

Name the default-estimate behaviour and the inline-versus-multi-statement distinction, and that a wrong estimate flips join method and order.

for a senior

Diagnose from estimated-versus-actual rows at the function node, then choose between inlining, declaring an estimate, and materializing with statistics — and justify the choice.

for a principal

Argue about where the abstraction belongs at all: views and inlinable functions preserve optimizability, procedural table functions create planner opacity that no amount of tuning fully removes.

## Where the plan goes wrong Cost-based optimization is estimation-driven: the planner predicts how many rows each operator produces and picks join order, join algorithms, and memory from those predictions. For a base table it has statistics — row counts, histograms, distinct counts, null fractions. For a table-valued function it usually has *nothing*, because the body is opaque at planning time. So it guesses with a constant. PostgreSQL's default is 1000 rows for a set-returning function without an explicit row estimate. SQL Server's multi-statement table-valued functions were fixed at 1 row before 2014 and 100 afterwards. Oracle's pipelined table functions default off a block-size-derived guess. All of these are arbitrary relative to your data. A cardinality error of three orders of magnitude does not degrade a plan gracefully; it inverts it. The planner sees "1000 rows" and chooses a nested loop with an index lookup on the other side — excellent for 1000 iterations, ruinous for 2 million. It sizes hash tables and sort memory for the guess, so the real data spills to disk. It may decline parallelism because the estimated work looks trivial. And because the error is at the leaf, it propagates upward through every join above it. ## Inline versus multi-statement The single most important distinction is whether the function can be **inlined** into the calling query. - An **inline** function is one SQL statement with no procedural control flow. Engines can substitute its text into the outer query, after which it is just a subquery: predicates push down, joins reorder freely, and the planner sees the underlying tables' statistics. Effectively the function disappears as a planning obstacle. - A **multi-statement** function has a procedural body building a result table. It must be executed and materialized before the outer query can use its rows. No pushdown, no reordering into it, and the only cardinality information is whatever default or declaration exists. This is why the same logic can be fast as a one-statement function and pathological as a procedural one. SQL Server 2019's *scalar UDF inlining* extended a comparable idea to scalar functions, which previously forced row-by-row invocation and blocked parallelism entirely — one of the biggest single-feature plan improvements in that engine's history. ## Scalar functions in predicates A related failure: `WHERE f(col) = 42`. Unless there is an expression index on `f(col)` and the function is deterministic, the engine cannot use an index on `col`, so it evaluates `f` for every row — a full scan plus a per-row function call. The fix is to make the predicate sargable: index the expression, restructure to `col = inverse(42)`, or store the computed value in a generated column and index that. ## The remedies, in order of preference 1. **Make it inlinable.** Collapse the body to a single SQL statement. This is usually the largest win and costs nothing at runtime. 2. **Tell the planner the truth.** Several engines accept an explicit row-count and per-call cost declaration on the routine; some support a callback that computes the estimate from the arguments. A static estimate that is roughly right beats a default that is wildly wrong. 3. **Materialize with statistics.** Write the function's output into a temporary table, gather statistics on it, then join against that. You pay writes and an analyze, but the planner now has real numbers — the standard technique for a multi-step ETL pipeline where one intermediate result feeds several later joins. 4. **Use an engine feature for late estimation.** Interleaved execution / adaptive plans re-plan the consumer after the function's actual cardinality is known. Where available this repairs the estimate automatically, but it does not exist everywhere and only helps at specific plan boundaries. 5. **Delete the abstraction.** If the function exists only for tidiness and its logic is a join, put the join in the query. A view is often the better encapsulation, because a view is expanded and optimized as part of the outer query rather than being a black box. ## Diagnosing it Run the plan with actual row counts and look at the function node: a large ratio between estimated and actual rows at that leaf, with the damage visible above it as a nested loop executing millions of times or a spilling hash. That estimate-versus-actual gap at the function is the fingerprint; everything above it is collateral damage.

  • Why does turning a multi-statement table function into a single-statement one often fix the plan outright?
    A single-statement function can be inlined into the calling query, so the planner replaces the function reference with its underlying query text and then sees the real tables and their statistics. Predicates from the outer query can be pushed into it and joins can be reordered across the boundary, both of which are impossible when the body must be executed and materialized first. The cardinality guess disappears because there is no longer an opaque node to guess about.
  • When is materializing into a temporary table the right answer rather than a workaround?
    When the intermediate result is consumed more than once, or when the function's output is genuinely expensive to recompute and its cardinality is data-dependent enough that no static estimate is honest. Materializing lets you gather real statistics and even index the intermediate, which is exactly the pattern multi-step ETL relies on. The costs are the write, the analyze, and the loss of any pipelining, so it is a trade rather than a free fix.

Planning around a table function is like routing a delivery when one stop's parcel count is written as "about 1000" on every job sheet, whatever the truth. Everything downstream is scheduled from that fiction.

saying these in an interview costs you the question

  • Blaming "the function is slow" without checking whether the function's own runtime or the resulting join plan is the cost.
  • Assuming the optimizer has statistics on a function's output.
  • Believing all table-valued functions behave identically — inline versus multi-statement is the decisive difference.
  • Adding indexes to base tables to fix a plan whose root cause is a leaf cardinality error.
  • Thinking a scalar function in a WHERE clause can use an ordinary index on the underlying column.

context