動くコード図鑑技術記事現場の渡り方キャリア論すべての記事About
技術記事

SQL Server の CROSS APPLY / OUTER APPLY でグループごとの上位N件を取る3パターン

バイブス父さん
現役の業務SE
2026年9月8日14 min read広告 (PR) を含む場合があります
SQL Server の CROSS APPLY / OUTER APPLY でグループごとの上位N件を取る3パターン

みなさんこんにちは!ヒロポンです!!

「カテゴリごとに売上 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」が一発で書けます!!いい感じですね。

実行結果:

CROSS APPLY でカテゴリごとに金額の上位2件を取った実行結果

食品・家電それぞれで、金額の高い順に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 で素直に縦持ちに直せる。

実行結果:

CROSS APPLY で CSV 列を行に展開した実行結果。Tags が NULL の OrderId=4 は結果から消えている

ただし、よく見ると 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 の行として結果に残る実行結果

OUTER APPLY にすると、OrderId = 4 は Tag が NULL の1行として残ります。INNER JOINLEFT 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 と組むなら、右側の関数は原則インライン。ここは 実行計画の推定行数と実測がズレる話 とも直結します。

CROSS APPLY / OUTER APPLY / JOIN の観点別の使い分け比較

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

列ごとに相関サブクエリを並べる書き方を CROSS 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 で経歴詳細を見る


この記事のコードと手順は ぜんぶ動作検証済み。 安心して現場で試してくれ。
バイブス父さん

現役の業務SE。C# / SQL Server 保守の現場から、コードも人もキャリアも全部書く。 実体験ベース。

運営者について