EF Core は書き方ひとつで速くも遅くもなります。同じ機能でも、追跡設定や射影の有無、往復回数の設計しだいで体感が何倍も変わるのが実際のところです。この記事では .NET 10 / EF Core 10 を前提に、本番で効いたチューニングを優先度の高い順にまとめました。ベンチマークの数字は環境によって上下するので、あくまで方向性の目安として読んでください。
まず計測してから手を入れる
推測でインデックスを足したりキャッシュを挟んだりする前に、どのクエリが重いのかを見るのが先です。EF Core が実際に投げている SQL は LogTo でそのまま吐けます。開発時だけ有効にしておくと、N+1 や意図しない全件取得にすぐ気づけます。
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString);
if (builder.Environment.IsDevelopment())
{
options.LogTo(Console.WriteLine, LogLevel.Information);
options.EnableSensitiveDataLogging(); // 本番では絶対に付けない
}
});
数字で当たりを付けたいときは BenchmarkDotNet、稼働中のプロセスを覗くなら dotnet-counters や dotnet-trace が手軽です。SQL Server 側なら sys.dm_exec_query_stats で実行回数と平均経過時間の多い順に並べると、直すべきクエリがはっきりします。
読み取りは追跡を切る
EF Core は既定でエンティティを変更追跡します。表示するだけのデータにこの追跡は要りません。AsNoTracking() を付けるとスナップショットを作らなくなるので、メモリと CPU の両方が軽くなります。読み取り専用の DbContext なら、既定の追跡動作ごと切ってしまうのが楽です。
// クエリ単位で切る
var orders = await db.Orders
.AsNoTracking()
.Where(o => o.Status == OrderStatus.Shipped)
.ToListAsync();
// コンテキスト単位で既定を切る
builder.Services.AddDbContext<ReadOnlyDbContext>(o => o
.UseSqlServer(connectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
同じエンティティが複数回出てくる結果を扱うなら、参照を一意にそろえてくれる AsNoTrackingWithIdentityResolution() を使うと重複インスタンスを避けられます。
必要な列だけ射影する
エンティティを丸ごと取ってから一部のプロパティしか使わないのは、帯域もメモリももったいない使い方です。Select で DTO に射影すると、SQL 側でも本当に要る列だけを SELECT するようになります。C# 14 のプライマリコンストラクターを使えば DTO の定義もすっきりします。
public record OrderSummary(int Id, string CustomerName, decimal TotalAmount, DateTime OrderDate);
var summaries = await db.Orders
.AsNoTracking()
.Where(o => o.OrderDate >= from)
.OrderByDescending(o => o.OrderDate)
.Select(o => new OrderSummary(
o.Id,
o.Customer.Name, // Include 不要。射影が JOIN を組み立てる
o.TotalAmount,
o.OrderDate))
.ToListAsync();
射影の中で関連プロパティを参照すると、EF Core が自動で JOIN を組んでくれます。表示専用の一覧なら Include よりも射影のほうが軽く済むことがほとんどです。
N+1 問題を見つけて潰す
パフォーマンス相談でいちばん多いのが N+1 です。一覧を回しながらループの中で関連データを引くと、件数ぶんだけクエリが飛びます。1,000 件のループなら 1,001 回の往復です。
// アンチパターン。ループのたびに DB へ往復する
var orders = await db.Orders.ToListAsync();
foreach (var order in orders)
{
var customer = await db.Customers.FindAsync(order.CustomerId);
Console.WriteLine($"{order.Id} / {customer!.Name}");
}
// 関連をまとめて取る
var withCustomer = await db.Orders
.AsNoTracking()
.Include(o => o.Customer)
.ToListAsync();
Include を重ねると 1 本のクエリで JOIN されますが、コレクションを複数 Include すると行が掛け算で膨らむデカルト爆発が起きます。そのときは AsSplitQuery() で関連ごとに分割すると、転送量が一気に減ります。
var orders = await db.Orders
.AsNoTracking()
.Include(o => o.OrderItems)
.Include(o => o.Shipments)
.AsSplitQuery() // コレクションごとに別クエリで取得
.ToListAsync();
一括更新・削除は ExecuteUpdate / ExecuteDelete で1往復に
以前はまとめて更新するのに、対象をぜんぶロードしてプロパティを書き換え SaveChanges する、あるいはサードパーティ拡張を入れる、という流れでした。いまは EF Core 標準の ExecuteUpdate / ExecuteDelete で、エンティティをメモリに載せずに 1 本の UPDATE / DELETE を投げられます。
// 該当行を1本の UPDATE にまとめる。ロードも SaveChanges も不要
await db.Orders
.Where(o => o.Status == OrderStatus.Pending && o.OrderDate < cutoff)
.ExecuteUpdateAsync(s => s
.SetProperty(o => o.Status, OrderStatus.Expired)
.SetProperty(o => o.UpdatedAt, DateTime.UtcNow));
// 古い注文の物理削除も1本の DELETE で
await db.Orders
.Where(o => o.OrderDate < retentionCutoff)
.ExecuteDeleteAsync();
EF Core 10 では ExecuteUpdate がさらに強化されて、JSON 列のプロパティを直接更新できるようになり、SetProperty の式に単純なラムダ本体(条件分岐を含むロジック)を書けるようになりました。数万行を更新するようなバッチ処理では、ロードして保存する方式との差が桁で効いてきます。
注意点として、ExecuteUpdate / ExecuteDelete は変更追跡を通さないため、コンテキストが持っているエンティティの状態とはズレます。同じ処理内で更新後のエンティティを使うなら、実行後に読み直すか別コンテキストで扱うのが安全です。
ページングは OFFSET よりキーセット(シーク)方式
Skip().Take() の OFFSET ページングは分かりやすい一方、後ろのページほど遅くなります。DB が読み飛ばすぶんの行を毎回スキャンするからです。並び順のキーを基準に「前ページの最後より後ろ」を取るキーセット方式なら、何ページ目でも一定の速さになります。
// 最初のページ
var firstPage = await db.Orders
.AsNoTracking()
.OrderByDescending(o => o.OrderDate).ThenByDescending(o => o.Id)
.Take(pageSize)
.ToListAsync();
// 次ページ。前ページ末尾の (OrderDate, Id) を境界にする
var nextPage = await db.Orders
.AsNoTracking()
.Where(o => o.OrderDate < lastDate
|| (o.OrderDate == lastDate && o.Id < lastId))
.OrderByDescending(o => o.OrderDate).ThenByDescending(o => o.Id)
.Take(pageSize)
.ToListAsync();
無限スクロールや「もっと見る」型の UI とは特に相性が良い方式です。任意のページ番号にジャンプする画面が必要なときだけ OFFSET を残す、といった使い分けが現実的です。
繰り返し投げるクエリはコンパイルしておく
EF Core は毎回 LINQ 式を SQL に翻訳します。ホットパスで同じ形のクエリを何千回も実行するなら、翻訳結果をキャッシュするコンパイル済みクエリが効きます。
private static readonly Func<AppDbContext, int, Task<Order?>> GetOrderById =
EF.CompileAsyncQuery((AppDbContext db, int id) =>
db.Orders.AsNoTracking().FirstOrDefault(o => o.Id == id));
// 呼び出し側は式の翻訳コストを払わない
var order = await GetOrderById(db, orderId);
起動時間が気になるアプリなら、モデル構築のコストを前倒しするコンパイル済みモデル(dotnet ef dbcontext optimize)も検討する価値があります。テーブル数の多い大規模モデルほど初回クエリの立ち上がりが速くなります。
DbContext プーリングと接続まわり
DbContext の生成は毎回それなりのコストがかかります。Web アプリのようにリクエストごとにコンテキストを使い捨てるなら、インスタンスを再利用するプーリングでその割り当てを減らせます。
builder.Services.AddDbContextPool<AppDbContext>(options =>
{
options.UseSqlServer(connectionString, sql =>
{
sql.EnableRetryOnFailure(maxRetryCount: 3);
sql.CommandTimeout(30);
});
}, poolSize: 128);
プーリングを使うときは、コンテキストにリクエスト固有の状態(テナント ID など)をコンストラクターで抱えさせない設計にしておきます。プールから使い回すぶん、状態が前のリクエストに引きずられると事故になります。接続文字列側では Max Pool Size を実際の同時実行数に合わせておくと、接続待ちのタイムアウトを避けられます。
EF Core 10 で新しくなったところ
2026 年時点の最新は EF Core 10(.NET 10 と同時リリース、LTS)です。パフォーマンスに直結する変更をいくつか挙げます。
LeftJoin / RightJoin が正式な演算子に
これまで LEFT OUTER JOIN を LINQ で書くには GroupJoin + SelectMany + DefaultIfEmpty の組み合わせが必要で、読みづらさの温床でした。EF Core 10 では LeftJoin / RightJoin が一級の演算子になり、素直に SQL の JOIN へ変換されます。
var rows = await db.Customers
.LeftJoin(
db.Orders,
c => c.Id,
o => o.CustomerId,
(c, o) => new { c.Name, OrderId = (int?)o.Id })
.ToListAsync();
名前付きクエリフィルター
論理削除とマルチテナントのように、複数のグローバルフィルターを別々に管理したい場面があります。EF Core 10 ではフィルターに名前を付けて定義でき、クエリ単位で特定のフィルターだけ無効化できるようになりました。
modelBuilder.Entity<Order>()
.HasQueryFilter("SoftDelete", o => !o.IsDeleted)
.HasQueryFilter("Tenant", o => o.TenantId == tenantProvider.CurrentTenantId);
// 管理画面などで論理削除フィルターだけ外す
var includingDeleted = await db.Orders
.IgnoreQueryFilters(["SoftDelete"])
.ToListAsync();
パラメーター化コレクションと大きな IN 句
ids.Contains(o.Id) のようなコレクション検索は、EF Core 8 以降で単一パラメーターとして展開され、値が変わってもクエリプランを再利用しやすくなりました。EF Core 10 ではこの変換戦略を全体またはクエリ単位で選べるようになり、大きな IN 句のプラン再利用がさらに改善しています。件数の読めない ID リストで検索するバッチ処理では効いてきます。
var targets = await db.Orders
.Where(o => orderIds.Contains(o.Id)) // 単一パラメーターに展開される
.ToListAsync();
ほかにも、JSON 列と複合型(Complex Types)のマッピング強化、ベクトル検索を含むプロバイダー側の対応など、EF Core 10 では実運用で効く改善が積み重なっています。移行時は ExecuteUpdate まわりの追跡挙動と、名前付きフィルターへの書き換えを重点的に確認しておくと安全です。
まとめ
EF Core の最適化は派手な一手より、効く順に地道に潰していくのが近道です。困ったときに見返せるよう、優先度の高いものからチェックリストにしておきます。
- 直す前にログと計測で重いクエリを特定する
- 読み取りは
AsNoTracking、使う列だけSelectで射影する - ループ内クエリ(N+1)は
Includeや射影でまとめ、デカルト爆発はAsSplitQueryで回避する - 一括更新・削除は
ExecuteUpdate/ExecuteDeleteで1往復にする - 大きな一覧のページングはキーセット方式にする
- ホットパスのクエリはコンパイル済みクエリ、Web アプリは
DbContextプーリングを効かせる
エンハンスド株式会社では、EF Core を使った .NET アプリのボトルネック調査からデータベース設計の見直しまで、パフォーマンス改善をお手伝いしています。
- レガシーシステム モダナイゼーション - レガシー資産を分析し、最新 .NET への安全な移行を支援
- モダンWeb開発支援 & 内製化 - C# と Next.js / Blazor でモダンな開発と内製化を支援
- クラウドネイティブ & 移行 - Azure / AWS でクラウドネイティブな設計と移行を支援
このテーマの全体像は .NET パフォーマンス最適化 完全ガイド にまとめています。
この記事をシェア
この記事に関連する支援サービス
記事で扱っている領域について、支援内容と事例をまとめたページがあります。
関連記事

.NET 10 Native AOT で高速起動を実現する実践ガイド
Native AOT は、.NET アプリを事前にネイティブコードへコンパイルして、起動の速さと省メモリを両立させるしくみです。JIT のウォームアップが要らないぶん立ち上がりが速く、…

.NET のメモリと GC チューニング実践ガイド
.NET アプリケーションのパフォーマンス問題の多くは、CPU よりも メモリ割り当てと GC(ガベージコレクション) に起因する。過剰なオブジェクト割り当ては GC の実行頻度を上げ、…

.NET パフォーマンス最適化 完全ガイド — 起動・スループット・メモリを速くする
.NET アプリの「遅い・重い・立ち上がりが遅い」を解消するための入り口ページです。起動時間、スループット、メモリ、データアクセスといったテーマごとに、…
