Back to Article List

CROSS APPLY and OUTER APPLY in SQL Server

CROSS APPLY and OUTER APPLY in SQL Server

Updated and tested 1 October 2026

Microsoft's definition of APPLY, from the FROM clause reference, is one sentence: "Specifies that the right_table_source of the APPLY operator is evaluated against every row of the left_table_source."

Once per row of the left side. That is the whole idea, and a JOIN cannot do it, because a join evaluates its two sides independently and then matches them. With APPLY the right side sees the current left row and uses its column values. Examples below use the schema from the rest of this series: dbo.customers, dbo.orders and dbo.order_items.

CROSS APPLY against OUTER APPLY

Two variants, and the analogy holds precisely. CROSS APPLY behaves like an inner join: no rows on the right for a given left row, and that left row disappears. OUTER APPLY in SQL Server behaves like a left join, keeping the left row and filling the right side's columns with NULLs.

SELECT c.company_name,
       latest.order_id,
       latest.order_date
FROM dbo.customers AS c
CROSS APPLY
(
    SELECT TOP (1) o.order_id, o.order_date
    FROM dbo.orders AS o
    WHERE o.customer_id = c.customer_id
    ORDER BY o.order_date DESC
) AS latest;

Run that against a customer table where 40 of 500 companies have never ordered anything and you get 460 rows. The 40 vanish silently. Fine if you wanted customers with a recent order. A bug if you wanted every customer with their last order attached. Swap one word:

SELECT c.company_name,
       latest.order_id,
       latest.order_date
FROM dbo.customers AS c
OUTER APPLY
(
    SELECT TOP (1) o.order_id, o.order_date
    FROM dbo.orders AS o
    WHERE o.customer_id = c.customer_id
    ORDER BY o.order_date DESC
) AS latest;

500 rows, with NULL in order_id and order_date for the 40. I have fixed this exact bug in three reporting stacks and the symptom is always the same: a total slightly too low, and nobody can say when it started.

There is no bare APPLY.

Write FROM dbo.customers AS c APPLY (...) AS x and the parser rejects it with an incorrect-syntax error. You pick CROSS or OUTER every time, which is better language design than JOIN quietly meaning INNER JOIN when you leave the word out.

APPLY with a table-valued function

This is the use case Microsoft documents on that page, and most people meet it last. A table-valued function takes a column from the left side as an argument, which a join cannot express.

CREATE OR ALTER FUNCTION dbo.fn_top_order_lines (@order_id int, @how_many int)
RETURNS TABLE
AS
RETURN
(
    SELECT TOP (@how_many)
           i.product_sku,
           i.quantity,
           i.unit_price,
           i.quantity * i.unit_price AS line_total
    FROM dbo.order_items AS i
    WHERE i.order_id = @order_id
    ORDER BY i.quantity * i.unit_price DESC
);
GO

SELECT o.order_id,
       o.total_amount,
       lines.product_sku,
       lines.line_total
FROM dbo.orders AS o
CROSS APPLY dbo.fn_top_order_lines(o.order_id, 3) AS lines
WHERE o.status = 'shipped';

That is an inline table-valued function, meaning the body is a single RETURN (SELECT ...) with no BEGIN block. Inline TVFs get expanded into the calling query and optimised as one unit, so the plan looks much like the equivalent hand-written subquery. Multi-statement TVFs, the kind that declare a table variable and fill it, behave far worse in a plan. Keep function bodies inline when you intend to APPLY them at scale.

Top N rows per group

This is the pattern that sends most people looking for MSSQL CROSS APPLY syntax in the first place. Three most recent orders per customer:

SELECT c.company_name,
       recent.order_id,
       recent.order_date,
       recent.total_amount
FROM dbo.customers AS c
CROSS APPLY
(
    SELECT TOP (3) o.order_id, o.order_date, o.total_amount
    FROM dbo.orders AS o
    WHERE o.customer_id = c.customer_id
    ORDER BY o.order_date DESC
) AS recent
ORDER BY c.company_name, recent.order_date DESC;

Be clear about what this is. Top-N-per-group with CROSS APPLY is a community idiom and not a documented use case on Microsoft's FROM reference. It works. The performance on your data and your indexes is yours to check.

CROSS APPLY against the ROW_NUMBER alternative

The same result with a window function inside a common table expression:

WITH ranked AS
(
    SELECT o.order_id,
           o.customer_id,
           o.order_date,
           o.total_amount,
           ROW_NUMBER() OVER (PARTITION BY o.customer_id
                              ORDER BY o.order_date DESC) AS rn
    FROM dbo.orders AS o
)
SELECT c.company_name, r.order_id, r.order_date, r.total_amount
FROM ranked AS r
JOIN dbo.customers AS c ON c.customer_id = r.customer_id
WHERE r.rn <= 3
ORDER BY c.company_name, r.order_date DESC;

