みなさんこんにちは!ヒロポンです!!
「カテゴリごとに売上 TOP3 を出しといて」
業務システムの現場だと、この一言がしれっと降ってくる。帳票でもダッシュボードでも、"グループ別の上位N件" は定番中の定番ですよね。
でもいざ SQL で書こうとすると手が止まる。
GROUP BY じゃ N 件ぜんぶ取れない。JOIN でも素直に書けない。で、相関サブクエリを列の数だけ並べて「うわ、これ列増えるたびにコピペするやつ??」ってなる。
俺も昔これで小一時間溶かしました。
その詰まりを一撃で抜けるのが CROSS APPLY / OUTER APPLY です。今日は SQL Server の CROSS APPLY 使い方を、現場でよく出る3パターンに絞って、コピペで動く形で置いていきます。
💡 グループ内の順位付けだけなら
ROW_NUMBER()が定番です。基本は SQL Server ROW_NUMBER の落とし穴 にまとめてあるので、そっちが素直なケースも多い。今回は 右側が関数やサブクエリで、行ごとにパラメータを渡したいとき の APPLY 版に絞ります。
結論: 右が「左の値に依存する」なら APPLY
先に答え。
CROSS APPLY は、右側のサブクエリやテーブル値関数に 左のテーブルの各行の値を渡して、行ごとに評価する演算子です。JOIN が「2つの集合を突き合わせる」のに対して、APPLY は「左の1行ごとに右を呼ぶ」。
- グループごと上位N件 →
CROSS APPLY (SELECT TOP (N) ... WHERE = 左.キー) - 各行にテーブル値関数を適用 →
CROSS APPLY dbo.関数(左.列) - CSV 列を行に展開 →
CROSS APPLY STRING_SPLIT(左.列, ',')
右側が空っぽ(0行)になったとき、CROSS APPLY はその左の行ごと消し、OUTER APPLY は NULL で残す。
ここだけ最初に押さえておけば事故りません。
なぜ JOIN では素直に書けないのか
普通の JOIN は、結合条件に書けるのが「列 = 列」みたいな 静的な条件 だけ。右側のサブクエリに「左の行のこの値を使って TOP 2 取ってきて」と パラメータを渡すことができない。
だから TOP-N-per-group を JOIN でやろうとすると、ROW_NUMBER() でいったん全行に順位を振ってから WHERE rn <= N で絞る、みたいな遠回りになる。
それ自体は正しいんですが、「右側がテーブル値関数」だと ROW_NUMBER では手も足も出ない。
APPLY は、その「右が左に依存する」ケース専用の武器です。SQL Server 2005 から入ってて、Oracle でいう LATERAL、PostgreSQL の LATERAL JOIN と同じ発想。ん、名前がゴツいだけで、やってることは「左の1行ずつに右を食わせる」だけなんですよね。
最短対処: コピペで動く3パターン
まず検証用のテーブルを用意します。SQL Server 2016 以降ならそのまま動きます(STRING_SPLIT が 2016 以降必須)。
CREATE TABLE Sales (
SaleId INT IDENTITY PRIMARY KEY,
Category NVARCHAR(20),
Product NVARCHAR(40),
Amount DECIMAL(10, 2),
SoldAt DATE
);
INSERT INTO Sales (Category, Product, Amount, SoldAt) VALUES
(N'食品', N'コーヒー豆', 1200, '2026-07-01'),
(N'食品', N'紅茶', 800, '2026-07-02'),
(N'食品', N'ミネラル水', 300, '2026-07-03'),
(N'家電', N'炊飯器', 18000, '2026-07-01'),
(N'家電', N'電気ケトル', 4500, '2026-07-02'),
(N'家電', N'トースター', 6000, '2026-07-03');
パターン1: グループごと上位N件
カテゴリごとに、金額の高い順で TOP 2 を取ります。
SELECT c.Category, t.Product, t.Amount
FROM (SELECT DISTINCT Category FROM Sales) AS c
CROSS APPLY (
SELECT TOP (2) s.Product, s.Amount
FROM Sales s
WHERE s.Category = c.Category
ORDER BY s.Amount DESC
) AS t
ORDER BY c.Category, t.Amount DESC;
ポイントは WHERE s.Category = c.Category。左の1カテゴリごとに、右のサブクエリが TOP (2) ... ORDER BY で呼ばれる。だから「グループの中で並べ替えて上位N」が一発で書けます!!いい感じですね。
実行結果:

