LINQ の GroupBy・OrderBy・Join は、データ処理の要です。基本的な使い方はすぐ覚えられますが、多段ソート・複合キーのグループ化・LEFT OUTER JOIN・ToLookup の使いどころなど、実務で必要になる応用知識は案外まとまった情報が少ないです。
本記事では各演算子を仕様レベルで掘り下げ、実践的なサンプルとともに解説します。基本的な Where/Select/Distinct はLINQ完全ガイドを、GroupBy 後の Sum/Average などの集約処理はLINQでグルーピング・集計を行う方法を参照してください。
本記事で使うサンプルデータ
// 本記事で使うサンプルデータ
public record Employee(int Id, string Name, string Department, int Age, decimal Salary);
public record Department(int Id, string Name, string Location);
public record SalesRecord(string Product, string Region, int Month, decimal Amount);
var employees = new List<Employee>
{
new(1, "Alice", "開発", 28, 550_000),
new(2, "Bob", "開発", 35, 680_000),
new(3, "Carol", "営業", 24, 420_000),
new(4, "Dave", "営業", 31, 510_000),
new(5, "Eve", "人事", 29, 460_000),
new(6, "Frank", "開発", 26, 530_000),
new(7, "Grace", "営業", 38, 590_000),
new(8, "Hiro", "人事", 33, 490_000),
};
var departments = new List<Department>
{
new(1, "開発", "東京"),
new(2, "営業", "大阪"),
new(3, "人事", "東京"),
new(4, "マーケ", "名古屋"), // 該当従業員なし(LEFT JOIN テスト用)
};
var sales = new List<SalesRecord>
{
new("製品A", "東京", 1, 1_200_000),
new("製品B", "東京", 1, 800_000),
new("製品A", "大阪", 1, 950_000),
new("製品A", "東京", 2, 1_350_000),
new("製品B", "大阪", 2, 620_000),
new("製品B", "東京", 2, 750_000),
new("製品A", "大阪", 3, 1_100_000),
new("製品B", "大阪", 3, 580_000),
};
OrderBy — ソートの完全解説
基本と OrderByDescending
// 昇順ソート var byAge = employees.OrderBy(e => e.Age); // Alice(28), Frank(26)... → 年齢の若い順 // 降順ソート var bySalaryDesc = employees.OrderByDescending(e => e.Salary); // Bob(680_000), Grace(590_000)... → 給与の高い順 // ソート後は IOrderedEnumerable<T> になる(ThenBy をチェーンできる)
ThenBy / ThenByDescending — 多段ソート
第1キーが同じ場合の第2・第3ソートは ThenBy/ThenByDescending をチェーンします。OrderBy を複数並べると前のソートが捨てられるため注意が必要です。
// NG: OrderBy を複数並べると最後の OrderBy しか効かない
var wrong = employees
.OrderBy(e => e.Department) // 無視される!
.OrderBy(e => e.Salary); // これだけ有効
// OK: ThenBy でチェーン
var correct = employees
.OrderBy(e => e.Department) // 第1キー: 部門名(昇順)
.ThenBy(e => e.Age) // 第2キー: 年齢(昇順)
.ThenByDescending(e => e.Salary);// 第3キー: 給与(降順)
foreach (var e in correct)
Console.WriteLine($"{e.Department,-6} {e.Age,2}歳 ¥{e.Salary:N0} {e.Name}");
// 開発 26歳 ¥530,000 Frank
// 開発 28歳 ¥550,000 Alice
// 開発 35歳 ¥680,000 Bob
// 人事 29歳 ¥460,000 Eve
// 人事 33歳 ¥490,000 Hiro
// 営業 24歳 ¥420,000 Carol
// 営業 31歳 ¥510,000 Dave
// 営業 38歳 ¥590,000 Grace
カスタム比較 — IComparer と StringComparer
\
// 文字列を「大文字小文字を無視してソート」
var names = new[] { "banana", "Apple", "cherry", "AVOCADO" };
var sorted = names.OrderBy(n => n, StringComparer.OrdinalIgnoreCase);
// → Apple, AVOCADO, banana, cherry
// 数字入り文字列を「自然順(Natural Sort)」でソート
// "Item2" < "Item10"(辞書順だと "Item10" < "Item2" になる)
var items = new[] { "Item10", "Item2", "Item1", "Item20", "Item3" };
var naturalSorted = items.OrderBy(
s => System.Text.RegularExpressions.Regex.Replace(s, @"\d+", m => m.Value.PadLeft(10, '0'))
);
// → Item1, Item2, Item3, Item10, Item20
// 独自 IComparer を使う例(長さ優先、同じ長さなら辞書順)
public class LengthThenAlpha : IComparer<string>
{
public int Compare(string? x, string? y)
{
if (x is null || y is null) return Comparer<string>.Default.Compare(x, y);
int lenDiff = x.Length - y.Length;
return lenDiff != 0 ? lenDiff : string.Compare(x, y, StringComparison.Ordinal);
}
}
var customSorted = names.OrderBy(n => n, new LengthThenAlpha());
// → Apple, banana, AVOCADO, cherry(長さ昇順 → 辞書順)
OrderBy の安定性
OrderBy は安定ソートです同じキー値を持つ要素は、元のシーケンスでの順序が保持されます(.NET の実装は TimSort ベース)。複数回ソートしても前のソート結果が壊れないため、
ThenBy を使わなくても段階的にソートを構築できます(ただし ThenBy の方が明示的で推奨)。GroupBy — グループ化の完全解説
4つのオーバーロード
| オーバーロード | 説明 |
|---|---|
GroupBy(keySelector) |
最もシンプル。要素はそのまま IGrouping<TKey,TSource> に |
GroupBy(keySelector, elementSelector) |
グループ内の要素を変換してから格納 |
GroupBy(keySelector, resultSelector) |
各グループをまとめて別の型に変換 |
GroupBy(keySelector, elementSelector, resultSelector) |
要素変換 + グループ変換の両方 |
// ① keySelector のみ
var byDept1 = employees.GroupBy(e => e.Department);
foreach (var g in byDept1)
Console.WriteLine($"{g.Key}: {string.Join(", ", g.Select(e => e.Name))}");
// 開発: Alice, Bob, Frank
// 営業: Carol, Dave, Grace
// 人事: Eve, Hiro
// ② keySelector + elementSelector(要素を変換して格納)
var byDept2 = employees.GroupBy(
e => e.Department,
e => e.Name // 名前だけ格納
);
// g.Key = "開発", g = ["Alice", "Bob", "Frank"]
// ③ keySelector + resultSelector(グループを集約して新しい型に)
var summary = employees.GroupBy(
e => e.Department,
(dept, members) => new
{
Department = dept,
Count = members.Count(),
AvgSalary = members.Average(e => e.Salary),
MaxSalary = members.Max(e => e.Salary),
}
);
foreach (var s in summary.OrderByDescending(s => s.AvgSalary))
Console.WriteLine($"{s.Department}: {s.Count}名, 平均¥{s.AvgSalary:N0}, 最高¥{s.MaxSalary:N0}");
// 開発: 3名, 平均¥586,667, 最高¥680,000
// 営業: 3名, 平均¥506,667, 最高¥590,000
// 人事: 2名, 平均¥475,000, 最高¥490,000
複合キーでグループ化
複数のプロパティをキーにしたい場合は、匿名型または ValueTuple をキーにします。
// 匿名型をキーに(部門 × 30代以上かどうか)
var grouped = employees.GroupBy(e => new
{
e.Department,
IsSenior = e.Age >= 30
});
foreach (var g in grouped.OrderBy(g => g.Key.Department).ThenBy(g => g.Key.IsSenior))
{
string label = g.Key.IsSenior ? "30代以上" : "20代";
Console.WriteLine($"{g.Key.Department} [{label}]: {string.Join(", ", g.Select(e => e.Name))}");
}
// 人事 [20代]: Eve
// 人事 [30代以上]: Hiro
// 営業 [20代]: Carol
// 営業 [30代以上]: Dave, Grace
// 開発 [20代]: Alice, Frank
// 開発 [30代以上]: Bob
// ValueTuple をキーにする方法
var grouped2 = sales.GroupBy(s => (s.Product, s.Region));
foreach (var g in grouped2)
Console.WriteLine($"{g.Key.Product} × {g.Key.Region}: 合計 ¥{g.Sum(s => s.Amount):N0}");
GroupBy vs ToLookup — 遅延評価 vs 即時評価
| 観点 | GroupBy |
ToLookup |
|---|---|---|
| 評価タイミング | 遅延評価(foreach 時に実行) |
即時評価(呼び出し時点でグループ化完了) |
| アクセス方法 | foreach でのみ走査 |
キーで直接アクセス可(lookup["開発"]) |
| 存在しないキー | グループなし(列挙されない) | 空シーケンスが返る(KeyNotFoundException なし) |
| 複数回の利用 | 毎回グループ化処理が走る | 1回だけ処理して再利用できる |
| 用途 | 一度だけ走査する集計処理 | キーで繰り返し参照する場合・静的なルックアップ表 |
// ToLookup: 即時評価でグループ化した Lookup<TKey, TElement> を生成
ILookup<string, Employee> lookup = employees.ToLookup(e => e.Department);
// キーで直接アクセスできる
foreach (var emp in lookup["開発"])
Console.WriteLine(emp.Name); // Alice, Bob, Frank
// 存在しないキーは KeyNotFoundException ではなく空シーケンスを返す
foreach (var emp in lookup["広報"]) // キーなし → 空シーケンス、例外なし
Console.WriteLine(emp.Name); // 何も出力されない
// ContainsKey 相当の確認
Console.WriteLine(lookup.Contains("人事")); // True
Console.WriteLine(lookup.Contains("広報")); // False
// GroupBy との差: 複数回参照するなら ToLookup の方が効率的
// GroupBy を複数回 foreach するとグループ化処理が毎回実行される
var byDept = employees.GroupBy(e => e.Department);
// 下の2回のforeachで2回グループ化処理が走る(遅延評価のため)
var devCount = byDept.First(g => g.Key == "開発").Count();
var salesList = byDept.First(g => g.Key == "営業").ToList();
// ToLookup なら1回だけ処理されてキャッシュされる
Join — 結合の完全解説
INNER JOIN(基本)
// departments: Id 1=開発, 2=営業, 3=人事, 4=マーケ(従業員なし)
// employees: Department は名前文字列で持つ(Id と一致させるには別途参照)
// 部門 Id × 従業員の Department 名を突き合わせる例
var deptById = departments.ToDictionary(d => d.Name);
// employees × departments を結合(Department 名がキー)
var empWithLocation = employees.Join(
departments,
emp => emp.Department, // 外部シーケンスのキー(従業員の部門名)
dept => dept.Name, // 内部シーケンスのキー(部門の名前)
(emp, dept) => new // 結果セレクタ
{
emp.Name,
emp.Department,
dept.Location,
emp.Salary,
}
);
foreach (var r in empWithLocation.OrderBy(r => r.Department))
Console.WriteLine($"{r.Name,-8} {r.Department,-6} {r.Location} ¥{r.Salary:N0}");
// Alice 開発 東京 ¥550,000
// Bob 開発 東京 ¥680,000
// ...
// ※ "マーケ" の部門は従業員がいないため結果に含まれない(INNER JOIN)
複合キーの Join
// 製品 × 地域ごとの月次予算データ
var budgets = new[]
{
new { Product = "製品A", Region = "東京", Budget = 1_500_000m },
new { Product = "製品A", Region = "大阪", Budget = 1_000_000m },
new { Product = "製品B", Region = "東京", Budget = 900_000m },
new { Product = "製品B", Region = "大阪", Budget = 700_000m },
};
// sales × budgets を 製品 × 地域 の複合キーで結合
var comparison = sales.GroupBy(s => (s.Product, s.Region))
.Join(
budgets,
g => (g.Key.Product, g.Key.Region), // GroupBy 後のキー
b => (b.Product, b.Region), // budgets のキー
(g, b) => new
{
g.Key.Product,
g.Key.Region,
TotalSales = g.Sum(s => s.Amount),
b.Budget,
AchievementRate = g.Sum(s => s.Amount) / b.Budget * 100,
}
);
foreach (var r in comparison.OrderBy(r => r.Product).ThenBy(r => r.Region))
Console.WriteLine($"{r.Product} × {r.Region}: 実績 ¥{r.TotalSales:N0} / 予算 ¥{r.Budget:N0} ({r.AchievementRate:F1}%)");
// 製品A × 大阪: 実績 ¥2,050,000 / 予算 ¥1,000,000 (205.0%)
// 製品A × 東京: 実績 ¥2,550,000 / 予算 ¥1,500,000 (170.0%)
// 製品B × 大阪: 実績 ¥1,200,000 / 予算 ¥700,000 (171.4%)
// 製品B × 東京: 実績 ¥1,550,000 / 予算 ¥900,000 (172.2%)
GroupJoin — LEFT OUTER JOIN(全件維持)
INNER JOIN では片方にしかないレコードが除外されます。左側(外部)のすべての要素を維持したい場合は GroupJoin を使います。
// departments(左)の全件と、対応する employees(右)を結合
// 「マーケ」のように従業員がいない部門も結果に含める
var deptWithEmps = departments.GroupJoin(
employees,
dept => dept.Name, // 外側(departments)のキー
emp => emp.Department, // 内側(employees)のキー
(dept, emps) => new // emps は IEnumerable<Employee>(0件の場合もある)
{
DeptName = dept.Name,
Location = dept.Location,
Employees = emps.ToList(),
HeadCount = emps.Count(),
TotalSalary = emps.Sum(e => e.Salary),
}
);
foreach (var d in deptWithEmps.OrderBy(d => d.DeptName))
{
Console.WriteLine($"{d.DeptName} ({d.Location}): {d.HeadCount}名, 合計¥{d.TotalSalary:N0}");
foreach (var e in d.Employees)
Console.WriteLine($" └ {e.Name} ¥{e.Salary:N0}");
}
// 営業 (大阪): 3名, 合計¥1,520,000
// └ Carol ¥420,000
// └ Dave ¥510,000
// └ Grace ¥590,000
// 人事 (東京): 2名, 合計¥950,000
// └ ...
// マーケ (名古屋): 0名, 合計¥0 ← 従業員なしでも行が出る
// 開発 (東京): 3名, 合計¥1,760,000
GroupJoin + SelectMany + DefaultIfEmpty — フラットな LEFT OUTER JOIN
GroupJoin の結果はネスト構造です。SQL の LEFT JOIN のようにフラットなレコードが欲しい場合は SelectMany + DefaultIfEmpty() を組み合わせます。
// SQL の LEFT JOIN 相当: 全部門 × 各従業員(従業員なし部門は null になる)
var leftJoinResult = departments.GroupJoin(
employees,
dept => dept.Name,
emp => emp.Department,
(dept, emps) => (dept, emps) // タプルで保持
)
.SelectMany(
t => t.emps.DefaultIfEmpty(), // 従業員が0件なら null を1件挿入
(t, emp) => new
{
DeptName = t.dept.Name,
Location = t.dept.Location,
EmpName = emp?.Name ?? "(なし)", // null なら "(なし)"
Salary = emp?.Salary ?? 0m,
}
);
foreach (var r in leftJoinResult.OrderBy(r => r.DeptName).ThenBy(r => r.EmpName))
Console.WriteLine($"{r.DeptName,-6} {r.Location} {r.EmpName,-8} ¥{r.Salary:N0}");
// 営業 大阪 Carol ¥420,000
// 営業 大阪 Dave ¥510,000
// 営業 大阪 Grace ¥590,000
// 人事 東京 Eve ¥460,000
// 人事 東京 Hiro ¥490,000
// マーケ 名古屋(なし) ¥0 ← LEFT JOIN で保持
// 開発 東京 Alice ¥550,000
// 開発 東京 Bob ¥680,000
// 開発 東京 Frank ¥530,000
| 結合種類 | LINQ での実装 | SQL 相当 |
|---|---|---|
| INNER JOIN | Join |
INNER JOIN(両方に存在するものだけ) |
| LEFT OUTER JOIN(ネスト) | GroupJoin |
左側全件 + 右側グループ |
| LEFT OUTER JOIN(フラット) | GroupJoin + SelectMany + DefaultIfEmpty |
LEFT JOIN(左側全件 + 右側 nullable) |
| CROSS JOIN | from a in A from b in B select ... |
CROSS JOIN(全組み合わせ) |
| 自己結合 | 同一コレクションを2回 Join | self JOIN |
クエリ構文 vs メソッド構文
GroupBy と Join はメソッド構文で書くとラムダのネストが深くなります。クエリ構文(SQL ライク)の方が読みやすい場面があります。
// メソッド構文
var method = employees
.GroupBy(e => e.Department)
.Select(g => new { Department = g.Key, Count = g.Count(), Avg = g.Average(e => e.Salary) });
// クエリ構文(SQL ライク)
var query =
from e in employees
group e by e.Department into g
select new { Department = g.Key, Count = g.Count(), Avg = g.Average(e => e.Salary) };
// 両者は同一の結果を生成する
// メソッド構文(ラムダが3段になり読みにくい)
var methodJoin = employees.Join(
departments,
e => e.Department,
d => d.Name,
(e, d) => new { e.Name, e.Salary, d.Location }
);
// クエリ構文(SQL の JOIN 構文と対応するので直感的)
var queryJoin =
from e in employees
join d in departments on e.Department equals d.Name
select new { e.Name, e.Salary, d.Location };
// GroupJoin のクエリ構文(LEFT JOIN)
var queryLeft =
from d in departments
join e in employees on d.Name equals e.Department into empGroup
from e in empGroup.DefaultIfEmpty()
select new
{
DeptName = d.Name,
EmpName = e?.Name ?? "(なし)",
};
・
Where/Select/OrderBy のみ → メソッド構文が簡潔・
GroupBy / Join / GroupJoin が絡む → クエリ構文の方が読みやすい場合が多い・混在も可能:
from ... join ... select の後に .OrderBy(...) をチェーン実践例
実践例1: 月次売上レポート(GroupBy + OrderBy + 集計)
// 製品 × 地域 の全組み合わせ集計 → 製品別合計でソート
var report = sales
.GroupBy(s => s.Product)
.Select(g => new
{
Product = g.Key,
TotalAmount = g.Sum(s => s.Amount),
ByRegion = g.GroupBy(s => s.Region)
.Select(rg => new { Region = rg.Key, Amount = rg.Sum(s => s.Amount) })
.OrderByDescending(rg => rg.Amount)
.ToList(),
BestMonth = g.GroupBy(s => s.Month)
.OrderByDescending(mg => mg.Sum(s => s.Amount))
.First().Key,
})
.OrderByDescending(r => r.TotalAmount);
foreach (var r in report)
{
Console.WriteLine($"
【{r.Product}】合計 ¥{r.TotalAmount:N0} 最好調月: {r.BestMonth}月");
foreach (var rg in r.ByRegion)
Console.WriteLine($" {rg.Region}: ¥{rg.Amount:N0}");
}
// 【製品A】合計 ¥5,600,000 最好調月: 2月
// 東京: ¥2,550,000
// 大阪: ¥2,050,000
// ...(以下略)
実践例2: 部門別スキルマップ(GroupJoin + 集計)
// 全部門(従業員なし部門も含む)の統計サマリーを生成
var dashboard = departments
.GroupJoin(employees, d => d.Name, e => e.Department, (d, emps) =>
{
var empList = emps.ToList();
return new
{
d.Name,
d.Location,
Count = empList.Count,
AvgAge = empList.Count > 0 ? empList.Average(e => e.Age) : (double?)null,
TotalSalary = empList.Sum(e => e.Salary),
SeniorCount = empList.Count(e => e.Age >= 30),
};
})
.OrderByDescending(d => d.TotalSalary);
Console.WriteLine($"{"部門",-8} {"拠点",-6} {"人数",4} {"平均年齢",8} {"合計給与",14} {"30代以上",8}");
Console.WriteLine(new string('-', 55));
foreach (var d in dashboard)
{
string avgAge = d.AvgAge.HasValue ? $"{d.AvgAge:F1}歳" : " ----";
Console.WriteLine($"{d.Name,-8} {d.Location,-6} {d.Count,4} {avgAge,8} ¥{d.TotalSalary,12:N0} {d.SeniorCount,4}名");
}
実践例3: 同一グループ内のランキング生成
// 各部門内で給与が高い順に順位を付ける
var ranked = employees
.GroupBy(e => e.Department)
.SelectMany(g =>
g.OrderByDescending(e => e.Salary)
.Select((e, i) => new
{
e.Department,
e.Name,
e.Salary,
Rank = i + 1, // 0始まりのインデックスを1始まりに
})
)
.OrderBy(r => r.Department)
.ThenBy(r => r.Rank);
foreach (var r in ranked)
Console.WriteLine($"{r.Department} #{r.Rank}: {r.Name} ¥{r.Salary:N0}");
// 人事 #1: Hiro ¥490,000
// 人事 #2: Eve ¥460,000
// 営業 #1: Grace ¥590,000
// ...
よくある落とし穴と注意点
GroupBy の結果を複数回 foreach すると処理が重複する
// NG: GroupBy の結果を複数回 foreach すると毎回グループ化処理が走る var grouped = employees.GroupBy(e => e.Department); // まだ実行されない var devCount = grouped.First(g => g.Key == "開発").Count(); // 1回目の実行 var devNames = grouped.First(g => g.Key == "開発").Select(e => e.Name).ToList(); // 2回目の実行 // → employees リストを2回スキャンしてグループ化している! // OK: ToLookup か ToList()/ToArray() で実体化してから使う var lookup = employees.ToLookup(e => e.Department); // 1回だけ実行 var devCount2 = lookup["開発"].Count(); // O(1) アクセス var devNames2 = lookup["開発"].Select(e => e.Name).ToList();
Join に null が含まれると一致しない
var data = new[]
{
new { Id = 1, Category = "A" },
new { Id = 2, Category = (string?)null }, // null
};
var categories = new[]
{
new { Name = "A", Label = "カテゴリA" },
new { Name = (string?)null, Label = "未分類" }, // null
};
// Join では null == null が一致しない(EqualityComparer の仕様)
var joined = data.Join(categories, d => d.Category, c => c.Name, (d, c) => (d.Id, c.Label));
// → Id=1 のみ結果に含まれる。Id=2(null)は一致しない!
// null を含む場合の対策: 事前にデフォルト値に変換する
var joined2 = data.Join(
categories,
d => d.Category ?? "",
c => c.Name ?? "",
(d, c) => (d.Id, c.Label)
);
よくある質問
Array.Sort はインプレース(元の配列を変更)で高速です。OrderBy は新しいシーケンスを返し、安定ソートです。配列をインプレースでソートして良い場合は Array.Sort の方が高速ですが、IEnumerable 全般に使えて安定性が保証されるという意味で OrderBy の方が汎用的です。.NET 7 以降は list.Order()/list.OrderDescending() という LINQ メソッドも追加されています。First(g => g.Key == "開発") で取り出せますが、前述のとおり複数回参照するなら ToLookup が効率的です。lookup["開発"] で直接アクセスでき、存在しないキーでも KeyNotFoundException が発生せず空シーケンスが返ります。GroupJoin(LEFT OUTER JOIN)を使います。両方のコレクションに存在するものだけが必要なら Join(INNER JOIN)で十分です。「部門一覧で従業員がいない部門も表示したい」→ GroupJoin、「従業員の部門名を表示したいだけ」→ Join が適切です。Join/GroupJoin はクエリ構文の方が意図が読み取りやすく、複数の結合を連鎖させるときも見通しが良いです。Where/Select/OrderBy だけならメソッド構文が短く書けます。まとめ
| 演算子 | ポイント |
|---|---|
OrderBy/OrderByDescending |
安定ソート。複数 OrderBy 連鎖は最後だけ有効 |
ThenBy/ThenByDescending |
多段ソートは ThenBy をチェーン。IOrderedEnumerable に対して使う |
GroupBy(4オーバーロード) |
遅延評価。複合キーは匿名型か ValueTuple。結果は IGrouping<K,V> |
ToLookup |
即時評価。複数回参照・キー検索が多い場合に GroupBy より効率的 |
Join(INNER JOIN) |
両コレクションに存在するもの同士を結合。null キーは一致しない |
GroupJoin(LEFT JOIN ネスト) |
左側全件 + 右側グループ。従業員なし部門も含む集計に |
GroupJoin + SelectMany + DefaultIfEmpty |
フラットな LEFT OUTER JOIN。SQL の LEFT JOIN に相当 |
集約関数(Sum/Average/Max/Min)と GroupBy の組み合わせはLINQでグルーピング・集計を行う方法を、IEnumerable と IQueryable(Entity Framework での LINQ)の違いはIEnumerableとIQueryableの違いを参照してください。