C# LINQ の遅延実行と即時実行の罠(同じクエリを2回回して DB に2回問い合わせる事故)
みなさんこんにちは!ヒロポンです!
Where で絞ったクエリを変数に入れて、あとで2回使ったら、DB のログに同じ SELECT が2回並んでた。業務系で LINQ を書いてると、こういうの踏みませんか??
犯人は LINQ の遅延実行です。
私も2年目のころ、パフォーマンス調査でスロークエリログを眺めてて「なんで同じ SELECT がこんな並んでるんや……」と血の気が引いたことがあります。原因はまさにこれでした。この記事は、その遅延実行を知らずに踏む罠を、実務でよく見る5つに絞ってまとめたやつです。コピペで動くコードつき。正体さえ掴めば、全部避けられます。
はじめに: なぜこの5つを選んだか
遅延実行って、知ってれば何でもないんですよね。ただ、知らないと「動いてるのに遅い」「たまに結果が変わる」みたいな、地味に厄介なバグを生みます。しかも再現しづらい。
想定してるのは、業務系で List や DB を相手に LINQ を普通に書いてる人。特に「DB のログを見たら同じクエリが何回も飛んでた」経験がある人は、たぶんどれかで刺さります。
⏱ 対処目安サマリ
先に全体像を出しておきます。それぞれ「気づいてから直すまで」のざっくり目安つき。
| 罠 | 症状 | 回避 | 対処目安 |
|---|---|---|---|
| 1. 書いた瞬間に動かない | クエリ定義行では何も起きない | 仕組みを理解する | 5分 |
| 2. 2回列挙で2回問い合わせ | DB に同じ SELECT が複数回 | ToList() で確定 |
10分 |
| 3. 列挙時に元データ再評価 | 結果が呼ぶたびに変わる | ToList() で確定 |
15分 |
| 4. ループ変数キャプチャ | 全クエリが最後の値で絞る | ループ内でローカルに退避 | 20分 |
| 5. IEnumerable を返す | 呼び出し側で破棄後に例外 | 返す前に ToList() |
15分 |
では1つずつ。
1. LINQ は「書いた瞬間」には実行されていない
まず正体から。Where や Select は、その行ではまだ何も実行していません。クエリの「定義」を作ってるだけ。
var numbers = new List<int> { 1, 2, 3, 4, 5 };
var query = numbers.Where(n =>
{
Console.WriteLine($"評価中: {n}");
return n > 2;
});
Console.WriteLine("--- クエリは定義しただけ。ここまで「評価中」は出ない ---");
foreach (var n in query) // ここで初めて実行される
Console.WriteLine($"結果: {n}");
実行結果(定義行では「評価中」が出ず、foreach で初めて出る):

var query = ... の行では「評価中」が1回も出ません。foreach で回した瞬間、初めて Where の中身が動く。
「定義」と「実行」が分かれてる。これが遅延実行の骨格です。こんな感じの流れになるので、図で押さえておきます。

教訓: LINQ の実行タイミングは「定義した時」ではなく「列挙した時」。
2. 同じクエリを2回列挙すると、DB に2回問い合わせが飛ぶ
これが一番痛いやつ。遅延実行のクエリは、列挙するたびに毎回実行されます。
int evalCount = 0;
var query = numbers.Where(n => { evalCount++; return n > 2; });
var count = query.Count(); // 1回目の列挙
var list = query.ToList(); // 2回目の列挙(また全部評価)
Console.WriteLine($"評価回数: {evalCount}"); // 5件を2回列挙したので評価は10回
実行結果(5件を2回列挙したので評価が10回):

List 相手なら「ちょっと無駄」で済みます。厄介なのは相手が DB(EF の IQueryable)の時。列挙のたびに SQL が発行されます。Count() で1回、foreach でもう1回なら、DB に2回問い合わせが飛ぶ。私がログで見たのはまさにこれでした。

教訓: DB 相手のクエリを2回以上使うなら、先に ToList() で確定させる。
3. 列挙するまでに元データが変わると、結果も変わる
遅延実行は「列挙した時点の元データ」で評価されます。クエリを定義してから列挙するまでに元コレクションを変えると、結果も変わるわけです。
var source = new List<int> { 1, 2, 3 };
var query = source.Where(n => n > 1);
source.Add(10); // クエリ定義の「後」に追加
foreach (var n in query) // 列挙は今なので、10 も対象になる
Console.WriteLine(n); // 2, 3, 10
実行結果(定義の後に Add した 10 も列挙対象になる):

