【C#】IEnumerable vs IQueryable 完全ガイド|式ツリー・クライアント/サーバー評価・Repository設計・落とし穴まで

【C#】IEnumerableとIQueryableの違いと使い分け C#

IEnumerable<T>IQueryable<T> は見た目が似ていて LINQ メソッドもほとんど共通していますが、処理が実行される場所が根本的に異なります。IEnumerableC# 側(クライアント)で、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
派生 IReadOnlyCollectionICollectionIList IEnumerable を継承
同じ LINQ 文でも「どこで実行されるか」が違う
// ① 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 は IEnumerable を継承
IQueryable<T> : IEnumerable<T> なので、IQueryable は「IEnumerable の機能 + 式ツリー情報を保持」した拡張型です。両者で同名の LINQ メソッド(WhereSelect 等)がオーバーロードされているため、コンパイル時にどちらが呼ばれるかが決まりますIQueryable のままなら System.Linq.Queryable のメソッドが呼ばれて式ツリーに記録され、IEnumerable にキャストすると System.Linq.Enumerable のメソッドが呼ばれてクライアント評価になります。

式ツリー(Expression Tree)— IQueryable の心臓部

Func と Expression の違い
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() は「その時点でクライアント評価に切り替える」境界
AsEnumerable() 以降は System.Linq.Enumerable のメソッドが呼ばれるため、全行が C# 側にロードされます。巨大なテーブルに対して .AsEnumerable().Where(...) を書くとフルスキャン + 全件転送となり、タイムアウトやメモリ枯渇を引き起こします。「C# でしか計算できないロジック」を通す場合だけ AsEnumerable を使い、可能な限り SQL に変換できる条件は IQueryable のまま適用してください。

EF Core の翻訳失敗と対処

C# メソッドを Where に使うと翻訳できない
// 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 3.0+ はクライアント評価を明示的にリジェクトする
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 を複数回使うなら .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 の切り替え

型を変える2つのメソッド
// 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> 式ツリー + 外部クエリプロバイダ 読み取りのみ
API の引数 / 戻り値で使い分け
// 引数: 「消費するだけ」なら最も緩い 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 を公開すべきか

2つの派閥: 公開 vs 非公開
// 派閥①: 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;

よくある落とし穴

落とし穴① — ToList() のタイミング
// 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() は
// 「ここから先はクライアント評価にしたい」明確な理由がない限り遅らせる
落とし穴② — クライアント側の関数を Where 内で使う
// 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();
落とし穴③ — IEnumerable を返す API の濫用
// 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() の誤解
// 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> を使う

よくある質問

Qメソッドの戻り値は IEnumerable と IReadOnlyList どちらがよいですか?
A呼び出し側が何を必要としているかで決めます。「for ループや LINQ を流すだけ」なら IEnumerable<T> が柔軟、「件数やインデックスアクセスが必要」なら IReadOnlyList<T> が意図を明確に表現できます。一般的には、同期メソッドで実体化した結果を返すなら IReadOnlyList<T>(利用側が期待する「Count が即座に取れる」を保証)、非同期でストリーミングしたいなら IAsyncEnumerable<T> が現代的です。
QRepository で IQueryable を返すのはアンチパターンですか?
A一概には言えません。「柔軟性」と「カプセル化」のトレードオフです。アプリケーション固有のクエリがリポジトリに数百種類必要なら IQueryable 公開が現実的です(全てのクエリをリポジトリメソッドにすると爆発する)。一方、ドメインロジックをリポジトリに閉じ込めたい DDD 的設計や、テストで EF Core の挙動を再現する必要をなくしたい場合は隠蔽が推奨されます。妥協案として Specification パターン(Expression<Func<T, bool>> を受け取る)があります。
QLINQ のどのメソッドが IQueryable で SQL 翻訳できますか?
AEF Core が翻訳可能なメソッドは公式に列挙されていますWhereSelectOrderByGroupByJoinCountSumAverageMinMaxAnyAllFirstSingleSkipTake など主要なものは翻訳されます。一方 Aggregate・カスタム拡張メソッド・string.Format・正規表現などは翻訳不能です。ToQueryString()(EF Core 5+)で生成 SQL を確認してください。
QIQueryable.AsEnumerable() と IQueryable.ToList() の違いは?
AAsEnumerable()型を変えるだけで SQL はまだ発行されない(遅延実行)のに対し、ToList()即座に SQL を発行して結果を List に詰める(即時実行)です。「ここから先はクライアント評価したい」なら AsEnumerable、「結果を List として取得したい」なら ToList を使い分けます。両者とも全件取得する点は同じなので、大量データでは事前に Where 等で絞り込むことが重要です。
Q非同期版 (ToListAsync) はどちらで使えますか?
AToListAsyncFirstOrDefaultAsyncCountAsync 等の非同期 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 を解説しています。