C# LINQのGroupByとJoinを深掘り:複合キー、ToLookup、CountBy
LINQのgroup byとjoinをもう一歩深く。複合キー、GroupByとToLookupの違い、nullキー、重複キーの結合、.NET 9のCountBy・AggregateByまで .NET 10で動かし、速度とメモリを実測。
2026年11月改訂: この記事は2021年に韓国語で書いた講座を、日本語で書き直したものです。4つの例を .NET 10.0.14(C# 14)でコンパイルし直したところ、どれもそのままではコンパイルできませんでした。 変数名の大文字小文字(
StudentList)、fromをformと書いた誤字、クラス名の不一致(Student/Person)、クエリのキーワードgroupを変数のように使ったこと、メソッドの外に置かれた実行文、stringプロパティへの数値の代入、プロパティ名の大文字小文字(score.Math)が原因です。外部結合の例は、直して動かすと成績のない学生が100点と表示される問題もありました。修正したコードに加えて、LINQの基本 で扱っていない深掘りの内容と実測を足しています。
group by と join の基本は書けるけれど、複合キーでうまくまとまらない、結合したら件数が増えた、大きなデータでメモリが跳ね上がった、という次の段階の問題に当たった人向けの記事です。結論を先に書くと、グループのキーは値で等価比較される型(匿名型・タプル・record)にすること、GroupBy は読むたびに再計算されること、件数や合計だけが必要なら .NET 9 の CountBy/AggregateBy が今回の計測で GroupBy より3〜6倍速く、メモリもほとんど使わないことです。クエリ式の基本と .NET 10 の LeftJoin は LINQの基本 にまとめています。
2021年の4つの例を .NET 10 で動かした結果
| 例 | コンパイル結果 | 原因 | 修正 |
|---|---|---|---|
| from の入れ子 | error CS0103: 現在のコンテキストに 'StudentList' という名前は存在しません |
宣言は studentList |
大文字小文字をそろえる |
| group by | error CS1002: ; が必要です など8件 |
from を form と書いた |
from |
| group by(続き) | error CS1525: 'group' は無効です など39件 |
into data で受けたのに select new { sexCheck = group.Key } とクエリのキーワード group を変数のように使った |
data.Key |
| group by(続き) | error CS0246: 型または名前空間の名前 'Person' が見つかりませんでした |
クラスは Student で宣言 |
Person |
| 内部結合 | 構文エラー9件(CS1519 など)、Main に移すと error CS0029: 型 'int' を 'string' に暗黙的に変換できません が10件 |
クエリと foreach がクラス本体に直接ある、math/english が string |
Main 内へ、int に |
| 外部結合 | error CS1061: 'Score' に 'Math' の定義が含まれておらず… |
プロパティは math |
score.math |
元記事の本文にあった group person by … into group も試しましたが、group はクエリ内ではキーワードなので同じく CS1525 になります。どうしてもその名前を使いたいなら into @group と @ を付ければ動きます(実行して確認)。
from の入れ子と group by:修正版
from を2回書くと、学生ごとの点数配列をさらに範囲として扱えます。メソッド構文では SelectMany です。元記事は変数名を failScore(不合格の点数)としながら90点以上を選んでいたので、名前と条件を意図に合わせました。
Student[] studentList =
[
new("A", [88, 99, 66, 91]),
new("B", [78, 67, 89, 51]),
new("C", [38, 73, 100, 44]),
];
var good = from student in studentList
from score in student.Scores
where score >= 90
select new { student.Name, Score = score };
// メソッド構文:studentList.SelectMany(st => st.Scores, (st, score) => new { st.Name, Score = score }).Where(x => x.Score >= 90)
record Student(string Name, int[] Scores);
A 99
A 91
C 100
group by の例は3か所を直すと意図どおりに動きます。
var groups = from p in people
group p by p.Sex == "male" into g
select new { IsMale = g.Key, People = g };
female: A, C, E
male: B, D
元記事は「into の後ろの変数には2つのグループが入る」と書いていましたが、2つなのはキーが bool だからです。グループの数は異なるキーの数で、順番は並べ替えではなくキーが最初に現れた順です。上の結果で female が先なのも、最初の要素が female だからにすぎません。
グループキーの罠:大文字小文字、複合キー、null
次の注文データで実験しました(Kim だけ大文字)。
var orders = new List<Order>
{
new(1, "kim", "web", 2026, 10, 1200),
new(2, "lee", "app", 2026, 10, 800),
new(3, "kim", "app", 2026, 11, 500),
new(4, "park", "web", 2026, 11, 3000),
new(5, "Kim", "web", 2026, 11, 700),
new(6, "lee", "app", 2026, 11, 900),
};
record Order(int Id, string Customer, string Channel, int Year, int Month, decimal Amount);
大文字小文字: 文字列キーは既定で大文字小文字を区別します。GroupBy(o => o.Customer) は kim:2, lee:2, park:1, Kim:1 に分かれ、比較子を渡すと kim:3, lee:2, park:1 になりました。クエリ式(group … by)には比較子を渡す場所がないので、メソッド構文を使います。
orders.GroupBy(o => o.Customer, StringComparer.OrdinalIgnoreCase)
複合キー: 年・月でまとめれば2グループ(2026-10、2026-11)になるはずです。キーの型によって結果が変わりました。
orders.GroupBy(o => new { o.Year, o.Month }) // 匿名型 → 2
orders.GroupBy(o => (o.Year, o.Month)) // タプル → 2
orders.GroupBy(o => new MonthKeyRecord(o.Year, o.Month)) // record → 2
orders.GroupBy(o => new MonthKeyClass(o.Year, o.Month)) // class → 6(!)
record MonthKeyRecord(int Year, int Month);
class MonthKeyClass(int y, int m) { public int Y = y; public int M = m; }
普通の class は Equals/GetHashCode をオーバーライドしなければ参照で比較されるので、値が同じでもすべて別のキーになり、要素の数だけグループができました。エラーも警告も出ないので見つけにくいバグです。キーには匿名型・タプル・record を選びましょう。
null キー: GroupBy は null も普通のキーとして扱い、(null):2, x:1 とグループを作りました。一方 Join はキーが null の要素をまったくマッチさせませんでした。 両側に null キーがあったのに、結果は b-xtag の1件だけです。同じデータを Where(t => t.Key == p.Tag) で直接比較すると、null == null は真なので3件になりました。Join を == のループに書き換えたり、その逆をしたりすると件数が変わる原因になります。
GroupBy は遅延実行、ToLookup は即時実行
GroupBy もほかの LINQ 演算子と同じく、取り出すたびに最初からグループ化し直します。 キー関数の呼び出し回数を数えました。
int keyCalls = 0;
var q = orders.GroupBy(o => { keyCalls++; return o.Channel; });
// 定義直後 keyCalls=0
foreach (var g in q) { }
// foreach 1回のあと keyCalls=6
_ = q.Count();
// Count() のあと keyCalls=12 ← 6件をもう一度グループ化
orders.Add(new(7, "choi", "store", 2026, 11, 100));
// 再度列挙するとグループは web, app, store keyCalls=19 ← 後から追加した注文も入る
keyCalls = 0;
var first = q.First();
// First() のあと keyCalls=7(7件中7件) ← 1グループ取るだけでも全部読む
最後の行が大事です。1つのグループしか要らなくても、GroupBy はソースを最後まで読む必要があります。そのグループの要素が末尾にあるかもしれないからです。無限シーケンスや非常に長いストリームに GroupBy(...).First() を使うと、終わらないかメモリを使い切ります。
ToLookup は呼んだ瞬間に1回だけグループ化し、結果を固定します。
keyCalls = 0;
var lk = orders.ToLookup(o => { keyCalls++; return o.Channel; });
// ToLookup 直後 keyCalls=7
orders.Add(new(8, "jung", "phone", 2026, 11, 50));
// lk のキー:web, app, store(3つ) ← 後から追加した phone は入らない
lk["fax"].Count(); // 0、例外なし
lk.Contains("fax"); // False
var dict = orders.GroupBy(o => o.Channel).ToDictionary(g => g.Key, g => g.ToList());
_ = dict["fax"]; // KeyNotFoundException: The given key 'fax' was not present in the dictionary.
存在しないキーを引いたとき、ILookup は空のシーケンス、Dictionary は例外です。「顧客ごとの注文一覧を作って何度も引く」コードなら、毎回 GroupBy を回し直さず ToLookup を1回呼ぶのが正解です。
Join の深掘り:重複キー、GroupJoin、外部結合
内部結合と外部結合(2021年の図)
内部結合は両側にあるキーだけを残します(図の「일치」は一致、「내부조인」は内部結合、「통합된 데이터」は統合されたデータの意味です)。

