CROSS APPLY and OUTER APPLY in SQL Server
APPLY runs the right side ONCE PER ROW of the left side, and the right side can USE COLUMNS of the left row (a JOIN cannot do that).
CROSS APPLY : like INNER JOIN -> left row removed if right side returns nothing
OUTER APPLY : like LEFT JOIN -> left row kept, right columns = NULL
Common uses:
- TOP N per group
- Calling a table-valued function per row
- Splitting strings (STRING_SPLIT) / reading JSON (OPENJSON)
- Reusing calculated columns (CROSS APPLY (SELECT ... ) as alias)
- Unpivoting with VALUES
Run the sample database script first. Every query below was run on that data, and the Output under each query is the real result.
USE SqlLearningDb;
GO
Topic table: Articles with comma separated Tags and JSON attributes
DROP TABLE IF EXISTS dbo.Articles;
CREATE TABLE dbo.Articles
(
ArticleID INT PRIMARY KEY,
Title VARCHAR(100) NOT NULL,
AuthorID INT NOT NULL, -- Employees.EmployeeID
Tags VARCHAR(200) NULL, -- 'sql,database,tips'
Attributes NVARCHAR(MAX) NULL -- JSON
);
INSERT INTO dbo.Articles (ArticleID, Title, AuthorID, Tags, Attributes) VALUES
(1, 'Getting started with SQL Server', 14, 'sql,database,beginner',
N'{"views": 1520, "likes": 230, "level": "Beginner", "readMinutes": 6}'),
(2, 'Window functions explained', 14, 'sql,analytics,window-functions',
N'{"views": 980, "likes": 175, "level": "Intermediate", "readMinutes": 12}'),
(3, 'Python for data engineers', 12, 'python,etl,data-engineering',
N'{"views": 2100, "likes": 410, "level": "Intermediate", "readMinutes": 15}'),
(4, 'Index tuning checklist', 15, 'sql,performance,indexes',
N'{"views": 760, "likes": 98, "level": "Advanced", "readMinutes": 9}'),
(5, 'Company holiday list', 16, NULL,
N'{"views": 300, "likes": 12, "level": "General"}');
GO
-- A table-valued function used later (returns the last N orders of a customer)
CREATE OR ALTER FUNCTION dbo.fn_LastOrders (@CustomerID INT, @N INT)
RETURNS TABLE
AS
RETURN
(
SELECT TOP (@N) o.OrderID, o.OrderDate, o.Status
FROM dbo.Orders o
WHERE o.CustomerID = @CustomerID
ORDER BY o.OrderDate DESC, o.OrderID DESC
);
GO
Messages(5 rows affected)
Part A: TOP N per Group
Latest 2 orders for every customer (CROSS APPLY drops customers with no orders)
SELECT c.CustomerID, c.CustomerName, lo.OrderID, lo.OrderDate
FROM dbo.Customers c
CROSS APPLY (
SELECT TOP (2) o.OrderID, o.OrderDate
FROM dbo.Orders o
WHERE o.CustomerID = c.CustomerID -- uses the outer row!
ORDER BY o.OrderDate DESC, o.OrderID DESC
) AS lo
ORDER BY c.CustomerID, lo.OrderDate DESC;
Output
| CustomerID | CustomerName | OrderID | OrderDate |
| 1 | Ravi Traders | 75 | 2025-05-21 |
| 1 | Ravi Traders | 73 | 2025-05-09 |
| 2 | Infosoft Solutions | 58 | 2025-01-26 |
| 2 | Infosoft Solutions | 57 | 2025-01-18 |
| 3 | Lakshmi Stores | 79 | 2025-06-15 |
| 3 | Lakshmi Stores | 76 | 2025-05-27 |
| 4 | Mumbai Mart | 69 | 2025-04-12 |
| 4 | Mumbai Mart | 68 | 2025-04-02 |
| 5 | Pune Tech Park | 80 | 2025-06-24 |
| 5 | Pune Tech Park | 77 | 2025-06-03 |
| 6 | Capital Electronics | 60 | 2025-02-09 |
| 6 | Capital Electronics | 18 | 2024-05-03 |
| 7 | Bengal Book House | 36 | 2024-09-01 |
| 7 | Bengal Book House | 8 | 2024-02-27 |
| 8 | Deccan Systems | 28 | 2024-07-09 |
... showing 15 of 33 rows
Same with OUTER APPLY: customers 18, 19, 20 appear with NULLs
SELECT c.CustomerID, c.CustomerName, lo.OrderID, lo.OrderDate
FROM dbo.Customers c
OUTER APPLY (
SELECT TOP (2) o.OrderID, o.OrderDate
FROM dbo.Orders o
WHERE o.CustomerID = c.CustomerID
ORDER BY o.OrderDate DESC, o.OrderID DESC
) AS lo
ORDER BY c.CustomerID;
Output
| CustomerID | CustomerName | OrderID | OrderDate |
| 1 | Ravi Traders | 75 | 2025-05-21 |
| 1 | Ravi Traders | 73 | 2025-05-09 |
| 2 | Infosoft Solutions | 58 | 2025-01-26 |
| 2 | Infosoft Solutions | 57 | 2025-01-18 |
| 3 | Lakshmi Stores | 79 | 2025-06-15 |
| 3 | Lakshmi Stores | 76 | 2025-05-27 |
| 4 | Mumbai Mart | 69 | 2025-04-12 |
| 4 | Mumbai Mart | 68 | 2025-04-02 |
| 5 | Pune Tech Park | 80 | 2025-06-24 |
| 5 | Pune Tech Park | 77 | 2025-06-03 |
| 6 | Capital Electronics | 60 | 2025-02-09 |
| 6 | Capital Electronics | 18 | 2024-05-03 |
| 7 | Bengal Book House | 36 | 2024-09-01 |
| 7 | Bengal Book House | 8 | 2024-02-27 |
| 8 | Deccan Systems | 28 | 2024-07-09 |
... showing 15 of 36 rows
Top 2 most expensive products in each category
SELECT cat.CategoryName, tp.ProductName, tp.UnitPrice
FROM dbo.Categories cat
CROSS APPLY (
SELECT TOP (2) p.ProductName, p.UnitPrice
FROM dbo.Products p
WHERE p.CategoryID = cat.CategoryID
ORDER BY p.UnitPrice DESC
) AS tp
ORDER BY cat.CategoryName, tp.UnitPrice DESC;
Output
| CategoryName | ProductName | UnitPrice |
| Accessories | 27 inch Monitor | 18000.00 |
| Accessories | Laser Printer | 16000.00 |
| Desks | Standing Desk | 22000.00 |
| Desktops | Lenovo ThinkCentre | 55000.00 |
| Desktops | Acer Aspire Desktop | 45000.00 |
| Laptops | MacBook Air M3 | 120000.00 |
| Laptops | HP Pavilion 14 | 85000.00 |
| Mobiles | iPhone 15 | 70000.00 |
| Mobiles | Samsung Galaxy A55 | 25000.00 |
| Office Chairs | Ergonomic Office Chair | 12000.00 |
| Office Chairs | Basic Office Chair | 8500.00 |
| Stationery | Desk Organizer | 450.00 |
| Stationery | A4 Notebook (Pack of 5) | 150.00 |
Best-selling product of each sales person (by revenue)
SELECT e.FirstName, best.ProductName, best.Revenue
FROM dbo.Employees e
CROSS APPLY (
SELECT TOP (1) p.ProductName, SUM(oi.LineTotal) AS Revenue
FROM dbo.Orders o
JOIN dbo.OrderItems oi ON oi.OrderID = o.OrderID
JOIN dbo.Products p ON p.ProductID = oi.ProductID
WHERE o.EmployeeID = e.EmployeeID
GROUP BY p.ProductName
ORDER BY Revenue DESC
) AS best;
Output
| FirstName | ProductName | Revenue |
| Arjun | MacBook Air M3 | 654000.00 |
| Pooja | MacBook Air M3 | 918000.00 |
| Rahul | HP Pavilion 14 | 510000.00 |
| Sneha | MacBook Air M3 | 462000.00 |
| Ritu | MacBook Air M3 | 912000.00 |
Part B: APPLY with a Table-valued Function
(You CANNOT pass a column to a function in a normal JOIN)
SELECT c.CustomerName, f.OrderID, f.OrderDate, f.Status
FROM dbo.Customers c
CROSS APPLY dbo.fn_LastOrders(c.CustomerID, 3) AS f
WHERE c.City = 'Hyderabad';
Output
| CustomerName | OrderID | OrderDate | Status |
| Ravi Traders | 75 | 2025-05-21 | Delivered |
| Ravi Traders | 73 | 2025-05-09 | Delivered |
| Ravi Traders | 71 | 2025-04-25 | Delivered |
| Deccan Systems | 28 | 2024-07-09 | Delivered |
| Deccan Systems | 3 | 2024-01-26 | Cancelled |
| Deccan Systems | 2 | 2024-01-17 | Cancelled |
Part C: Aggregates per Row (instead of many correlated subqueries)
SELECT c.CustomerName, s.TotalOrders, s.FirstOrder, s.LastOrder, s.Revenue
FROM dbo.Customers c
OUTER APPLY (
SELECT COUNT(DISTINCT o.OrderID) AS TotalOrders,
MIN(o.OrderDate) AS FirstOrder,
MAX(o.OrderDate) AS LastOrder,
SUM(oi.LineTotal) AS Revenue
FROM dbo.Orders o
JOIN dbo.OrderItems oi ON oi.OrderID = o.OrderID
WHERE o.CustomerID = c.CustomerID
) AS s
ORDER BY s.Revenue DESC;
Output
| CustomerName | TotalOrders | FirstOrder | LastOrder | Revenue |
| Lakshmi Stores | 13 | 2024-02-06 | 2025-06-15 | 1925592.50 |
| Pune Tech Park | 8 | 2024-08-13 | 2025-06-24 | 1649975.00 |
| Ravi Traders | 11 | 2024-01-09 | 2025-05-21 | 1527910.00 |
| Marina Digital | 6 | 2024-02-14 | 2025-06-10 | 1326609.00 |
| Infosoft Solutions | 6 | 2024-04-05 | 2025-01-26 | 1163225.00 |
| Awadh Enterprises | 5 | 2024-07-01 | 2024-11-26 | 673885.00 |
| Kerala Kart | 3 | 2024-06-19 | 2025-01-06 | 644925.00 |
| Silicon Valley Pvt Ltd | 3 | 2024-01-29 | 2025-05-02 | 574857.00 |
| Shivaji Stationers | 4 | 2024-03-17 | 2025-03-16 | 548650.00 |
| Pink City Furnishers | 4 | 2024-11-19 | 2025-02-17 | 536100.00 |
| Gateway Retail | 1 | 2024-08-06 | 2024-08-06 | 476850.00 |
| Mumbai Mart | 4 | 2024-02-20 | 2025-04-12 | 365735.00 |
| Northern Distributors | 2 | 2024-03-25 | 2024-12-24 | 275200.00 |
| Bengal Book House | 2 | 2024-02-27 | 2024-09-01 | 259075.00 |
| Deccan Systems | 3 | 2024-01-17 | 2024-07-09 | 220310.00 |
... showing 15 of 20 rows
Part D: Reuse Calculated Columns (avoid repeating long expressions)
SELECT p.ProductName, calc.Margin, calc.MarginPct, grade.MarginGrade
FROM dbo.Products p
CROSS APPLY (SELECT p.UnitPrice - p.CostPrice AS Margin,
CAST(100.0 * (p.UnitPrice - p.CostPrice) / p.UnitPrice AS DECIMAL(5,2)) AS MarginPct) AS calc
CROSS APPLY (SELECT CASE WHEN calc.MarginPct >= 35 THEN 'High'
WHEN calc.MarginPct >= 20 THEN 'Medium'
ELSE 'Low' END AS MarginGrade) AS grade
ORDER BY calc.MarginPct DESC;
Output
| ProductName | Margin | MarginPct | MarginGrade |
| Wireless Mouse | 500.00 | 41.67 | High |
| Ball Pen (Pack of 10) | 25.00 | 41.67 | High |
| A4 Notebook (Pack of 5) | 60.00 | 40.00 | High |
| Desk Organizer | 170.00 | 37.78 | High |
| USB-C Hub | 1300.00 | 37.14 | High |
| Mechanical Keyboard | 900.00 | 36.00 | High |
| Ergonomic Office Chair | 4000.00 | 33.33 | Medium |
| Basic Office Chair | 2500.00 | 29.41 | Medium |
| Standing Desk | 6000.00 | 27.27 | Medium |
| Redmi Note 13 | 4000.00 | 26.67 | Medium |
| Samsung Galaxy A55 | 6000.00 | 24.00 | Medium |
| 27 inch Monitor | 4000.00 | 22.22 | Medium |
| Laser Printer | 3500.00 | 21.88 | Medium |
| Dell Inspiron 15 | 13000.00 | 20.00 | Medium |
| Lenovo ThinkCentre | 11000.00 | 20.00 | Medium |
... showing 15 of 20 rows
Part E: STRING_SPLIT and Openjson with APPLY
Split tags into rows (CROSS APPLY drops the article with NULL tags)
SELECT a.ArticleID, a.Title, LTRIM(t.value) AS Tag
FROM dbo.Articles a
CROSS APPLY STRING_SPLIT(a.Tags, ',') AS t;
Output
| ArticleID | Title | Tag |
| 1 | Getting started with SQL Server | sql |
| 1 | Getting started with SQL Server | database |
| 1 | Getting started with SQL Server | beginner |
| 2 | Window functions explained | sql |
| 2 | Window functions explained | analytics |
| 2 | Window functions explained | window-functions |
| 3 | Python for data engineers | python |
| 3 | Python for data engineers | etl |
| 3 | Python for data engineers | data-engineering |
| 4 | Index tuning checklist | sql |
| 4 | Index tuning checklist | performance |
| 4 | Index tuning checklist | indexes |
Count articles per tag
SELECT LTRIM(t.value) AS Tag, COUNT(*) AS Articles
FROM dbo.Articles a
CROSS APPLY STRING_SPLIT(a.Tags, ',') AS t
GROUP BY LTRIM(t.value)
ORDER BY Articles DESC, Tag;
Output
| Tag | Articles |
| sql | 3 |
| analytics | 1 |
| beginner | 1 |
| data-engineering | 1 |
| database | 1 |
| etl | 1 |
| indexes | 1 |
| performance | 1 |
| python | 1 |
| window-functions | 1 |
Read JSON attributes into columns (openjson with schema)
SELECT a.Title, j.Views, j.Likes, j.Level, j.ReadMinutes
FROM dbo.Articles a
CROSS APPLY OPENJSON(a.Attributes)
WITH ( Views INT '$.views',
Likes INT '$.likes',
Level VARCHAR(20) '$.level',
ReadMinutes INT '$.readMinutes' ) AS j
ORDER BY j.Views DESC;
Output
| Title | Views | Likes | Level | ReadMinutes |
| Python for data engineers | 2100 | 410 | Intermediate | 15 |
| Getting started with SQL Server | 1520 | 230 | Beginner | 6 |
| Window functions explained | 980 | 175 | Intermediate | 12 |
| Index tuning checklist | 760 | 98 | Advanced | 9 |
| Company holiday list | 300 | 12 | General | NULL |
Part F: UNPIVOT with CROSS APPLY (VALUES)
SELECT e.FirstName, v.DateType, v.TheDate
FROM dbo.Employees e
CROSS APPLY (VALUES ('Hire Date', e.HireDate),
('1st Work Anniversary', DATEADD(YEAR, 1, e.HireDate)),
('5th Work Anniversary', DATEADD(YEAR, 5, e.HireDate))) AS v(DateType, TheDate)
WHERE e.EmployeeID <= 3
ORDER BY e.FirstName, v.TheDate;
Output
| FirstName | DateType | TheDate |
| Anita | Hire Date | 2016-03-15 |
| Anita | 1st Work Anniversary | 2017-03-15 |
| Anita | 5th Work Anniversary | 2021-03-15 |
| Rajesh | Hire Date | 2015-01-10 |
| Rajesh | 1st Work Anniversary | 2016-01-10 |
| Rajesh | 5th Work Anniversary | 2020-01-10 |
| Vikram | Hire Date | 2016-06-01 |
| Vikram | 1st Work Anniversary | 2017-06-01 |
| Vikram | 5th Work Anniversary | 2021-06-01 |
Practice Questions
- For each department, show the 2 most recently hired employees (CROSS APPLY + TOP).
- For each product, show the most recent order date that included it (OUTER APPLY, keep never-ordered products).
- Write an inline TVF fn_TopProductsByCategory(@CategoryID, @N) and call it with CROSS APPLY for every category.
- List articles whose JSON 'likes' is more than 15% of 'views'.
- Use CROSS APPLY to compute order total, tax (18%), and grand total in a readable way.