Limiting GraphQL Query Depth and Aliases¶
A GraphQL endpoint executes whatever the client sends, within the schema. Recursive relationships — users with friends, comments with replies, categories with children — let a short query request an exponential amount of work, and aliases let one request repeat an expensive field dozens of times. Async execution makes this worse rather than better: all that work runs concurrently on one event loop, so one hostile or careless query degrades every request on the worker. Measured with Strawberry 0.329 on Python 3.14, with a friends field returning ten users: a query nesting friends four levels deep made 1,111 resolver calls and took 73.5 ms; five levels made 11,111 calls and took 947 ms — each level multiplies the work by ten. Fifty aliases of a two-level query made 550 calls in 79 ms. With QueryDepthLimiter(max_depth=4) and MaxAliasesLimiter(max_alias_count=15), the same queries were rejected in 0.9–7.7 ms with zero resolver calls. This guide sets those limits and chooses their values.
Prerequisites¶
- Python 3.11+,
pip install strawberry-graphql. - A Strawberry schema, from building async GraphQL APIs with Strawberry.
- Load shedding concepts, from load shedding when the event loop is overloaded.
1. Measure how recursion multiplies work¶
Any field that returns a list of the same type makes depth exponential:
@strawberry.type
class User:
id: int
@strawberry.field
async def friends(self) -> list["User"]:
await asyncio.sleep(0) # stands in for a lookup
return [User(id=self.id * 10 + i) for i in range(10)]
Measured for { user(id: 1) { friends { id friends { id ... } } } }:
friends depth 2: 11 resolver calls 2.4 ms
friends depth 3: 111 resolver calls 6.5 ms
friends depth 4: 1,111 resolver calls 73.5 ms
friends depth 5: 11,111 resolver calls 947.0 ms
Each level multiplied calls by the fan-out and time by roughly ten to thirteen; a depth of eight would be over a hundred million resolver calls. With real lookups behind friends — database queries, even batched by a DataLoader — the cost per call is higher, and the query occupies the event loop for its whole duration.
Verify: for each recursive field in your schema, run the query at depths 2 through 5 against a test dataset and record the resolver count.
2. Add a depth limit¶
Strawberry's QueryDepthLimiter walks the parsed query during validation and rejects it before any resolver runs:
import strawberry
from strawberry.extensions import QueryDepthLimiter, MaxAliasesLimiter, MaxTokensLimiter
schema = strawberry.Schema(
query=Query,
extensions=[
lambda: QueryDepthLimiter(max_depth=4),
lambda: MaxAliasesLimiter(max_alias_count=15),
lambda: MaxTokensLimiter(max_token_count=2000),
],
)
Measured: queries five, six and eight levels deep were rejected in 0.9–1.9 ms with 'anonymous' exceeds maximum operation depth of 4, and the resolver counter stayed at zero. Depth counts every selection level from the root field, so user { friends { friends { friends { id } } } } is depth 5 under this limiter — the query labelled "friends depth 4" above. Strawberry 0.329 warns that passing extension instances is deprecated; passing a factory (the lambdas above) or the class gives each request a fresh extension object.
Verify: your deepest legitimate client query passes, and that query plus one level fails.
3. Limit aliases and tokens too¶
Depth limits do not stop width. Aliases let a client request the same field many times in one operation, each with its own resolver calls:
{
a0: user(id: 0) { friends { friends { id } } }
a1: user(id: 1) { friends { friends { id } } }
# ... 48 more
}
Measured: 50 such aliases made 550 resolver calls in 79 ms on an open schema; with MaxAliasesLimiter(max_alias_count=15) the operation was rejected in 7.7 ms with 50 aliases found. Allowed: 15. MaxTokensLimiter caps the size of the query document itself, which bounds parsing and validation work and catches enormous generated queries before they are analysed. Together the three limits bound how much work any single document can request; none of them depends on the data, so they are cheap and predictable.
Verify: an operation with one alias more than the limit is rejected, and the limit is above what your own clients send.
4. Cap list sizes in the schema¶
Depth and alias limits bound the shape of a query; list arguments bound how wide each level is. Require pagination on every list that can grow:
MAX_PAGE = 100
@strawberry.type
class User:
@strawberry.field
async def friends(self, info: strawberry.Info, first: int = 20,
after: str | None = None) -> FriendConnection:
first = max(1, min(first, MAX_PAGE)) # clamp, never trust the client
rows = await info.context["loaders"].friends.load((self.id, first, after))
return FriendConnection.from_rows(rows, first)
With a maximum page of 100 and a depth limit of 4, the worst case is bounded — 100^3 objects at the third nested level is still large, which is why limits work together: a smaller page size at nested levels, a depth limit that reflects real clients, and a cost budget for the remainder. The per-field execution cost of about 2.5 µs measured in batching GraphQL resolvers with DataLoader turns object counts into time: a million fields is two and a half seconds of event-loop time before any I/O.
Verify: no list field in the schema returns an unbounded number of items.
5. Bound execution time as a backstop¶
Static limits reject obviously expensive documents; a deadline stops the ones that are expensive because of the data. Wrap execution in a timeout so no single operation can hold a worker indefinitely:
from graphql import GraphQLError
from strawberry.fastapi import GraphQLRouter
from strawberry.types import ExecutionResult
class TimeoutRouter(GraphQLRouter):
async def execute_operation(self, *args, **kwargs):
try:
async with asyncio.timeout(5.0):
return await super().execute_operation(*args, **kwargs)
except TimeoutError:
return ExecutionResult(data=None,
errors=[GraphQLError("query exceeded its time budget")])
Tested with a 0.5 s budget and a resolver that slept for 3 s: the response was a normal GraphQL error, {"data": null, "errors": [{"message": "query exceeded its time budget"}]}, after 0.516 s, and the resolver recorded its own cancellation. Raising GraphQLError from execute_operation instead of returning an ExecutionResult produced an HTTP 500. Cancellation reaches every in-flight async resolver in the operation, so a timed-out query stops its database calls too, provided they use async drivers. The deadline should sit below the HTTP server's and proxy's own timeouts, so the client gets a GraphQL error rather than a dropped connection; the layering of deadlines is covered in propagating deadlines across async service calls. For public APIs, persisted queries — accepting only pre-registered query hashes — remove the problem entirely for clients you control.
Verify: a query constructed to take ten seconds against a large test dataset returns an error at the deadline, and its resolvers stop running.
Verification¶
A GraphQL API is protected from expensive queries when:
- Depth, alias and token limits are configured as extension factories and reject oversize documents before execution.
- Every list field clamps its page size.
- Each operation runs under a deadline shorter than the server's and proxy's timeouts.
- Legitimate client queries pass, verified in tests against the limits.
Diagnostic Hook: log rejected operations with the limit that rejected them, and record resolver calls per accepted operation. A steady stream of depth or alias rejections from one client is either an attack or a client that needs a different query; accepted operations with resolver counts orders of magnitude above the median are candidates for a lower page size or a cost limit.
Pitfalls & edge cases¶
- No depth limit on recursive types. Measured: one extra level turned 73 ms into 947 ms.
- Depth limits without alias limits. Width is as dangerous as depth.
- Passing extension instances. Deprecated in Strawberry 0.329; pass factories.
- Unclamped
firstarguments. A client can ask for a million items at one level.
Frequently Asked Questions¶
How do I limit GraphQL query depth in Strawberry?
Add QueryDepthLimiter(max_depth=N) to the schema's extensions, preferably as a factory such as lambda: QueryDepthLimiter(max_depth=4). Deep queries were rejected during validation in about 1 ms with no resolvers run.
Why are deep GraphQL queries so expensive?
Each level of a list field multiplies the work by the list size: with 10 friends per user, nesting went from 1,111 resolver calls at four levels to 11,111 at five, and from 73 ms to 947 ms.
What is a GraphQL alias attack?
A query that repeats an expensive field under many aliases in one request. Fifty aliases made 550 resolver calls in testing; MaxAliasesLimiter(15) rejected the operation before execution.
Are static query limits enough for GraphQL?
No. They bound the shape of a query, not data-dependent cost; also clamp list sizes and put a deadline on execution.
Related¶
- Async GraphQL — up to the topic overview.
- Timing and tracing async GraphQL resolvers — finding which fields are expensive.
- Network I/O & Protocol Handling — the section overview.