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

C# LINQ の遅延実行と即時実行の罠(同じクエリを2回回して DB に2回問い合わせる事故)

バイブス父さん
現役の業務SE
2026年9月9日11 min read広告 (PR) を含む場合があります
C# LINQ の遅延実行と即時実行の罠(同じクエリを2回回して DB に2回問い合わせる事故)

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 は「書いた瞬間」には実行されていない

まず正体から。WhereSelect は、その行ではまだ何も実行していません。クエリの「定義」を作ってるだけ。

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 で初めて出る):

LINQ の遅延実行。クエリ定義行では評価が走らず foreach で初めて評価中と結果が出る実行結果

var query = ... の行では「評価中」が1回も出ません。foreach で回した瞬間、初めて Where の中身が動く。

「定義」と「実行」が分かれてる。これが遅延実行の骨格です。こんな感じの流れになるので、図で押さえておきます。

LINQ クエリを定義した時点では実行されず、foreach/ToList/Count で列挙した時に初めて実行され、複数回列挙するとその回数だけ実行される流れ

教訓: 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回):

同じクエリを Count と ToList で2回列挙し、評価回数が10回になる実行結果

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

IQueryable を2回列挙して DB に2回 SELECT が飛ぶ before と、ToList で1回確定してメモリで使い回す after の対比

教訓: 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 も列挙対象になる):

クエリ定義の後に追加した 10 も列挙対象になり 2 と 3 と 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):

ループ変数キャプチャの NG 版は全部該当なし、ローカルに退避した OK 版は全部該当ありになる実行結果

教訓: ループ変数を遅延クエリでキャプチャしない。ローカルに退避してから使う。

5. IEnumerable をそのまま返すと、呼び出し側で例外になる

メソッドが遅延クエリを IEnumerable で返すと、実行されるのは呼び出し側が列挙した時です。メソッド内の usingDbContext を破棄してると、呼び出し側の foreachObjectDisposedException が飛びます。

遅延クエリをそのまま返すと呼び出し側の列挙時に DbContext 破棄済みで例外になる before と、ToList で確定してから返す after の対比

戻り値の型を 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 の遅延実行とは何ですか?

WhereSelect で書いたクエリが、その行では実行されず、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 で経歴詳細を見る


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

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

運営者について