Cost Analysis

If you expose a GraphQL API to the public internet, you cannot predict what queries clients will send. A single deeply nested query requesting thousands of nodes can bring your server to its knees. Cost analysis prevents this by calculating the cost of a query before executing it and rejecting queries that exceed your budget.

Hot Chocolate implements static cost analysis based on the draft IBM Cost Analysis specification. It assigns weights to fields and estimates list sizes, then computes two metrics: field cost (execution impact) and type cost (data impact). Queries that exceed either limit are rejected before any resolver runs.

Why This Matters for Public APIs

With REST, each endpoint has a predictable cost. You know that GET /users returns a page of users and takes a roughly constant amount of server time. With GraphQL, a client can construct a query that fans out across relationships:

GraphQL
query {
users(first: 50) {
edges {
node {
orders(first: 50) {
edges {
node {
items(first: 50) {
edges {
node {
product {
reviews(first: 50) {
edges {
node {
author {
name
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}

This query requests up to 50 x 50 x 50 x 50 = 6,250,000 nodes. Without cost analysis, the server would attempt to resolve all of them.

Cost analysis catches this at validation time and rejects the query before it consumes resources.

How Cost Is Calculated

Hot Chocolate assigns default weights and computes two metrics:

  • Field cost represents the execution impact on the server. Async resolvers default to 10, composite types to 1, and scalars to 0.
  • Type cost represents the number of objects the server instantiates.

Field Cost Example

GraphQL
query {
book {
# 10 (async resolver)
title # 0 (scalar)
author {
# 1 (composite type)
name # 0 (scalar)
}
}
}
# Field cost: 11

For paginated fields, costs multiply by the page size:

GraphQL
query {
books(first: 50) {
# 10 (async resolver)
edges {
# 1 (composite type)
node {
# 50 (1 x 50 items)
title # 0 (scalar)
author {
# 50 (1 x 50 items)
name # 0 (scalar)
}
}
}
}
}
# Field cost: 111

Type Cost Example

GraphQL
query {
# 1 Query
books(first: 50) {
# 50 BooksConnections
edges {
# 1 BooksEdge
node {
# 50 Books
title
author {
# 50 Authors
name
}
}
}
}
}
# Type cost: 152

Defaults for Paginated Fields

Hot Chocolate automatically annotates paginated fields with cost and list size directives. For connection-based pagination:

GraphQL
books(first: Int, after: String, last: Int, before: String): BooksConnection
@listSize(
assumedSize: 50
slicingArguments: ["first", "last"]
sizedFields: ["edges", "nodes"]
)
@cost(weight: "10")

The assumedSize defaults to the MaxPageSize from your pagination options.

Applying a Cost Weight

Override the default cost for a specific field:

C#
// Types/BookQueries.cs
[QueryType]
public static partial class BookQueries
{
[Cost(100)]
public static async Task<Book> GetBookAsync(int id, CatalogContext db, CancellationToken ct)
=> await db.Books.FindAsync([id], ct);
}

Applying List Size Settings

For fields that return lists, control how cost analysis estimates the list size:

C#
// Types/BookQueries.cs
[QueryType]
public static partial class BookQueries
{
[ListSize(
AssumedSize = 100,
SlicingArguments = ["first", "last"],
SizedFields = ["edges", "nodes"],
RequireOneSlicingArgument = false)]
public static IEnumerable<Book> GetBooks()
=> [new Book("C# in depth", new Author("Jon Skeet"))];
}

Inspecting Cost Metrics

To see the cost of a query without changing enforcement, set the GraphQL-Cost HTTP header:

Header ValueBehavior
reportExecutes the request and includes cost metrics in the response.
validateReturns cost metrics without executing the request.

This is invaluable when tuning your cost configuration. Send representative queries from your client applications and review their costs before deploying changes.

Accessing Costs in Code

Read cost metrics from IResolverContext or IMiddlewareContext:

C#
// Types/BookQueries.cs
public static Book GetBook(IResolverContext context)
{
var costMetrics = (CostMetrics)context.ContextData[WellKnownContextData.CostMetrics]!;
double fieldCost = costMetrics.FieldCost;
double typeCost = costMetrics.TypeCost;
// Use for logging, monitoring, etc.
}

Tuning Guide

Start with Defaults

The defaults (MaxFieldCost = 1000, MaxTypeCost = 1000) work for many schemas. Deploy with defaults first and observe which queries are rejected.

Measure Real Queries

Use the GraphQL-Cost: report header to measure the cost of your actual client queries. This gives you a baseline to tune from.

Adjust MaxFieldCost and MaxTypeCost

Increase the limits if legitimate queries are rejected. Decrease them if you want tighter protection. The right values depend on your infrastructure and acceptable load.

C#
// Program.cs
builder
.AddGraphQL()
.ModifyCostOptions(options =>
{
options.MaxFieldCost = 5_000;
options.MaxTypeCost = 5_000;
});

Assign Custom Weights to Expensive Fields

If a resolver calls an external API or runs an expensive query, increase its cost weight:

C#
[Cost(50)]
public static async Task<Report> GetReportAsync(/* ... */)

Use RequirePagingBoundaries

Force clients to specify first or last on paginated fields. Without this, the cost analyzer uses MaxPageSize as the assumed list size, which may overestimate the cost of well-behaved queries:

C#
builder
.AddGraphQL()
.ModifyPagingOptions(opt => opt.RequirePagingBoundaries = true);

Real-World Example

Consider a product catalog API with this schema:

GraphQL
type Query {
products(first: Int, after: String): ProductsConnection
}
type Product {
name: String
reviews(first: Int, after: String): ReviewsConnection
}
type Review {
text: String
author: User
}

With MaxPageSize = 50 and default costs, a query requesting products(first: 50) { ... reviews(first: 50) { ... } } has:

  • Field cost: 10 (products resolver) + 1 (edges) + 50 (node) + 500 (reviews resolver, 10 x 50) + 50 (reviews edges) + 2500 (review node, 50 x 50) + 2500 (author, 50 x 50) = ~5,611
  • Type cost: 1 (Query) + 50 (Products) + 50 (ProductEdges) + 2500 (Reviews) + 2500 (ReviewEdges) + 2500 (Authors) = ~7,601

With default limits of 1,000, this query is rejected. You can either increase the limits or reduce MaxPageSize for the reviews field:

C#
[UsePaging(MaxPageSize = 10)]
public IQueryable<Review> GetReviews([Parent] Product product, CatalogContext db)
=> db.Reviews.Where(r => r.ProductId == product.Id);

Now the cost drops to a level within the default budget.

Options Reference

Cost Options

OptionDefaultDescription
MaxFieldCost1_000Maximum allowed field cost.
MaxTypeCost1_000Maximum allowed type cost.
EnforceCostLimitstrueWhether to reject queries that exceed cost limits.
ApplyCostDefaultstrueWhether to apply default cost weights to the schema.
DefaultResolverCost10.0Default cost for an async resolver.
C#
// Program.cs
builder
.AddGraphQL()
.ModifyCostOptions(options =>
{
options.MaxFieldCost = 5_000;
options.MaxTypeCost = 5_000;
options.EnforceCostLimits = true;
options.ApplyCostDefaults = true;
options.DefaultResolverCost = 10.0;
});

Filtering Cost Options

OptionDefaultDescription
DefaultFilterArgumentCost10.0Cost for a filter argument.
DefaultFilterOperationCost10.0Cost for a filter operation.
DefaultExpensiveFilterOperationCost20.0Cost for an expensive filter operation.
VariableMultiplier5Multiplier when a variable is used for the filter argument.
C#
options.Filtering.DefaultFilterArgumentCost = 10.0;
options.Filtering.DefaultFilterOperationCost = 10.0;

Sorting Cost Options

OptionDefaultDescription
DefaultSortArgumentCost10.0Cost for a sort argument.
DefaultSortOperationCost10.0Cost for a sort operation.
VariableMultiplier5Multiplier when a variable is used for the sort argument.
C#
options.Sorting.DefaultSortArgumentCost = 10.0;
options.Sorting.DefaultSortOperationCost = 10.0;

Disabling Cost Enforcement

If you protect your API through other means (such as trusted documents), you can disable cost enforcement. The analyzer still computes costs for reporting, but does not reject queries:

C#
// Program.cs
builder
.AddGraphQL()
.ModifyCostOptions(o => o.EnforceCostLimits = false);

Next Steps

Last updated on April 13, 2026 by Michael Staib