Skip to content

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
CustomerIDCustomerNameOrderIDOrderDate
1Ravi Traders752025-05-21
1Ravi Traders732025-05-09
2Infosoft Solutions582025-01-26
2Infosoft Solutions572025-01-18
3Lakshmi Stores792025-06-15
3Lakshmi Stores762025-05-27
4Mumbai Mart692025-04-12
4Mumbai Mart682025-04-02
5Pune Tech Park802025-06-24
5Pune Tech Park772025-06-03
6Capital Electronics602025-02-09
6Capital Electronics182024-05-03
7Bengal Book House362024-09-01
7Bengal Book House82024-02-27
8Deccan Systems282024-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
CustomerIDCustomerNameOrderIDOrderDate
1Ravi Traders752025-05-21
1Ravi Traders732025-05-09
2Infosoft Solutions582025-01-26
2Infosoft Solutions572025-01-18
3Lakshmi Stores792025-06-15
3Lakshmi Stores762025-05-27
4Mumbai Mart692025-04-12
4Mumbai Mart682025-04-02
5Pune Tech Park802025-06-24
5Pune Tech Park772025-06-03
6Capital Electronics602025-02-09
6Capital Electronics182024-05-03
7Bengal Book House362024-09-01
7Bengal Book House82024-02-27
8Deccan Systems282024-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
CategoryNameProductNameUnitPrice
Accessories27 inch Monitor18000.00
AccessoriesLaser Printer16000.00
DesksStanding Desk22000.00
DesktopsLenovo ThinkCentre55000.00
DesktopsAcer Aspire Desktop45000.00
LaptopsMacBook Air M3120000.00
LaptopsHP Pavilion 1485000.00
MobilesiPhone 1570000.00
MobilesSamsung Galaxy A5525000.00
Office ChairsErgonomic Office Chair12000.00
Office ChairsBasic Office Chair8500.00
StationeryDesk Organizer450.00
StationeryA4 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
FirstNameProductNameRevenue
ArjunMacBook Air M3654000.00
PoojaMacBook Air M3918000.00
RahulHP Pavilion 14510000.00
SnehaMacBook Air M3462000.00
RituMacBook Air M3912000.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
CustomerNameOrderIDOrderDateStatus
Ravi Traders752025-05-21Delivered
Ravi Traders732025-05-09Delivered
Ravi Traders712025-04-25Delivered
Deccan Systems282024-07-09Delivered
Deccan Systems32024-01-26Cancelled
Deccan Systems22024-01-17Cancelled

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
CustomerNameTotalOrdersFirstOrderLastOrderRevenue
Lakshmi Stores132024-02-062025-06-151925592.50
Pune Tech Park82024-08-132025-06-241649975.00
Ravi Traders112024-01-092025-05-211527910.00
Marina Digital62024-02-142025-06-101326609.00
Infosoft Solutions62024-04-052025-01-261163225.00
Awadh Enterprises52024-07-012024-11-26673885.00
Kerala Kart32024-06-192025-01-06644925.00
Silicon Valley Pvt Ltd32024-01-292025-05-02574857.00
Shivaji Stationers42024-03-172025-03-16548650.00
Pink City Furnishers42024-11-192025-02-17536100.00
Gateway Retail12024-08-062024-08-06476850.00
Mumbai Mart42024-02-202025-04-12365735.00
Northern Distributors22024-03-252024-12-24275200.00
Bengal Book House22024-02-272024-09-01259075.00
Deccan Systems32024-01-172024-07-09220310.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
ProductNameMarginMarginPctMarginGrade
Wireless Mouse500.0041.67High
Ball Pen (Pack of 10)25.0041.67High
A4 Notebook (Pack of 5)60.0040.00High
Desk Organizer170.0037.78High
USB-C Hub1300.0037.14High
Mechanical Keyboard900.0036.00High
Ergonomic Office Chair4000.0033.33Medium
Basic Office Chair2500.0029.41Medium
Standing Desk6000.0027.27Medium
Redmi Note 134000.0026.67Medium
Samsung Galaxy A556000.0024.00Medium
27 inch Monitor4000.0022.22Medium
Laser Printer3500.0021.88Medium
Dell Inspiron 1513000.0020.00Medium
Lenovo ThinkCentre11000.0020.00Medium
... 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
ArticleIDTitleTag
1Getting started with SQL Serversql
1Getting started with SQL Serverdatabase
1Getting started with SQL Serverbeginner
2Window functions explainedsql
2Window functions explainedanalytics
2Window functions explainedwindow-functions
3Python for data engineerspython
3Python for data engineersetl
3Python for data engineersdata-engineering
4Index tuning checklistsql
4Index tuning checklistperformance
4Index tuning checklistindexes

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
TagArticles
sql3
analytics1
beginner1
data-engineering1
database1
etl1
indexes1
performance1
python1
window-functions1

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
TitleViewsLikesLevelReadMinutes
Python for data engineers2100410Intermediate15
Getting started with SQL Server1520230Beginner6
Window functions explained980175Intermediate12
Index tuning checklist76098Advanced9
Company holiday list30012GeneralNULL

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
FirstNameDateTypeTheDate
AnitaHire Date2016-03-15
Anita1st Work Anniversary2017-03-15
Anita5th Work Anniversary2021-03-15
RajeshHire Date2015-01-10
Rajesh1st Work Anniversary2016-01-10
Rajesh5th Work Anniversary2020-01-10
VikramHire Date2016-06-01
Vikram1st Work Anniversary2017-06-01
Vikram5th Work Anniversary2021-06-01

Practice Questions

  1. For each department, show the 2 most recently hired employees (CROSS APPLY + TOP).
  2. For each product, show the most recent order date that included it (OUTER APPLY, keep never-ordered products).
  3. Write an inline TVF fn_TopProductsByCategory(@CategoryID, @N) and call it with CROSS APPLY for every category.
  4. List articles whose JSON 'likes' is more than 15% of 'views'.
  5. Use CROSS APPLY to compute order total, tax (18%), and grand total in a readable way.