食品・家電それぞれで、金額の高い順に2件ずつ出ています。TOP の数を変数にすれば、「上位N件」の N も自由に変えられる。
パターン2: テーブル値関数を各行に適用
APPLY の本領はここから。まず、カテゴリを渡すと直近の売上を返す インラインテーブル値関数 を作ります。
CREATE FUNCTION dbo.GetRecentSales (@category NVARCHAR(20), @n INT)
RETURNS TABLE
AS RETURN (
SELECT TOP (@n) Product, Amount, SoldAt
FROM Sales
WHERE Category = @category
ORDER BY SoldAt DESC
);
これを各カテゴリに適用する。
SELECT c.Category, f.Product, f.SoldAt
FROM (SELECT DISTINCT Category FROM Sales) AS c
CROSS APPLY dbo.GetRecentSales(c.Category, 2) AS f
ORDER BY c.Category, f.SoldAt DESC;
dbo.GetRecentSales(c.Category, 2) の引数に、左の列 c.Category をそのまま渡している。これ、JOIN では絶対に書けない書き方なんですよね。関数に行ごとの値を食わせられるのが APPLY の一番おいしいところ。
パターン3: CSV 列を行に展開する(STRING_SPLIT)
「1セルにカンマ区切りでタグが入ってる」やつ、レガシーな業務テーブルだと本当によく見ます。
CREATE TABLE Orders (
OrderId INT PRIMARY KEY,
Tags NVARCHAR(200)
);
INSERT INTO Orders (OrderId, Tags) VALUES
(1, N'急ぎ,ギフト,割れ物'),
(2, N'通常'),
(3, N'急ぎ,大口'),
(4, NULL);
STRING_SPLIT もテーブル値関数なので、APPLY で各行に適用できます。
SELECT o.OrderId, s.value AS Tag
FROM Orders o
CROSS APPLY STRING_SPLIT(o.Tags, ',') AS s
ORDER BY o.OrderId;
これで、1行のカンマ区切りが複数行にバラけます。正規化されてない列も、こんな感じで APPLY で素直に縦持ちに直せる。
実行結果:

ただし、よく見ると OrderId = 4(Tags が NULL)の注文が 消えています。理由は次のハマりポイントで。
ハマりポイント: 知らないと一晩飛ぶやつ
① CROSS APPLY は右が空だと行ごと消える
さっきのパターン3、OrderId = 4 は Tags が NULL でした。STRING_SPLIT(NULL, ',') は 0行 を返す。CROSS APPLY は右が0行だと、その左の行を丸ごと落とします。
だから OrderId = 4 が結果から消えた。
「あれ、注文が1件足りない??」ってなったら、まずこれを疑ってください。
残したいなら OUTER APPLY。
SELECT o.OrderId, s.value AS Tag
FROM Orders o
OUTER APPLY STRING_SPLIT(o.Tags, ',') AS s
ORDER BY o.OrderId;
実行結果:

OUTER APPLY にすると、OrderId = 4 は Tag が NULL の1行として残ります。INNER JOIN と LEFT JOIN の関係とまったく同じ発想。
「全部の親行を残したいか / マッチした親だけでいいか」で CROSS と OUTER を選ぶ。 ここを雑に CROSS で書いて件数が合わず、締め日の集計が狂う。業務系だと普通に事故ります。
② STRING_SPLIT は「順序を保証しない」
STRING_SPLIT の返す行順は、入力の並び順を保証しません。「1個目のタグ」を取りたくて TOP 1 しても、それが本当に先頭要素とは限らない。
要素番号が欲しいなら、SQL Server 2022(互換レベル 160)以降の STRING_SPLIT(..., ',', 1) で ordinal 列が使えます。2019 以前ではこの引数が無いので、順序に依存する処理は自前で ROW_NUMBER() を振るか、別の分割方法を使う。
あと、版でもう1つ。STRING_SPLIT は SQL Server 2016 で入ったんですが、使うには DB の互換レベルが 130 以上 必要です。2008 R2 / 2012 から移行したてで、DB の互換レベルが 110 や 120 のまま残っているレガシー環境だと、'STRING_SPLIT' is not a recognized built-in function name でハードに落ちる。インスタンスが 2016 でも、DB 側が古いままだと動かないやつです。ALTER DATABASE ... SET COMPATIBILITY_LEVEL = 130 を先に確認しておいてください。
バージョン差で普通に落とし穴になるやつ。
③ 使う関数は「インライン TVF」にする
パターン2で作った関数、RETURNS TABLE だけの インラインテーブル値関数 で書きました。
これが地味に大事です。BEGIN ... END で中に処理を書く マルチステートメント TVF にすると、SQL Server はその関数の戻り行数をうまく見積もれず、実行計画が崩れて激遅になることがある。
APPLY と組むなら、右側の関数は原則インライン。ここは 実行計画の推定行数と実測がズレる話 とも直結します。