修正した2021年の例(プロフィール A〜E、成績 B・E・F・G・H)の内部結合の結果です。
{ Name = B, Age = 22, Math = 20, English = 70 }
{ Name = E, Age = 15, Math = 50, English = 20 }
外部(左)結合は、基準側(左)を1件も落とさず、相手がなければ空の値で埋めます(図の「기준데이터」は基準データ、「외부조인」は外部結合です)。

2021年のコードは空きを DefaultIfEmpty(new Score() { math = 100, english = 100 }) で埋めていました。直して実行するとこうなります。
{ Name = A, Age = 11, Math = 100, English = 100 }
{ Name = B, Age = 22, Math = 20, English = 70 }
{ Name = C, Age = 33, Math = 100, English = 100 }
...
成績がない学生が満点に見えます。既定値は「ない」ことを表せるべきなので、DefaultIfEmpty() で null を受け取り ?. で取り出すほうが安全です。
var outer = from p in profiles
join s in scores on p.Name equals s.Name into temp
from s in temp.DefaultIfEmpty()
select new { p.Name, p.Age, Math = s?.Math, English = s?.English };
{ Name = A, Age = 11, Math = , English = }
{ Name = B, Age = 22, Math = 20, English = 70 }
...
.NET 10 の LeftJoin で書いた結果と SequenceEqual で比べると True でした。.NET 10 には逆向きの RightJoin もあり、orders.RightJoin(customers, …) を実行すると、注文のない顧客 han が han:- として残りました。
重複キーは行を掛け算する
結合相手の側に同じキーが2つあると、その行は掛け算で増えます。 顧客テーブルに kim が2回(Seoul と Busan)入っている状態で注文と結合しました。
var customers = new[] { new C("kim", "Seoul"), new C("kim", "Busan"), new C("lee", "Daegu") };
var j = orders.Join(customers, o => o.Customer, c => c.Name, (o, c) => (o.Id, c.City)).ToList();
(1, Seoul) (1, Busan) (2, Daegu) (3, Seoul) (3, Busan) (6, Daegu)
kim の注文2件は4行になり、顧客テーブルにない park と大文字の Kim の注文は消えました。合計件数はたまたま6のままなので、Count() だけで検証していたら見逃していたはずです。マスターデータと結合する前に、キーが一意か確かめましょう。customers.CountBy(c => c.Name).Where(kv => kv.Value > 1) はここでは [kim, 2] を返しました。
GroupJoin:平らにしない結合
GroupJoin(クエリ式の join … into)は、左の要素1つに右側の一致リストを丸ごと付けます。「顧客ごとの注文数と合計」のような1対多の集計に向いていて、一致がない顧客も0件として残ります。
var summary = customers.DistinctBy(c => c.Name).Append(new C("han", "Incheon"))
.GroupJoin(orders, c => c.Name, o => o.Customer,
(c, os) => new { c.Name, Count = os.Count(), Total = os.Sum(o => o.Amount) });
{ Name = kim, Count = 2, Total = 1700 }
{ Name = lee, Count = 2, Total = 1700 }
{ Name = han, Count = 0, Total = 0 }
.NET 9 の CountBy・AggregateBy と速度の実測
グループの要素一覧は要らず、件数や合計だけ欲しい場面はよくあります。.NET 9 でそのための CountBy と AggregateBy が入りました。結果は KeyValuePair<TKey, TValue> のシーケンスで、これも遅延実行です。
foreach (var kv in orders.CountBy(o => o.Channel)) Console.Write($"{kv.Key}={kv.Value} ");
// web=3 app=3
foreach (var kv in orders.AggregateBy(o => o.Channel, seed: 0m, (sum, o) => sum + o.Amount)) Console.Write($"{kv.Key}={kv.Value} ");
// web=4900 app=2200
どれくらい差があるか測りました。キー1000種類・500万要素(readonly record struct Item(int Key, int Value))をキーごとに数える/足す処理と、2万×2万の結合です。.NET 10.0.14、Release、Apple M1 Pro、3回実行。割り当て量は GC.GetAllocatedBytesForCurrentThread() の差です。
| 処理 | 1回目 | 2回目 | 3回目 | 割り当て |
|---|---|---|---|---|
GroupBy + Count() |
53.9 ms | 52.4 ms | 51.7 ms | 125.4 MB |
ToLookup + Count() |
60.7 ms | 59.0 ms | 57.8 ms | 125.4 MB |
CountBy(.NET 9) |
17.5 ms | 16.9 ms | 17.1 ms | 0.1 MB |
Dictionary で自前に数える |
12.3 ms | 12.2 ms | 12.2 ms | 0.1 MB |
GroupBy + Sum() |
111.2 ms | 107.9 ms | 107.3 ms | 125.4 MB |
AggregateBy(.NET 9)で合計 |
18.5 ms | 18.2 ms | 18.0 ms | 0.1 MB |
Join 2万×2万 |
2.5 ms | 2.4 ms | 2.4 ms | 2.2 MB |
二重 foreach 2万×2万 |
189.1 ms | 187.7 ms | 187.9 ms | 0 MB |
SelectMany + Where 2万×2万 |
159.1 ms | 158.3 ms | 160.0 ms | 4.9 MB |
GroupBy/ToLookupは500万要素をすべてグループ内にコピーして持つため、125MB を割り当てました。CountBy/AggregateByはキーごとに値を1つ持つだけなので 0.1MB、時間は3〜6倍速くなりました。Dictionaryで自前に数えるコード(CollectionsMarshal.GetValueRefOrAddDefault)が最速でしたが、CountByとの差は5ミリ秒ほどです。たいていはCountByの読みやすさが勝ちます。Joinは右側でハッシュテーブルを作って1回ずつ引くので、二重ループより約77倍速くなりました。「LINQ は遅い」とは逆に、結合では手書きの二重ループのほうがずっと遅かったわけです。SelectManyの中でWhereを使って結合のまねをするコードも、二重ループと同じ O(N×M) です。
どれをいつ使うか
| やりたいこと | おすすめ |
|---|---|
| グループごとの件数 | CountBy(.NET 9+)、それ以前は GroupBy(…).Select(g => (g.Key, g.Count())) |
| グループごとの合計・最大など1つの値 | AggregateBy(.NET 9+) |
| グループの要素一覧が必要、列挙は1回 | GroupBy |
| 同じグループを何度も引く、ないキーも安全に | ToLookup |
| 複数プロパティでまとめる | 匿名型・タプル・record のキー(普通の class は不可) |
| 大文字小文字を無視するなど比較ルールを変える | メソッド構文 + IEqualityComparer |
| 両側にあるものだけ | Join |
| 左側はすべて残す | LeftJoin(.NET 10)、それ以前は join … into + DefaultIfEmpty() |
| 1対多を集計 | GroupJoin |
キーに null がありうる |
Join はマッチしないと覚えておき、必要なら null を別の値に置き換えてから結合 |
基本の句(from・where・orderby・select)と遅延実行の基本は LINQの基本、LINQ に渡すラムダが変数をキャプチャしたときのコストは C#のラムダ式 を参照してください。
確認環境:.NET SDK 10.0.403 / ランタイム 10.0.14、C# 14、macOS(Apple M1 Pro)。
よくある質問
GroupByとToLookupの違いは?
GroupByは遅延実行で、結果を取り出すたびにソース全体をグループ化し直します。6件に対してforeachを1回、Count()を1回実行すると、キー関数は12回呼ばれました。ToLookupはその場で1回だけグループ化して結果を固定し、存在しないキーを引いても例外ではなく空のシーケンスを返します。
複数のプロパティでグループ化(複合キー)するには?
匿名型 new { o.Year, o.Month }、タプル (o.Year, o.Month)、record をキーにします。Equals をオーバーライドしていない普通の class をキーにすると、値が同じでも別のキー扱いになり、2つになるはずのグループが6つ(要素数ぶん)に分かれました。
グループごとの件数だけ欲しいとき、一番速い方法は?
.NET 9 以降なら CountBy です。500万件・キー1000種類で、GroupBy+Count は約52ミリ秒・125MBの割り当て、CountBy は約17ミリ秒・0.1MB、Dictionary で自前に数えると約12ミリ秒でした(.NET 10、Release、Apple M1 Pro)。