Pagination

When a dataset is too large to return in a single response, you need pagination. Hot Chocolate implements cursor-based connection pagination following the Relay Cursor Connections Specification. Connections give clients a standardized way to traverse pages using opaque cursors, and they translate directly to efficient database queries when backed by IQueryable.

How Connections Work

Instead of returning a flat list, a paginated field returns a Connection. The connection wraps the data with page metadata and cursors for navigation.

GraphQL
type Query {
users(first: Int, after: String, last: Int, before: String): UsersConnection
}
type UsersConnection {
pageInfo: PageInfo!
edges: [UsersEdge!]
nodes: [User!]
}
type UsersEdge {
cursor: String!
node: User!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}

Clients use first/after to page forward and last/before to page backward. Each edge carries a cursor that points to its position in the dataset.

Adding Pagination

Apply the [UsePaging] attribute to a resolver that returns IEnumerable<T> or IQueryable<T>. The middleware handles slicing the result, computing cursors, and building the PageInfo.

C#
// Types/UserQueries.cs
[QueryType]
public static partial class UserQueries
{
[UsePaging]
public static IQueryable<User> GetUsers(CatalogContext db)
=> db.Users.OrderBy(u => u.Id);
}

When backed by IQueryable<T>, the pagination operations translate directly to native database queries. Hot Chocolate does not load the entire dataset into memory.

The Connection<T> Type

When you need full control over the pagination process, return a Connection<T> from your resolver. This is useful when you build cursors from an external API, implement a custom data source, or need to control the page info values.

C#
// Types/UserQueries.cs
[QueryType]
public static partial class UserQueries
{
[UsePaging]
public static async Task<Connection<User>> GetUsersAsync(
string? after,
int? first,
UserService userService,
CancellationToken ct)
{
var result = await userService.GetUsersPageAsync(after, first, ct);
var edges = result.Items
.Select(u => new Edge<User>(u, u.Id.ToString()))
.ToList();
var pageInfo = new ConnectionPageInfo(
result.HasNextPage,
result.HasPreviousPage,
edges.FirstOrDefault()?.Cursor,
edges.LastOrDefault()?.Cursor);
return new Connection<User>(
edges,
pageInfo,
totalCount: _ => ValueTask.FromResult(result.TotalCount));
}
}

Pagination Options

You can configure pagination behavior per field or globally.

Per-Field Options

C#
// Types/UserQueries.cs
[QueryType]
public static partial class UserQueries
{
[UsePaging(MaxPageSize = 100, DefaultPageSize = 25, IncludeTotalCount = true)]
public static IQueryable<User> GetUsers(CatalogContext db)
=> db.Users.OrderBy(u => u.Id);
}

Global Defaults

Apply consistent pagination settings across your entire schema:

C#
// Program.cs
builder
.AddGraphQL()
.ModifyPagingOptions(opt =>
{
opt.MaxPageSize = 100;
opt.DefaultPageSize = 25;
opt.IncludeTotalCount = true;
});

All PagingOptions

PropertyDefaultDescription
MaxPageSize50Maximum number of items a client can request via first or last.
DefaultPageSize10Number of items returned if the client does not specify first or last.
IncludeTotalCountfalseAdds a totalCount field to the Connection.
AllowBackwardPaginationtrueIncludes before and last arguments on the Connection.
RequirePagingBoundariesfalseRequires the client to specify first or last.
InferConnectionNameFromFieldtrueInfers the Connection name from the field name instead of the return type.
ProviderNamenullName of the pagination provider to use.
NullOrderingUnspecifiedControls how null values are ordered when a nullable field is used as a cursor key.

MaxPageSize and Cost Analysis

The MaxPageSize setting works together with cost analysis to protect your API. Cost analysis uses the MaxPageSize as the assumed list size when calculating the cost of a paginated field. If you increase MaxPageSize, the cost of queries against that field increases proportionally.

For public APIs, keep MaxPageSize conservative and use RequirePagingBoundaries = true to force clients to declare how many items they want.

Connection Naming

The Connection and Edge type names are inferred from the field name by default. A field called users generates UsersConnection and UsersEdge.

Override the name with ConnectionName:

C#
[UsePaging(ConnectionName = "TeamMembers")]
public static IQueryable<User> GetUsers(CatalogContext db)
=> db.Users.OrderBy(u => u.Id);

This produces TeamMembersConnection and TeamMembersEdge.

Total Count

Enable the totalCount field to let clients request the total number of items in the dataset:

C#
[UsePaging(IncludeTotalCount = true)]
public static IQueryable<User> GetUsers(CatalogContext db)
=> db.Users.OrderBy(u => u.Id);

When your resolver returns IEnumerable<T> or IQueryable<T>, the total count is computed automatically. When you return a Connection<T>, provide the count through the totalCount delegate:

C#
var connection = new Connection<User>(
edges,
pageInfo,
totalCount: ct => ValueTask.FromResult(totalItems));

Extending Connection and Edge Types

Add fields to a Connection or Edge type using type extensions. This is useful for aggregation fields or metadata.

C#
// Types/UsersConnectionExtension.cs
[ExtendObjectType("UsersConnection")]
public class UsersConnectionExtension
{
public double GetAverageAge([Parent] Connection<User> connection)
{
return connection.Edges.Average(e => e.Node.Age);
}
}
C#
// Types/UsersEdgeExtension.cs
[ExtendObjectType("UsersEdge")]
public class UsersEdgeExtension
{
public int GetIndex([Parent] Edge<User> edge)
{
// Custom edge field logic
return int.Parse(edge.Cursor);
}
}

If you use projections, some properties on your model may not be populated depending on what the client requested.

Nullable Cursor Keys

When your cursor key field can be null, you must tell Hot Chocolate how the database orders null values so that cursor-based pagination produces correct results across pages.

Set NullOrdering on PagingOptions to match your database:

ValueWhen to use
UnspecifiedDefault. The EF Core paging handler auto-detects ordering for known providers.
NativeNullsFirstNulls sort before non-null values (SQL Server, SQLite, in-memory LINQ).
NativeNullsLastNulls sort after non-null values (PostgreSQL default).
C#
// Program.cs
builder
.AddGraphQL()
.ModifyPagingOptions(opt => opt.NullOrdering = NullOrdering.NativeNullsLast);

When NullOrdering is Unspecified and the EF Core paging handler is used, ordering is detected automatically for PostgreSQL (NativeNullsLast) and SQL Server, SQLite, and in-memory (NativeNullsFirst). For unrecognized providers, an error is thrown when nullable cursor keys are present. Set NullOrdering explicitly to resolve it.

Pagination Providers

The UsePaging middleware provides a unified API that adapts to different data sources through pagination providers. The default provider supports IEnumerable<T> and IQueryable<T>. Other providers handle specific databases like MongoDB.

C#
// Program.cs
builder
.AddGraphQL()
.AddMongoDbPagingProviders();

Name a provider to reference it explicitly on specific fields:

C#
builder
.AddGraphQL()
.AddMongoDbPagingProviders(providerName: "MongoDB");
C#
[UsePaging(ProviderName = "MongoDB")]
public static IExecutable<User> GetUsers(IMongoCollection<User> collection)
=> collection.AsExecutable();

If no ProviderName is specified, the correct provider is selected based on the return type. If it cannot be inferred, the first registered provider is used.

Learn more about database integrations

Next Steps

Last updated on April 13, 2026 by Michael Staib