<style> .reveal-up, .reveal-left, .reveal-right, .reveal-stagger > * { opacity: 1 !important; transform: none !important; } </style>
  1. Learn
  2. SQL
  3. Advanced querying
All lessons in this course

Lesson 33 of 54 15 minAdvanced

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.

A join combines two tables that each make sense on their own. Sometimes the right-hand side depends on the left-hand row: “the two dearest products of this category”. APPLY runs a table expression once for each row on its left, and that expression can use the left row’s columns:

Query.sql
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;
Output
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

For each category, the inner query runs with that category’s id and returns up to two rows. Each is joined to the category row. Safety has only one product, so it gets one row; Plumbing has none, so it disappears, just as with an inner join.

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:

Query.sql
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;
Output
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
Interactive

One inner query per category

TOP
Outer: dbo.Categories AS c
Inner query for Tools
SELECT TOP (2) p.Name, p.Price
FROM dbo.Products AS p
WHERE p.CategoryId = 1  -- c.CategoryId
ORDER BY p.Price DESC
NamePrice
1Cordless drill89.00
2Claw hammer18.50
2 rows: each is joined to the Tools row and added to the result (purple below).
CategoryNamePrice
1ToolsCordless drill89.00
2ToolsClaw hammer18.50
3BuildingCement 25 kg7.20
4BuildingSand 25 kg4.10
5TimberPlywood sheet 18 mm42.00
6TimberTimber 2x4 2.4 m6.80
7FixingsWood screws (200)11.90
8FixingsWall plugs (100)4.50
9PaintMasonry paint 10 L54.00
10PaintPaint roller set12.50
11SafetySafety goggles6.80
CROSS APPLY ran the inner query 7 times, once per category, and returned 11 rows. Safety has fewer than 2 products, so it gets fewer rows. Plumbing has no products and is missing. Try OUTER APPLY.

Why a join cannot do this

A derived table on the right of a JOIN is worked out on its own, before it is matched with anything. It cannot see the left table’s columns:

Query.sql
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;
Output
Msg 4104, Level 16, State 1, Line 6
The multi-part identifier "c.CategoryId" could not be bound.

Moving the filter into ON does not help either: then TOP (2) picks the two dearest products of the whole table before any matching happens. APPLY is the operator that lets the right side depend on the left row, which is called a lateral join in other databases.

Each customer’s latest order

The inner query can return several columns, which a scalar subquery in SELECT cannot. OUTER APPLY keeps the customers who have never ordered:

Query.sql
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;
Output
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

A column alias from SELECT cannot be used in the same SELECT, in WHERE or in GROUP BY. CROSS APPLY with a one-row SELECT (no FROM) gives a calculation a name that every later clause can use, so you write it only once:

Query.sql
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;
Output
OrderId  ProductId     Net    Vat   Gross
-------  ---------  ------  -----  ------
      4          6  272.00  54.40  326.40
      4          7  252.00  50.40  302.40

The second APPLY uses v.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

The right side of APPLY can be a table-valued function that takes the left row’s columns as arguments. STRING_SPLIT(text, separator) is one built into SQL Server: it returns one row per piece in a column called value:

Query.sql
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;
Output
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  timber

Unpivoting with VALUES

As the PIVOT lesson showed, 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:

Query.sql
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;
Output
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
Quick check

Categories has 7 rows. Plumbing has no products, Safety has 1, every other category has 2 or 3. How many rows does Categories CROSS APPLY (SELECT TOP (2) … WHERE p.CategoryId = c.CategoryId) return?

Quick check

Which of these can APPLY do that a JOIN to a derived table cannot?

Practice

For every product in categories 1 and 3, show the product name and the most recent order that included it (OrderId and OrderDate), or NULLs if it was never ordered. Sort by ProductId.

Show a hint

Products OUTER APPLY (SELECT TOP (1) o.OrderId, o.OrderDate FROM OrderItems JOIN Orders WHERE oi.ProductId = p.ProductId ORDER BY o.OrderDate DESC).

Show the solution
Solution.sql
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;
Output
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.
0 of 54 lessons completed