Query Optimization (N+1)¶
django-graphex inspects the incoming GraphQL selection and builds an
optimized Django queryset for the list resolvers, so nested relations do not
trigger the classic N+1 query problem. This happens automatically in
DjangoListObjectField, DjangoFilterPaginateListField, DjangoFilterListField
and DjangoModelType.QueryFields(), and for single-object lookups
(DjangoObjectField and DjangoModelType retrieve / RetrieveField) — all
routed through django_graphex.utils.queryset_factory.
Optimization surface at a glance¶
This page is the hub for every query-optimization feature — each is demonstrated below with models + types + a GraphQL query, and links to its dedicated reference page for the deep detail:
select_related/prefetch_relatedderived from the selection (+ prefetch-crossingselect_relateddrop) — below, this page..only()column projection (root span + inside eachPrefetchchild) — below, this page.- DB-side nested pagination (
ROW_NUMBER()window slicing + filter-awaretotalCount) — below; full decline list in Nested Lists → Performance (N+1). AnnotatedField(selection-driven.alias()/.annotate()) — below; full reference in Fields → AnnotatedField.- Per-field
optimize_<field>hook — below; full reference in Nested Lists → Per-field optimize hook. - Typed
GenericForeignKeyunions (per-content-typeGenericPrefetchnarrowing) — below; full type-side declaration in Types → DjangoUnionType.
All of it is governed by the OPTIMIZE_* settings and the global
OPTIMIZER_SAFE_MODE fail-safe.
What it does¶
For the fields requested in the query, the optimizer:
- adds
select_relatedfor forwardForeignKey/OneToOneField(and reverse one-to-one) — including nested dotted paths (a__b__c); - adds
prefetch_relatedforManyToManyFieldand reverse relations (also nested, e.g.author__posts); - adds
prefetch_relatedfor aGenericForeignKey(resolved in a second query onfield.name; the parent's content-type-id and object-id columns are kept), and for aGenericRelationreverse side — which is.only()-narrowed, keeping its content-type / object-id attnames; - applies a conservative
.only()column projection both across theselect_relatedspan and inside eachPrefetchchild queryset (see caveats).
An automatic FK or forward/reverse OneToOne field whose destination type
overrides get_queryset is a deliberate exception to the join plan. The
optimizer replaces that unscoped join with a batched, request-scoped prefetch
and resolves the nullable field from its visible result. This also runs for
ordinary list reads when OPTIMIZE_QUERYSET=False; a custom-returned parent
without a planned prefetch is checked at the field resolver instead. A cached
unscoped relation is never used as visibility evidence. Automatic declared
and reverse M2M output lists also use a scoped prefetch when the destination
type overrides get_queryset. Their totalCount is taken from visible
members before nested pagination; M2M window slicing is not introduced.
The optimized M2M destination queryset is bound to the originating list's
database alias before its visibility hook runs, matching ordinary traversal.
Explicit resolver fields keep their separate contract.
It is transparent to the DjangoListObjectType wrapper (results / totalCount
/ pageInfo), to fragments and to inline fragments, and matches both
camelCase and snake_case field names.
When GraphQL merges repeated selections of the same root response field, the
optimizer plans from all of their selection sets. For example, a direct
allPosts { results { title } } selection and a fragment selecting
allPosts { results { author { name } } } share one result; the optimizer still
joins author and keeps its requested columns. A relation selected only in a
later root field node does not require one extra query per returned post.
Repeated spreads of an acyclic fragment are planned once per distinct optimizer
context (model, GraphQL type, and relation path), rather than expanding the same
subtree once for every spread route. For example, two spreads of a fragment
selecting author { name } keep the same select_related("author") and column
projection as before. Reusing that fragment under different relation paths still
plans each path separately. This changes optimizer planning work, not GraphQL
execution, response fields, query cost, or the number of SQL queries. Repeated
fragment plans are reapplied in selection order, so a later spread retains its
existing precedence over an interleaved selection of the same relation or
annotation alias.
Example¶
# models.py
class Author(models.Model):
name = models.CharField(max_length=100)
class Tag(models.Model):
label = models.CharField(max_length=50)
class Post(models.Model):
title = models.CharField(max_length=200)
body = models.TextField()
author = models.ForeignKey(Author, related_name="posts", on_delete=models.CASCADE)
tags = models.ManyToManyField(Tag, related_name="posts")
# schema.py
from django_graphex.fields import DjangoListObjectField
from django_graphex.core import ObjectType
from django_graphex.types import DjangoListObjectType, DjangoObjectType
class AuthorType(DjangoObjectType):
class Meta:
model = Author
class TagType(DjangoObjectType):
class Meta:
model = Tag
class PostType(DjangoObjectType):
class Meta:
model = Post
class PostListType(DjangoListObjectType):
class Meta:
model = Post
class Query(ObjectType):
all_posts = DjangoListObjectField(PostListType)
A nested query:
{
allPosts {
results {
title
author { name } # ForeignKey -> select_related("author")
tags { label } # ManyToMany -> prefetch_related("tags")
}
totalCount
}
}
runs a constant number of database queries no matter how many posts are returned:
SELECT ... FROM post JOIN author ...(rows + author joined,select_related)SELECT ... FROM tag ...(one extra query for theprefetch_related)SELECT COUNT(*) ...(from the list field'stotalCount)
Without optimization the same query would run 1 + N (one author query per
post) + N (one tag query per post). This is verified in the test-suite with
assertNumQueries.
Single objects¶
The same optimization applies to single-object queries. For:
{
post(id: 1) {
title
author { name } # select_related("author")
tags { label } # prefetch_related("tags")
}
}
the lookup runs 1 query (the row with its forward relations joined in) + 1 per prefetched relation, instead of one query per nested relation.
.only() column projection¶
When OPTIMIZE_ONLY_FIELDS is enabled (default), the optimizer also restricts the
selected columns with .only(), loading just the fields the query asks for. To
stay correct it is conservative:
- it always keeps the primary key (for every model in the
select_relatedspan), forwardForeignKeycolumns and the model'sMeta.orderingcolumns; - a model that selects a computed / property / custom-named field is loaded in full (not narrowed), so that property keeps working;
- narrowing also applies inside each
Prefetchchild queryset: the child gets its own.only()keeping the reverse-FK back column, the child'sMeta.orderingcolumns, its pk and anyGenericRelationct/fk attnames; a small set of forward-FK heads is added back to the childselect_relatedso the narrowing does not re-introduce an N+1. A child whose sub-selection hits a computed / property leaf is full-loaded (bare-string prefetch, no.only()).
Models with properties / custom resolvers
.only() defers the columns you did not request. If a model property,
__str__, a signal or a custom resolver reads a column that is not part of
the GraphQL selection, accessing it will trigger one extra query per row (a
new N+1) or surface incomplete data. The full-model safety valve covers the
common case (a selected computed field), but if your models read non-selected
columns out of band, disable it:
Settings¶
Configure in settings.py under DJANGO_GRAPHEX:
| Setting | Default | Description |
|---|---|---|
OPTIMIZE_QUERYSET |
True |
Apply nested select_related / prefetch_related derived from the query. Set to False to return the plain queryset (escape hatch). |
OPTIMIZE_ONLY_FIELDS |
True |
Additionally narrow columns with .only() across the select_related span and inside each Prefetch child queryset (conservative; see the warning above). |
OPTIMIZE_NESTED_PAGINATION |
True |
DB-side ROW_NUMBER() window slicing for reverse-FK nested paginated lists (LimitOffset/Page). False = in-memory order+slice fallback. See DB-side nested pagination (this page) and Nested Lists. |
OPTIMIZER_SAFE_MODE |
False |
When True, any exception in the optimization block degrades to the un-optimized queryset and logs a WARNING (instead of a 500). Default fail-loud. See OPTIMIZER_SAFE_MODE below. |
OPTIMIZER_WORK_BUDGET |
2000 |
Per-operation-execution cap on the selection contexts each optimizer walker expands. Fragments reached through distinct relation paths describe exponentially many lookups; past the cap the root field is resolved without the optimizer plan (same data, more queries). None disables the cap; 0 or a negative value raises ImproperlyConfigured. See Query Optimization. |
OPTIMIZE_ANNOTATED_FIELDS |
True |
Inject AnnotatedField DB annotations only when the field is selected. Runtime kill-switch for annotation injection. See Fields → AnnotatedField. |
DJANGO_GRAPHEX = {
"OPTIMIZE_QUERYSET": True,
"OPTIMIZE_ONLY_FIELDS": True,
"OPTIMIZE_NESTED_PAGINATION": True,
"OPTIMIZER_SAFE_MODE": False,
"OPTIMIZER_WORK_BUDGET": 2000,
"OPTIMIZE_ANNOTATED_FIELDS": True,
}
OPTIMIZER_SAFE_MODE (fail-safe degrade)¶
By default (OPTIMIZER_SAFE_MODE = False) the optimizer fails loud: if
building the optimized queryset raises, the exception propagates and you get a
500 — so you find the bug.
Setting it to True wraps the whole optimization in a try/except: on any
exception the entire resolve degrades to the un-optimized base queryset and a
WARNING is logged (django_graphex.utils) instead of surfacing the error. The
boundary is coarse — it degrades the whole resolve, not a single field — so a
raising per-field optimize_<field> hook is caught here too. It does not
cover a malformed AnnotatedField expression that raises FieldError at
SQL-eval time (that happens outside the build boundary; annotation injection has
its own narrower guard that skips just the annotation).
OPTIMIZER_WORK_BUDGET (work cap)¶
Fragments make a short document describe a huge selection tree. When a fragment
reaches the next one through different relations (author, coAuthors,
posts, coauthoredPosts, ...), every path is a different ORM lookup, so the
lookups the optimizer would emit grow as 4**n for n fragments. Memoizing by
fragment cannot plan an exponential set in linear time, and emitting it would
also mean one query per lookup once there is data.
OPTIMIZER_WORK_BUDGET (default 2000) therefore caps, per operation execution and per
walker, how many selection contexts the optimizer expands. A realistic document
stays orders of magnitude below it. The budget is shared by all root fields
(aliases) of one execution; each item of a batch is its own execution. Once any walker spends it, the root field being
planned is resolved without the optimizer plan, exactly as with
OPTIMIZE_QUERYSET = False, and so are the request's later root fields: the
data is identical and relations behind a get_queryset scope stay scoped, but
relations load through the normal resolvers (possibly one query per parent). A
partial plan is never kept, because it could disagree with itself and drop the
ordering, limit or offset of a nested list. A DEBUG record on the
django_graphex.utils logger names the walker that hit the cap. Use
MAX_QUERY_DEPTH / MAX_QUERY_COST to reject such
documents outright; set the budget to None only to restore the unbounded
planning.
DB-side nested pagination (window slicing)¶
When a reverse-FK nested list is paginated (with a LimitOffset or Page
paginator), the optimizer slices each parent's page in the database instead
of loading every child and slicing in memory. It does this inside the single
Prefetch it already builds for that level — so the query count stays constant
as the number of parents grows.
Using the same Author (1) ─→ (N) Post models from above, with a paginator and
a nested filter:
# models.py
class Post(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, related_name="posts", on_delete=models.CASCADE)
class Meta:
ordering = ("-id",)
# schema.py
from django_graphex.fields import DjangoListObjectField
from django_graphex.core import ObjectType
from django_graphex.paginations import LimitOffsetGraphqlPagination
from django_graphex.types import DjangoListObjectType, DjangoObjectType
class PostType(DjangoObjectType):
class Meta:
model = Post
filter_fields = {"title": ["icontains"]} # enables the nested filter
class PostListType(DjangoListObjectType):
class Meta:
model = Post
pagination = LimitOffsetGraphqlPagination(default_limit=10, ordering="-id")
class AuthorType(DjangoObjectType):
class Meta:
model = Author
class AuthorListType(DjangoListObjectType):
class Meta:
model = Author
class Query(ObjectType):
authors = DjangoListObjectField(AuthorListType)
Filter on the field, paginate/order on results:
{
authors {
results {
name
posts(filter: { title: { icontains: "x" } }) { # filter on the field
results(limit: 2, ordering: "-id") { title } # paginate/order on results
totalCount
}
}
totalCount
}
}
What it optimizes: the one Prefetch covering every author's posts adds
two window functions and filters to the page window, so it fetches only each
author's requested page rows DB-side (not all-then-slice-in-memory):
_gqx_rn = Window(RowNumber(), partition_by=[F("author_id")], order_by=...)
_gqx_total = Window(Count("*"), partition_by=[F("author_id")])
# ...then .filter(_gqx_rn__gt=offset, _gqx_rn__lte=offset + limit)
totalCount is the per-partition filtered COUNT(*) read off _gqx_total,
so it reflects the nested filter without an extra query. Adding authors never
adds queries — the level stays at a constant query count.
Controlled by OPTIMIZE_NESTED_PAGINATION (default True). When a
relation is not windowable, the same single Prefetch is reused and the page is
ordered + sliced in memory instead — see
Nested Lists → Performance (N+1) for the full
decline list (cursor paginator, M2M, relation-aggregate child, non-concrete
ordering, full-load sub-selection, .distinct(), OPTIMIZE_QUERYSET=False).
Per-field optimize hook¶
To customize the child queryset for a specific nested list field — add a
select_related, a custom annotation, a default ordering — declare an
optimize_<snake_field> static method on the parent type:
from django_graphex.fields import DjangoNestedListObjectField
class AuthorType(DjangoObjectType):
posts = DjangoNestedListObjectField(PostListType, accessor="posts")
class Meta:
model = Author
@staticmethod
def optimize_posts(queryset, info, **kwargs):
# kwargs: filter_value (filter input or None), is_window (True on the
# DB-side windowed path). Compose on the optimizer-built child queryset.
return queryset.select_related("category")
select_related(<fk>) in a hook needs the client to select that relation
The optimizer applies its .only() column narrowing to the child queryset
after the hook runs. If the client does not also select category,
category_id is deferred and select_related("category") raises a Django
FieldError. So a bare select_related(<fk>) in a hook is only safe when the
relation is part of the selection — otherwise compose with an ordering (e.g.
queryset.order_by("-id", "title")) instead. The example playground uses the
ordering form for exactly this reason (AuthorType.optimize_posts in
blog/schema.py).
- The method name is
optimize_+ the snake_case GraphQL field name (blogPosts→optimize_blog_posts), declared on the parent type. - It is called once per query (not once per parent) and must return a
QuerySet— anything else emits aWARNINGand the built queryset is used unchanged. kwargsarefilter_value(the filter input orNone) andis_window(Trueonly on the DB-side windowed path).- A raising hook is caught by
OPTIMIZER_SAFE_MODEwhen it isTrue(whole resolve degrades); with the defaultFalseit propagates. OnlyDjangoNestedListObjectFieldsupports the hook.
See Nested Lists → Per-field optimize hook for the full rules, the safe-mode interaction and a complete example.
Typed GenericForeignKey unions (per-content-type narrowing)¶
A GenericForeignKey exposed as a DjangoUnionType
(member types in Meta.types, owner opting in via Meta.unions) lets
clients select per-member fields with inline fragments:
{
attachments {
results {
caption
target {
__typename
... on AccountType { balance }
... on InvoiceType { amount }
}
}
}
}
What it optimizes: on Django 5.0+ with OPTIMIZE_ONLY_FIELDS on, the
optimizer routes the union GFK through a GenericPrefetch that builds one
.only()-narrowed queryset per content type — the Account queryset fetches
balance, the Invoice queryset fetches amount — batched across all parents
(no N+1). On Django < 5.0 it degrades gracefully to a single bare full-load
Prefetch (it never imports GenericPrefetch and never narrows columns).
Named fragment spreads are equivalent to the inline form here: a document
that declares fragment Money on AccountType { balance } and spreads it under
target narrows the Account queryset exactly as ... on AccountType
{ balance } would, and a selection that mixes both forms merges them into a
single per-member queryset instead of deferring the spread's columns.
You may also group member selections under a valid fragment on the union itself. Both inline and named union wrappers retain the same per-member batching and nested relation joins as direct member fragments:
query {
allComments {
results {
target {
... on CommentTargetUnion {
... on OrderType { total customer { label } }
}
...AllAccountTargets
}
}
}
}
fragment AllAccountTargets on CommentTargetUnion {
... on AccountType { balance }
}
The wrapper has no Django model of its own: the optimizer descends into its valid member selections, then builds the same per-content-type querysets it would build without the wrapper. This does not infer GraphQL type relationships from Django multi-table inheritance or make invalid fragments valid.
Inline-fragment type-condition guard
This routing relies on a correctness guard: the optimizer never descends
into an inline fragment whose type_condition names a different concrete
type than the one being walked, so ... on InvoiceType { amount } is never
mis-attributed against the Account relation map (which would yield wrong
.only() columns and a Django FieldError). See the
1.2.0 changelog.
A condition naming an interface the walked type implements does apply,
so ... on Titled { title } is descended into and its columns are narrowed
like any other selection.
The type-side declaration order is load-bearing (members → union → owner LAST). See Types → DjangoUnionType and Types → per-content-type narrowing for the full declaration and the proxy-model de-dup behavior.
Selection-driven annotations (AnnotatedField)¶
An AnnotatedField is a declarative GraphQL field backed by a Django ORM
annotation that the optimizer injects only when the field is selected in the
incoming query (and only when OPTIMIZE_ANNOTATED_FIELDS is True, the default).
Unselected annotated fields add no SQL. A built-in resolver reads the annotation
off the row, so no resolve_<field> is needed.
A forward-FK relation that the optimizer placed in select_related is
auto-promoted to prefetch_related when its child sub-selection contains an
AnnotatedField — DB annotations cannot be pushed through a SQL JOIN, so the
child annotation rides on the promoted Prefetch's queryset instead.
This promotion also follows applicable named and inline GraphQL fragments on
the relation or its list results wrapper. It respects fragment type conditions
and bound @include/@skip directives: an excluded AnnotatedField does not
trigger the extra prefetch. Previously, selecting a related annotation only
through a valid fragment could resolve it as null without a GraphQL error.
The promotion follows the whole forward-FK chain, not just its first hop: in
{ comments { post { author { postCount } } } } it is post__author that gets
promoted, so an AnnotatedField several hops down resolves like any other. (It
used to be detected only one hop from the root, which left the deeper field
resolving to null with no error.)
Declare it on the type and let the selection drive it (Author (1) ─→ (N) Post):
# schema.py
from graphql import GraphQLInt
from django.db.models import Count
from django_graphex.fields import AnnotatedField, DjangoListObjectField
from django_graphex.core import ObjectType
from django_graphex.types import DjangoListObjectType, DjangoObjectType
class AuthorType(DjangoObjectType):
# Injected ONLY when `postCount` is selected; the built-in resolver reads it
# off the row, so no resolve_post_count is needed.
post_count = AnnotatedField(GraphQLInt, Count("posts"))
class Meta:
model = Author
class AuthorListType(DjangoListObjectType):
class Meta:
model = Author
class Query(ObjectType):
authors = DjangoListObjectField(AuthorListType)
{
authors {
results {
name
postCount # selected -> optimizer adds .annotate(_gqx_ann_post_count=Count("posts"))
}
}
}
What it optimizes: when postCount is in the selection the optimizer adds
the Count("posts") annotation (one aggregate, no extra round-trip per author);
when it is not selected, no annotation and no extra SQL are emitted. The
expression may also be a zero-arg callable (lambda: Count("posts")) for fresh
per-request construction, and aliases= applies .alias() before .annotate().
See Fields → AnnotatedField for the full signature, arguments table and the forward-FK promotion note.
@skip and @include directives¶
The optimizer honors @skip and @include on every selection node — fields,
inline fragments, and fragment spreads. A selection that is excluded is not
added to select_related / prefetch_related and its columns are not
included in .only(). This prevents over-fetching related rows that the client
will never use.
query GetPosts($loadAuthor: Boolean!, $skipTags: Boolean!) {
posts {
results {
title
# When $loadAuthor is false the optimizer skips the author join entirely
author @include(if: $loadAuthor) {
name
}
# When $skipTags is true the tags prefetch is skipped entirely
tags @skip(if: $skipTags) {
label
}
}
}
}
With these variables, the author FK is not added to select_related (and
author_id is not included in the .only() projection), and the tags M2M is
not added to prefetch_related. Passing the flag as a variable is how
@skip / @include are used in practice: the client toggles sections of one
static query per request (a detail panel open or closed, a mobile vs. desktop
view) instead of maintaining and re-parsing different query strings.
Variable-driven directives
The optimizer runs at execution time when all variables are already bound, so
@skip(if: $flag) and @include(if: $show) are always resolved exactly.
Output-formatting directives do not affect fetch planning
Custom application-level directives such as @date and @number are
output-formatting directives: they transform the resolved value of an
already-fetched field. They have no effect on the optimizer's
select/prefetch/only planning.
Custom resolvers¶
If your query type defines resolve_<field> that returns a QuerySet, the
optimizer adopts it as the base queryset and still applies select_related /
prefetch_related on top of it. (.only() is skipped for custom-resolved
querysets, since they may already shape their own columns.) Resolvers that return
anything other than a QuerySet are left untouched.
Example — scope a list to the current user while keeping optimizer benefits:
from django_graphex.fields import DjangoListObjectField
from django_graphex.core import ObjectType
from django_graphex.types import DjangoListObjectType
class PostListType(DjangoListObjectType):
class Meta:
model = Post
pagination = LimitOffsetGraphqlPagination(default_limit=10, ordering="-id")
class Query(ObjectType):
my_posts = DjangoListObjectField(PostListType, description="Posts by the current user")
def resolve_my_posts(self, info, **kwargs):
# Return a queryset — the optimizer still adds select_related / prefetch_related
# on top of this base. For per-request scoping that composes cleanly with
# pagination and filtering, use get_queryset on the NODE type (PostType) or
# Meta.queryset on the list type — a list type has no scoping hook of its own.
return Post.objects.filter(author=info.context.user)
The resolver returns a QuerySet, so the optimizer picks it up and adds
select_related("author") / prefetch_related("tags") etc. based on the
GraphQL selection. If the resolver returned a plain list, the optimizer would
leave it untouched (no queryset to decorate).
For an automatic forward FK whose destination type overrides
get_queryset, the optimizer uses a scoped prefetch in place of that FK's
select_related join. This lets one destination query serve all selected
parents while excluding hidden targets. Reverse-FK list prefetches and
window-pagination queries apply the child type's scope before counting or
slicing. A default, unchanged hook keeps the ordinary join/prefetch plan.
With OPTIMIZE_QUERYSET=False, output still honors these scopes, but each
uncached related value may require its own database check. This is a
correctness guarantee, not a promise that disabling optimization preserves
batching.
A manual prefetch of the same relation is replaced
A base queryset — from a resolver, from Meta.queryset, or from a type's
get_queryset hook — may already carry prefetch_related("posts") for a
relation the optimizer derives for itself. The optimizer's own lookup
replaces the manual one: the derived version is narrowed with .only()
and, for a nested list, filtered and windowed, so it is the one to keep.
Manual prefetches of relations the optimizer is not touching are left
alone.
Django refuses two lookups on the same path outright, so this used to fail
the whole field with 'posts' lookup was already seen with a different
queryset. If you need specific prefetch options (a custom Prefetch
queryset, say), declare an
optimize_<field> hook on the parent type
instead of prefetching in the base queryset — the optimizer applies the
hook to the queryset it builds.
Optimized mutation re-read¶
DjangoModelType.perform_mutate re-reads the saved object from the database
so the mutation response reflects the freshest DB state (annotations, default
values, etc.). Starting in v1.2.1, this re-read is selection-aware:
the optimizer inspects the mutation's selection set, locates the sub-field node
for Meta.output_field_name (e.g. post inside { ok post { … } }), and
applies select_related for every to-one relation (ForeignKey,
OneToOneField) that appears in that sub-selection.
For destination types with the default no-op row hook, this eliminates N+1 queries on mutation responses that nest related objects:
mutation CreatePost($input: NewPostInput!) {
postCreate(newPost: $input) {
ok
post {
title
author { name } # joined via select_related — no extra query
category { title } # joined via select_related — no extra query
}
}
}
If a destination has a row-scope hook, its automatic to-one field still checks that hook. A mutation response without a batched scoped prefetch can issue an additional membership query for the destination.
No code changes are required; the optimization is applied automatically
whenever DjangoModelType.perform_mutate is called. If the selection set
cannot be parsed (e.g. a custom info stub without field nodes), the method
falls back to the plain unoptimized re-read so existing behaviour is
preserved.
Depth limits apply to mutation selection sets too
MAX_QUERY_DEPTH and Meta.max_depth are enforced on all GraphQL
operation types — including the mutation response selection set. A mutation
that requests deeper nesting than the limit permits is rejected before
any database write occurs. See Query depth & cost limits.