Loading learning content...
While scalar functions transform individual values, table-valued functions (TVFs) return entire result sets—virtual tables that can be queried, joined, and filtered just like physical tables or views. This capability fundamentally changes what functions can accomplish in SQL.
Consider the difference: a scalar function GetEmployeeAge(employee_id) returns a single integer for one employee. A table-valued function GetEmployeesByDepartment(dept_id) returns a complete result set—potentially hundreds of rows with multiple columns—that you can SELECT from, JOIN with other tables, or filter with WHERE clauses.
TVFs bridge the gap between procedural and set-based thinking. They allow you to encapsulate complex query logic, parameterized data retrieval, and sophisticated transformations while maintaining SQL's powerful set-based semantics. When designed correctly, TVFs enable code reuse without sacrificing performance—a combination that's notoriously difficult to achieve in database programming.
By the end of this page, you will understand the two types of table-valued functions: inline TVFs and multi-statement TVFs. You'll master when to use each type, learn critical performance implications, and develop the skills to design TVFs that the query optimizer can efficiently integrate into complex query plans.
A table-valued function returns a result set that appears to the caller as a table. Unlike scalar functions that return a single value and can appear in SELECT lists or WHERE conditions, TVFs appear in the FROM clause of queries—exactly where you would reference a table or view.
This fundamental difference means TVFs participate in query planning as data sources rather than row-by-row computations. The optimizer can apply predicates to them, join them with other tables, and process their results through the full query execution machinery.
1234567891011121314151617181920212223242526272829303132
-- Table-valued functions appear in FROM clause -- Basic TVF invocationSELECT * FROM dbo.GetEmployeesByDepartment(10); -- TVF with aliasSELECT e.EmployeeId, e.FullName, e.SalaryFROM dbo.GetEmployeesByDepartment(10) AS eWHERE e.Salary > 50000; -- TVF in JOIN operationsSELECT d.DepartmentName, e.EmployeeId, e.FullNameFROM Departments dCROSS APPLY dbo.GetEmployeesByDepartment(d.DepartmentId) AS eWHERE d.IsActive = 1; -- CROSS APPLY executes the TVF for each row from Departments-- Each execution receives that row's DepartmentId as parameter -- OUTER APPLY preserves rows even when TVF returns emptySELECT d.DepartmentName, ISNULL(e.EmployeeCount, 0) AS EmployeeCountFROM Departments dOUTER APPLY ( SELECT COUNT(*) AS EmployeeCount FROM dbo.GetEmployeesByDepartment(d.DepartmentId)) AS e;Inline table-valued functions (iTVFs) are the performance champions of the TVF world. An inline TVF contains a single SELECT statement, and the query optimizer expands this SELECT directly into the calling query's execution plan. This inline expansion enables the optimizer to apply all its normal optimization techniques—predicate pushdown, join reordering, index selection—across both the TVF logic and the enclosing query.
Think of inline TVFs as parameterized views. Views encapsulate a SELECT statement that gets inlined into queries that reference them. Inline TVFs do the same thing but accept parameters, enabling runtime customization that static views cannot provide.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
-- SQL Server: Inline Table-Valued Function SyntaxCREATE OR ALTER FUNCTION dbo.GetOrdersByDateRange( @StartDate DATE, @EndDate DATE)RETURNS TABLE -- No column definition = inline TVFASRETURN( -- Single SELECT statement defines both structure and logic SELECT o.OrderId, o.CustomerId, c.CustomerName, o.OrderDate, o.TotalAmount, o.Status FROM Orders o INNER JOIN Customers c ON o.CustomerId = c.CustomerId WHERE o.OrderDate >= @StartDate AND o.OrderDate <= @EndDate);GO -- Usage: optimizer inlines this into the calling querySELECT * FROM dbo.GetOrdersByDateRange('2024-01-01', '2024-03-31')WHERE TotalAmount > 1000ORDER BY OrderDate; -- The optimizer sees and optimizes the complete query:-- SELECT ... FROM Orders o JOIN Customers c ... WHERE date range AND TotalAmount > 1000 -- Multiple parameters with defaultsCREATE FUNCTION dbo.SearchProducts( @CategoryId INT = NULL, @MinPrice DECIMAL(10,2) = NULL, @MaxPrice DECIMAL(10,2) = NULL, @SearchTerm NVARCHAR(100) = NULL)RETURNS TABLEASRETURN( SELECT ProductId, ProductName, CategoryId, Price, Description, StockQuantity FROM Products WHERE (@CategoryId IS NULL OR CategoryId = @CategoryId) AND (@MinPrice IS NULL OR Price >= @MinPrice) AND (@MaxPrice IS NULL OR Price <= @MaxPrice) AND (@SearchTerm IS NULL OR ProductName LIKE '%' + @SearchTerm + '%')); -- Dynamic filtering at call timeSELECT * FROM dbo.SearchProducts(@CategoryId = 5, @MinPrice = 10);SELECT * FROM dbo.SearchProducts(@SearchTerm = 'Widget');When you call an inline TVF, the optimizer substitutes the function's SELECT statement into the calling query BEFORE optimization. This means predicates from the outer query can be pushed into the TVF, indexes can be chosen based on the complete picture, and join strategies consider all tables. This is fundamentally different from multi-statement TVFs. Always prefer inline TVFs when a single SELECT can express your logic.
Multi-statement table-valued functions (msTVFs) provide procedural flexibility when a single SELECT cannot express the required logic. They declare a table variable, populate it through multiple statements (including loops, conditionals, and multiple INSERTs), and return the populated table.
However, this procedural power comes at a severe cost: the query optimizer treats msTVFs as black boxes. It cannot see inside, cannot push predicates into them, and must use fixed cardinality estimates (often 1 row in older SQL Server versions, 100 rows in newer versions). This frequently leads to suboptimal query plans.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
-- SQL Server: Multi-Statement TVF SyntaxCREATE OR ALTER FUNCTION dbo.GetHierarchyPath( @EmployeeId INT)RETURNS @Result TABLE -- Named table variable = multi-statement TVF( Level INT, EmployeeId INT, EmployeeName NVARCHAR(100), ManagerId INT, Path NVARCHAR(MAX))ASBEGIN -- Complex logic requiring multiple statements DECLARE @CurrentId INT = @EmployeeId; DECLARE @Level INT = 0; DECLARE @Path NVARCHAR(MAX) = ''; -- Walk up the hierarchy WHILE @CurrentId IS NOT NULL BEGIN INSERT INTO @Result (Level, EmployeeId, EmployeeName, ManagerId, Path) SELECT @Level, EmployeeId, EmployeeName, ManagerId, @Path FROM Employees WHERE EmployeeId = @CurrentId; -- Move to parent SELECT @Path = EmployeeName + CASE WHEN @Path = '' THEN '' ELSE ' -> ' END + @Path, @CurrentId = ManagerId FROM Employees WHERE EmployeeId = @CurrentId; SET @Level = @Level + 1; -- Safety limit IF @Level > 50 BREAK; END; -- Update paths now that we have complete hierarchy UPDATE @Result SET Path = (SELECT TOP 1 Path FROM @Result WHERE Level = (SELECT MAX(Level) FROM @Result)); RETURN;END;GO -- Another example: generating date rangesCREATE FUNCTION dbo.GenerateDateRange( @StartDate DATE, @EndDate DATE)RETURNS @Dates TABLE( DateValue DATE PRIMARY KEY, DayOfWeek INT, DayName VARCHAR(10), WeekNumber INT, MonthName VARCHAR(10), IsWeekend BIT, IsHoliday BIT -- Could be populated from holiday table)ASBEGIN DECLARE @Current DATE = @StartDate; WHILE @Current <= @EndDate BEGIN INSERT INTO @Dates (DateValue, DayOfWeek, DayName, WeekNumber, MonthName, IsWeekend, IsHoliday) VALUES ( @Current, DATEPART(WEEKDAY, @Current), DATENAME(WEEKDAY, @Current), DATEPART(WEEK, @Current), DATENAME(MONTH, @Current), CASE WHEN DATEPART(WEEKDAY, @Current) IN (1, 7) THEN 1 ELSE 0 END, 0 -- Default, could lookup from holiday table ); SET @Current = DATEADD(DAY, 1, @Current); END; -- Mark holidays from lookup table UPDATE d SET IsHoliday = 1 FROM @Dates d INNER JOIN Holidays h ON d.DateValue = h.HolidayDate; RETURN;END;Multi-statement TVFs are optimization barriers. The query optimizer: (1) Cannot push WHERE predicates into the TVF, (2) Uses fixed row estimates causing join strategy errors, (3) Cannot consider indexes within the TVF logic, (4) Must execute the entire TVF before the outer query can process rows. Use msTVFs only when inline TVFs or CTEs cannot express the logic.
| Characteristic | Inline TVF | Multi-Statement TVF |
|---|---|---|
| Syntax indicator | RETURNS TABLE (no columns) | RETURNS @var TABLE (columns defined) |
| Body structure | Single RETURN (SELECT...) | BEGIN...END with multiple statements |
| Optimization | Inlined into calling query | Black box; optimizer cannot see inside |
| Predicate pushdown | Yes | No |
| Cardinality estimates | Based on underlying tables | Fixed estimate (1 or 100 rows) |
| Parallel execution | Fully parallelizable | Serial only |
| Use when | Single SELECT suffices | Loop/conditional logic required |
The APPLY operator is the gateway to TVF power. APPLY executes a TVF once for each row from the left table, passing column values as parameters to the TVF. This row-by-row correlation enables scenarios that are awkward or impossible with standard JOINs.
Two forms exist: CROSS APPLY (equivalent to INNER JOIN semantics—omits left rows when TVF returns empty) and OUTER APPLY (equivalent to LEFT JOIN semantics—preserves left rows with NULL for TVF columns when empty).
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
-- Setup: Inline TVF for demonstrationCREATE FUNCTION dbo.GetTopOrdersForCustomer( @CustomerId INT, @TopN INT = 5)RETURNS TABLEASRETURN( SELECT TOP (@TopN) OrderId, OrderDate, TotalAmount, Status FROM Orders WHERE CustomerId = @CustomerId ORDER BY TotalAmount DESC);GO -- CROSS APPLY: Get top 3 orders for each active customerSELECT c.CustomerId, c.CustomerName, o.OrderId, o.OrderDate, o.TotalAmountFROM Customers cCROSS APPLY dbo.GetTopOrdersForCustomer(c.CustomerId, 3) AS oWHERE c.IsActive = 1; -- Customers with no orders are EXCLUDED from results (INNER join behavior) -- OUTER APPLY: Include customers even with no ordersSELECT c.CustomerId, c.CustomerName, o.OrderId, o.OrderDate, ISNULL(o.TotalAmount, 0) AS TotalAmountFROM Customers cOUTER APPLY dbo.GetTopOrdersForCustomer(c.CustomerId, 3) AS oWHERE c.IsActive = 1; -- Customers with no orders have NULL in order columns (LEFT join behavior) -- APPLY with inline subquery (no separate TVF needed)SELECT d.DepartmentId, d.DepartmentName, empStats.EmployeeCount, empStats.AvgSalary, empStats.MaxSalaryFROM Departments dCROSS APPLY ( SELECT COUNT(*) AS EmployeeCount, AVG(Salary) AS AvgSalary, MAX(Salary) AS MaxSalary FROM Employees e WHERE e.DepartmentId = d.DepartmentId) AS empStats; -- Powerful pattern: APPLY for "TOP N per group"-- Get latest 3 orders per customer (a classic problem)SELECT c.CustomerId, c.CustomerName, o.OrderId, o.OrderDate, o.TotalAmountFROM Customers cCROSS APPLY ( SELECT TOP 3 OrderId, OrderDate, TotalAmount FROM Orders WHERE CustomerId = c.CustomerId ORDER BY OrderDate DESC) AS o;Use APPLY when: (1) Calling a TVF that needs values from the left table, (2) Implementing 'TOP N per group' queries, (3) Parsing complex columns (JSON, XML, comma-separated values), (4) Correlating to a subquery that's simpler expressed inline. Use JOIN for static relationships between tables without row-by-row customization.
PostgreSQL uses different terminology: set-returning functions (SRFs) produce multiple rows. The most common pattern uses RETURNS TABLE or RETURNS SETOF. PostgreSQL automatically integrates SRFs into query plans without the inline vs multi-statement distinction that SQL Server requires.
PostgreSQL's approach is generally simpler: you write a function that returns a set, and the optimizer handles it based on the function's volatility and implementation language.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
-- PostgreSQL: RETURNS TABLE syntaxCREATE OR REPLACE FUNCTION get_orders_by_status(order_status TEXT)RETURNS TABLE ( order_id INTEGER, customer_id INTEGER, order_date DATE, total_amount NUMERIC)LANGUAGE SQLSTABLE -- Same result within transactionAS $$ SELECT order_id, customer_id, order_date, total_amount FROM orders WHERE status = order_status;$$; -- UsageSELECT * FROM get_orders_by_status('PENDING'); -- RETURNS SETOF for existing typeCREATE OR REPLACE FUNCTION active_employees()RETURNS SETOF employees -- Returns rows matching employees table structureLANGUAGE SQLSTABLEAS $$ SELECT * FROM employees WHERE is_active = true;$$; -- Usage with lateral join (PostgreSQL equivalent of APPLY)SELECT d.department_id, d.department_name, e.employee_name, e.salaryFROM departments dCROSS JOIN LATERAL get_employees_by_dept(d.department_id) AS e; -- LEFT JOIN LATERAL preserves departments with no employeesSELECT d.department_id, d.department_name, e.employee_nameFROM departments dLEFT JOIN LATERAL get_employees_by_dept(d.department_id) AS e ON true; -- PL/pgSQL with procedural logic (like multi-statement TVF)CREATE OR REPLACE FUNCTION generate_date_series( start_date DATE, end_date DATE)RETURNS TABLE ( date_value DATE, day_name TEXT, is_weekend BOOLEAN)LANGUAGE plpgsqlSTABLEAS $$DECLARE current_date DATE := start_date;BEGIN WHILE current_date <= end_date LOOP date_value := current_date; day_name := TO_CHAR(current_date, 'Day'); is_weekend := EXTRACT(DOW FROM current_date) IN (0, 6); RETURN NEXT; -- Yield this row to the result set current_date := current_date + INTERVAL '1 day'; END LOOP; RETURN;END;$$; -- Simpler using generate_series (PostgreSQL built-in SRF)SELECT d::DATE AS date_value, TO_CHAR(d, 'Day') AS day_name, EXTRACT(DOW FROM d) IN (0, 6) AS is_weekendFROM generate_series('2024-01-01'::DATE, '2024-12-31'::DATE, '1 day') AS d;PostgreSQL provides powerful built-in SRFs: generate_series() creates number or date sequences, unnest() expands arrays into rows, json_each/jsonb_each() expands JSON objects. These are highly optimized and often preferred over custom SRFs for common operations.
Effective TVF design requires understanding common patterns that arise in real-world database applications. These patterns, when implemented as inline TVFs where possible, provide reusable building blocks for complex queries.
123456789101112131415161718192021222324252627282930313233
-- Pattern: Parameterized View-- Use TVF when you need a view with runtime parameters -- Instead of a view that everyone must filter themselves:CREATE VIEW v_active_orders ASSELECT * FROM orders WHERE status = 'ACTIVE';-- Caller: SELECT * FROM v_active_orders WHERE region = 'EAST' -- Create a parameterized TVF:CREATE FUNCTION dbo.GetOrdersByStatusAndRegion( @Status VARCHAR(20), @Region VARCHAR(50) = NULL -- Optional parameter)RETURNS TABLEASRETURN( SELECT o.OrderId, o.CustomerId, o.OrderDate, o.TotalAmount, o.Status, o.Region FROM Orders o WHERE o.Status = @Status AND (@Region IS NULL OR o.Region = @Region)); -- Callers get exactly what they need:SELECT * FROM dbo.GetOrdersByStatusAndRegion('ACTIVE', 'EAST');SELECT * FROM dbo.GetOrdersByStatusAndRegion('PENDING', NULL); -- All regionsTVF performance optimization centers on one critical principle: enable the query optimizer to do its job. Inline TVFs naturally support this; multi-statement TVFs actively prevent it. Beyond this fundamental choice, several techniques can improve TVF performance.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
-- Analyze TVF execution plan -- Enable execution planSET STATISTICS XML ON; -- Inline TVF: Check for plan integrationSELECT o.OrderId, o.CustomerName, o.TotalAmountFROM dbo.GetOrdersByDateRange('2024-01-01', '2024-12-31') AS oWHERE o.TotalAmount > 500; -- In the execution plan:-- ✓ Inline TVF: See predicates pushed into the scan/seek-- ✗ msTVF: See Table Valued Function operator as a black box -- Check cardinality estimates vs actual-- For msTVFs, estimated rows are often 1 or 100 (fixed)-- Actual rows may be thousands, causing bad join strategies -- SQL Server 2017+: Interleaved Execution-- Optimizer runs msTVF first to get actual cardinality-- Then re-optimizes the rest of the plan -- Force interleaved execution in testingSELECT o.OrderIdFROM dbo.SomeMultiStatementTVF(10) AS tvfINNER JOIN Orders o ON tvf.OrderId = o.OrderIdOPTION (USE HINT ('ENABLE_QUERY_OPTIMIZER_HOTFIXES')); -- Convert msTVF to inline TVF (example refactoring) -- BEFORE: Multi-statement with loop (slow)CREATE FUNCTION dbo.GetDateRangeOld(@Start DATE, @End DATE)RETURNS @Dates TABLE (DateValue DATE)ASBEGIN WHILE @Start <= @End BEGIN INSERT INTO @Dates VALUES (@Start); SET @Start = DATEADD(DAY, 1, @Start); END; RETURN;END; -- AFTER: Inline using numbers table (fast)CREATE FUNCTION dbo.GetDateRangeNew(@Start DATE, @End DATE)RETURNS TABLEASRETURN( SELECT DATEADD(DAY, n.n, @Start) AS DateValue FROM ( SELECT TOP (DATEDIFF(DAY, @Start, @End) + 1) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) - 1 AS n FROM sys.objects a CROSS JOIN sys.objects b ) AS n);In production workloads, converting a frequently-called msTVF to an inline TVF often reduces query time by 90% or more. If you must use msTVFs, consider caching results in a temporary table before joining to avoid repeated execution, or use SQL Server 2017+ interleaved execution.
Let's examine production-quality TVFs that solve common business requirements. Each example demonstrates design decisions, trade-offs, and optimization considerations.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
-- =====================================================-- EXAMPLE 1: Configurable Search with Pagination-- =====================================================CREATE FUNCTION dbo.SearchOrdersAdvanced( @CustomerId INT = NULL, @Status VARCHAR(20) = NULL, @MinAmount DECIMAL(18,2) = NULL, @MaxAmount DECIMAL(18,2) = NULL, @StartDate DATE = NULL, @EndDate DATE = NULL, @SearchText NVARCHAR(100) = NULL, @PageNumber INT = 1, @PageSize INT = 25, @SortColumn VARCHAR(50) = 'OrderDate', @SortDirection VARCHAR(4) = 'DESC')RETURNS TABLEASRETURN( WITH FilteredOrders AS ( SELECT o.OrderId, o.CustomerId, c.CustomerName, o.OrderDate, o.TotalAmount, o.Status, o.Notes, COUNT(*) OVER () AS TotalCount -- Total matching rows FROM Orders o INNER JOIN Customers c ON o.CustomerId = c.CustomerId WHERE (@CustomerId IS NULL OR o.CustomerId = @CustomerId) AND (@Status IS NULL OR o.Status = @Status) AND (@MinAmount IS NULL OR o.TotalAmount >= @MinAmount) AND (@MaxAmount IS NULL OR o.TotalAmount <= @MaxAmount) AND (@StartDate IS NULL OR o.OrderDate >= @StartDate) AND (@EndDate IS NULL OR o.OrderDate <= @EndDate) AND (@SearchText IS NULL OR c.CustomerName LIKE '%' + @SearchText + '%' OR o.Notes LIKE '%' + @SearchText + '%') ), SortedOrders AS ( SELECT *, ROW_NUMBER() OVER ( ORDER BY CASE WHEN @SortColumn = 'OrderDate' AND @SortDirection = 'ASC' THEN OrderDate END, CASE WHEN @SortColumn = 'OrderDate' AND @SortDirection = 'DESC' THEN OrderDate END DESC, CASE WHEN @SortColumn = 'TotalAmount' AND @SortDirection = 'ASC' THEN TotalAmount END, CASE WHEN @SortColumn = 'TotalAmount' AND @SortDirection = 'DESC' THEN TotalAmount END DESC, CASE WHEN @SortColumn = 'CustomerName' AND @SortDirection = 'ASC' THEN CustomerName END, CASE WHEN @SortColumn = 'CustomerName' AND @SortDirection = 'DESC' THEN CustomerName END DESC, OrderId -- Stable sort tiebreaker ) AS RowNum FROM FilteredOrders ) SELECT OrderId, CustomerId, CustomerName, OrderDate, TotalAmount, Status, Notes, TotalCount, @PageNumber AS CurrentPage, CEILING(TotalCount * 1.0 / @PageSize) AS TotalPages FROM SortedOrders WHERE RowNum BETWEEN ((@PageNumber - 1) * @PageSize + 1) AND (@PageNumber * @PageSize)); -- Usage: Paginated, filtered, sorted searchSELECT * FROM dbo.SearchOrdersAdvanced( @Status = 'PENDING', @MinAmount = 100, @PageNumber = 2, @PageSize = 20, @SortColumn = 'TotalAmount', @SortDirection = 'DESC'); -- =====================================================-- EXAMPLE 2: JSON Array Expansion-- =====================================================CREATE FUNCTION dbo.ParseJsonArray(@JsonArray NVARCHAR(MAX))RETURNS TABLEASRETURN( SELECT [key] AS ArrayIndex, value AS ArrayValue, JSON_VALUE(value, '$.id') AS Id, JSON_VALUE(value, '$.name') AS Name, JSON_VALUE(value, '$.amount') AS Amount FROM OPENJSON(@JsonArray)); -- Usage: Expand JSON array into rowsSELECT ar.ArrayIndex, ar.Id, ar.Name, ar.AmountFROM dbo.ParseJsonArray('[ {"id": 1, "name": "Item A", "amount": 100}, {"id": 2, "name": "Item B", "amount": 200}, {"id": 3, "name": "Item C", "amount": 150}]') AS ar; -- =====================================================-- EXAMPLE 3: Running Balance Calculation-- =====================================================CREATE FUNCTION dbo.GetAccountStatement( @AccountId INT, @StartDate DATE, @EndDate DATE)RETURNS TABLEASRETURN( WITH Transactions AS ( SELECT TransactionId, TransactionDate, Description, CASE WHEN TransactionType = 'CREDIT' THEN Amount ELSE 0 END AS Credits, CASE WHEN TransactionType = 'DEBIT' THEN Amount ELSE 0 END AS Debits, CASE WHEN TransactionType = 'CREDIT' THEN Amount ELSE -Amount END AS NetAmount FROM AccountTransactions WHERE AccountId = @AccountId AND TransactionDate BETWEEN @StartDate AND @EndDate ) SELECT t.TransactionId, t.TransactionDate, t.Description, t.Credits, t.Debits, SUM(t.NetAmount) OVER (ORDER BY t.TransactionDate, t.TransactionId ROWS UNBOUNDED PRECEDING) AS RunningBalance FROM Transactions t); -- Usage: Generate bank-style statementSELECT * FROM dbo.GetAccountStatement(12345, '2024-01-01', '2024-01-31')ORDER BY TransactionDate, TransactionId;Table-valued functions expand what functions can accomplish in SQL by returning complete result sets. The distinction between inline and multi-statement TVFs is the most critical performance consideration in TVF development.
You now understand table-valued functions comprehensively—from inline expansion to multi-statement procedural logic. The next page explores deterministic functions and how function purity affects optimization, indexing, and result consistency.