CROSS APPLY and OUTER APPLY in SQL Server, with practical examples
What APPLY does that a JOIN can't: top N rows per group, calling table-valued functions per row, unpivoting columns and reusing calculations. With reads compared against ROW_NUMBER.
APPLY runs a table expression once for each row of the table on its left, and the expression can use that row’s columns. A JOIN can’t do that: both sides of a join are independent, and they meet only in the ON clause.
CROSS APPLYkeeps the left row only if the right side returns at least one row, like an inner join.OUTER APPLYkeeps every left row, with NULLs when the right side returns nothing, like a left join.
The examples run against the SpDevDemo database.
Top N rows per group
The classic use: each customer’s three most recent orders.
SELECT c.CustomerId, c.Name, o.OrderId, o.OrderDate, o.Total
FROM dbo.Customers AS c
CROSS APPLY (
SELECT TOP (3) OrderId, OrderDate, Total
FROM dbo.Orders
WHERE CustomerId = c.CustomerId -- uses the outer row: not possible in a JOIN
ORDER BY OrderDate DESC, OrderId DESC
) AS o
WHERE c.Country = 'PK' AND c.CustomerId <= 20;
For each Pakistani customer among the first 20, the inner query runs with that customer’s id and returns at most three orders. A customer without orders disappears; with OUTER APPLY, they’d appear once with NULL order columns.
APPLY or ROW_NUMBER?
The same result with a window function:
WITH ranked AS (
SELECT CustomerId, OrderId, OrderDate, Total,
ROW_NUMBER() OVER (PARTITION BY CustomerId ORDER BY OrderDate DESC, OrderId DESC) AS rn
FROM dbo.Orders
)
SELECT c.CustomerId, c.Name, r.OrderId, r.OrderDate, r.Total
FROM dbo.Customers AS c
JOIN ranked AS r ON r.CustomerId = c.CustomerId AND r.rn <= 3
WHERE c.Country = 'PK' AND c.CustomerId <= 20;
Which is faster depends on how many groups you want. Give both an index that matches the inner query, then compare them for a few customers chosen through a join, the German customers who signed up in four days of March 2026:
CREATE INDEX IX_Orders_Customer_Date ON dbo.Orders (CustomerId, OrderDate DESC, OrderId DESC) INCLUDE (Total);
SET STATISTICS IO ON;
SELECT c.CustomerId, o.OrderId, o.Total
FROM dbo.Customers AS c
CROSS APPLY (SELECT TOP (3) OrderId, Total FROM dbo.Orders
WHERE CustomerId = c.CustomerId ORDER BY OrderDate DESC, OrderId DESC) AS o
WHERE c.Country = 'DE' AND c.CreatedAt >= '2026-03-01' AND c.CreatedAt < '2026-03-05';
WITH ranked AS (
SELECT CustomerId, OrderId, Total,
ROW_NUMBER() OVER (PARTITION BY CustomerId ORDER BY OrderDate DESC, OrderId DESC) AS rn
FROM dbo.Orders
)
SELECT c.CustomerId, r.OrderId, r.Total
FROM dbo.Customers AS c
JOIN ranked AS r ON r.CustomerId = c.CustomerId AND r.rn <= 3
WHERE c.Country = 'DE' AND c.CreatedAt >= '2026-03-01' AND c.CreatedAt < '2026-03-05';
SET STATISTICS IO OFF;
-- CROSS APPLY: one seek per customer, 14 customers
Table 'Orders'. Scan count 14, logical reads 42
-- ROW_NUMBER: numbers every order of every customer, then keeps 3 each for the 14
Table 'Orders'. Scan count 1, logical reads 655
Now ask for the latest three orders of all 20,000 customers, and it reverses:
-- CROSS APPLY: 20,000 seeks
Table 'Orders'. Scan count 20000, logical reads 63978
-- ROW_NUMBER: one ordered scan of the index
Table 'Orders'. Scan count 1, logical reads 655
The rule: APPLY wins when you want a few groups out of many, and the window function wins when you want most of them. When the filter is on the partitioning column itself (WHERE CustomerId = 1), SQL Server pushes it into the window query and both read only what they need; the difference appears when the groups are chosen through a join, as here.
Calling a table-valued function per row
APPLY is how you pass a column to a table-valued function:
CREATE OR ALTER FUNCTION dbo.OrderLinesOf (@OrderId INT)
RETURNS TABLE
AS
RETURN
SELECT l.ProductId, p.Name, l.Quantity, l.Quantity * l.UnitPrice AS LineTotal
FROM dbo.OrderLines AS l
JOIN dbo.Products AS p ON p.ProductId = l.ProductId
WHERE l.OrderId = @OrderId;
GO
SELECT o.OrderNumber, x.Name, x.Quantity, x.LineTotal
FROM dbo.Orders AS o
CROSS APPLY dbo.OrderLinesOf(o.OrderId) AS x
WHERE o.OrderId BETWEEN 1 AND 3;
An inline function like this one is expanded into the query, so this costs the same as writing the join yourself.
Unpivoting columns into rows
CROSS APPLY with a VALUES list turns columns into rows, more flexibly than UNPIVOT (it keeps NULLs and handles several columns at once):
SELECT c.CustomerId, v.Attribute, v.Value
FROM dbo.Customers AS c
CROSS APPLY (VALUES
(N'Name', c.Name),
(N'Email', CAST(c.Email AS NVARCHAR(200))),
(N'Country', CAST(c.Country AS NVARCHAR(200)))
) AS v (Attribute, Value)
WHERE c.CustomerId = 42;
CustomerId Attribute Value
---------- --------- ----------------------
42 Name Customer 42
42 Email [email protected]
42 Country DE
Naming a calculation once
CROSS APPLY with a single-row SELECT gives a calculation a name you can use in WHERE, ORDER BY and later calculations, without repeating the expression:
SELECT TOP (5) o.OrderId, t.Net, t.Tax, t.Net + t.Tax AS Gross
FROM dbo.Orders AS o
JOIN dbo.Customers AS c ON c.CustomerId = o.CustomerId
CROSS APPLY (SELECT o.Total AS Net,
o.Total * CASE c.Country WHEN 'GB' THEN 0.20 WHEN 'DE' THEN 0.19 ELSE 0 END AS Tax) AS t
WHERE t.Tax > 500
ORDER BY t.Tax DESC;
The optimizer folds it into the query: there is no per-row cost.
OUTER APPLY: keep the rows with nothing on the right
The latest order of each customer, including customers who have none:
SELECT TOP (3) c.CustomerId, c.Name, last_order.OrderDate
FROM dbo.Customers AS c
OUTER APPLY (
SELECT TOP (1) OrderDate FROM dbo.Orders
WHERE CustomerId = c.CustomerId
ORDER BY OrderDate DESC
) AS last_order
ORDER BY c.CustomerId;
Things to know
APPLYis T-SQL; the SQL standard’s equivalent isLATERAL, which SQL Server doesn’t support.- With a multi-statement table-valued function on the right, the function really runs once per row. With an inline function or a subquery, it’s optimized as one query.
- Without an index that supports the inner query, “once per row” means a scan per row. Check the plan’s executions on the inner side.
SpDev Pro reports operators that run thousands of times inside a profiled procedure. An APPLY whose inner side scans a table once per outer row is one of the patterns it shows, with the number of executions and the reads they cost.