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).
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.
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. |
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,
"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).
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.
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.
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).
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.
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
}
}
}
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.