「昔はこう書いてた」を APPLY に直すと、こうなります。

before は「列が増えるたびに同じサブクエリをコピペ」する地獄。しかも同じ WHERE を2回スキャンしてる。
after は APPLY 1回で複数列まとめて取れて、スキャンも1回で済む。いい感じに軽くなります。列が3つ4つと増えるほど、この差は開きます。
現場メモ: 締め集計で件数を落とさないために
俺が APPLY を本気で使い出したのは、流通系の基幹で「取引先ごとに直近の入荷明細 TOP5 を帳票に出す」っていう要件が来たときでした。
最初は相関サブクエリを列ごとに並べて書いてたんです。で、明細の項目が増えるたびにサブクエリをコピペする羽目になって、WHERE 条件を1箇所直し忘れて数字がズレた。
しょうもないミスなんだけど、締め日直前だと血の気が引くやつ。
CROSS APPLY に直したら、右側のサブクエリ1個を直すだけで済むようになって、コピペ由来のバグが消えた。数百万行のテーブルでも、WHERE に効くインデックスさえあれば、行ごとの評価でも体感で待たされることはなかったです。
一点だけ。件数を1件も落とせない帳票(未入荷の取引先も「0件」で出したい系)は、必ず OUTER APPLY にする。ここを CROSS のままにして「あれ、取引先が減ってる??」ってなったこと、一度や二度じゃないので。
まとめ
CROSS APPLY/OUTER APPLYは「右側が左の各行の値に依存する」ときの演算子- グループごと上位N件・テーブル値関数の行適用・CSV 展開の3つが定番の使いどころ
- CROSS は右が空だと行が消える、OUTER なら NULL で残る(
INNER JOIN/LEFT JOINと同じ関係) - 右側の関数は原則インライン TVF。マルチステートメントは実行計画が崩れがち
APPLY を1個手札に入れておくと、これまで「ROW_NUMBER と CTE で頑張る」しかなかったグループ別集計が、ぐっと素直に書けるようになります。JOIN で無理してた人ほど効きます。
よくある質問
Q1. CROSS APPLY と INNER JOIN はどう違いますか?
INNER JOIN は「列 = 列」のような静的な条件で2つの集合を突き合わせます。CROSS APPLY は左の各行ごとに右のサブクエリや関数を呼び、その引数に左の値を渡せます。右側が左に依存する(TOP N や関数呼び出し)なら APPLY、静的な突き合わせなら JOIN、という切り分けで大丈夫です。
Q2. CROSS APPLY と OUTER APPLY の使い分けは?
右側が0行になったときに、左の行を残すかどうかです。CROSS APPLY は右が空だと左の行ごと消え、OUTER APPLY は NULL を埋めて残します。INNER JOIN と LEFT JOIN の関係と同じで、欠損も出したい帳票では OUTER APPLY を選びます。
Q3. グループごと上位N件は ROW_NUMBER と APPLY のどちらが良いですか?
順位を振って絞るだけなら ROW_NUMBER() が素直です。右側がテーブル値関数だったり、グループの元がマスタ側の別テーブルだったりする場合は APPLY が向きます。迷ったら ROW_NUMBER の記事も見比べてください。
Q4. STRING_SPLIT で要素の順番は保証されますか?
いいえ。SQL Server 2019 以前の STRING_SPLIT は行順を保証しません。要素番号が必要なら SQL Server 2022(互換レベル 160)以降の ordinal 引数を使うか、自前で順序を管理します。
Q5. APPLY は遅くないですか?
右側の WHERE に効くインデックスがあれば、行ごとの評価でも実用速度で動きます。遅いときは、マルチステートメント TVF を使っていないか、右側にインデックスが無いか、をまず疑ってください。
次に読むべき記事





以上!
執筆者
バイブス父さん — 業務 SE 7 年 (SIer 正社員 2 / フリーランス 5)。 現職は SEO 直轄部の AI アドバイザー兼 PL、 副業で中小 SIer の CTO。 SIer の正社員からフリーランスに転じ、 複数のエージェント経由で案件を回してきた経験ベースで「業務 SE 視点」 の技術 + キャリア記事を書いています。
🐦 X: @hiro_progra0524 (日々の現場メモ更新中)
📝 About Me で経歴詳細を見る




