GraphQL lets clients request related data in a single API operation. For example, a client can request a list of projects along with the users who own them. The API returns everything in one response, but that does not mean the server executes only one database query.
A common problem occurs when the resolver for each project runs a separate query to load its owner. The API might return the correct response, but the number of database queries increases with the number of projects. This is known as the N+1 query problem.
The problem can remain unnoticed when an endpoint returns only a few records. Once the application handles larger lists or more concurrent requests, database activity increases and response times become unpredictable.
This article explains how to identify the N+1 query problem in GraphQL APIs, fix it with batching and DataLoader, and prevent it from returning as the application grows.
What Is the N+1 Query Problem?
Consider a GraphQL API that returns projects and their owners.
A client sends this query:
query {
projects {
id
name
owner {
id
name
}
}
}
The API needs to retrieve the projects first and then load the owner of each project.
A naive implementation might execute one query to retrieve the projects and another query for every project's owner.
SELECT Id, Name, OwnerId
FROM Projects;
If the API returns 50 projects, it could then execute 50 additional queries:
SELECT Id, Name
FROM Users
WHERE Id = @OwnerId;
The total is 51 database queries: one for the projects and 50 for their owners.
The same issue can appear when loading comments for posts, products for categories, departments for employees, or orders for customers.
The important point is that the number of database queries grows with the number of parent records.
Why GraphQL Resolvers Can Cause This Problem
GraphQL resolves fields independently according to the query structure. This makes it convenient to organize an API into small, reusable resolvers, but it can also create inefficient database access.
For example, a project resolver might retrieve the list of projects:
public async Task<List<Project>> GetProjectsAsync(
AppDbContext db,
CancellationToken cancellationToken)
{
return await db.Projects
.AsNoTracking()
.ToListAsync(cancellationToken);
}
A separate resolver might retrieve the owner of a project:
public async Task<User?> GetOwnerAsync(
[Parent] Project project,
AppDbContext db,
CancellationToken cancellationToken)
{
return await db.Users
.AsNoTracking()
.FirstOrDefaultAsync(
user => user.Id == project.OwnerId,
cancellationToken);
}
Both methods are reasonable when viewed independently. The problem is how they work together.
If the projects resolver returns 50 records and the owner resolver runs once for every record, the application can execute 51 queries for one GraphQL request.
This pattern is not limited to Entity Framework Core. It can occur with Dapper, raw SQL, REST calls to another service, or any resolver that performs an individual lookup for each parent record.
How to Identify the N+1 Query Problem
Before changing the resolver design, confirm that the application is actually making repeated queries. A slow GraphQL response does not always mean an N+1 problem; it could also be caused by an inefficient query plan, network latency, locks, or an overloaded database.
Enable EF Core SQL logging
If the application uses Entity Framework Core, SQL logging is a useful starting point.
In a development environment, configure logging when registering the database context:
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(
builder.Configuration.GetConnectionString("DefaultConnection"));
options.LogTo(
Console.WriteLine,
LogLevel.Information);
});
Run a GraphQL query that requests multiple projects and their owners.
Look for repeated SQL statements that differ only in the owner ID. If the same query executes many times during one GraphQL operation, the resolver may be causing an N+1 problem.
Use detailed SQL logging carefully. Database commands can contain sensitive values, and verbose logs can become expensive under production traffic. Restrict diagnostic logging to appropriate environments and apply your organization's data-redaction rules.
Measure database commands per GraphQL request
Application logs are more useful when you can associate database activity with an individual GraphQL operation.
Track metrics such as:
Number of database commands per GraphQL request.
Total database execution time.
Number of returned parent and child records.
GraphQL operation duration.
Slow database commands and their execution plans.
Distributed tracing and application performance monitoring tools can help connect the GraphQL operation to its database calls.
For example, if a request returns 100 projects and executes 101 database commands, investigate whether the additional 100 commands come from the owner resolver.
Do not rely on a fixed query-count threshold for every endpoint. A report with several independent data sources may legitimately need more queries than a simple list operation. Look for query counts that increase unexpectedly as the result size grows.
Fix the Problem by Batching Database Queries
The usual solution is to collect the required IDs and load the related records in one query instead of issuing one query per parent.
For the project example, the application can retrieve all required owners with a single database command:
SELECT Id, Name
FROM Users
WHERE Id IN (@OwnerId1, @OwnerId2, @OwnerId3);
The actual parameters depend on the projects returned by the first query.
The application then matches each project to its owner in memory. This changes the database workload from one query per owner to a batched lookup.
There are several ways to implement this pattern. The best choice depends on the GraphQL framework, the relationship being loaded, and the shape of the data.
Use DataLoader in GraphQL APIs
DataLoader is a common pattern for batching and caching data loads within a request. Instead of querying the database immediately for every owner, resolvers request owners through a loader. The loader groups compatible requests and fetches the records together.
In a .NET GraphQL application using Hot Chocolate, a batch DataLoader can load users by their IDs.
The following example illustrates the batching logic. It uses the DataLoader abstraction from Hot Chocolate; exact registration and injection syntax can vary by framework version.
public sealed class UserByIdDataLoader
: BatchDataLoader<int, User>
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
public UserByIdDataLoader(
IDbContextFactory<AppDbContext> dbContextFactory,
IBatchScheduler batchScheduler,
DataLoaderOptions options)
: base(batchScheduler, options)
{
_dbContextFactory = dbContextFactory;
}
protected override async Task<
IReadOnlyDictionary<int, User>> LoadBatchAsync(
IReadOnlyList<int> keys,
CancellationToken cancellationToken)
{
await using var db =
await _dbContextFactory.CreateDbContextAsync(
cancellationToken);
var users = await db.Users
.AsNoTracking()
.Where(user => keys.Contains(user.Id))
.ToListAsync(cancellationToken);
return users.ToDictionary(user => user.Id);
}
}
This loader accepts multiple user IDs and returns a dictionary keyed by user ID. Instead of executing a separate query for every key, it retrieves the matching users in one batch.
Using a context factory also avoids sharing one DbContext instance across concurrent operations. EF Core does not support running multiple concurrent operations on the same context instance.
The owner resolver can then request a user through the loader:
public Task<User?> GetOwnerAsync(
[Parent] Project project,
UserByIdDataLoader userLoader,
CancellationToken cancellationToken)
{
return userLoader.LoadAsync(
project.OwnerId,
cancellationToken);
}
The GraphQL framework coordinates compatible loads and invokes the batch method with the collected keys.
For a list of 50 projects, the API can retrieve the projects in one query and their owners in one batched query, rather than executing 51 individual queries.
This assumes the loader can batch the keys into one database command. Large key sets, database parameter limits, or framework configuration may require multiple batches.
Register and use the loader correctly
A DataLoader must be registered with the GraphQL server according to the framework's supported registration mechanism. The database context factory must also be registered with dependency injection.
For example, an EF Core context factory can be configured as follows:
builder.Services.AddPooledDbContextFactory<AppDbContext>(
options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString(
"DefaultConnection")));
Configure the GraphQL server and DataLoader using the registration API supported by your installed Hot Chocolate version.
Avoid creating a new DataLoader manually inside each resolver. The framework needs to manage loader execution so requests can be grouped into batches.
Another Option: Load Related Data with EF Core
DataLoader is not always necessary. If an endpoint has a predictable response shape, EF Core can retrieve related entities using a join or eager loading.
For example:
public async Task<List<Project>> GetProjectsAsync(
AppDbContext db,
CancellationToken cancellationToken)
{
return await db.Projects
.AsNoTracking()
.Include(project => project.Owner)
.ToListAsync(cancellationToken);
}
This approach is straightforward when the API always needs each project's owner.
However, eager loading is not automatically the best choice for every GraphQL API. GraphQL clients can request different fields, and a large object graph may contain several collections. Loading every possible relationship can transfer more data than the client needs.
For example, including projects, tasks, comments, and attachments in one query can produce a large joined result with repeated parent data. EF Core may also use multiple queries for certain collection-loading configurations.
Choose the query shape according to the fields requested and the relationships involved. Use eager loading for predictable data requirements, and consider DataLoader for independently resolved fields that need batching.
Use Projection When the Client Needs Only a Few Fields
Another way to reduce database work is to retrieve only the columns and relationships required by the GraphQL operation.
Suppose the client requests project names and owner names. The application does not necessarily need to load every property of the project and user entities.
A projection can return a smaller result:
var projects = await db.Projects
.AsNoTracking()
.Select(project => new ProjectSummary
{
Id = project.Id,
Name = project.Name,
OwnerName = project.Owner.Name
})
.ToListAsync(cancellationToken);
The database can resolve the owner relationship as part of the generated query, depending on the provider and query shape.
Projection works well when the response is known in advance. For dynamic GraphQL selections, use a supported projection feature or build a query that respects the selected fields. Avoid combining multiple collection relationships without checking the generated SQL and result size.
Common Mistakes When Fixing N+1 Queries
Replacing many small queries with one enormous query
A single SQL statement is not always faster than several queries. Joining several one-to-many relationships can multiply the number of rows returned and increase memory usage.
Measure the generated SQL, returned row count, and database execution time rather than optimizing only for the number of commands.
Caching every result globally
DataLoader commonly caches results for the lifetime of a GraphQL request. This avoids loading the same key repeatedly during that operation.
A global cache shared between users is different. If cached data depends on permissions, tenant IDs, or user-specific visibility, a global cache can return data to the wrong caller unless its keys and invalidation rules account for those boundaries.
Keep request-scoped caching as the default. Use cross-request caching only when its authorization, freshness, and invalidation behavior are explicitly designed.
Ignoring authorization in the batch query
A batch loader must apply the same authorization and tenant restrictions as the original resolver.
For example, filtering only by user ID may be insufficient when users can see records only within their organization. Apply the relevant tenant and permission predicates to the batch query, and do not assume that an ID is safe to expose simply because it was requested by a resolver.
Forgetting cancellation
Pass the GraphQL request's cancellation token through to EF Core database operations. If the client disconnects or the request is cancelled, the application should stop work where possible rather than continue performing unnecessary queries.
Testing only with one parent record
An N+1 problem is often invisible when a test returns one project. Test with multiple parents and verify that the query count stays bounded as the number of parents grows.
How to Prevent N+1 Problems in Production
A reliable prevention strategy combines query design, observability, and tests.
First, use DataLoader or another batching strategy for fields that load related records independently. Second, capture database command counts and query durations so repeated lookups are visible. Third, test representative GraphQL operations with realistic list sizes.
For example, an integration test can execute a query that returns 20 projects with their owners and inspect the database commands generated during the operation. The exact expected count depends on the application's middleware, query shape, and framework, so avoid asserting an arbitrary number without understanding the execution path.
It is also useful to test missing related records. A loader should handle IDs that do not exist without failing the entire operation unnecessarily. If the application uses soft deletion, tenant filtering, or authorization rules, the batch query must preserve those rules.
Finally, monitor the database as well as the GraphQL server. A reduction in query count is helpful, but the real objective is to reduce unnecessary database work and deliver predictable response times.
Conclusion
The N+1 query problem occurs when a GraphQL API executes an additional database query for every parent record. It often starts with individually reasonable resolvers that become inefficient when combined.
Start by inspecting SQL logs and measuring database commands per GraphQL request. Then use DataLoader to batch related lookups, or use EF Core projection and eager loading when the response shape is predictable.
The best solution is not necessarily the one with the fewest SQL statements. It is the one that retrieves the required data efficiently, respects authorization boundaries, avoids excessive memory use, and continues to perform well as the result set grows.
Join the conversation! Your thoughts help the community grow.