Both are correct. They perform very differently, and what decides it is how much of the orders table you end up touching.

I think the ROW_NUMBER version is over-taught. It is the answer everyone gives and it reads well. It also has no shortcut available to it. ROW_NUMBER numbers every row in every partition before WHERE rn <= 3 throws most of them away, so it reads the whole orders table however little you asked for. APPLY with an index on (customer_id, order_date DESC) turns each left row into a seek. Three index rows, then it stops. Fifty customers, fifty tiny seeks. On a small driving set that is not a close call, and I reach for APPLY first.

The window function wins when the left side is large, or when you were going to read most of the orders table anyway. One scan and one sort beats two million index seeks. It also handles ties properly if you switch to RANK or DENSE_RANK, which APPLY with TOP cannot do without WITH TIES.

Why the CTE half of that query adds no cost of its own is a longer story, told in common table expressions in SQL Server. Run both versions under SET STATISTICS IO ON and compare logical reads.

Your idea deserves better hosting

24/7 support 30-day money-back guaranteeRefund covers the VPS plan on your first order. Cancel anytime
Abonament

VPS.S1

$5.99 Save  17 %
$4.99 Lunar
  • 2 vCPU AMD EPYC
  • 2 GB RAMMEMORIE
  • 30 GB NVMeSTOCARE
  • Trafic nelimitat
  • IPv4 & IPv6Suportul IPv6 este momentan indisponibil în Franța, Finlanda sau Țările de Jos. incluse

VPS.S3

$14.99 Save  33 %
$9.99 Lunar
  • 4 vCPU AMD EPYC
  • 6 GB RAMMEMORIE
  • 70 GB NVMeSTOCARE
  • Trafic nelimitat
  • IPv4 & IPv6Suportul IPv6 este momentan indisponibil în Franța, Finlanda sau Țările de Jos. incluse

VPS.S4

$19.99 Save  20 %
$15.99 Lunar
  • 6 vCPU AMD EPYC
  • 8 GB RAMMEMORIE
  • 80 GB NVMeSTOCARE
  • Trafic nelimitat
  • IPv4 & IPv6Suportul IPv6 este momentan indisponibil în Franța, Finlanda sau Țările de Jos. incluse

EPYC VPS.P1

$8.99 Save  22 %
$6.99 Lunar
  • 2 vCPU AMD EPYC
  • 4 GB RAMMEMORIE
  • 40 GB NVMeSTOCARE
  • Trafic nelimitat
  • IPv4 & IPv6Suportul IPv6 este momentan indisponibil în Franța, Finlanda sau Țările de Jos. incluse
  • Backup automat gratuitInclude un spațiu de backup pe care îl poți configura pentru rulare zilnică, săptămânală sau lunară.

EPYC VPS.P2

$16.99 Save  24 %
$12.99 Lunar
  • 2 vCPU AMD EPYC
  • 8 GB RAMMEMORIE
  • 80 GB NVMeSTOCARE
  • Trafic nelimitat
  • IPv4 & IPv6Suportul IPv6 este momentan indisponibil în Franța, Finlanda sau Țările de Jos. incluse
  • Backup automat gratuitInclude un spațiu de backup pe care îl poți configura pentru rulare zilnică, săptămânală sau lunară.

EPYC VPS.P4

$29.99 Save  23 %
$22.99 Lunar
  • 4 vCPU AMD EPYC
  • 16 GB RAMMEMORIE
  • 160 GB NVMeSTOCARE
  • Trafic nelimitat
  • IPv4 & IPv6Suportul IPv6 este momentan indisponibil în Franța, Finlanda sau Țările de Jos. incluse
  • Backup automat gratuitInclude un spațiu de backup pe care îl poți configura pentru rulare zilnică, săptămânală sau lunară.

EPYC VPS.P5

$39.99 Save  25 %
$29.99 Lunar
  • 8 vCPU AMD EPYC
  • 16 GB RAMMEMORIE
  • 180 GB NVMeSTOCARE
  • Trafic nelimitat
  • IPv4 & IPv6Suportul IPv6 este momentan indisponibil în Franța, Finlanda sau Țările de Jos. incluse
  • Backup automat gratuitInclude un spațiu de backup pe care îl poți configura pentru rulare zilnică, săptămânală sau lunară.

EPYC VPS.P6

$59.99 Save  25 %
$44.99 Lunar
  • 8 vCPU AMD EPYC
  • 32 GB RAMMEMORIE
  • 200 GB NVMeSTOCARE
  • Trafic nelimitat
  • IPv4 & IPv6Suportul IPv6 este momentan indisponibil în Franța, Finlanda sau Țările de Jos. incluse
  • Backup automat gratuitInclude un spațiu de backup pe care îl poți configura pentru rulare zilnică, săptămânală sau lunară.

EPYC VPS.P7

