Nested Lists¶
Every related list field — a ManyToManyField, a reverse ForeignKey, a
reverse M2M, or a GenericRelation — is exposed with the same shape as a
root list: a DjangoListObjectType with results + totalCount, supporting
filtering, pagination and ordering. This also applies to nested lists
returned in mutation responses.
{
authors {
results {
name
posts { # reverse FK -> list type
results(limit: 10, offset: 0, ordering: "-id") {
title
}
totalCount
}
}
totalCount
}
}
As at the root, pagination and ordering arguments live on results, while
filter arguments live on the nested field itself:
{
authors {
results {
posts(filter: { title: { icontains: "graphql" } }) { # filter on the field
results(limit: 5, ordering: "title") { title } # paginate/order on results
totalCount
}
}
}
}
Custom @filter_field filters work here too
The nested filter: argument uses the same <Model>FilterInput the
root list mounts, so any @filter_field
the node type declares is available on the nested field — and runs, in the
same three-stage order as the root: base
scope, then the standard filter_fields lookups, then the custom methods.
Both results and totalCount reflect them.
Worked example (2 models)¶
Author (1) ─→ (N) Post:
# models.py
class Author(models.Model):
name = models.CharField(max_length=100)
class Post(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, related_name="posts", on_delete=models.CASCADE)
# 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 AuthorType(DjangoObjectType):
class Meta:
model = Author
class AuthorListType(DjangoListObjectType):
class Meta:
model = Author
pagination = LimitOffsetGraphqlPagination(default_limit=50)
class Query(ObjectType):
authors = DjangoListObjectField(AuthorListType)
Nested list, paginated + ordered + filtered (posts was a plain [Post]
before; now it is a list type):
{
authors {
results {
name
posts(filter: { title: { icontains: "Post 0" } }) { # filter on the field
results(limit: 2, ordering: "-id") { # paginate/order on results
title
}
totalCount
}
}
totalCount
}
}
{
"authors": {
"results": [
{
"name": "Author 0",
"posts": {
"results": [{ "title": "Author 0 Post 3" }, { "title": "Author 0 Post 2" }],
"totalCount": 4
}
}
],
"totalCount": 5
}
}
N+1 eliminated — even with the nested filter
With 5 authors (4 posts each), the query above runs 3 database queries total, independent of the number of authors:
SELECT … FROM author …(the paginated authors)SELECT … FROM post WHERE title ILIKE '%Post 0%' AND author_id IN (…)— one filteredPrefetchfor every author's postsSELECT COUNT(*) … FROM author(the roottotalCount)
Without the optimizer the nested posts would run one query per author
(N+1). The filter is pushed into a single Prefetch, and by default
(OPTIMIZE_NESTED_PAGINATION=True) the page is sliced DB-side via a
ROW_NUMBER() OVER (PARTITION BY author_id) window inside that single
Prefetch — so each author's posts query fetches only the requested page
rows, not every post then sliced in memory. totalCount is the
per-partition filtered COUNT(*) carried on the row. (When the window path
declines, the same single Prefetch is reused and limit / offset /
ordering are applied in memory over its cache — see
Performance (N+1) below.) Adding authors never adds
queries. See Query Optimization.
Deeper lists under a filtered nested list
A nested list under a filtered one (e.g. a filtered posts whose own
comments are also selected) is prefetched through the filtered parent's
queryset, so it stays N+1-safe and never raises
'X' lookup was already seen with a different queryset.
Which list type / paginator is used¶
The nested field reuses the related model's registered list container when
there is one — a DjangoListObjectType you declared yourself, or the
<Model>ListGenericType a DjangoModelType mints for the model. Either way its
pagination and filter_fields are honored even when the model appears nested
under a different model:
from django.contrib.auth.models import Group, User
from django_graphex.paginations import PageGraphqlPagination
from django_graphex.types import DjangoModelType, DjangoObjectType
class UserModelType(DjangoModelType):
class Meta:
model = User
pagination = PageGraphqlPagination(page_size=25) # User's list paginator
filter_fields = {"username": ("exact", "icontains"), "is_active": ("exact",)}
class GroupType(DjangoObjectType):
class Meta:
model = Group
# Group.user_set (the reverse of User.groups) compiles to a `userSet` field
# returning UserListGenericType — the container UserModelType registered. It
# paginates with PageGraphqlPagination and filters with the declared
# filter_fields.
When a model has no registered list type, one is auto-generated using the
default paginator (DJANGO_GRAPHEX["DEFAULT_PAGINATION_CLASS"], or
LimitOffsetGraphqlPagination when unset) and the node type's filter_fields.
Performance (N+1)¶
Hub
This section owns the nested-list specifics: the decline list (when
DB-side slicing falls back to in-memory) and the fallback semantics. For
the full window-slicing mechanics — the Window() queryset it builds and a
worked example with the constant-query-count payoff — see
Query Optimization → DB-side nested pagination.
Nested lists are designed to keep the query optimizer's
N+1 elimination intact — even when filtered or paginated — and to fetch only
the requested page rows from the database where possible. In short: a
windowable reverse-FK nested list (one paginated with a
LimitOffsetGraphqlPagination or PageGraphqlPagination paginator, filtered or
not) is sliced DB-side inside the single Prefetch built for the whole
level, with a filter-aware per-parent totalCount — one query for the level,
only each parent's page rows fetched. It is on by default, controlled by
DJANGO_GRAPHEX["OPTIMIZE_NESTED_PAGINATION"] (see
Settings).
The nested-list payoff, using the worked example above:
{
authors {
results {
posts(filter: { title: { icontains: "x" } }) {
results(limit: 5) { title }
totalCount
}
}
}
}
runs a constant number of queries regardless of how many authors are returned.
When window slicing falls back to in-memory
When any applicability check fails, the optimizer still uses one
Prefetch for the whole level but applies pagination/ordering in memory
over its prefetch cache (the exact pre-Phase-C behavior). The DB-side window
path declines — and the in-memory order+slice path is used — when:
DJANGO_GRAPHEX["OPTIMIZE_NESTED_PAGINATION"] = False(global opt-out);- the paginator is
CursorGraphqlPagination(opaque keyset), unbounded, or a negative page number; - the relation is many-to-many or a reverse M2M;
- the child sub-selection has a relation-traversing aggregate
annotation (its
GROUP BYcannot compose withWindow()); - any ordering term is not a concrete column on the child, or names a
column the child type does not publish the value of (the same predicate
the root list uses — see
Types › The projection is a security boundary)
— this path applies
the
ORDER BYin SQL and hands back rows already sliced, so the ordering allowlist never runs; declining routes the query to the plain prefetch path, which rejects a client term with the same error the root list gives. The check does not read the term's provenance, so a paginator whose configuredordering=names a projected-away column declines here too: that costs the optimization on exactly that configuration, never correctness; - the sub-selection is a full-load (an unknown/computed/property leaf);
- the filter (or an
optimize_<field>hook) forces.distinct(); - the relation was not prefetched at all — e.g.
DJANGO_GRAPHEX["OPTIMIZE_QUERYSET"] = False, which uses a per-parent database query instead.
Fallback
The per-parent database query is used only when the relation was not
prefetched — e.g. with DJANGO_GRAPHEX["OPTIMIZE_QUERYSET"] = False.
In any in-memory fallback the filtered/paginated set is loaded into memory
(no per-parent SQL LIMIT); for very large sets that is the trade-off of the
in-memory approach. Setting OPTIMIZE_NESTED_PAGINATION back to its default
True restores DB-side slicing wherever the relation is windowable.
Always-on, uniform shape
The uniform results + totalCount shape is applied to all related
list fields (and mutation outputs); that shape has no flag. What is
configurable is the slicing strategy: DB-side window slicing is the
default and is governed by OPTIMIZE_NESTED_PAGINATION (default True),
which falls back to in-memory order+slice when set False or when a relation
is not windowable. Clients always read nested lists exactly like root lists:
field { results { … } totalCount }.
Per-field optimize hook¶
Hub
This is the authoritative reference for the hook. The query-optimization hub has a short summary alongside the other optimization features.
For cases where you need to customize the child queryset for a specific nested
list field — for example to add a select_related, a custom annotation, or a
default ordering — declare an optimize_<snake_field> static method on the
parent type:
class AuthorType(DjangoObjectType):
class Meta:
model = Author
# Customize the queryset used to prefetch Author.posts.
# Called once per query — NOT once per parent row.
@staticmethod
def optimize_posts(queryset, info, **kwargs):
"""Add select_related and a default ordering to the posts prefetch."""
filter_value = kwargs.get("filter_value") # filter input or None
is_window = kwargs.get("is_window", False) # True when DB-side windowed
# Compose freely on top of the optimizer-built queryset.
return queryset.select_related("category").order_by("-views", "id")
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>) is only safe when the relation
is part of the GraphQL selection; otherwise compose with an ordering instead
(e.g. queryset.order_by("-id", "title")). The example playground uses the
ordering form for exactly this reason — see AuthorType.optimize_posts in
examples/playground/blog/schema.py.
Rules
- The method name is
optimize_+ the snake_case form of the GraphQL field name (e.g.blogPosts→optimize_blog_posts). - It is declared on the parent type (the type that owns the nested field), not on the child type.
- It MUST return a
QuerySet. Returning anything else emits a WARNING and the optimizer-built queryset is used unchanged. - Keyword arguments passed to the hook:
filter_value— the filter input for this field, orNone.is_window—Truewhen the DB-side window-slice path is taken (i.e.OPTIMIZE_NESTED_PAGINATION=Trueand the field is windowed);Falseon all plain prefetch paths, including the fallback after a window opt-out.- It scopes the whole field, not just the rows on screen:
totalCountcounts the hook's row set on every page, including an empty or past-the-end one. - When no
optimize_<field>method is declared the optimizer behaves byte-identically to the pre-hook baseline — purely additive, zero overhead.
Safe-mode interaction
If OPTIMIZER_SAFE_MODE=True (default False) and the hook raises an
exception, the entire resolve degrades to the un-optimized base queryset
(same boundary as all other optimizer errors) and a WARNING is logged. With the
default False setting the exception propagates normally.
What NOT to use it for
- Root queryset customization — use
resolve_<field>on the root type instead. - Hooks on
DjangoFilterListFieldorDjangoFilterPaginateListField— not supported; onlyDjangoNestedListObjectFieldhas this hook. - Per-field
SAFE_MODEisolation — field-level try/except is a separate, currently unspecified feature.
Full example
from django.db.models import Prefetch
from django_graphex.fields import DjangoListObjectField, DjangoNestedListObjectField
from django_graphex.core import ObjectType
from django_graphex.types import DjangoObjectType, DjangoListObjectType
class PostListType(DjangoListObjectType):
class Meta:
model = Post
pagination = LimitOffsetGraphqlPagination(default_limit=10)
class AuthorType(DjangoObjectType):
posts = DjangoNestedListObjectField(PostListType, accessor="posts")
class Meta:
model = Author
@staticmethod
def optimize_posts(queryset, info, **kwargs):
# Add select_related so Post.category is fetched in the same query.
return queryset.select_related("category")
class AuthorListType(DjangoListObjectType):
class Meta:
model = Author
class Query(ObjectType):
authors = DjangoListObjectField(AuthorListType)
With optimize_posts declared, a query requesting posts { results { title category { name } } } will
join the category table in the prefetch query instead of issuing one extra
query per post — without disabling the global N+1 optimizer.