DataLoader

DataLoaders solve the N+1 problem in GraphQL. When the execution engine resolves a list of objects and each object needs related data, a naive implementation fires one database query per object. A DataLoader collects all those individual requests, waits for the execution engine to finish the current batch of resolvers, and then sends one query for all requested keys at once.

This page covers the source-generated DataLoader (the recommended approach) and manual DataLoader classes. If you are new to GraphQL data fetching, start with Resolvers first.

The N+1 Problem

Consider a schema where each product has a brand, and clients can query a list of products with their brands.

GraphQL schema

GraphQL
type Query {
products(first: 5): ProductsConnection
}
type Product {
id: ID!
name: String!
brand: Brand!
}
type Brand {
id: ID!
name: String!
}

Client query

GraphQL
query {
products(first: 5) {
nodes {
name
brand {
name
}
}
}
}

Without a DataLoader, the brand resolver executes once per product. Five products means five database queries for brands, even if several products share the same brand. With 50 products, that becomes 50 queries. This is the N+1 problem: 1 query for the product list, plus N queries for related data.

A DataLoader batches those N brand lookups into a single query. The resolver asks the DataLoader for each brand by key. The DataLoader collects all keys, waits for the execution engine to drain the current resolver batch, and then fires one WHERE id IN (...) query for all requested brands.

Source-Generated DataLoader

The recommended way to define a DataLoader is with the [DataLoader] attribute and the source generator. You write a static method that accepts a list of keys and returns a dictionary of results. The source generator creates the DataLoader class and its interface at build time.

Batch DataLoader (one-to-one)

Use a batch DataLoader when each key maps to at most one result. This is the most common pattern: fetching an entity by ID.

C# DataLoader

C#
internal static class BrandDataLoaders
{
[DataLoader]
public static async Task<Dictionary<int, Brand>> GetBrandByIdAsync(
IReadOnlyList<int> ids,
CatalogContext db,
CancellationToken ct)
=> await db.Brands
.Where(b => ids.Contains(b.Id))
.ToDictionaryAsync(b => b.Id, ct);
}

The source generator produces an IBrandByIdDataLoader interface and a BrandByIdDataLoader class. The name is derived from the method name: Get prefix and Async suffix are stripped, leaving BrandById.

C# resolver

C#
[ObjectType<Product>]
public static partial class ProductNode
{
public static async Task<Brand> GetBrandAsync(
[Parent] Product product,
IBrandByIdDataLoader brandById,
CancellationToken ct)
=> await brandById.LoadAsync(product.BrandId, ct);
}

The resolver requests a brand by key. The DataLoader collects all keys from all concurrently executing resolvers, then calls GetBrandByIdAsync once with the full list.

Group DataLoader (one-to-many)

Use a group DataLoader when each key maps to multiple results. This is common for relationships like "all products for a brand" or "all reviews for a product".

C# DataLoader

C#
internal static class ProductDataLoaders
{
[DataLoader]
public static async Task<Dictionary<int, Product[]>> GetProductsByBrandIdAsync(
IReadOnlyList<int> brandIds,
CatalogContext db,
CancellationToken ct)
=> await db.Products
.Where(p => brandIds.Contains(p.BrandId))
.GroupBy(p => p.BrandId)
.Select(g => new { g.Key, Items = g.OrderBy(p => p.Name).ToArray() })
.ToDictionaryAsync(g => g.Key, g => g.Items, ct);
}

The return type is Dictionary<int, Product[]>. The source generator recognizes the array value type and generates a group DataLoader. The generated interface is IProductsByBrandIdDataLoader.

C# resolver

C#
[ObjectType<Brand>]
public static partial class BrandNode
{
public static async Task<Product[]> GetProductsAsync(
[Parent] Brand brand,
IProductsByBrandIdDataLoader productsByBrandId,
CancellationToken ct)
=> await productsByBrandId.LoadAsync(brand.Id, ct) ?? [];
}

When the DataLoader returns null for a key (the brand has no products), use the null-coalescing operator to return an empty array.

DataLoader Options

The [DataLoader] attribute accepts configuration options.

PropertyTypeDefaultDescription
Namestring?Derived from methodOverride the generated DataLoader class name.
ServiceScopeDataLoaderServiceScopeDefaultControls how injected services are resolved. DataLoaderScope creates a dedicated scope. OriginalScope uses the request scope.
AccessModifierDataLoaderAccessModifierDefaultControls generated class visibility. Public makes both class and interface public. Internal makes both internal. PublicInterface makes the interface public and the class internal.
C#
[DataLoader(ServiceScope = DataLoaderServiceScope.DataLoaderScope)]
public static async Task<Dictionary<int, Brand>> GetBrandByIdAsync(
IReadOnlyList<int> ids,
CatalogContext db,
CancellationToken ct)
=> await db.Brands
.Where(b => ids.Contains(b.Id))
.ToDictionaryAsync(b => b.Id, ct);

Use DataLoaderServiceScope.DataLoaderScope when your data access service (like DbContext) is registered as scoped and you want the DataLoader to have its own scope, separate from the request scope. This prevents lifetime conflicts when the DataLoader outlives a single resolver.

How Execution Works

The execution engine resolves fields in waves. Within each wave, resolvers run concurrently. When a resolver calls LoadAsync on a DataLoader, the key is queued but no database call happens yet. After all resolvers in the current wave finish, the engine dispatches all pending DataLoader batches. Each DataLoader fires a single call to its data source with the full set of collected keys.

Wave 1: Resolve products(first: 5) → [Product1, Product2, Product3, Product4, Product5]
↓
Wave 2: Resolve brand for each product → DataLoader collects keys [1, 2, 1, 3, 2]
↓
Dispatch: DataLoader deduplicates → sends one query for brand IDs [1, 2, 3]
↓
Wave 3: Return cached Brand objects to each product resolver

DataLoaders also deduplicate keys. If two products share the same brand ID, the DataLoader sends that ID only once and returns the same Brand instance to both resolvers.

Data Consistency

A DataLoader caches results for the duration of a single GraphQL request. If the same key is requested multiple times within one request, the DataLoader returns the cached result without hitting the data source again. This guarantees that all resolvers within a request see the same data for the same key.

The cache is not shared across requests. Each request gets a fresh DataLoader instance with an empty cache.

Manual DataLoader Classes

You can write DataLoader classes by hand when you need full control over the batching logic. This is rarely needed since the source generator covers most cases.

C#
public class BrandByIdDataLoader : BatchDataLoader<int, Brand>
{
private readonly IServiceProvider _services;
public BrandByIdDataLoader(
IServiceProvider services,
IBatchScheduler batchScheduler,
DataLoaderOptions options)
: base(batchScheduler, options)
{
_services = services;
}
protected override async Task<IReadOnlyDictionary<int, Brand>> LoadBatchAsync(
IReadOnlyList<int> keys,
CancellationToken ct)
{
await using var scope = _services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<CatalogContext>();
return await db.Brands
.Where(b => keys.Contains(b.Id))
.ToDictionaryAsync(b => b.Id, ct);
}
}

The IReadOnlyList<TKey> passed to LoadBatchAsync and source-generated methods is a rented list. Do not store or use it outside the method body.

Next Steps

  • Need simpler batching without caching? See Batch Resolvers.
  • Need to understand resolver basics? See Resolvers.
  • Need pagination? See Pagination for cursor-based connections.
  • Using Entity Framework? See Entity Framework for integration patterns with DataLoaders.
Last updated on May 13, 2026 by Michael Staib