CROSS APPLY and OUTER APPLY
Run a query once per row of another: CROSS APPLY and OUTER APPLY, why a join cannot do it, top N per row, naming calculations, splitting strings with STRING_SPLIT, and unpivoting with VALUES.
APPLY runs a table expression once for each row on its left, and that expression can use the left row’s columns:
SELECT c.Name AS Category, t.Name, t.PriceFROM dbo.Categories AS cCROSS APPLY ( SELECT TOP (2) p.Name, p.Price FROM dbo.Products AS p WHERE p.CategoryId = c.CategoryId ORDER BY p.Price DESC) AS tORDER BY c.CategoryId, t.Price DESC;Category Name Price -------- ------------------- ----- Tools Cordless drill 89.00 Tools Claw hammer 18.50 Building Cement 25 kg 7.20 Building Sand 25 kg 4.10 Timber Plywood sheet 18 mm 42.00 Timber Timber 2x4 2.4 m 6.80 Fixings Wood screws (200) 11.90 Fixings Wall plugs (100) 4.50 Paint Masonry paint 10 L 54.00 Paint Paint roller set 12.50 Safety Safety goggles 6.80
OUTER APPLY keeps every left row
OUTER APPLY is to CROSS APPLY what LEFT JOIN is to INNER JOIN: a left row whose inner query returns nothing is kept, with NULLs:
SELECT c.Name AS Category, t.Name, t.PriceFROM dbo.Categories AS cOUTER APPLY ( SELECT TOP (1) p.Name, p.Price FROM dbo.Products AS p WHERE p.CategoryId = c.CategoryId ORDER BY p.Price DESC) AS tORDER BY c.CategoryId;Category Name Price -------- ------------------- ----- Tools Cordless drill 89.00 Building Cement 25 kg 7.20 Timber Plywood sheet 18 mm 42.00 Fixings Wood screws (200) 11.90 Paint Masonry paint 10 L 54.00 Safety Safety goggles 6.80 Plumbing NULL NULL
Why a join cannot do this
SELECT c.Name AS Category, t.Name, t.PriceFROM dbo.Categories AS cJOIN ( SELECT TOP (2) p.Name, p.Price, p.CategoryId FROM dbo.Products AS p WHERE p.CategoryId = c.CategoryId ORDER BY p.Price DESC) AS t ON t.CategoryId = c.CategoryId;Msg 4104, Level 16, State 1, Line 6 The multi-part identifier "c.CategoryId" could not be bound.
Each customer’s latest order
SELECT c.FirstName, lo.OrderId, lo.OrderDate, lo.StatusFROM dbo.Customers AS cOUTER APPLY ( SELECT TOP (1) o.OrderId, o.OrderDate, o.Status FROM dbo.Orders AS o WHERE o.CustomerId = c.CustomerId ORDER BY o.OrderDate DESC) AS loORDER BY c.CustomerId;FirstName OrderId OrderDate Status --------- ------- ---------- --------- Asha 3 2024-03-01 Shipped Ben 9 2024-06-01 Pending Chloe 6 2024-04-20 Cancelled Dev 7 2024-05-05 Shipped Ella NULL NULL NULL Farid 10 2024-06-12 Shipped Grace NULL NULL NULL Zoë 8 2024-05-18 Pending
Naming a calculation
SELECT (no FROM) gives a calculation a name that every later clause can use, so you write it only once:
SELECT oi.OrderId, oi.ProductId, v.Net, g.Vat, g.GrossFROM dbo.OrderItems AS oiCROSS APPLY (SELECT oi.Quantity * oi.UnitPrice AS Net) AS vCROSS APPLY (SELECT CAST(v.Net * 0.20 AS decimal(10,2)) AS Vat, CAST(v.Net * 1.20 AS decimal(10,2)) AS Gross) AS gWHERE g.Gross > 200ORDER BY g.Gross DESC;OrderId ProductId Net Vat Gross
------- --------- ------ ----- ------
4 6 272.00 54.40 326.40
4 7 252.00 50.40 302.40v.Net from the first: each APPLY can see everything to its left. This costs nothing: the optimiser folds the expressions into the query as if you had written them out.
APPLY with a table-valued function: STRING_SPLIT
STRING_SPLIT(text, separator) is one built into SQL Server: it returns one row per piece in a column called value:
CREATE TABLE #Tags (ProductId int, Tags nvarchar(100));INSERT INTO #Tags VALUES (1, N'hand tool,steel'), (2, N'power tool,cordless,18V'), (6, N'timber'); SELECT t.ProductId, p.Name, s.value AS TagFROM #Tags AS tJOIN dbo.Products AS p ON p.ProductId = t.ProductIdCROSS APPLY STRING_SPLIT(t.Tags, N',') AS sORDER BY t.ProductId, s.value;ProductId Name Tag
--------- ---------------- ----------
1 Claw hammer hand tool
1 Claw hammer steel
2 Cordless drill 18V
2 Cordless drill cordless
2 Cordless drill power tool
6 Timber 2x4 2.4 m timberUnpivoting with VALUES
CROSS APPLY (VALUES …) turns columns into rows and, unlike UNPIVOT, keeps NULLs (here filtered out on purpose). One row per way to reach each customer:
SELECT c.FirstName, v.Method, v.ContactFROM dbo.Customers AS cCROSS APPLY (VALUES ('Email', CAST(c.Email AS nvarchar(100))), ('Phone', CAST(c.Phone AS nvarchar(100)))) AS v(Method, Contact)WHERE v.Contact IS NOT NULL AND c.CustomerId <= 4ORDER BY c.CustomerId, v.Method;FirstName Method Contact --------- ------ ----------------- Asha Email asha@example.com Asha Phone 0113 496 0001 Ben Phone 01904 555 010 Chloe Email chloe@example.com Dev Email dev@example.com Dev Phone 01274 555 220
Show a hint
Show the solution
SELECT p.Name, lo.OrderId, lo.OrderDateFROM dbo.Products AS pOUTER APPLY ( SELECT TOP (1) o.OrderId, o.OrderDate FROM dbo.OrderItems AS oi JOIN dbo.Orders AS o ON o.OrderId = oi.OrderId WHERE oi.ProductId = p.ProductId ORDER BY o.OrderDate DESC) AS loWHERE p.CategoryId IN (1, 3)ORDER BY p.ProductId;Name OrderId OrderDate ------------------- ------- ---------- Claw hammer 8 2024-05-18 Cordless drill 3 2024-03-01 Tape measure 8 m 10 2024-06-12 Timber 2x4 2.4 m 10 2024-06-12 Plywood sheet 18 mm 8 2024-05-18
Remember
APPLY runs its right side once per left row, and that side can use the left row’s columns. CROSS APPLY drops left rows with no result; OUTER APPLY keeps them with NULLs. A derived table in a JOIN cannot see the outer columns: that is what APPLY is for. TOP (n) … ORDER BY inside APPLY gives the top N per row, with as many columns as you like. CROSS APPLY (SELECT expr AS name) names a calculation once for SELECT, WHERE and ORDER BY. APPLY feeds the left row into table-valued functions such as STRING_SPLIT. CROSS APPLY (VALUES …) unpivots columns into rows and keeps NULLs.