$69.99 Save  29 %
$49.99 Lunar
  • 16 vCPU AMD EPYC
  • 32 GB RAMMEMORIE
  • 240 GB NVMeSTOCARE
  • Trafic nelimitat
  • IPv4 & IPv6Suportul IPv6 este momentan indisponibil în Franța, Finlanda sau Țările de Jos. incluse
  • Backup automat gratuitInclude un spațiu de backup pe care îl poți configura pentru rulare zilnică, săptămânală sau lunară.

Genoa VPS.G2

$24.99 Save  20 %
$19.99 Lunar
  • 2 vCPUAMD EPYC Genoa generația a 4-a 9xx4 cu 3,25 GHz sau similar, pe arhitectura Zen 4. AMD EPYC G4
  • 4 GB DDR5MEMORIE
  • 50 GB NVMeSTOCARE
  • Trafic nelimitat
  • IPv4 & IPv6Suportul IPv6 este momentan indisponibil în Franța, Finlanda sau Țările de Jos. incluse
  • Backup automat gratuitInclude un spațiu de backup pe care îl poți configura pentru rulare zilnică, săptămânală sau lunară.

Genoa VPS.G4

$44.99 Save  22 %
$34.99 Lunar
  • 4 vCPUProcesor AMD EPYC cu nuclee vCPU dedicate, pe hardware de server pentru companii. AMD EPYC G4
  • 8 GB DDR5MEMORIE
  • 100 GB NVMeSTOCARE
  • Trafic nelimitat
  • IPv4 & IPv6Suportul IPv6 este momentan indisponibil în Franța, Finlanda sau Țările de Jos. incluse
  • Backup automat gratuitInclude un spațiu de backup pe care îl poți configura pentru rulare zilnică, săptămânală sau lunară.

Genoa VPS.G6

$89.99 Save  22 %
$69.99 Lunar
  • 8 vCPUProcesor AMD EPYC cu nuclee vCPU dedicate, pe hardware de server pentru companii. AMD EPYC G4
  • 16 GB DDR5MEMORIE
  • 200 GB NVMeSTOCARE
  • Trafic nelimitat
  • IPv4 & IPv6Suportul IPv6 este momentan indisponibil în Franța, Finlanda sau Țările de Jos. incluse
  • Backup automat gratuitInclude un spațiu de backup pe care îl poți configura pentru rulare zilnică, săptămânală sau lunară.

Genoa VPS.G7

$159.99 Save  22 %
$124.99 Lunar
  • 8 vCPUProcesor AMD EPYC cu nuclee vCPU dedicate, pe hardware de server pentru companii. AMD EPYC G4
  • 32 GB DDR5MEMORIE
  • 250 GB NVMeSTOCARE
  • Trafic nelimitat
  • IPv4 & IPv6Suportul IPv6 este momentan indisponibil în Franța, Finlanda sau Țările de Jos. incluse
  • Backup automat gratuitInclude un spațiu de backup pe care îl poți configura pentru rulare zilnică, săptămânală sau lunară.

AMD Ryzen VPS.R1

$16.99 Save  18 %
$13.99 Lunar
  • 1 CPU dedicat AMD Ryzen 9 7950X cu 4,5 GHz sau similar, pe arhitectura Zen 4. vCPU
  • 4 GB DDR5MEMORIE
  • 50 GB NVMeSTOCARE
  • Trafic nelimitat
  • IPv4 & IPv6 incluse Suportul IPv6 este momentan indisponibil în Franța, Finlanda sau Țările de Jos.
  • Backup automat inclus

AMD Ryzen VPS.R2

$29.99 Save  17 %
$24.99 Lunar
  • 2 CPU dedicate AMD Ryzen 9 7950X cu 4,5 GHz sau similar, pe arhitectura Zen 4. vCPU
  • 8 GB DDR5MEMORIE
  • 100 GB NVMeSTOCARE
  • Trafic nelimitat
  • IPv4 & IPv6 incluse Suportul IPv6 este momentan indisponibil în Franța, Finlanda sau Țările de Jos.
  • Backup automat inclus

AMD Ryzen VPS.R4

$109.99 Save  18 %
$89.99 Lunar
  • 8 CPU dedicate AMD Ryzen 9 7950X cu 4,5 GHz sau similar, pe arhitectura Zen 4. vCPU
  • 32 GB DDR5MEMORIE
  • 400 GB NVMeSTOCARE
  • Trafic nelimitat
  • IPv4 & IPv6 incluse Suportul IPv6 este momentan indisponibil în Franța, Finlanda sau Țările de Jos.
  • Backup automat inclus

*VAT excluded.

INCLUDED WITH EVERY PLAN

✓ No setup fees ✓1 Gbps network
✓ Full root access on KVM ✓ Free server monitoring ✓ Firewall management ✓24/7 support

