C# DataTable を LINQ でフィルタ・GroupBy・分割する3パターン
みなさんこんにちは!ヒロポンです!!
C# の DataTable いじってると、「これ LINQ で書きたい」って思った瞬間にコンパイルエラーで止まる、ってこと、ないっすか??
// ❌ これは動かない
var actives = dt.Where(r => r["IsActive"].ToString() == "1");
// ↑ 'DataTable' に 'Where' の定義はない
俺もこれで最初の正社員時代によく詰まった。for (int i = 0; i < dt.Rows.Count; i++) で30行近く書いてた処理を、LINQ で3行にしたい時に「ん?書けないやん…??」とハマる。答えは1行で済んで、AsEnumerable() を挟むだけ。これだけで DataTable に LINQ 一式がそのまま使えるようになって、こんな感じでスッキリした書き方になります。
この記事では VS2019・.NET Framework 4.7.2・C# 7.3 の業務系で実際に使ってる DataTable に LINQ をかける3パターン(フィルタ/GroupBy/分割)を、コード4本+DBNull のハマり1本でまとめる。
3行で結論:
- AsEnumerable() を挟む →
IEnumerable<DataRow>に化けるので LINQ 一式が使える- 値の取得は
r.Field<T>("col")→ 型安全+ NULL 許容ならField<int?>で受ける- DataTable に戻したい時は
.CopyToDataTable()→ ただし空シーケンスで例外を吐くので分岐が要る
前提:AsEnumerable() で IEnumerable 化する
DataTable は IEnumerable<DataRow> を直接実装していない(旧来の IEnumerable は実装してる)ので、Where や GroupBy がそのまま生えてない。これを AsEnumerable() という拡張メソッドで IEnumerable<DataRow> に変換すると、LINQ が一式かかるようになる。
using System.Data;
using System.Linq;
// using System.Data.DataSetExtensions; // .NET Framework 4.0 以降は標準で参照済みのはず
DataTable dt = LoadFromDb(); // SqlDataAdapter で埋めた DataTable
// AsEnumerable() で IEnumerable<DataRow> 化
IEnumerable<DataRow> rows = dt.AsEnumerable();
// ここから先は普通の LINQ
var firstActive = rows.FirstOrDefault(r => r.Field<bool>("IsActive"));
ポイントは:
AsEnumerable()はSystem.Data.DataSetExtensions名前空間 にある拡張メソッド- 戻り値は
EnumerableRowCollection<DataRow>(IEnumerable<DataRow>を実装) - 値の取得は
r["col"]でも書けるけど、r.Field<T>("col")の方が型安全+ NULL 安全
r["col"] は object を返すので、ほとんどのケースで Convert.ToInt32(r["col"]) のような変換が要る。NULL が来た瞬間に InvalidCastException が飛ぶ罠もある。Field<T> を覚えてしまえばこの辺りの面倒事が一気に減る。これを使い始めて、DataTable コードの可読性は体感で半分くらいになった。
パターン1: フィルタ(Where)
一番出番が多いやつ。「特定条件の行だけ別の DataTable や DataGridView に出したい」みたいな業務要件で使う。
// 価格 1000 以上、かつ "在庫あり" のフラグが立ってる行を抽出
var filtered = dt.AsEnumerable()
.Where(r => r.Field<int>("price") >= 1000
&& r.Field<bool>("in_stock"));
foreach (var r in filtered)
{
Console.WriteLine($"{r.Field<string>("name")}: {r.Field<int>("price")}");
}
これで for ループ+ if 分岐+一時変数 の昔ながらの書き方がいい感じに3〜4行へ圧縮できる。読み手が条件を1行で把握できるのがデカい。
業務系のコードレビューで「この for ループ何やってるの?」と聞かれて、AsEnumerable().Where(...) に書き直すと「ああ、そういうことね」と一瞬で伝わる!!LINQ の Where は、ロジックを「条件式そのもの」にすることでコメントが要らなくなる 効果が一番強いパターンだ。
ただし欠点として、Where した結果は IEnumerable<DataRow> なので、そのまま DataGridView の DataSource に貼れない。DataGridView に流したいなら次のパターン3で CopyToDataTable() する。
パターン2: GroupBy + 集計(Sum / Count / Average)
「カテゴリ別の件数」「店舗別の売上合計」みたいな集計をクライアント側でやりたい時に使う。本当は SQL 側で GROUP BY を書く方が速いけど、DataTable に既にロード済みのデータを再集計するケースで重宝する。
// カテゴリ別の件数と合計金額
var summary = dt.AsEnumerable()
.GroupBy(r => r.Field<string>("category"))
.Select(g => new
{
Category = g.Key,
Count = g.Count(),
TotalPrice = g.Sum(r => r.Field<int>("price")),
AvgPrice = g.Average(r => r.Field<int>("price"))
})
.OrderByDescending(x => x.TotalPrice)
.ToList();
foreach (var s in summary)
{
Console.WriteLine($"{s.Category}: {s.Count}件 / 合計{s.TotalPrice} / 平均{s.AvgPrice:F0}");
}
GroupBy が返すのは IEnumerable<IGrouping<TKey, DataRow>> で、g.Key がキー(カテゴリ名)、g 自体が IEnumerable<DataRow> として該当行を保持してる。Sum / Count / Average / Min / Max あたりは標準の LINQ 集計メソッドがそのまま使える。
業務系で「在庫一覧画面のカテゴリ別件数を画面下に出したい」「日次バッチでサマリ表を作りたい」みたいな時にこれ1ブロックで終わる。for ループで Dictionary<string, int> を組み立ててたコードの 8〜9割は GroupBy で潰せる。
ただし欠点として、GroupBy の挙動は 遅延評価+全行スキャン。データが万行を超える DataTable に対して画面再描画ごとに GroupBy をかけるのは重い。集計結果は一度 ToList() で固めてキャッシュする方が現場では安全だ。
パターン3: 分割(CopyToDataTable / ImportRow)
「フィルタした結果を別の DataGridView に貼りたい」「条件で2つの DataTable に分けたい」みたいな要件で使う。CopyToDataTable() という拡張メソッドで IEnumerable<DataRow> を新しい DataTable に戻せる。
// 価格 1000 以上の行だけ別 DataTable に分割
var highPriceRows = dt.AsEnumerable()
.Where(r => r.Field<int>("price") >= 1000);
DataTable dtHighPrice;
if (highPriceRows.Any())
{
dtHighPrice = highPriceRows.CopyToDataTable();
}
else
{
// ⚠️ 空シーケンスで CopyToDataTable() を呼ぶと InvalidOperationException
dtHighPrice = dt.Clone(); // スキーマだけコピーした空 DataTable を作る
}
dgvHighPrice.DataSource = dtHighPrice;
CopyToDataTable() は 元の DataTable のスキーマ(列定義・制約)を保ったまま 新しい DataTable を返してくれる。なので元の DataGridView と同じ列構成で別画面に出せる。
ただし CopyToDataTable() は 空シーケンスで呼ぶと InvalidOperationException を吐く。これ、結構踏みやすい。if (rows.Any()) { ... } else { dt.Clone(); } の分岐をテンプレ化しておく方が事故が減る。
CopyToDataTable() を使わず、新しい DataTable に1行ずつ ImportRow する書き方もある:
DataTable dtCopy = dt.Clone(); // スキーマだけコピー
foreach (var r in highPriceRows)
{
dtCopy.ImportRow(r);
}
dgvHighPrice.DataSource = dtCopy;
こっちの欠点は 行数が多いとループが重くなる こと。基本は CopyToDataTable() + 空チェック で書いて、空対策の分岐が嫌なら ImportRow の foreach 版にする、くらいの判断軸で十分だ。
DBNull のハマり:Field vs Field<int?>
DataTable + LINQ で 一番踏みやすいのが DBNull だ。Field<T> は型引数の指定で挙動が変わる。
// ❌ NG: NULL 許容な列に Field<int> を使うと NULL 行で例外
var prices = dt.AsEnumerable()
.Select(r => r.Field<int>("amount")) // amount が NULL の行で例外(Typed DataSet なら StrongTypingException、一般 ADO.NET では InvalidCastException)
.ToList();
// ✅ OK: Nullable<T> で受ける
var prices = dt.AsEnumerable()
.Select(r => r.Field<int?>("amount")) // NULL は null として返ってくる
.ToList();
// ✅ OK: NULL 許容を扱いつつ ?? で初期値
var pricesWithDefault = dt.AsEnumerable()
.Select(r => r.Field<int?>("amount") ?? 0)
.ToList();
ルールは単純で:
- NOT NULL 列なら
Field<T>(int/bool/DateTime等) - NULL 許容列なら
Field<T?>(int?/bool?/DateTime?等) stringは参照型なのでField<string>のままで OK(NULL は null として返る)
これ俺、最初の正社員時代に 半日デバッガで追った経験 がある。流通系の基幹システムで集計画面を組んでて、「特定の店舗だけで Where の中で例外」が出てて、原因が「その店舗だけ amount 列に NULL が入ってる」というやつ。Field<int> を Field<int?> に変えて1行で解決した。SQL 側で IS NULL を見れば判明する話だけど、コード読んでも分からないので潰すのに時間がかかった。
DBNull 周りの体系的なハンドリングは別記事 SQL Server の DBNull を C# で安全にハンドリングする5つのイディオム で5パターンまとめて整理してるので、ハマりが多い人はそっちと併せて押さえておくのが早い。
ハマりポイント3つ — 俺が踏んだやつ
① Field の型違いで例外(StrongTypingException または InvalidCastException)
上の DBNull 例と被るけど、Field<T> の型と DataColumn の DataType が一致してないと例外が飛ぶ。Typed DataSet 経由なら StrongTypingException、一般的な ADO.NET DataTable では InvalidCastException が出るケースが多い(型違いの組み合わせやプロバイダで挙動が変わるので、両方頭に入れておくのが安全)。int 列に Field<long> でアクセスすると例外。
// テーブル定義: amount INT NOT NULL
// ❌ NG: long で受けると例外
var v = r.Field<long>("amount");
// ✅ OK: int で受けてから long にキャスト
var v = (long)r.Field<int>("amount");
これは 30分くらいデバッガで追ってハマった。「ん?null じゃないのに型エラー??」と最初は混乱したが、DataColumn.DataType を見て型が違うことに気づいた。テーブル定義が SQL の INT なら C# 側は int で受けないと駄目、というのを覚えておく。
② CopyToDataTable() で空シーケンス例外
これも上で触れたが、踏みやすいので再掲。
// ❌ NG: 該当行が0件だと InvalidOperationException
var dtNew = dt.AsEnumerable()
.Where(r => r.Field<int>("price") > 99999) // 空ヒット
.CopyToDataTable();
// ✅ OK: Any() で先に空チェック
var rows = dt.AsEnumerable().Where(r => r.Field<int>("price") > 99999);
DataTable dtNew = rows.Any() ? rows.CopyToDataTable() : dt.Clone();
業務系のフィルタ条件は 「条件で全件落ちる」が普通に起きる ので、本番でいきなり踏むと画面に「予期しないエラー」ダイアログが出て関係者が血の気を引かせる。コードレビューで CopyToDataTable() を見たら空チェックがあるか毎回確認する、くらいでちょうどいい。最初に踏んだ時、夕方の本番リリース直後にユーザーから「画面が落ちる」報告が来て1時間半対応に追われた。
③ 大きな DataTable で Where を多重に重ねると遅い
LINQ の遅延評価が裏目に出るパターン。同じフィルタを複数回 foreach で回すと、毎回先頭から再評価される。
// ❌ NG: 同じフィルタを2回評価する
var filtered = dt.AsEnumerable().Where(r => r.Field<int>("price") > 1000);
int count = filtered.Count(); // 1回目スキャン
int sum = filtered.Sum(r => r.Field<int>("price")); // 2回目スキャン
// ✅ OK: 一度 ToList() で固める
var filtered = dt.AsEnumerable()
.Where(r => r.Field<int>("price") > 1000)
.ToList(); // ここで1回だけ評価
int count = filtered.Count;
int sum = filtered.Sum(r => r.Field<int>("price"));
万行クラスの DataTable を画面に乗せてる業務アプリで、この多重評価が原因で「画面再描画が3秒かかる」みたいな症状が出る。ToList() / ToArray() でメモリに固める か、集計結果だけ別変数に取る のが基本対処。これも気づくのに 数日経ってからプロファイラかけてやっと見えた 類の遅延バグ。
著者の現場メモ — 流通系SIer時代の DataTable 業務
最初の正社員時代、流通系SIer の受託で2年間 C# WinForms ばっか書いてた。VS2019・.NET Framework 4.7.2・C# 7.3 の構成で、業務ロジックは DataAdapter + DataTable + 生SQL という、業務系C#でよく見るスタックだった。
その現場で DataTable 周りに LINQ を持ち込むかどうか がチーム内で揺れた時期があった。古参のメンバーは for (int i = 0; i < dt.Rows.Count; i++) で書く文化、若手は LINQ 派、というよくある構図。最終的に 「30行を超えるループで if 分岐がある場合は LINQ に書き直してOK」 という規約を後輩と一緒に作った。短いループはどっちでもいい、長くて分岐が増えるところだけ LINQ に寄せる、という運用。
この時に役立ったのが AsEnumerable() + Field<T> の型安全アクセスで、コード行数が3割減って、コードレビュー時の認識ズレも減った。r["price"] を読み取る時に「何型だっけ?」を毎回考えなくて良くなったのが地味に効いた。LINQ そのものより、Field<T> を強制する規約の方がチーム的には効果が大きかった印象だ。
業務SE が DataTable を扱う時、LINQ は 使い方より使わない判断の方が難しい。短い for ループを LINQ で書き直すのは趣味の領域だけど、長くなって分岐や集計が入った瞬間に LINQ に倒すと一気に読みやすくなる。判断軸を1回決めてしまえば、現場で迷う時間が確実に減る。
まとめ
ここまでで DataTable + LINQ の3パターンと DBNull のハマりはだいたい押さえた。要点をもう一度:
AsEnumerable()を挟むだけ で DataTable に LINQ 一式がかかるようになる- 値の取得は
Field<T>/Field<T?>を使い分ける(NOT NULL はField<T>、NULL 許容はField<T?>) - 3パターン: フィルタ(
Where)/集計(GroupBy + Sum/Count)/分割(CopyToDataTable) - 空シーケンスで
CopyToDataTable()は例外 を吐くので分岐 orAny()チェック - 大きな DataTable は
ToList()で1回固めて多重評価を避ける
VS2019・.NET Framework 4.7.2・C# 7.3 の業務系でも、最新の .NET 8 + DataTable でも、この3パターンは挙動が変わらない。業務系C#でDataTable を扱う限り長く効く 知識なので、1回まとめて覚えてしまうとラクだ。
よくある質問
Q1. AsEnumerable() の参照が解決できないと言われる時は?
A. System.Data.DataSetExtensions の参照が抜けてるパターン。.NET Framework 4.0 以降の通常プロジェクトテンプレートでは標準参照だけど、SDK スタイルのプロジェクトや .NET 5+ では明示的に追加が要る場合がある。<PackageReference Include="System.Data.DataSetExtensions" Version="..." /> を csproj に足すか、Visual Studio の「参照の追加」から System.Data.DataSetExtensions.dll を選ぶ。
Q2. DataTable.Select(条件文字列) と LINQ の Where、どっち使うべき?
A. 基本 LINQ の Where 推奨。DataTable.Select("price > 1000 AND in_stock = true") は文字列で条件を書く ADO.NET 流の API で、SQL風の式を渡せるけど:
- 文字列なのでコンパイル時の型チェックが効かない
- IDE の補完・リファクタが効かない
- 例外が実行時に出る
LINQ の Where は型安全・IDE 補完・コンパイル時チェックが全部効くので、新規コードはこっちに寄せた方が保守しやすい。DataTable.Select は既存コードで出てきた時に「ああ、こういう書き方もあったな」程度の認識で OK。
Q3. 複数の DataTable を JOIN したい時は?
A. LINQ の join キーワードで書ける。
var joined = from o in dtOrders.AsEnumerable()
join c in dtCustomers.AsEnumerable()
on o.Field<int>("customer_id") equals c.Field<int>("id")
select new
{
OrderId = o.Field<int>("id"),
Amount = o.Field<int>("amount"),
CustomerName = c.Field<string>("name")
};
ただし JOIN は 基本 SQL 側で書く方が速い。DataTable 同士の JOIN を書きたくなったら、まず「これ SQL 側で1クエリにまとめられないか?」を先に検討する方が現場では正解になることが多い。
Q4. DataTable + LINQ vs Dapper、どっちが今風?
A. 新規プロジェクトなら Dapper(or EF Core) の方が今風。Dapper.Query<Customer>("SELECT ... FROM customers") で POCO クラスに直接マップ されるので、Field<T> を毎回書く手間自体がなくなる。
ただし業務系の保守案件は DataAdapter + DataTable で書かれてる現場が多いので、既存資産を触る時は AsEnumerable + Field
ここまでで DataTable + LINQ の挙動と DBNull の罠は押さえた。同じ DataTable 系で 取得時の DBNull 処理を5パターン体系で押さえたい人 は、関連記事側に進むと自然な流れになる。
関連記事
- SQL Server の DBNull を C# で安全にハンドリングする5つのイディオム —
Field<T?>の話の延長で、DataReader / DataAdapter / EF Core の DBNull 全パターンを整理したい時に効く
以上!


コメント