「さっき定義したクエリだから中身は固定でしょ」と思い込むと、ここでズレます。こんな感じで、遅延は「いつ列挙したか」に結果が引っぱられる。別スレッドや別メソッドで元データをいじってると、再現しづらいバグになるやつです。あなたも一度は「同じコードなのに結果が違う」で悩んだことないですか?
教訓: 定義した時点の結果が欲しいなら、その場で ToList() して固定する。
4. ループ変数をキャプチャすると、全クエリが最後の値で絞られる
for ループの中で遅延クエリを作って貯めると、クロージャがループ変数を参照ごとキャプチャします。列挙は後。その時にはループ変数が最終値になっている。
var values = new List<int> { 0, 1, 2 };
var queries = new List<IEnumerable<int>>();
for (int i = 0; i < 3; i++)
queries.Add(values.Where(n => n == i)); // i をキャプチャ(まだ実行しない)
// ループが終わった時点で i は 3。全クエリが「n == 3」で絞る。3 は values に無い
foreach (var q in queries)
Console.WriteLine(q.Any() ? "該当あり" : "該当なし"); // 全部「該当なし」
i == 0, 1, 2 で絞るつもりが、全部 i == 3 になる。なんで全部同じ??ってなるやつです。回避は、ループ内でローカル変数に退避してからキャプチャ。
for (int i = 0; i < 3; i++)
{
int captured = i; // その回の値をローカルに固定
queries.Add(values.Where(n => n == captured));
}
実行結果(NG は該当なし×3、ローカルに退避した OK は該当あり×3):

教訓: ループ変数を遅延クエリでキャプチャしない。ローカルに退避してから使う。
5. IEnumerable をそのまま返すと、呼び出し側で例外になる
メソッドが遅延クエリを IEnumerable で返すと、実行されるのは呼び出し側が列挙した時です。メソッド内の using で DbContext を破棄してると、呼び出し側の foreach で ObjectDisposedException が飛びます。

戻り値の型を IEnumerable にしておくと「遅延のまま漏れる」。確定した結果を返すメソッドは List<T> を返しておくのが、読む側にも分かりやすいです。
教訓: メソッドの外にクエリを渡すなら、ToList() で確定させてから返す。
まとめ・チートシート
C# LINQ の遅延実行、5つの罠を一枚に畳むとこうです。
- LINQ は列挙した時に動く(定義した時ではない)
- 同じクエリを2回列挙 = DB に2回問い合わせ →
ToList()で確定 - 列挙までに元データが変わると結果も変わる → 固定したいなら
ToList() - ループ変数をキャプチャすると全部最後の値 → ローカルに退避
IEnumerableを返すと呼び出し側で再評価・破棄後例外 → 返す前にToList()
ほとんどの罠は 「使い回す・持ち回すなら ToList() で早めに確定」 でいい感じに消えます。逆に、一度しか回さないなら遅延のままで軽くていい。この線引きが体に入ると、意図しない再評価に振り回されなくなります!!
動作確認メモ: 罠2・罠5 の DB(EF / IQueryable)のコードは、構文と挙動の妥当性まで確認しています(実行確認は List ベースの例で代替)。実際の SQL 発行回数や
ObjectDisposedExceptionは、EF + 実 DB の環境で別途お試しください。
よくある質問
Q1. LINQ の遅延実行とは何ですか?
Where や Select で書いたクエリが、その行では実行されず、foreach で回すか ToList / Count などを呼んだ時に初めて実行される仕組みです。クエリの「定義」と「実行」が分かれているため、同じクエリ変数を2回使うと2回実行されます。
Q2. 同じクエリを使い回すと DB に何回問い合わせが飛びますか?
遅延実行のまま IQueryable を2回列挙すると、その回数だけ SQL が発行されます。Count() で1回、foreach でもう1回なら計2回です。ToList() で一度確定させてからメモリ上で使い回せば、DB への問い合わせは1回で済みます。
Q3. ToList と ToArray はどちらを使えばいいですか?
多くの場面ではどちらでもよく、結果を確定(即時実行)させる目的は同じです。後から要素を足し引きするなら List<T> を返す ToList、固定長で軽く持ちたいなら ToArray が向きます。
Q4. 遅延実行は悪者ですか? 常に ToList すべき?
いいえ。一度しか回さないなら遅延のままが軽くて有利です(途中で打ち切れる・無駄に全件を読み切らない)。ToList が要るのは「2回以上使う」「元データが変わる前に固定したい」「メソッドの外に渡す」時だけ。使い分けが本質です。
次に読むべき記事





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