Unpivoting columns into rows with APPLY and VALUES

This one is elegant and I use it constantly. dbo.orders holds order_date and shipped_date as two columns, and you want one row per event:

SELECT o.order_id,
       stages.stage,
       stages.stage_date
FROM dbo.orders AS o
CROSS APPLY
(
    VALUES ('ordered', o.order_date),
           ('shipped', o.shipped_date)
) AS stages (stage, stage_date)
WHERE stages.stage_date IS NOT NULL
ORDER BY o.order_id, stages.stage_date;

An order with both dates produces two rows and an unshipped order produces one. The WHERE filters out the NULL shipped_date entries. Types across the rows of the constructor have to be compatible. Precedence decides the result type, so check SQL Server data types and precedence when the two columns are not identical. For the VALUES side, see Microsoft's table value constructor reference.

The UNPIVOT operator does this too. It is more verbose and it insists every column share a data type. It gives you no room to add a computed value alongside them either, which the APPLY form does happily. UNPIVOT arrived in SQL Server 2005 and has barely been touched since. Nobody I know reaches for it first.

Splitting a delimited column with STRING_SPLIT

Someone put a comma-separated list in a column. It happens in every database eventually, usually in a column called tag_list, and APPLY is how you get it back into rows:

SELECT o.order_id,
       LTRIM(s.value) AS tag
FROM dbo.orders AS o
CROSS APPLY STRING_SPLIT(o.tag_list, ',') AS s
WHERE s.value <> '';

Four things about STRING_SPLIT bite people. The separator has to be a single character, so a two-character delimiter needs a REPLACE first. Output order is not guaranteed, and Microsoft says so directly: "The output rows might be in any order. The order isn't guaranteed to match the order of the substrings in the input string." A NULL input returns an empty table, so CROSS APPLY drops that order entirely and OUTER APPLY is what you want when untagged orders still belong in the output. And the function needs database compatibility level 130 or higher, which is why it goes missing on databases restored from an older instance. SQL Server 2022 added a third argument returning an ordinal column:

SELECT o.order_id, s.ordinal, LTRIM(s.value) AS tag
FROM dbo.orders AS o
CROSS APPLY STRING_SPLIT(o.tag_list, ',', 1) AS s
ORDER BY o.order_id, s.ordinal;

Trimming, padding and the rest of the cleanup around this is in the SQL Server string functions walkthrough.

What per-row evaluation costs

APPLY almost always produces a nested loops join in the plan, with the right side re-executed per outer row. That is the mechanism and it is also the bill. Ten thousand left rows means ten thousand executions of the inner query, and with no index to seek on, ten thousand scans.

So ask one question before writing an APPLY: can the right side seek? The answer sits in writing WHERE clauses SQL Server can seek on, and it applies to the inner query exactly as it applies to any other. An index covering the correlation column plus whatever you order by turns each execution into a handful of page reads. Without one, a plain JOIN with a hash or merge operator beats APPLY by a wide margin. The optimiser reads each table once. APPLY reads one of them ten thousand times.

Open the actual execution plan and hover the operator on the inner side. There is a Number of Executions property, and it's the first thing I read in any APPLY plan, since it tells me if the rest is worth reading. If it reads 1, the optimiser managed to decorrelate what you wrote. If it reads 2,400,000, you have your answer about the eleven-minute report.

One limit on my side. I have not retested APPLY against a multi-statement TVF since interleaved execution landed. How much of the old penalty survives on a current compatibility level, I cannot tell you.

Questions?

Can I use CROSS APPLY with OPENJSON to read a JSON column?

Yes, it is the documented way to expand a JSON array stored in a column into one row per element next to its parent row: SELECT o.order_id, j.sku, j.qty FROM dbo.orders AS o CROSS APPLY OPENJSON(o.payload, '$.items') WITH (sku varchar(40) '$.sku', qty int '$.qty') AS j;. The WITH clause names and types the output columns, and without it you get generic key, value and type columns. SQL Server only finds OPENJSON at database compatibility level 130 and above, while the other JSON functions work at any level. Switch to OUTER APPLY to keep the orders whose payload has no items, since a missing path returns an empty result in the default lax mode.

Jörg L. Günther
About the author

Jörg L. Günther

Jörg has spent much of his career in enterprise software, working first with database deployments and customer environments, then in technical training and documentation. Since then he has written for companies working in virtualisation, database tooling and cloud infrastructure. The subjects he takes on often have a large operational component: database migrations, high-availability setups, storage configuration and procedures carried out on live systems. Jörg lives near Frankfurt. He plays piano and cycles for transport as much as for recreation. He's also been doing woodworking for several years - he says most of the furniture in his study is his own work.

Deploy your next project in seconds

Bring your winning ideas online faster, with modern hardware and unmetered bandwidth. Join a European cloud trusted by thousands of developers and businesses worldwide.