IEnumerable<T> と IQueryable<T> は見た目が似ていて LINQ メソッドもほとんど共通していますが、処理が実行される場所が根本的に異なります。IEnumerable はC# 側(クライアント)で、IQueryable はデータソース側(SQL サーバー等)で実行されます。この違いを理解せずに使うと、「DB から全件ロードしてから C# 側で絞り込む」というパフォーマンス災害を引き起こします。
本記事では両者のインターフェース階層と式ツリー(Expression Tree)の役割、クライアント評価 vs サーバー評価の境界、AsEnumerable/AsQueryable の切り替え、EF Core での翻訳失敗と対処、Repository パターンでの設計判断、IReadOnlyCollection/IReadOnlyList との使い分け、よくある落とし穴まで体系的に解説します。
本質的な違い — 処理が実行される場所
| 項目 | IEnumerable<T> |
IQueryable<T> |
|---|---|---|
| 定義 | System.Collections.Generic |
System.Linq |
| 処理場所 | C# 側(クライアント) | データソース側(DB など) |
| LINQ 種別 | LINQ to Objects | LINQ to Entities / SQL |
| 関数の型 | Func<T, bool>(デリゲート) |
Expression<Func<T, bool>>(式ツリー) |
| 取得データ量 | 全要素を順に処理 | WHERE 等が SQL に変換されて絞り込まれる |
| 遅延実行 | ○ | ○ |
| 主な実装 | 配列・List・HashSet・Dictionary | EF Core の DbSet・LINQ to SQL |
| 派生 | IReadOnlyCollection・ICollection・IList |
IEnumerable を継承 |
// ① IEnumerable<User>: 全件をメモリから取得してから絞り込む IEnumerable<User> allUsers = GetUsersFromCache(); // 既にメモリ上 var activeA = allUsers.Where(u => u.IsActive).ToList(); // → メモリ内で全件スキャン(通常の foreach 相当) // ② IQueryable<User>: SQL の WHERE 句に変換してサーバー側で絞る IQueryable<User> query = dbContext.Users; var activeB = query.Where(u => u.IsActive).ToList(); // → 実行される SQL: SELECT * FROM Users WHERE IsActive = 1 // → 該当する行だけが DB → アプリに転送される
IQueryable<T> : IEnumerable<T> なので、IQueryable は「IEnumerable の機能 + 式ツリー情報を保持」した拡張型です。両者で同名の LINQ メソッド(Where・Select 等)がオーバーロードされているため、コンパイル時にどちらが呼ばれるかが決まります。IQueryable のままなら System.Linq.Queryable のメソッドが呼ばれて式ツリーに記録され、IEnumerable にキャストすると System.Linq.Enumerable のメソッドが呼ばれてクライアント評価になります。式ツリー(Expression Tree)— IQueryable の心臓部
using System.Linq.Expressions; // ラムダを Func として受けると「実行可能なデリゲート」になる Func<int, bool> funcIsPositive = x => x > 0; bool result1 = funcIsPositive(5); // true(すぐ実行) // 同じラムダを Expression<Func> として受けると「データ構造(式ツリー)」になる Expression<Func<int, bool>> exprIsPositive = x => x > 0; // → 中身: LambdaExpression // ParameterExpression(x) // BinaryExpression(GreaterThan, ParameterExpression(x), ConstantExpression(0)) // コンパイルすれば Func として実行可能になる Func<int, bool> compiled = exprIsPositive.Compile(); bool result2 = compiled(5); // true // IQueryable は式ツリーを受け取るので、 // プロバイダー(EF Core 等)が式を解析して SQL に変換できる IQueryable<User> users = dbContext.Users; users.Where(u => u.Age > 18); // → プロバイダーは「u.Age > 18」の式ツリーを受け取り、"WHERE Age > 18" に変換 // IEnumerable.Where は Func しか受け取らないので式の中身は分からない // → C# 側で全要素にデリゲートを適用するしかない
クライアント評価 vs サーバー評価
// ケース①: IQueryable のまま Where → サーバー側で絞り込み
int count1 = dbContext.Users.Where(u => u.IsActive).Count();
// SQL: SELECT COUNT(*) FROM Users WHERE IsActive = 1
// → 結果だけが DB → C# に転送(数 KB)
// ケース②: AsEnumerable で一度変換 → そこから先はクライアント評価
int count2 = dbContext.Users.AsEnumerable().Where(u => u.IsActive).Count();
// SQL: SELECT * FROM Users ← ★ 全件取得
// → 100 万件なら全部ダウンロード、C# 側でフィルタ
// → 数百 MB 〜 GB の転送が発生する可能性
// ケース③: ToList で明示的に取得してから絞り込み(②と同じ)
int count3 = dbContext.Users.ToList().Count(u => u.IsActive);
// ケース④: Select + ToList → 必要な列だけサーバー側で絞る
var names = dbContext.Users
.Where(u => u.IsActive)
.Select(u => u.Name) // 名前だけに射影
.ToList();
// SQL: SELECT Name FROM Users WHERE IsActive = 1
// → データ転送量を最小化
AsEnumerable() 以降は System.Linq.Enumerable のメソッドが呼ばれるため、全行が C# 側にロードされます。巨大なテーブルに対して .AsEnumerable().Where(...) を書くとフルスキャン + 全件転送となり、タイムアウトやメモリ枯渇を引き起こします。「C# でしか計算できないロジック」を通す場合だけ AsEnumerable を使い、可能な限り SQL に変換できる条件は IQueryable のまま適用してください。EF Core の翻訳失敗と対処
// NG: C# のカスタムメソッド / プロパティを Where 内で呼ぶ
bool IsRecentUser(User u) => u.CreatedAt > DateTime.UtcNow.AddMonths(-3);
// EF Core 3.0+ はデフォルトで「SQL に翻訳できないクエリは例外」
var recent = dbContext.Users.Where(u => IsRecentUser(u)).ToList();
// → InvalidOperationException: could not be translated
// OK ①: 条件を展開して EF Core が理解できる式にする
var threshold = DateTime.UtcNow.AddMonths(-3);
var recent2 = dbContext.Users.Where(u => u.CreatedAt > threshold).ToList();
// → SQL: WHERE CreatedAt > @threshold
// OK ②: 再利用したい条件は Expression<Func<T, bool>> として定義
Expression<Func<User, bool>> isRecent = u =>
u.CreatedAt > DateTime.UtcNow.AddMonths(-3);
var recent3 = dbContext.Users.Where(isRecent).ToList();
// → 同じ式ツリーを複数クエリで再利用できる
// OK ③: 複雑な条件を組み立てる場合は LinqKit や PredicateBuilder を活用
// または、複雑な処理はストアドプロシージャ / Raw SQL で書く
// NG パターンを検出: 生成される SQL を必ず確認
// .NET 5+ EF Core は ToQueryString() で SQL を取得できる
string sql = dbContext.Users.Where(u => u.IsActive).ToQueryString();
Console.WriteLine(sql);
EF Core 2.x までは「SQL に翻訳できない部分は自動的にクライアント評価」していましたが、これが「DB から全件取得 → C# でフィルタ」の原因になっていたため、EF Core 3.0 からは翻訳不能クエリは例外を投げる設計に変わりました。意図的に一部をクライアント評価にしたい場合は明示的に
AsEnumerable() を挟む必要があります。この設計変更は「見えないパフォーマンス劣化」を防ぐための大きな改善です。遅延実行の仕組みと違い
// IEnumerable の遅延実行
IEnumerable<int> query1 = Enumerable.Range(1, 1000).Where(x => x > 100);
// ↑ この時点ではまだ1件も処理されていない
foreach (var x in query1) // ← ここで初めて Where のデリゲートが実行される
Console.WriteLine(x);
// 再度 foreach すると再評価される(処理が2回走る)
foreach (var x in query1) { /* 2回目: 再評価 */ }
// IQueryable の遅延実行
IQueryable<User> query2 = dbContext.Users.Where(u => u.IsActive);
// ↑ この時点では SQL はまだ発行されていない
var count = query2.Count(); // ← 1回目: COUNT SQL 発行
var list = query2.ToList(); // ← 2回目: SELECT SQL 発行
// 同じ IQueryable を複数回列挙すると毎回 DB にアクセスする!
// → 結果をキャッシュしたいなら一度 .ToList() で実体化してから使い回す
IQueryable は同じインスタンスを列挙するたびに新しい SQL を発行します。var query = dbContext.Users.Where(...); を定義して、var count = query.Count(); と var list = query.ToList(); を両方呼ぶとSQL が2回走ります。同じデータを複数回使う場合は、一度 var users = query.ToList(); で実体化してから List を再利用してください。AsEnumerable / AsQueryable の切り替え
// AsEnumerable(): IQueryable<T> → IEnumerable<T>(同じ参照、型だけ変える)
IQueryable<User> query = dbContext.Users.Where(u => u.IsActive);
IEnumerable<User> enumerable = query.AsEnumerable();
// 以降は LINQ to Objects(クライアント評価)
// → この呼び出し時点ではまだ SQL は発行されない(遅延実行)
// → 列挙時に SELECT * FROM Users WHERE IsActive = 1 が発行され、全件取得される
// 用途: 「ここから先は C# のメソッドを使う」と明示
var result = dbContext.Users
.Where(u => u.IsActive) // ここは SQL に翻訳される
.AsEnumerable() // ← 境界
.Where(u => ComplexCSharpCheck(u)) // ここは C# メソッド
.ToList();
// AsQueryable(): IEnumerable<T> → IQueryable<T>(式ツリーを使えるようにする)
List<int> numbers = new() { 1, 2, 3, 4, 5 };
IQueryable<int> asQueryable = numbers.AsQueryable();
// ただしメモリ内コレクションの AsQueryable は
// EnumerableQuery プロバイダを使うので、本質はクライアント評価
// → 主にジェネリックなコードでリポジトリに渡す場面で有用
// 実務での用途: テストでモック可能なリポジトリ
public interface IRepository
{
IQueryable<User> Users { get; }
}
// 本番実装: DbContext.Users をそのまま返す
// テスト実装: new List<User> { ... }.AsQueryable() を返す
関連インターフェース階層
| インターフェース | 表現できること | 読み書き |
|---|---|---|
IEnumerable<T> |
列挙できる | 読み取りのみ(foreach) |
IReadOnlyCollection<T> |
列挙 + 件数 (Count) |
読み取りのみ |
IReadOnlyList<T> |
上記 + インデックスアクセス | 読み取りのみ |
ICollection<T> |
列挙 + 件数 + Add / Remove |
読み書き |
IList<T> |
上記 + インデックスアクセス | 読み書き |
IQueryable<T> |
式ツリー + 外部クエリプロバイダ | 読み取りのみ |
// 引数: 「消費するだけ」なら最も緩い IEnumerable で受ける
public void Print<T>(IEnumerable<T> items)
{
foreach (var item in items) Console.WriteLine(item);
}
// 引数: 件数が欲しいなら IReadOnlyCollection
public string Summarize<T>(IReadOnlyCollection<T> items)
=> $"{items.Count} 件";
// 引数: ランダムアクセスが欲しいなら IReadOnlyList
public T GetMiddle<T>(IReadOnlyList<T> items)
=> items[items.Count / 2];
// 戻り値: 呼び出し側が足せる配列や可変リストが必要なら IList
public IList<User> LoadMutable() => new List<User>();
// 戻り値: 読み取り専用で返したいなら IReadOnlyList(意図を明示)
public IReadOnlyList<User> LoadReadOnly() => new List<User>().AsReadOnly();
// 戻り値: リポジトリは IQueryable を返すべきか?
// → 「呼び出し側で追加クエリを組み立てたい」なら IQueryable
// → 「リポジトリで完結した結果を返したい」なら IReadOnlyList<T>
Repository パターン — IQueryable を公開すべきか
// 派閥①: IQueryable を公開する(柔軟性重視)
public interface IUserQueryable
{
IQueryable<User> Users { get; }
}
public class EfUserRepository(AppDbContext db) : IUserQueryable
{
public IQueryable<User> Users => db.Users;
}
// 呼び出し側: 自由に追加クエリを組み立てられる
var activeAdmins = repo.Users
.Where(u => u.IsActive && u.IsAdmin)
.OrderBy(u => u.Name)
.Take(100)
.ToList();
// デメリット:
// - リポジトリの利用側が IQueryable / EF Core の知識を要求される
// - 公開された IQueryable は EF Core 依存なので差し替えにくい
// - 複雑な SQL 生成が意図せず発生する可能性
// 派閥②: IQueryable を隠蔽する(カプセル化重視)
public interface IUserRepository
{
Task<IReadOnlyList<User>> GetActiveAdminsAsync(int limit, CancellationToken ct);
Task<User?> GetByIdAsync(int id, CancellationToken ct);
}
public class UserRepository(AppDbContext db) : IUserRepository
{
public async Task<IReadOnlyList<User>> GetActiveAdminsAsync(int limit, CancellationToken ct)
=> await db.Users
.Where(u => u.IsActive && u.IsAdmin)
.OrderBy(u => u.Name)
.Take(limit)
.ToListAsync(ct);
}
// 呼び出し側: 宣言的な API だけを使う
var admins = await repo.GetActiveAdminsAsync(100, ct);
// Specification パターン: その中間(式ツリーを抽象化)
public sealed record UserSpec(Expression<Func<User, bool>> Predicate)
{
public static UserSpec ActiveAdmins() => new(u => u.IsActive && u.IsAdmin);
}
Task<IReadOnlyList<User>> FindAsync(UserSpec spec, CancellationToken ct);
パフォーマンスの比較
// NG① — .ToList() を早く呼ぶ
var activeUsers = dbContext.Users
.ToList() // ★ 全件ダウンロード
.Where(u => u.IsActive)
.ToList();
// SQL: SELECT * FROM Users(100万件)→ 1GB 転送 → C# で絞る
// OK: IQueryable のまま最後に .ToListAsync()
var activeUsers2 = await dbContext.Users
.Where(u => u.IsActive) // DB 側で絞る
.ToListAsync();
// SQL: SELECT * FROM Users WHERE IsActive = 1
// NG② — N+1 問題(関連エンティティを1件ずつロード)
var users = await dbContext.Users.ToListAsync();
foreach (var u in users)
{
// ユーザーごとに別のクエリが発行される!
var orders = await dbContext.Orders.Where(o => o.UserId == u.Id).ToListAsync();
}
// OK: Include で一度に取得
var usersWithOrders = await dbContext.Users
.Include(u => u.Orders)
.ToListAsync();
// JOIN で一度に取得
// NG③ — 複数回 IQueryable を列挙
IQueryable<User> q = dbContext.Users.Where(u => u.IsActive);
int count = q.Count(); // ★ SQL 発行 1
bool any = q.Any(); // ★ SQL 発行 2
var list = q.ToList(); // ★ SQL 発行 3
// → 同じクエリが3回 DB に投げられる
// OK: 一度実体化してから複数回使う
var cache = await q.ToListAsync();
int count2 = cache.Count;
bool any2 = cache.Count > 0;
var list2 = cache;
よくある落とし穴
// NG: .ToList() した後に Where → 全件ロードしてからフィルタ var result = dbContext.Users.ToList().Where(u => u.IsActive); // OK: IQueryable のまま Where → SQL で絞り込み var result2 = dbContext.Users.Where(u => u.IsActive).ToList(); // 教訓: .ToList() / .ToArray() / .AsEnumerable() は // 「ここから先はクライアント評価にしたい」明確な理由がない限り遅らせる
// NG: カスタムメソッドは SQL に翻訳できない(EF Core 3.0+ で例外)
var valid = dbContext.Users.Where(u => MyValidator.IsValid(u)).ToList();
// OK ①: 条件を IQueryable のまま書く
var valid2 = dbContext.Users.Where(u => u.Age >= 18 && u.Email != null).ToList();
// OK ②: クライアント側処理が必要なら明示的に AsEnumerable
var valid3 = dbContext.Users
.Where(u => u.Age >= 18) // SQL で一次絞り込み
.AsEnumerable() // クライアント評価に切り替え
.Where(u => MyValidator.IsValid(u)) // C# で二次絞り込み
.ToList();
// NG: IEnumerable を返すと呼び出し側が毎回列挙してしまう
public IEnumerable<User> GetAdmins()
=> dbContext.Users.Where(u => u.IsAdmin); // 実体化していない!
// 呼び出し側
var admins = repo.GetAdmins();
Console.WriteLine(admins.Count()); // SQL 発行 1
Console.WriteLine(admins.First().Name); // SQL 発行 2
// OK: 実体化した結果を返す(IReadOnlyList で意図を明示)
public async Task<IReadOnlyList<User>> GetAdminsAsync(CancellationToken ct)
=> await dbContext.Users.Where(u => u.IsAdmin).ToListAsync(ct);
// AsQueryable() をつけると SQL に翻訳されるようになるわけではない
List<User> users = new() { /* ... */ };
IQueryable<User> asQueryable = users.AsQueryable();
var filtered = asQueryable.Where(u => u.IsActive).ToList();
// → これは C# 側(EnumerableQuery プロバイダ)で動く
// SQL は発行されない(データソースが List なので)
// AsQueryable は「IQueryable 型として渡したい」場面(テスト・ジェネリックメソッド)用
// 本当に DB クエリにしたいなら最初から DbSet<T> / IQueryable<T> を使う
よくある質問
IEnumerable<T> が柔軟、「件数やインデックスアクセスが必要」なら IReadOnlyList<T> が意図を明確に表現できます。一般的には、同期メソッドで実体化した結果を返すなら IReadOnlyList<T>(利用側が期待する「Count が即座に取れる」を保証)、非同期でストリーミングしたいなら IAsyncEnumerable<T> が現代的です。Expression<Func<T, bool>> を受け取る)があります。Where・Select・OrderBy・GroupBy・Join・Count・Sum・Average・Min・Max・Any・All・First・Single・Skip・Take など主要なものは翻訳されます。一方 Aggregate・カスタム拡張メソッド・string.Format・正規表現などは翻訳不能です。ToQueryString()(EF Core 5+)で生成 SQL を確認してください。AsEnumerable() は型を変えるだけで SQL はまだ発行されない(遅延実行)のに対し、ToList() は即座に SQL を発行して結果を List に詰める(即時実行)です。「ここから先はクライアント評価したい」なら AsEnumerable、「結果を List として取得したい」なら ToList を使い分けます。両者とも全件取得する点は同じなので、大量データでは事前に Where 等で絞り込むことが重要です。ToListAsync・FirstOrDefaultAsync・CountAsync 等の非同期 LINQ メソッドは、EF Core の IQueryable<T>(Microsoft.EntityFrameworkCore 名前空間)または IAsyncEnumerable<T>(System.Linq.Async パッケージ)で使えます。IEnumerable<T> には非同期版がないため、.AsAsyncEnumerable() で変換するか、非同期 LINQ を使うなら最初から IQueryable or IAsyncEnumerable を使ってください。まとめ
| 場面 | 推奨 |
|---|---|
| メモリ内コレクションの列挙 | IEnumerable<T> |
| DB クエリの構築 | IQueryable<T> |
| 式ツリーでの柔軟な条件 | Expression<Func<T, bool>> |
| API 引数(消費のみ) | IEnumerable<T> |
| API 戻り値(件数・インデックス必要) | IReadOnlyList<T> |
| 非同期ストリーム | IAsyncEnumerable<T> |
| クエリプロバイダ公開 | Specification パターン検討 |
ToList() タイミング |
「必ず絞り込みの後」が鉄則 |
| クライアント評価 | AsEnumerable() で明示的に |
| 翻訳失敗対策 | ToQueryString() で SQL を確認 |
| 複数回列挙 | ToList() で実体化してから使い回す |
| N+1 問題 | Include() で一括取得 |
関連する LINQ 機能は以下を参照してください。LINQ 完全ガイドで同期 LINQ 全般、LINQ GroupBy・OrderBy・Join 完全ガイドで集約・結合、LINQ 集約処理の実戦パターン集で集計手法、非同期 LINQ 完全ガイドで IAsyncEnumerable と System.Linq.Async を解説しています。

