EF Core'da İlişkili Veri Yükleme: Include, Explicit ve Lazy Loading, Split Query
EF Core'da ilişkili veriyi getirmenin üç yolunu gerçek SQL çıktılarıyla karşılaştırıyoruz: Include/ThenInclude ve filtered include, Entry API ile explicit loading, proxy'lerle lazy loading ve N+1 faturası; sonra cartesian explosion ve AsSplitQuery.
Sürüm notu: Bu yazıdaki kodlar EF Core 6 ile yazılmıştır ve EF Core 10'da da değişmeden çalışır.
Önce Tahmin Et: Ursula K. Le Guin'in Kaç Kitabı Var?
Veritabanında Ursula K. Le Guin'e ait üç kitap kayıtlı. Şu kod konsola ne yazar?
var authors = db.Authors.OrderBy(a => a.Id).ToList();
foreach (var author in authors)
{
Console.WriteLine($"{author.Name}: {author.Books.Count} books");
}
Ursula K. Le Guin: 0 books
Isaac Asimov: 0 books
Stanislaw Lem: 0 books
Hata yok, uyarı yok, sadece yanlış bir sonuç. EF Core yalnızca istediğini yükler; bu sorgu Authors tablosunu istedi ve çalışan SQL'de Books tablosunun adı bile geçmiyor. Books property'si new() ile başlatıldığı için null değil, boş bir liste. Bir kitapçının kasasında "bu yazarın kitabı yok" demek ile "kitaplarına bakmadım" demek arasındaki fark burada görünmez hâle geliyor.
İlişkili veriyi getirmenin üç yolu var: eager loading (sorguyla birlikte yükle), explicit loading (sonradan, açıkça iste) ve lazy loading (property'ye dokunulduğunda kendiliğinden yükle). Bu yazıda üçünü de çalıştıracağız ve en sonda tek sorgu ile split query arasındaki seçimi konuşacağız.
Model: Yazarlar, Kitaplar, Etiketler ve Siparişler
Bu yazıda Bookstore modelinin beş entity'si de var. Book hem many-to-many ilişkiyle Tag'lere hem de one-to-many ilişkiyle OrderItem'lara bağlı:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace Bookstore;
public class Author
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public List<Book> Books { get; set; } = new();
}
public class Book
{
public int Id { get; set; }
public string Title { get; set; } = null!;
public int PublishedYear { get; set; }
public int AuthorId { get; set; }
public Author Author { get; set; } = null!;
public List<Tag> Tags { get; set; } = new();
public List<OrderItem> OrderItems { get; set; } = new();
}
public class Tag
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public List<Book> Books { get; set; } = new();
}
public class Order
{
public int Id { get; set; }
public string CustomerName { get; set; } = null!;
public List<OrderItem> Items { get; set; } = new();
}
public class OrderItem
{
public int Id { get; set; }
public int OrderId { get; set; }
public Order Order { get; set; } = null!;
public int BookId { get; set; }
public Book Book { get; set; } = null!;
public int Quantity { get; set; }
}
public class BookstoreContext : DbContext
{
public DbSet<Author> Authors => Set<Author>();
public DbSet<Book> Books => Set<Book>();
public DbSet<Tag> Tags => Set<Tag>();
public DbSet<Order> Orders => Set<Order>();
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options
.UseSqlite("Data Source=bookstore.db")
.LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted, RelationalEventId.MultipleCollectionIncludeWarning });
}
Veriyi bir seed method'u ekliyor: üç yazar, dört kitap, üç etiket ve üç sipariş. A Wizard of Earthsea iki etikete sahip ve üç siparişte geçiyor; bu ayrıntı yazının sonunda önemli olacak. Her örnek temiz bir veritabanı ve yeni bir BookstoreContext ile çalışıyor.
Eager Loading: Include
İlişkiyi sorguyla birlikte istemek için Include:
var authors = db.Authors.Include(a => a.Books).OrderBy(a => a.Id).ToList();
SELECT "a"."Id", "a"."Name", "b"."Id", "b"."AuthorId", "b"."PublishedYear", "b"."Title"
FROM "Authors" AS "a"
LEFT JOIN "Books" AS "b" ON "a"."Id" = "b"."AuthorId"
ORDER BY "a"."Id"
Ursula K. Le Guin: 3 books
Isaac Asimov: 1 books
Stanislaw Lem: 0 books
LEFT JOIN olmasının sebebi kitabı olmayan Stanislaw Lem'i de kaybetmemek. EF Core gelen satırları yazar başına gruplar, aynı yazarın üç satırını tek bir Author nesnesine ve onun Books listesine dönüştürür. Bu işleme fix-up denir: change tracker aynı key'e sahip nesneleri tek bir instance'ta toplar ve navigation'ları birbirine bağlar. Ayrıca kitapların Author property'si de dolu gelir; tersi yönü ayrıca Include etmen gerekmez.
Bir Seviye Daha: ThenInclude
Kitapların etiketlerini de istiyorsan bir alt seviyeye ThenInclude ile inersin:
var authors = db.Authors
.Include(a => a.Books)
.ThenInclude(b => b.Tags)
.OrderBy(a => a.Id)
.ToList();
A Wizard of Earthsea: fantasy, classic
The Left Hand of Darkness: classic, science-fiction
The Dispossessed: science-fiction
Foundation: classic, science-fiction
Üretilen SQL artık iç içe iki LEFT JOIN ve join tablosu BookTag üzerinden bir INNER JOIN içeriyor. Uzun olduğu için buraya koymuyorum; kendin ToQueryString() ile bakabilirsin. Logda SQL'den önce bir de uyarı var, onu yazının sonunda konuşacağız.
Aynı seviyede birden fazla yol istiyorsan Include'u tekrarlarsın: .Include(b => b.Tags).Include(b => b.OrderItems). Derin bir yol için zincir uzar: .Include(o => o.Items).ThenInclude(i => i.Book).ThenInclude(b => b.Author). Bir de string tabanlı Include("Books.Tags") var ama derleyicinin kontrolünden çıktığı için lambda'lı hâli tercih et.
Filtered Include (EF Core 5+)
Bazen ilişkinin tamamını değil bir kısmını istersin: her yazarın yalnızca 1970'ten önce yayımlanmış kitapları, başlığa göre sıralı. EF Core 5'ten beri Include'un içine Where, OrderBy, Skip ve Take yazabilirsin:
var authors = db.Authors
.Include(a => a.Books.Where(b => b.PublishedYear < 1970).OrderBy(b => b.Title))
.OrderBy(a => a.Id)
.ToList();
SELECT "a"."Id", "a"."Name", "t"."Id", "t"."AuthorId", "t"."PublishedYear", "t"."Title"
FROM "Authors" AS "a"
LEFT JOIN (
SELECT "b"."Id", "b"."AuthorId", "b"."PublishedYear", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."PublishedYear" < 1970
) AS "t" ON "a"."Id" = "t"."AuthorId"
ORDER BY "a"."Id", "t"."Title"
Ursula K. Le Guin: A Wizard of Earthsea, The Left Hand of Darkness
Isaac Asimov: Foundation
Stanislaw Lem:
Filtre yazarları değil, yüklenen kitapları süzüyor. Hiç uygun kitabı olmayan yazar da listede kalıyor. EF Core 10 aynı SQL'i üretir, yalnızca alt sorgunun alias'ı "t" yerine "b0" olur. Bu tür alias farkları sürümler arasında sık görülür ve anlamı değiştirmez.
Explicit Loading: Sonradan, Açıkça
Ana kaydı zaten yüklediysen ve ilişkiye ancak bir koşul gerçekleşince ihtiyaç duyuyorsan, Entry API'siyle sonradan yükleyebilirsin:
var author = db.Authors.Single(a => a.Name == "Ursula K. Le Guin");
db.Entry(author).Collection(a => a.Books).Load();
Console.WriteLine($"{author.Name}: {author.Books.Count} books");
var book = db.Books.Single(b => b.Title == "Foundation");
db.Entry(book).Reference(b => b.Author).Load();
Console.WriteLine($"{book.Title} by {book.Author.Name}");
SELECT "b"."Id", "b"."AuthorId", "b"."PublishedYear", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."AuthorId" = @__p_0
Ursula K. Le Guin: 3 books
Foundation by Isaac Asimov
Koleksiyon için Collection, tekil ilişki için Reference. Her Load() ayrı bir SQL komutu. Daha kullanışlı olanı Query(): ilişkiyi yüklemeden üzerine sorgu kurmanı sağlar.
var count = db.Entry(author).Collection(a => a.Books).Query().Count(b => b.PublishedYear > 1970);
Console.WriteLine($"After 1970: {count}");
SELECT COUNT(*)
FROM "Books" AS "b"
WHERE ("b"."AuthorId" = @__p_0) AND ("b"."PublishedYear" > 1970)
Kitaplar belleğe gelmedi, yalnızca sayıları geldi. EF Core 6'da explicit loading entity'nin takip ediliyor olmasını ister. AsNoTracking() ile yüklenmiş bir yazar üzerinde Load() çağırırsan:
System.InvalidOperationException: The navigation 'Author.Books' cannot be loaded because the entity is not being tracked. Navigations can only be loaded for tracked entities.
EF Core 8 bu kısıtı kaldırdı; aynı kod EF Core 10'da kitapları yüklüyor ve yazar Detached durumda kalıyor. Tracking'i bir sonraki yazıda ayrıntılı göreceğiz.
Lazy Loading: Sihir ve Faturası
Lazy loading, yazının başındaki sorunu "otomatik olarak" çözer: navigation property'ye dokunduğun anda EF Core gidip veriyi getirir. EF Core'da bunun için ayrı bir paket gerekir:
dotnet add package Microsoft.EntityFrameworkCore.Proxies --version 6.0.36
Paket, entity'lerinden türeyen proxy class'lar üretir ve navigation property'leri override eder. Bu yüzden iki değişiklik gerekiyor: options'a UseLazyLoadingProxies() eklemek ve bütün navigation'ları virtual yapmak.
public class Author
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public virtual List<Book> Books { get; set; } = new();
}
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options
.UseSqlite("Data Source=bookstore.db")
.UseLazyLoadingProxies()
.LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted, RelationalEventId.MultipleCollectionIncludeWarning });
Bir navigation'ı virtual yapmayı unutursan uygulama ilk sorguda değil, model oluşturulurken durur:
Unhandled exception. System.InvalidOperationException: Property 'Author.Books' is not virtual. 'UseChangeTrackingProxies' requires all entity types to be public, unsealed, have virtual properties, and have a public or protected constructor. 'UseLazyLoadingProxies' requires only the navigation properties be virtual.
Şimdi yazının başındaki döngüyü hiç değiştirmeden tekrar çalıştıralım. Okunabilirlik için logdan yalnızca SQL'leri bıraktım:
SELECT "a"."Id", "a"."Name"
FROM "Authors" AS "a"
ORDER BY "a"."Id"
SELECT "b"."Id", "b"."AuthorId", "b"."PublishedYear", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."AuthorId" = @__p_0
Ursula K. Le Guin: 3 books
SELECT "b"."Id", "b"."AuthorId", "b"."PublishedYear", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."AuthorId" = @__p_0
Isaac Asimov: 1 books
SELECT "b"."Id", "b"."AuthorId", "b"."PublishedYear", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."AuthorId" = @__p_0
Stanislaw Lem: 0 books
Sonuç doğru, ama fatura dört sorgu: yazarlar için bir, her yazar için bir tane daha. Bu, N+1 problemi. Üç yazarla dört sorgu masum görünür; bin yazarlı bir listede bin bir sorgu olur ve her biri ağ üzerinden ayrı bir gidiş dönüş demek. Daha kötüsü, kodda bunu gösteren hiçbir şey yok. author.Books.Count sıradan bir property okuması gibi duruyor. author.GetType().Name çağrısı da AuthorProxy döndürüyor; elindeki nesne artık senin class'ın değil, ondan türeyen bir proxy.
İkinci fatura, context kapandıktan sonra gelir. Yükleme zamanı belirsiz olduğu için nesneyi context'in ömrü dışına taşıdığında, örneğin bir web isteğinde serialization sırasında, henüz yüklenmemiş bir navigation'a dokunmak hata verir:
System.InvalidOperationException: An error was generated for warning 'Microsoft.EntityFrameworkCore.Infrastructure.LazyLoadOnDisposedContextWarning': An attempt was made to lazy-load navigation 'BookProxy.Tags' after the associated DbContext was disposed. This exception can be suppressed or logged by passing event ID 'CoreEventId.LazyLoadOnDisposedContextWarning' to the 'ConfigureWarnings' method in 'DbContext.OnConfiguring' or 'AddDbContext'.
Context açıkken yüklenen Books hâlâ okunabiliyor, ama Tags hiç yüklenmediği için patladı. Lazy loading'i bir masaüstü uygulamasında, ömrü uzun bir context ile ve sorgu sayısını izleyerek kullanabilirsin. Web API'lerde ise varsayılan olarak kapalı tut; ne yükleneceğini sorguda açıkça söylemek daha öngörülebilir.
Tek Sorgu mu, Split Query mi?
Şimdi o uyarıya dönelim. EF Core, tek bir sorgu birden fazla koleksiyon navigation'ı yüklediğinde uyarır; ThenInclude örneğinde bunlar Books ve Tags idi. Asıl tehlikeli durum, aynı seviyede iki koleksiyon yüklemek. Bir kitabın hem etiketlerini hem sipariş kalemlerini isteyelim:
var books = db.Books
.Include(b => b.Tags)
.Include(b => b.OrderItems)
.Where(b => b.Title == "A Wizard of Earthsea")
.ToList();
warn: 4.11.2026 01:03:14.032 RelationalEventId.MultipleCollectionIncludeWarning[20504] (Microsoft.EntityFrameworkCore.Query)
Compiling a query which loads related collections for more than one collection navigation, either via 'Include' or through projection, but no 'QuerySplittingBehavior' has been configured. By default, Entity Framework will use 'QuerySplittingBehavior.SingleQuery', which can potentially result in slow query performance. See https://go.microsoft.com/fwlink/?linkid=2134277 for more information. To identify the query that's triggering this warning call 'ConfigureWarnings(w => w.Throw(RelationalEventId.MultipleCollectionIncludeWarning))'.
SELECT "b"."Id", "b"."AuthorId", "b"."PublishedYear", "b"."Title", "t0"."BooksId", "t0"."TagsId", "t0"."Id", "t0"."Name", "o"."Id", "o"."BookId", "o"."OrderId", "o"."Quantity"
FROM "Books" AS "b"
LEFT JOIN (
SELECT "b0"."BooksId", "b0"."TagsId", "t"."Id", "t"."Name"
FROM "BookTag" AS "b0"
INNER JOIN "Tags" AS "t" ON "b0"."TagsId" = "t"."Id"
) AS "t0" ON "b"."Id" = "t0"."BooksId"
LEFT JOIN "OrderItem" AS "o" ON "b"."Id" = "o"."BookId"
WHERE "b"."Title" = 'A Wizard of Earthsea'
ORDER BY "b"."Id", "t0"."BooksId", "t0"."TagsId", "t0"."Id"
Kitabın 2 etiketi ve 3 sipariş kalemi var. Aynı JOIN'leri sqlite3 ile, okunur olsun diye yalnızca dört kolon seçerek çalıştırınca kaç satır döndüğüne bak:
Title|Tag|OrderId|Quantity
A Wizard of Earthsea|fantasy|1|1
A Wizard of Earthsea|fantasy|2|1
A Wizard of Earthsea|fantasy|3|3
A Wizard of Earthsea|classic|1|1
A Wizard of Earthsea|classic|2|1
A Wizard of Earthsea|classic|3|3
Beş nesne için altı satır, yani 2 × 3. İki kardeş koleksiyon birbirleriyle çarpılıyor ve kitabın kolonları her satırda tekrar ediyor. Buna cartesian explosion denir. Etiket sayısı 10, sipariş kalemi 1.000 olsa tek bir kitap için 10.000 satır gelirdi. Aynı sorguya AsSplitQuery() eklersen:
var books = db.Books
.Include(b => b.Tags)
.Include(b => b.OrderItems)
.Where(b => b.Title == "A Wizard of Earthsea")
.AsSplitQuery()
.ToList();
SELECT "b"."Id", "b"."AuthorId", "b"."PublishedYear", "b"."Title"
FROM "Books" AS "b"
WHERE "b"."Title" = 'A Wizard of Earthsea'
ORDER BY "b"."Id"
SELECT "t0"."BooksId", "t0"."TagsId", "t0"."Id", "t0"."Name", "b"."Id"
FROM "Books" AS "b"
INNER JOIN (
SELECT "b0"."BooksId", "b0"."TagsId", "t"."Id", "t"."Name"
FROM "BookTag" AS "b0"
INNER JOIN "Tags" AS "t" ON "b0"."TagsId" = "t"."Id"
) AS "t0" ON "b"."Id" = "t0"."BooksId"
WHERE "b"."Title" = 'A Wizard of Earthsea'
ORDER BY "b"."Id"
SELECT "o"."Id", "o"."BookId", "o"."OrderId", "o"."Quantity", "b"."Id"
FROM "Books" AS "b"
INNER JOIN "OrderItem" AS "o" ON "b"."Id" = "o"."BookId"
WHERE "b"."Title" = 'A Wizard of Earthsea'
ORDER BY "b"."Id"
Üç sorgu, toplamda 1 + 2 + 3 satır. Çarpım toplama dönüştü. Uyarı da kayboldu, çünkü seçimini açıkça yaptın.
Split query bedava değil. Üç ayrı gidiş dönüş var; ağ gecikmesi yüksekse küçük veri için tek sorgudan yavaş olabilir. Daha önemlisi, üç sorgu aynı anlık görüntüyü okumaz: ilk sorguyla ikincisi arasında biri bir etiket eklerse sonuçlar birbiriyle tutarsız olabilir. Bu riski kaldıramıyorsan sorguları bir transaction içinde çalıştırman gerekir. EF Core 3.0'dan önce varsayılan davranış zaten buna benziyordu; 3.0 her LINQ sorgusunu tek SQL'e çevirmeye geçti, split query de 5.0'da isteğe bağlı olarak geri geldi.
Kural olarak: tekil navigation'lar (Author, Book) ve tek bir koleksiyon için tek sorgu iyidir. Aynı seviyede birden fazla koleksiyon ya da büyük bir koleksiyon zinciri varsa split query'yi dene ve ölç. Varsayılanı uygulama genelinde değiştirmek istersen provider ayarında UseSqlite("...", o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)) yazabilir, tek tek sorgularda AsSingleQuery() ile geri dönebilirsin.
Sık Düşülen Tuzaklar
Filtered include ve önceden takip edilen nesneler. Aynı context'te önce bütün kitapları yükleyip ardından filtered include yaparsan filtre işe yaramıyormuş gibi görünür:
var allBooks = db.Books.ToList();
var authors = db.Authors
.Include(a => a.Books.Where(b => b.PublishedYear < 1970))
.OrderBy(a => a.Id)
.ToList();
Ursula K. Le Guin: 3 books
Isaac Asimov: 1 books
Stanislaw Lem: 0 books
SQL'deki WHERE "b"."PublishedYear" < 1970 doğru çalıştı. Ama change tracker 1974 tarihli The Dispossessed'i zaten tanıyordu ve fix-up sırasında onu da yazarın listesine bağladı. Filtered include, "listede yalnızca bunlar olsun" garantisi vermez; "veritabanından yalnızca bunları getir" der. Bu garantiye ihtiyacın varsa yeni bir context ya da AsNoTracking() kullan.
Projection'da Include. db.Books.Include(b => b.Tags).Select(b => b.Title) sorgusu SELECT "b"."Title" FROM "Books" üretir; Include sessizce yok sayılır. Include yalnızca sorgu entity döndürdüğünde anlamlıdır. Projection yazıyorsan ihtiyacın olan ilişkiyi doğrudan Select'in içinde iste.
Her şeyi Include etmek. "Belki lazım olur" diye eklenen her Include bir JOIN ve daha fazla kolon demek. Bir ekran yalnızca yazar adını gösteriyorsa bütün kitap ağacını yükleme; çoğu okuma ekranı için önceki yazıdaki projection daha ucuzdur.
Lazy loading ile serialization. Lazy loading açıkken bir entity'yi doğrudan JSON'a çevirmek, serializer'ın her navigation'a dokunmasıyla onlarca sorguyu ya da kapanmış context hatasını tetikler. API'lerden entity değil DTO döndürmenin sebeplerinden biri bu.
Küçük Bir Deneme
★☆☆ Siparişleri, kalemleri ve her kalemin kitabıyla birlikte yükle: db.Orders.Include(o => o.Items).ThenInclude(i => i.Book). Her siparişi müşteri adı ve kitap başlıklarıyla yazdır, üretilen SQL'de kaç JOIN olduğunu say.
★★☆ Yazının başındaki "0 books" döngüsünü üç farklı yolla düzelt: Include ile, her yazar için explicit loading ile ve yazar adını ve kitap sayısını döndüren bir projection ile. Üçünün loglarındaki SQL komut sayısını karşılaştır.
★★★ Seed'e A Wizard of Earthsea için 200 sipariş kalemi ve 10 etiket ekle. Aynı Include(b => b.Tags).Include(b => b.OrderItems) sorgusunu tek sorgu ve split query olarak Stopwatch ile ölç, tek sorgunun kaç satır döndürdüğünü sqlite3 ile doğrula.
Bu yazı boyunca birkaç kez change tracker'a atıf yaptık: fix-up, Find'ın veritabanına gitmemesi, filtered include'un beklenmedik sonucu. Bir sonraki yazıda bu mekanizmanın içine giriyoruz: entity state'leri, AsNoTracking, identity resolution ve DetectChanges.