Query limits
A GraphQL endpoint accepts whatever the client asks for. Without a ceiling, three shapes of query can take a production app down before auth even runs:
- Recursive —
{ a { a { a { … 50 levels … } } } }. Tiny payload, massive resolve work. - Wide / flat — a single selection with hundreds of fields.
- Volumetric fanout —
{ users { posts { comments { … } } } }where every step ishas_many. Three levels of 10× fanout = 1000 rows resolved per parent.
nestrs wraps async-graphql’s two validation visitors that cover the three vectors. Both are on by default; tune the ceilings to your traffic.
The two ceilings
Section titled “The two ceilings”| Limit | Catches | Config field | Env var |
|---|---|---|---|
| Depth | Recursive | max_depth | NESTRS_GRAPHQL__MAX_DEPTH |
| Complexity | Wide + fanout (with per-field annotations) | max_complexity | NESTRS_GRAPHQL__MAX_COMPLEXITY |
Both are on by default (max_depth = 15, max_complexity = 2000).
Keep both — depth alone misses wide queries, complexity alone misses
queries that depth-bomb the parser before the visitor even runs.
use nest_rs_graphql::{GraphqlConfig, GraphqlModule};
#[module(imports = [ GraphqlModule::for_root(GraphqlConfig { max_depth: Some(15), max_complexity: Some(1000), ..Default::default() }),])]pub struct AppModule;NESTRS_GRAPHQL__MAX_DEPTH=15NESTRS_GRAPHQL__MAX_COMPLEXITY=1000The validation runs before any resolver, so a query that exceeds
either limit costs the parse pass plus one visit — no DB round-trip,
no dataloader batch, no Repo call.
What each ceiling does
Section titled “What each ceiling does”The visitor walks the AST and tracks the deepest selection chain.
A query nesting deeper than max_depth returns a validation error.
Typical production value: 10 to 20. Real queries rarely go past 6 even with multi-level relation traversal; 15 leaves headroom for admin tooling.
Complexity
Section titled “Complexity”The visitor sums a score across every selected field. The default
scoring is async-graphql’s: each field contributes 1 + child_complexity (one for the field itself, plus the sum of its
selected sub-fields). A flat wide query of 500 fields scores 500;
a 4-level relation chain scores roughly 4.
The per-field override is where complexity becomes a real
anti-DoS mechanism. The #[expose] macro applies one
automatically — see below.
Typical production value: 1000 to 5000, tuned from observed
legitimate queries. The check is strict > — max_complexity = 1000 admits a query scoring exactly 1000; pin 999 to reject it.
Setting max_depth = Some(0) or max_complexity = Some(0) fails at
boot with a config error: 0 would block every query, so None is
the way to disable a check.
The auto-emitted HasMany factor
Section titled “The auto-emitted HasMany factor”A HasMany relation resolves through an unbounded loader: the
dataloader returns every child row of the parent. With async-graphql’s
default scoring, that field costs 1 — no penalty for the fanout, even
though 10 000 rows resolve.
#[expose] patches that automatically:
#[graphql(complexity = "10 * child_complexity")]async fn users(&self, …) -> Vec<User> { … }The 10× factor models a typical fan-out — three levels of HasMany
(organizations → teams → members) score 10 × 10 × 10 = 1000. With
the strict > check, max_complexity = 1000 lets that query through
(exactly at the ceiling); pin 999 if you want to reject the canonical
3-deep chain. Tune the ceiling, not the factor — the factor is
hard-wired across the framework so the calibration story stays the same
app-wide.
BelongsTo resolvers keep async-graphql’s default 1 + child_complexity — they load at most one parent row, and an ability
denial drops that to zero, so no fanout penalty is needed. That means
HasMany and BelongsTo use asymmetric base scoring by default
(multiplicative vs additive); the asymmetry is the point.
Override on the field
Section titled “Override on the field”#[expose(complexity = …)] overrides the auto-emitted factor:
#[expose(name = "Org", service = …)]#[sea_orm::model]pub struct Model { #[expose] pub id: Uuid, #[expose] pub name: String,
#[sea_orm(has_many)] #[expose(complexity = "20 * child_complexity")] pub users: HasMany<crate::users::Entity>,}Two shapes:
- Literal —
#[expose(complexity = 5)]pins a fixed score. - Expression —
#[expose(complexity = "10 * child_complexity")]uses async-graphql’s expression grammar with the reservedchild_complexityvariable. Pure arithmetic onchild_complexityand literals only — the auto-emitted relation resolver takes no GraphQL arguments, so a name likefirstin the expression is treated as an unbound identifier and the macro expansion fails to compile.
The override applies to any exposed field, scalar or relation:
#[expose(name = "Report", service = …)]pub struct Model { #[expose] pub id: Uuid, /// Aggregates 24h of metric points server-side. #[expose(complexity = 50)] pub rolling_avg: f64,}Argument-aware expressions: the #[field_resolver] path
Section titled “Argument-aware expressions: the #[field_resolver] path”first * child_complexity and similar expressions that read a
GraphQL argument only resolve when the field resolver declares
that argument. The auto-emitted relation does not. For a
paginated relation taking first (or limit, or per_page), leave
the relation unexposed (no #[expose]) and write the resolver by
hand with a regular #[graphql(complexity = …)]:
// No #[expose] => no auto-emission; own it on the resolver.#[sea_orm(has_many)]pub posts: HasMany<crate::posts::Entity>,
// in the resolver file:#[ComplexObject]impl User { #[graphql(complexity = "first * child_complexity")] async fn posts(&self, first: i32) -> Vec<Post> { … }}On the wire
Section titled “On the wire”Validation failures come back as a regular errors[] array with a
200 OK:
{ "errors": [{ "message": "Query is nested too deep." }], "data": null}The message comes straight from async-graphql. Inspect it as you would any other validation error.
Calibration walkthrough
Section titled “Calibration walkthrough”- Set
max_depth = 15from day one. Cheap, almost never a false positive, catches the recursive bomb. - Set
max_complexity = 5000(generous) and log every denial atwarnfor a few weeks. Watch what hits the ceiling. - Once denials cluster around abusive queries (deep relation chains on public endpoints, scrapers requesting hundreds of fields), tighten to the lowest value that doesn’t trip legitimate traffic.
- Override on individual fields only when a relation’s realistic
fanout differs from the 10× default — a paginated cursor connection
typically uses
first * child_complexity; a known-bounded set can use a smaller literal.
Underlying library
Section titled “Underlying library”The depth and complexity visitors live in
async-graphql. The framework
just exposes them through GraphqlConfig and threads the
#[graphql(complexity = "…")] attribute through #[expose]. The
expression grammar — child_complexity plus field arguments —
is async-graphql’s; see its
query complexity docs
for the full reference.
Reference
Section titled “Reference”crates/nest-rs-graphql/src/config.rs— the twoOption<usize>config fields and their env parsing.crates/nest-rs-graphql/src/resolver.rs—build_schemaapplies the limits to theSchemaBuilderbefore.finish().crates/nest-rs-resource-macros/src/relations.rs— thedefault_has_many_complexity()constant and the#[expose(complexity = …)]parsing.