GraphQL with Django
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1 on PyPI, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python
3.12-3.14 and PostgreSQL 15 or newer, all checked on 2026-10-03. The GraphQL libraries are Strawberry
(strawberry-graphql 0.329.0, 2026-10-02), its Django integration Strawberry-Django (strawberry-graphql-django 0.92.0,
2026-10-01) and, for the comparison, Graphene-Django 3.2.3 (2025-03-13) on Graphene 3.4.3. Strawberry-Django’s PyPI
classifiers stop at Django 6.0 and Graphene-Django’s at 4.2, but both ran under Django 6.1.1 in the scratch project used
for this page; on Django 5.2 LTS everything here works the same except the FETCH_RAISE guard, which needs the 6.1 fetch
modes. Every listing was run on Python 3.13 and Django 6.1.1 with SQLite: manage.py check (also under -W error) and
nine tests pass (eight for Strawberry-Django, one for Graphene-Django), the schema was exported with
manage.py export_schema, and the HTTP examples were exercised with curl against Uvicorn.
GraphQL itself — the type system, queries and mutations, resolvers, the N+1 problem in general, DataLoaders, authorization and demand control — is covered in the GraphQL section; this page does not repeat it. It covers only what is specific to Django: turning models into GraphQL types, letting the ORM do the filtering, paging and ordering, reusing Django’s permissions, sessions and CSRF protection, and keeping the number of SQL queries per GraphQL request constant. If you are new to Strawberry, read Python: Getting Started first. In the Bookshelf application that grows across this section the running example is a read API over books, authors and reviews, plus two mutations: librarians create authors and readers post reviews.
In a hurry: what goes where
| Need | Rule of thumb | Section |
|---|---|---|
Pick a library |
Strawberry-Django for new projects (typed, async-capable, maintained); Graphene-Django only for existing schemas. |
|
Expose a model |
|
|
List, filter, sort, page |
|
Queries: lists, single objects, filters, ordering and pagination |
Change data |
|
|
Restrict access |
Permission extensions ( |
|
Serve it |
|
|
Stop N+1 |
Always add |
|
Test |
|
Where GraphQL sits in the request cycle
A GraphQL endpoint is one ordinary Django URL. The request goes through the whole middleware stack first, so sessions, authentication and CSRF protection have already run when the GraphQL view sees it; the view then parses the document, validates it against the schema and executes it, calling one resolver per selected field. Strawberry-Django’s resolvers turn the root fields into QuerySets and the nested fields into attribute access on model instances — which is exactly where an unoptimized schema issues one SQL query per object.
Two consequences follow. First, everything Django already gives a view — request.user, the session, the CSRF check,
middleware such as LoginRequiredMiddleware — applies to the GraphQL view as well, and nothing needs to be reinvented
in resolvers. Second, a GraphQL response is normally HTTP 200 even when a field failed: errors are reported in the
errors key or, with Strawberry-Django’s error handling, as typed payloads (see Mutations). Monitoring that only
looks at status codes will miss them.
Source: Strawberry: Django integration and Django middleware
Choosing a library
Three Python projects can serve GraphQL from Django. They all run on graphql-core, so the wire format and the GraphiQL
experience are the same; they differ in how you declare the schema and how much of the ORM they understand.
| Strawberry (plain) | Strawberry-Django | Graphene-Django | |
|---|---|---|---|
Package |
|
|
|
Schema style |
Code-first, type annotations |
Code-first, type annotations plus model introspection ( |
Code-first, |
Views |
|
The same views |
|
ORM support |
None: you write every resolver |
Types, filters, ordering, pagination, CRUD mutations, permissions, query optimizer |
Types and filtering (via django-filter); no optimizer |
Latest release (2026-10-03) |
0.329.0, 2026-10-02 |
0.92.0, 2026-10-01 |
3.2.3, 2025-03-13 |
The rest of this page uses Strawberry-Django: it is the integration that Strawberry’s own documentation recommends for models, it declares support for Django 5.2 and 6.0 (6.0 since 0.68.0, 2025-12-03), and its optimizer solves the N+1 problem without hand-written prefetching. Graphene-Django in brief shows the same API in Graphene-Django for teams maintaining one.
Installing and wiring Strawberry-Django
Install the package into the project’s virtual environment; it pulls in strawberry-graphql and graphql-core.
$ python -m pip install strawberry-graphql-django
$ python -m pip list | grep -i -E "strawberry|graphql-core"
graphql-core 3.3.0
strawberry-graphql 0.329.0
strawberry-graphql-django 0.92.0
Add strawberry_django to INSTALLED_APPS — it provides the GraphiQL template and the export_schema management
command — and, optionally, tune the integration with the STRAWBERRY_DJANGO setting:
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"strawberry_django",
"catalog",
]
STRAWBERRY_DJANGO = {
"FIELD_DESCRIPTION_FROM_HELP_TEXT": True, # help_text -> field description
"TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING": True, # model docstring -> type description
"PAGINATION_DEFAULT_LIMIT": 20, # when the client sends no limit
"PAGINATION_MAX_LIMIT": 100, # hard cap, also for limit: null
}
The two description switches are off by default because existing models often have docstrings written for developers,
not API consumers; turn them on only if your help_text and docstrings are fit to publish. Both pagination keys default
to 100. Other keys set the default argument name of the CRUD mutations (MUTATIONS_DEFAULT_ARGUMENT_NAME, "data"),
whether mutations handle Django errors by default (MUTATIONS_DEFAULT_HANDLE_ERRORS, False) and the name of the
primary-key argument of single-object fields (DEFAULT_PK_FIELD_NAME, "pk").
The Bookshelf models
The examples use three models in a catalog app: an author writes many books, and a reader (the user model) reviews a
book with a rating from 1 to 5, enforced by a check constraint.
from django.conf import settings
from django.db import models
class Author(models.Model):
"""A person who wrote one or more books."""
name = models.CharField(max_length=200, help_text="Full name as printed on the cover")
def __str__(self):
return self.name
class Book(models.Model):
"""A book in the shelf."""
title = models.CharField(max_length=200)
isbn = models.CharField(max_length=13, unique=True)
published = models.DateField()
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
def __str__(self):
return self.title
class Review(models.Model):
"""A reader's opinion of a book."""
book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="reviews")
reader = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reviews"
)
rating = models.PositiveSmallIntegerField()
body = models.TextField(blank=True)
created = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.CheckConstraint(
condition=models.Q(rating__gte=1, rating__lte=5),
name="review_rating_1_to_5",
),
]
Source: Models and CheckConstraint
Types from models
@strawberry_django.type(Model) turns a class into a GraphQL object type backed by that model. Fields annotated with
strawberry.auto take their GraphQL type from the model field (CharField becomes String!, DateField becomes Date!,
a nullable column becomes a nullable type), and relations are annotated with the related GraphQL type: a ForeignKey
with the type itself, a reverse foreign key or many-to-many with a list[…]. Only the attributes you declare are
exposed — there is no "all fields" switch, which keeps a new password_hash column from leaking into the API by accident.
import strawberry
import strawberry_django
from strawberry import auto
from . import models
@strawberry_django.type(models.Author)
class AuthorType:
id: auto
name: auto
books: list["BookType"]
@strawberry_django.type(
models.Book, filters=BookFilter, ordering=BookOrder, pagination=True
)
class BookType:
id: auto
title: auto
isbn: auto
published: auto
author: AuthorType
reviews: list["ReviewType"]
@strawberry_django.type(models.Review)
class ReviewType:
id: auto
rating: auto
body: auto
created: auto
book: BookType
BookFilter and BookOrder are defined just above these types (see Queries: lists, single objects, filters, ordering and pagination). The reader foreign key is
deliberately left out of ReviewType: a public API has no business listing which user wrote what. The
export_schema command prints the SDL that these classes produce, which is worth committing and diffing in code review:
$ python manage.py export_schema catalog.schema --path schema.graphql
$ grep -A6 '^type AuthorType' schema.graphql
"""A person who wrote one or more books."""
type AuthorType {
id: ID!
"""Full name as printed on the cover"""
name: String!
books(filters: BookFilter, ordering: [BookOrder!]! = [], pagination: OffsetPaginationInput): [BookType!]!
}
The descriptions come from the model docstring and help_text because of the two settings above, and AuthorType.books
gained filters, ordering and pagination arguments because BookType declares them: a nested list is filterable
exactly like a root list. The model docstring is also copied onto input types built from the same model (BookFilter
is described as "A book in the shelf."), one more reason to keep docstrings user-facing when the switch is on.
strawberry_django.input(Model) and strawberry_django.partial(Model) build input types the same way: input keeps the
model’s required fields required, partial makes every auto field optional, which is what an update needs.
@strawberry_django.input(models.Author)
class AuthorInput:
name: auto
@strawberry_django.partial(models.Author)
class AuthorPartial:
id: auto
name: auto
Queries: lists, single objects, filters, ordering and pagination
On the root Query type, strawberry_django.field() does the resolving for you: a list[BookType] field returns
Book.objects.all() (as a lazy QuerySet that the filters, ordering, pagination and optimizer then refine), and a
single BookType field gains a required pk: ID! argument and returns one row.
import strawberry
import strawberry_django
from .types import AuthorType, BookType
@strawberry.type
class Query:
books: list[BookType] = strawberry_django.field()
book: BookType = strawberry_django.field()
authors: list[AuthorType] = strawberry_django.field()
A filter type is declared with @strawberry_django.filter_type(Model); with lookups=True every field gets an input of
Django lookups (exact, iContains, gte, inList, isNull …), and a relation can nest the related model’s filter.
Each filter input also gets AND, OR, NOT and DISTINCT fields. An ordering type is declared with
@strawberry_django.order_type(Model); clients pass a list of one-field objects, so the order of the criteria is
preserved. pagination=True adds an OffsetPaginationInput (offset, limit) argument, capped by
PAGINATION_MAX_LIMIT.
@strawberry_django.filter_type(models.Author, lookups=True)
class AuthorFilter:
name: auto
@strawberry_django.filter_type(models.Book, lookups=True)
class BookFilter:
title: auto
published: auto
author: AuthorFilter | None
@strawberry_django.order_type(models.Book)
class BookOrder:
title: auto
published: auto
All three become ORM calls — filter(publishedgte=…, authorname__icontains=…), order_by("-published") and a
slice — so the database does the work, not Python:
$ curl -s -b cookies.txt http://127.0.0.1:8000/graphql/ \
-H "Content-Type: application/json" -H "X-CSRFToken: $CSRF" \
-d '{"query": "query Recent($since: Date!) { books(filters: {published: {gte: $since}, author: {name: {iContains: \"le guin\"}}}, ordering: [{published: DESC}], pagination: {limit: 5}) { title published author { name } } }", "variables": {"since": "1970-01-01"}}'
{
"data": {
"books": [
{
"title": "The Dispossessed",
"published": "1974-05-01",
"author": {
"name": "Ursula K. Le Guin"
}
}
]
}
}
The cookie jar and $CSRF come from the GraphiQL page, see CSRF with GraphQL. A single-object field with a non-existent primary
key raises Book.DoesNotExist, which surfaces as "errors": [{"message": "Book matching query does not exist.", …}]
with "data": null, because the field is non-null.
Filters only narrow what the type’s QuerySet already allows. To hide rows from everybody (unpublished books, other
users' drafts), override the get_queryset class method on the type: it receives the default QuerySet and info (and
so the current user) and returns a narrower QuerySet. Guarding against regressions with FETCH_RAISE (Django 6.1) shows one.
Mutations
Strawberry-Django offers two ways to write a mutation. For plain create/update/delete of one model, the generated
mutations.create, mutations.update and mutations.delete take an input type and do the save() (running
full_clean() first) for you. For anything with business logic, decorate a method with @strawberry_django.mutation,
which differs from Strawberry’s own @strawberry.mutation in three ways: a synchronous resolver is wrapped in
sync_to_async when the schema runs under ASGI, so ORM calls are safe; it integrates with the permission extensions; and
with handle_django_errors=True it catches ValidationError, PermissionDenied and ObjectDoesNotExist and returns
them as data instead of a top-level error.
import strawberry
import strawberry_django
from strawberry_django import mutations
from strawberry_django.auth.utils import get_current_user
from strawberry_django.permissions import HasPerm, IsAuthenticated
from . import models
from .types import AuthorInput, AuthorPartial, AuthorType, ReviewType
@strawberry.type
class Mutation:
create_author: AuthorType = mutations.create(
AuthorInput,
handle_django_errors=True,
extensions=[HasPerm("catalog.add_author")],
)
update_author: AuthorType = mutations.update(
AuthorPartial,
handle_django_errors=True,
extensions=[HasPerm("catalog.change_author")],
)
@strawberry_django.mutation(
handle_django_errors=True, extensions=[IsAuthenticated()]
)
def add_review(
self, info: strawberry.Info, book_id: strawberry.ID, rating: int, body: str = ""
) -> ReviewType:
review = models.Review(
book_id=book_id,
reader=get_current_user(info),
rating=rating,
body=body,
)
review.full_clean()
review.save()
return review
With handle_django_errors=True the return type becomes a union of the declared type and OperationInfo, so the
exported schema reads addReview(bookId: ID!, rating: Int!, body: String! = ""): AddReviewPayload! with
union AddReviewPayload = ReviewType | OperationInfo. Clients select both members with inline fragments. The reader is
taken from the session (get_current_user(info) returns info.context.request.user), never from an argument, so a
client cannot post a review in somebody else’s name. full_clean() runs the model’s validators and, since Django 4.1,
its constraints, so the check constraint above comes back as a VALIDATION message instead of an IntegrityError:
mutation {
addReview(bookId: "2", rating: 9) {
... on ReviewType { rating }
... on OperationInfo { messages { kind field message code } }
}
}
{
"data": {
"addReview": {
"messages": [
{
"kind": "VALIDATION",
"field": null,
"message": "Constraint “review_rating_1_to_5” is violated.",
"code": null
}
]
}
}
}
A field-level validation error (a ValidationError keyed by field name) fills field; give the constraint a
violation_error_message (and violation_error_code) if clients should show something friendlier. The generated CRUD
mutations take their input in an argument called data (createAuthor(data: {name: "…"})); mutations.update
looks the row up by the primary key in the partial input (id here), or by another unique field passed as
key_attr. Never expose a
generated mutation without a permission extension: it can write every field that the input type declares.
GraphQL mutations must not be sent with GET; Strawberry’s views refuse them (mutations are not allowed when using GET,
HTTP 400), so the usual "state changes need POST and CSRF" rule holds without extra code.
Permissions and the current user
Authorization belongs to the server, not to the client’s choice of fields; the theory is in
Authorization. Strawberry-Django maps it onto Django’s own permission system with
field extensions from strawberry_django.permissions:
| Extension | Passes when |
|---|---|
|
|
|
|
|
The user has the model-level permission ( |
|
The user has the permission on the parent object of the field. |
|
The user has the permission on each returned object; with an object-permission backend such as django-guardian this filters lists row by row. |
When a check fails, the extension does not simply raise. If the field can return OperationInfo (a mutation with
handle_django_errors=True), it returns a PERMISSION message; otherwise an optional field resolves to null, a list to
[] and a connection to an empty connection; only a non-null single value raises a PermissionDenied error. Pass
fail_silently=False to raise instead of returning an empty value, and message="…" to change the text. The exported
schema documents the checks as directives (@isAuthenticated, @hasPerm(…)), so clients can see them. For example,
an authenticated reader without catalog.add_author gets:
{
"data": {
"createAuthor": {
"messages": [
{
"kind": "PERMISSION",
"message": "You don't have permission to access this app."
}
]
}
}
}
The same extensions work on query fields, e.g.
drafts: list[BookType] = strawberry_django.field(extensions=[IsStaff()]). Who the user is comes from Django’s
AuthenticationMiddleware; for login and logout over GraphQL, strawberry_django.auth.login(), logout() and
current_user() provide ready-made fields that call django.contrib.auth.login/logout and therefore rotate the
session key and need the CSRF token like any other POST. Token-based clients (mobile apps, other services) typically
authenticate in middleware instead and never touch the session; see Authentication
and Permissions & Authorization for the Django side.
Serving the schema: views, GraphiQL and CSRF
The schema object ties the root types and the schema extensions together; the view serves it on a URL.
from strawberry_django.optimizer import DjangoOptimizerExtension
schema = strawberry.Schema(
query=Query,
mutation=Mutation,
extensions=[DjangoOptimizerExtension],
)
strawberry.django.views has two views. AsyncGraphQLView is an async Django view: under ASGI (Uvicorn, Daphne,
Granian, Hypercorn) it runs on the event loop, supports async resolvers and is the only one that can use DataLoaders.
GraphQLView is synchronous for WSGI deployments. Both accept the same main options:
| Option | Default | Meaning |
|---|---|---|
|
required |
The |
|
|
The in-browser IDE served to |
|
|
Accept queries (never mutations) in the query string of a |
|
|
Accept file uploads via the GraphQL multipart request spec; read the spec’s security notes before turning it on. |
from django.contrib import admin
from django.urls import path
from strawberry.django.views import AsyncGraphQLView
from catalog.schema import schema
urlpatterns = [
path("admin/", admin.site.urls),
path("graphql/", AsyncGraphQLView.as_view(schema=schema)),
]
CSRF with GraphQL
Neither view is exempt from CSRF protection, so with CsrfViewMiddleware enabled (the default) a cookie-session POST
without a token is rejected before the schema runs:
$ curl -s -i -X POST http://127.0.0.1:8000/graphql/ \
-H "Content-Type: application/json" -d '{"query": "{ books { title } }"}' | head -1
HTTP/1.1 403 Forbidden
That is the correct default: the API trusts the session cookie, so it must be protected from cross-site requests exactly
like a form view. The bundled GraphiQL page already reads the csrftoken cookie and sends it back as the X-CSRFToken
header — but the GraphQL view never sets that cookie, so a browser that has not visited another Django page cannot run
mutations from GraphiQL. Wrapping the view in ensure_csrf_cookie fixes that, and turning the IDE off outside
development keeps the production endpoint from serving it:
from django.conf import settings
from django.contrib import admin
from django.urls import path
from django.views.decorators.csrf import ensure_csrf_cookie
from catalog.schema import schema
from catalog.views import BookshelfGraphQLView
graphql_view = BookshelfGraphQLView.as_view(
schema=schema,
graphql_ide="graphiql" if settings.DEBUG else None, # no IDE in production
)
urlpatterns = [
path("admin/", admin.site.urls),
# ensure_csrf_cookie: GraphiQL reads the "csrftoken" cookie and sends it back
# as X-CSRFToken, so session-authenticated POSTs pass CsrfViewMiddleware.
path("graphql/", ensure_csrf_cookie(graphql_view)),
]
BookshelfGraphQLView is an AsyncGraphQLView subclass defined in DataLoaders for computed fields; Django’s view decorators have
supported async views since Django 5.0. A JavaScript client does the same as GraphiQL: read the cookie, send the header.
With curl:
$ curl -s -c cookies.txt -o /dev/null -H "Accept: text/html" http://127.0.0.1:8000/graphql/
$ CSRF=$(awk '/csrftoken/ {print $7}' cookies.txt)
$ curl -s -b cookies.txt http://127.0.0.1:8000/graphql/ \
-H "Content-Type: application/json" -H "X-CSRFToken: $CSRF" \
-d '{"query": "mutation { addReview(bookId: \"1\", rating: 5) { ... on OperationInfo { messages { kind message } } } }"}'
{"data":{"addReview":{"messages":[{"kind":"PERMISSION","message":"User is not authenticated."}]}}}
The token passed the CSRF check, and the anonymous request was then stopped by IsAuthenticated. Strawberry’s own Django
page wraps the view in csrf_exempt; do that only when the API never authenticates with cookies (for example, it
accepts only Authorization: Bearer tokens), because otherwise any website can make a logged-in user’s browser run
mutations. Cross-origin browser clients additionally need CORS, covered in CORS.
The N+1 problem with the ORM
Why nested resolvers multiply queries, and the general batch-and-cache cure, are explained in
Performance & the N+1 problem; the Django side of the same problem
(lazy relations, select_related, prefetch_related) is in
Query Optimization & Fetch Modes. In a Django schema the two
meet: each nested field is resolved by attribute access on a model instance, so book.author and book.reviews.all()
each run a query per book unless the root QuerySet loaded them in advance. Here is the query
{ books { title author { name } reviews { rating } } } against two books, run once on a schema built without the
optimizer and once on the real one:
import strawberry
from django.db import connection
from django.test.utils import CaptureQueriesContext
from catalog.schema import Mutation, Query, schema
QUERY = "{ books { title author { name } reviews { rating } } }"
naive = strawberry.Schema(query=Query, mutation=Mutation) # no optimizer
for s in (naive, schema):
with CaptureQueriesContext(connection) as ctx:
s.execute_sync(QUERY)
print(len(ctx.captured_queries))
for q in ctx.captured_queries:
print(" ", q["sql"])
5
SELECT "catalog_book"."id", "catalog_book"."title", "catalog_book"."isbn", "catalog_book"."published", "catalog_book"."author_id" FROM "catalog_book" ORDER BY "catalog_book"."id" ASC
SELECT "catalog_author"."id", "catalog_author"."name" FROM "catalog_author" WHERE "catalog_author"."id" = 1 LIMIT 21
SELECT "catalog_review"."id", "catalog_review"."book_id", "catalog_review"."reader_id", "catalog_review"."rating", "catalog_review"."body", "catalog_review"."created" FROM "catalog_review" WHERE "catalog_review"."book_id" = 1 ORDER BY "catalog_review"."id" ASC
SELECT "catalog_author"."id", "catalog_author"."name" FROM "catalog_author" WHERE "catalog_author"."id" = 1 LIMIT 21
SELECT "catalog_review"."id", "catalog_review"."book_id", "catalog_review"."reader_id", "catalog_review"."rating", "catalog_review"."body", "catalog_review"."created" FROM "catalog_review" WHERE "catalog_review"."book_id" = 2 ORDER BY "catalog_review"."id" ASC
2
SELECT "catalog_book"."id", "catalog_book"."title", "catalog_book"."author_id", "catalog_author"."id", "catalog_author"."name" FROM "catalog_book" INNER JOIN "catalog_author" ON ("catalog_book"."author_id" = "catalog_author"."id") ORDER BY "catalog_book"."id" ASC
SELECT "catalog_review"."id", "catalog_review"."book_id", "catalog_review"."rating" FROM "catalog_review" WHERE "catalog_review"."book_id" IN (1, 2) ORDER BY "catalog_review"."id" ASC
Without the optimizer the count is 1 + 2N (21 queries for ten books, 201 for a hundred); with it, it is two, whatever the number of books, and every query selects only the requested columns.
Source: Strawberry-Django: Query Optimizer and assertNumQueries()
The query optimizer extension
DjangoOptimizerExtension, added to strawberry.Schema(extensions=[…]) as in Serving the schema: views, GraphiQL and CSRF, reads the selection set of
each root field before the QuerySet is evaluated and rewrites it: select_related() for every selected foreign key and
one-to-one, prefetch_related() (with nested Prefetch objects, carrying the nested filters, ordering and pagination)
for every selected reverse or many-to-many relation, only() for the selected columns, and annotate() for fields
declared with strawberry_django.field(annotate=…). Each step can be switched off with a constructor argument
(enable_only_optimization, enable_select_related_optimization, enable_prefetch_related_optimization,
enable_annotate_optimization, enable_nested_relations_prefetch, all True by default), which is mostly useful for
debugging. It is the one extension every Strawberry-Django schema should have; the library’s own quick start adds it
from the first example.
The optimizer can only see what is declared. A custom resolver that touches other columns or relations defeats it — only() defers the columns it did not see, and each access then costs a query. Tell it what the resolver needs with
optimization hints (only, select_related, prefetch_related, annotate) on the field:
@strawberry_django.type(models.Book)
class NoHint:
@strawberry_django.field
def byline(self) -> str:
return f"{self.title} by {self.author.name}"
@strawberry_django.type(models.Book)
class WithHint:
@strawberry_django.field(only=["title"], select_related=["author"])
def byline(self) -> str:
return f"{self.title} by {self.author.name}"
For two books, { byline } on NoHint ran seven queries (the root query selected only id, then one query each for the
deferred title, the deferred author_id and the author, per book); on WithHint it ran one query with a JOIN. The
same hints can live on the model with strawberry_django.model_property, so the type can annotate the property with
auto. Hand-written select_related()/prefetch_related() calls are what you write when there is no optimizer at all,
as in the Graphene example in Graphene-Django in brief.
DataLoaders for computed fields
Some fields are not relations at all — an aggregate, a value from another service, a row in another database — so
there is nothing for the optimizer to join. That is the job of a DataLoader: every resolver asks the loader for one key,
the loader collects the keys requested in the same tick of the event loop and calls one batch function with all of
them. DataLoaders are async, so they need AsyncGraphQLView (the Strawberry-Django docs list them among the features a
WSGI deployment misses). The loader must be created per request — its cache would otherwise serve one user’s data to
another — which is what a custom context is for:
from dataclasses import dataclass, field
from django.db.models import Avg
from strawberry.dataloader import DataLoader
from strawberry.django.context import StrawberryDjangoContext
from .models import Review
async def load_average_ratings(book_ids: list[int]) -> list[float | None]:
rows = (
Review.objects.filter(book_id__in=book_ids)
.values("book_id")
.annotate(avg=Avg("rating"))
)
averages = {row["book_id"]: row["avg"] async for row in rows}
return [averages.get(book_id) for book_id in book_ids] # same order as keys
@dataclass
class BookshelfContext(StrawberryDjangoContext):
average_rating: DataLoader[int, float | None] = field(
default_factory=lambda: DataLoader(load_fn=load_average_ratings)
)
from strawberry.django.views import AsyncGraphQLView
from .loaders import BookshelfContext
class BookshelfGraphQLView(AsyncGraphQLView):
async def get_context(self, request, response):
# A fresh context -- and fresh DataLoader cache -- per HTTP request.
return BookshelfContext(request=request, response=response)
Subclassing StrawberryDjangoContext keeps info.context.request working, which the permission extensions and
get_current_user() rely on. The field itself is an async resolver on the type; the only=["pk"] hint tells the
optimizer that it needs nothing but the primary key:
@strawberry_django.field(only=["pk"])
async def average_rating(self, info: strawberry.Info) -> float | None:
return await info.context.average_rating.load(self.pk)
The batch function must return one value per key, in the order of the keys, which is why it builds a dictionary and
maps the keys through it. It uses the async ORM (async for over a QuerySet), so it never blocks the event loop.
{ books { title averageRating } } now runs two queries for any number of books — the books, and one
SELECT book_id, AVG(rating) … WHERE book_id IN (…) GROUP BY book_id — which the test in Testing asserts.
For this particular field strawberry_django.field(annotate=Avg("reviews__rating")) would also do (the optimizer adds the
annotation to the root query); the DataLoader is the general tool for data the ORM cannot join.
Guarding against regressions with FETCH_RAISE (Django 6.1)
Django 6.1’s fetch modes make a lazy load an error instead of a silent query. Setting FETCH_RAISE in a type’s
get_queryset turns any relation or deferred column that the optimizer did not load into FieldFetchBlocked, which is
a cheap way to fail tests the moment a schema change reintroduces N+1:
from django.db import models as m
@strawberry_django.type(models.Book)
class GuardedBook:
title: auto
author: AuthorType
@classmethod
def get_queryset(cls, queryset, info, **kwargs):
return queryset.fetch_mode(m.FETCH_RAISE)
With the optimizer, { books { title author { name } } } on this type returns data in one query; on a schema without
the optimizer the same query fails with "errors": [{"message": "Fetching of Book.author blocked.", …}]. Use it in
tests or behind a setting rather than unconditionally in production, where a missed hint would turn a slow field into a
failing one. Django 5.2 has no fetch modes; there, assertNumQueries (next section) is the guard.
Sync and async
Strawberry-Django is written so that the same schema runs under both views. Its own resolvers (strawberry_django.field(),
the CRUD mutations, @strawberry_django.mutation) detect an async context and run ORM work through sync_to_async, so
a plain def resolver that touches the ORM is safe under AsyncGraphQLView. Plain @strawberry.field resolvers get no
such wrapping: under the async view, a synchronous ORM call there raises SynchronousOnlyOperation, so either make the
resolver async def and use the async ORM (aget, acount, async for), or use @strawberry_django.field instead.
Since 0.92.0 (2026-10-01), list fields hand graphql-core an unevaluated QuerySet that it iterates with async for in
async execution.
Choosing between them follows the deployment, not the schema: run AsyncGraphQLView under an ASGI server if you want
DataLoaders, async resolvers or subscriptions (which additionally need Channels, see
Channels & WebSockets); run GraphQLView under WSGI otherwise. Django’s
async story in general — what is truly async in the ORM and what still runs in a thread — is on
Async Support.
Graphene-Django in brief
Graphene-Django predates Strawberry and still runs many production schemas. Types subclass DjangoObjectType and name
their model and exposed fields in an inner Meta; resolvers are resolve_<field> methods; the view is a synchronous
GraphQLView that takes the schema from the GRAPHENE setting. Its 3.2.3 release (2025-03-13) is the latest on
2026-10-03 and its classifiers stop at Django 4.2; it installed and passed its test under Django 6.1.1 here, in a separate
virtual environment, because Graphene 3.4.3 requires graphql-core below 3.3 while current Strawberry requires 3.3.
INSTALLED_APPS = [..., "graphene_django", "catalog"]
GRAPHENE = {"SCHEMA": "catalog.schema_graphene.schema"}
# config/urls.py
from django.conf import settings
from django.urls import path
from graphene_django.views import GraphQLView
urlpatterns = [
path("graphql/", GraphQLView.as_view(graphiql=settings.DEBUG)),
]
import graphene
from graphene_django import DjangoObjectType
from .models import Author, Book
class AuthorNode(DjangoObjectType):
class Meta:
model = Author
fields = ("id", "name", "books")
class BookNode(DjangoObjectType):
class Meta:
model = Book
fields = ("id", "title", "isbn", "published", "author")
class Query(graphene.ObjectType):
books = graphene.List(BookNode)
def resolve_books(root, info):
# No optimizer: join the author by hand to avoid N+1.
return Book.objects.select_related("author").order_by("title")
schema = graphene.Schema(query=Query)
Always set fields (or exclude): without either, DjangoObjectType exposes every model field and warns that this is
deprecated. Graphene-Django has no query optimizer, so the select_related("author") above is mandatory — and it is
also wrong for a query that does not select author, which is the trade-off the optimizer removes. As with Strawberry,
the view is not CSRF-exempt; set graphiql from DEBUG in production.
| Strawberry-Django | Graphene-Django | |
|---|---|---|
Type declaration |
Annotated class, |
|
Filtering, ordering, pagination |
Built in ( |
django-filter via |
Mutations |
CRUD helpers and |
|
N+1 |
|
Hand-written |
Async |
|
Synchronous view |
Testing |
|
|
The Graphene test case posts to GRAPHQL_URL and offers assertResponseNoErrors:
import datetime
import json
from graphene_django.utils.testing import GraphQLTestCase
from .models import Author, Book
class GrapheneBooksTests(GraphQLTestCase):
GRAPHQL_URL = "/graphql/"
@classmethod
def setUpTestData(cls):
for i in range(3):
author = Author.objects.create(name=f"Author {i}")
Book.objects.create(title=f"Book {i}", isbn=f"97800000000{i}",
published=datetime.date(2020, 1, 1), author=author)
def test_books_with_authors_in_one_query(self):
with self.assertNumQueries(1):
response = self.query("{ books { title author { name } } }")
self.assertResponseNoErrors(response)
self.assertEqual(json.loads(response.content)["data"]["books"][0],
{"title": "Book 0", "author": {"name": "Author 0"}})
When migrating a Graphene schema to Strawberry-Django, export both schemas as SDL and diff them so that clients see no change. The GraphQL section’s Python: Getting Started page puts Graphene in the wider Python landscape.
Testing
Strawberry-Django ships two test clients built on Django’s: strawberry_django.test.client.TestClient (wrapping
django.test.Client) and AsyncTestClient (wrapping AsyncClient). They take the endpoint path, post the document
and variables as JSON, assert by default that the response has no errors (assert_no_errors=False turns that off for
negative tests), return a Response with data, errors and extensions, and offer a login(user) context manager
that logs a user into the client’s session. Because they go through the URL, the middleware and the view, they test the
real wiring — including permissions and the custom context. Django’s test client does not enforce CSRF checks unless
created with enforce_csrf_checks=True, so CSRF behaviour is better checked with curl as in CSRF with GraphQL.
The most valuable GraphQL test is a query-count test: assertNumQueries around a list query with several rows fails as
soon as a schema change brings back N+1.
import datetime
import strawberry
from django.contrib.auth.models import Permission, User
from django.test import TestCase
from strawberry_django.test.client import AsyncTestClient, TestClient
from .models import Author, Book, Review
from .schema import Mutation, Query, schema
BOOKS_QUERY = """
query {
books(ordering: [{title: ASC}]) {
title
author { name }
reviews { rating }
}
}
"""
class BookshelfDataMixin:
@classmethod
def setUpTestData(cls):
cls.reader = User.objects.create_user("ada") # unusable password
for i in range(5):
author = Author.objects.create(name=f"Author {i}")
book = Book.objects.create(
title=f"Book {i}",
isbn=f"978000000000{i}",
published=datetime.date(2020, 1, i + 1),
author=author,
)
Review.objects.create(book=book, reader=cls.reader, rating=4)
class OptimizerTests(BookshelfDataMixin, TestCase):
def test_optimizer_avoids_n_plus_1(self):
with self.assertNumQueries(2):
result = schema.execute_sync(BOOKS_QUERY)
self.assertIsNone(result.errors)
self.assertEqual(len(result.data["books"]), 5)
def test_without_optimizer_is_n_plus_1(self):
naive = strawberry.Schema(query=Query, mutation=Mutation)
with self.assertNumQueries(1 + 5 + 5):
result = naive.execute_sync(BOOKS_QUERY)
self.assertIsNone(result.errors)
class EndpointTests(BookshelfDataMixin, TestCase):
def setUp(self):
self.client_gql = TestClient("/graphql/")
def test_filter_and_paginate(self):
response = self.client_gql.query(
"""
query ($q: String!) {
books(filters: {title: {iContains: $q}},
ordering: [{published: DESC}],
pagination: {limit: 2}) {
title
}
}
""",
variables={"q": "book"},
)
self.assertEqual(
response.data, {"books": [{"title": "Book 4"}, {"title": "Book 3"}]}
)
def test_anonymous_cannot_review(self):
response = self.client_gql.query(
"""
mutation { addReview(bookId: "1", rating: 5) {
... on OperationInfo { messages { kind message } }
} }
"""
)
self.assertEqual(
response.data["addReview"]["messages"][0]["kind"], "PERMISSION"
)
def test_reader_can_review_and_validation_runs(self):
book = Book.objects.get(title="Book 0")
mutation = """
mutation ($book: ID!, $rating: Int!) {
addReview(bookId: $book, rating: $rating) {
... on ReviewType { rating book { title } }
... on OperationInfo { messages { kind field message } }
}
}
"""
with self.client_gql.login(self.reader):
ok = self.client_gql.query(mutation, variables={"book": book.pk, "rating": 5})
bad = self.client_gql.query(mutation, variables={"book": book.pk, "rating": 9})
self.assertEqual(ok.data["addReview"], {"rating": 5, "book": {"title": "Book 0"}})
self.assertEqual(bad.data["addReview"]["messages"][0]["kind"], "VALIDATION")
def test_create_author_needs_permission(self):
mutation = """
mutation { createAuthor(data: {name: "Ursula K. Le Guin"}) {
... on AuthorType { name }
... on OperationInfo { messages { kind } }
} }
"""
with self.client_gql.login(self.reader):
denied = self.client_gql.query(mutation)
self.reader.user_permissions.add(
Permission.objects.get(codename="add_author")
)
allowed = self.client_gql.query(mutation)
self.assertEqual(denied.data["createAuthor"]["messages"][0]["kind"], "PERMISSION")
self.assertEqual(allowed.data["createAuthor"], {"name": "Ursula K. Le Guin"})
class DataLoaderTests(BookshelfDataMixin, TestCase):
def test_average_rating_is_batched(self):
client = TestClient("/graphql/") # the async view runs via async_to_sync
with self.assertNumQueries(2): # books + one batched AVG query
response = client.query("{ books { title averageRating } }")
self.assertEqual(response.data["books"][0]["averageRating"], 4.0)
class AsyncEndpointTests(BookshelfDataMixin, TestCase):
async def test_async_client(self):
client = AsyncTestClient("/graphql/")
response = await client.query("{ books(pagination: {limit: 1}) { title averageRating } }")
self.assertEqual(response.data, {"books": [{"title": "Book 0", "averageRating": 4.0}]})
$ python -W error manage.py test catalog.tests
Found 8 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
........
----------------------------------------------------------------------
Ran 8 tests in 0.471s
OK
Destroying test database for alias 'default'...
A few details are worth copying. setUpTestData creates the rows once per class; five books make the difference between
2 and 1 + 5 + 5 queries unmistakable. The DataLoader test uses the synchronous client on purpose: the async view runs
through async_to_sync, and assertNumQueries itself cannot be entered inside an async def test (it raises
SynchronousOnlyOperation), so query counting stays in synchronous tests while AsyncTestClient covers the async path.
The permission test grants catalog.add_author between two requests inside the same login() block; each request loads
the user afresh, so the second request sees the new permission. For pure schema logic without HTTP,
schema.execute_sync() (or await schema.execute()) runs a document directly, as the optimizer tests do. With pytest,
the same clients work in tests that request pytest-django’s db fixture.
Production checklist
-
Serve GraphiQL only in development (
graphql_ide=NoneunlessDEBUG), and consider disabling introspection for public APIs; see Introspection. -
Cap work per request: keep
PAGINATION_MAX_LIMITlow, and add Strawberry’sQueryDepthLimiterextension (and other demand-control measures from Security & demand control) so that a deeply nested query cannot fan out into thousands of prefetches. -
Keep CSRF protection for cookie-authenticated clients; exempt the view only for token-only APIs.
-
Put a permission extension on every mutation and on every field that is not public, and narrow row visibility in
get_queryset. -
Keep
DjangoOptimizerExtensionin the schema and a query-count test per list field. -
Export the schema in CI (
manage.py export_schema catalog.schema --path schema.graphql) and fail the build on an unreviewed diff.
from strawberry.extensions import QueryDepthLimiter
schema = strawberry.Schema(
query=Query,
mutation=Mutation,
extensions=[
DjangoOptimizerExtension,
lambda: QueryDepthLimiter(max_depth=6), # a factory: one instance per request
],
)
A query nested deeper than six levels is rejected before execution with
"'anonymous' exceeds maximum operation depth of 6". Pass extensions as classes or factories: since Strawberry 0.316.0
(2026-05-19) passing an instance such as QueryDepthLimiter(max_depth=6) directly is deprecated, because the one object
would be shared by concurrent requests (the extension’s own documentation page still shows the instance form).
Pitfalls
-
Forgetting the optimizer. Everything works and every list query is 1 + N; nothing warns you. Add the extension and the query-count test from day one.
-
Custom resolvers without hints. A method field that reads
self.authoror a deferred column silently adds queries per row, even with the optimizer; declareonly/select_related/prefetch_related. -
Plain
@strawberry.fieldresolvers calling the sync ORM under the async view raiseSynchronousOnlyOperation; use@strawberry_django.fieldor anasync defresolver with the async ORM. -
A module-level DataLoader. Its cache outlives the request and leaks data between users; create loaders in
get_context. -
csrf_exemptcopied from a quick start onto a cookie-authenticated API opens it to cross-site request forgery. -
Taking the user from an argument (
readerId: ID!) instead ofget_current_user(info)lets clients act as anybody. -
Relying on HTTP status codes. GraphQL errors arrive with HTTP 200; log and alert on the
errorskey and onOperationInfopayloads. -
Unbounded lists. Before 0.85.0 (2026-05-23)
PAGINATION_MAX_LIMITdefaulted toNone, solimit: nullreturned whole tables; projects pinned to older releases must set it explicitly. -
Deprecated decorator names.
strawberry_django.filter(renamedfilter_typein 0.59.0) andstrawberry_django.order(superseded byorder_type, also 0.59.0) still work but emit deprecation warnings, whichpython -W errorturns into failures.
Where the books are outdated
None of the four books consulted for this section (see the section
bibliography) teaches GraphQL: they target Django 1.11 to 2.1, and _Practical Django 2 and Channels 2 mentions GraphQL
only in passing, as the newer alternative to REST that single-page applications consume. What they do teach that
carries over is the ORM side — lazy relations, select_related() and prefetch_related() — and that advice is still
correct; in a GraphQL schema the optimizer applies it per request from the selection set instead of you writing it per
view. Material from the same years on GraphQL with Django is outdated in specific ways:
-
Graphene 2 and graphql-core 2 (2018-2019) are superseded by Graphene 3 and graphql-core 3; Graphene 3 no longer depends on the
promiselibrary, so promise-based DataLoaders from that era do not carry over. -
DjangoObjectTypeclasses withoutfieldsorexcludeexposed every column; that is now deprecated and warns. -
Wrapping the view in
csrf_exempt"to make GraphiQL work" is unnecessary: keep CSRF protection and let the page send the token (CSRF with GraphQL). -
Synchronous-only examples predate async views (Django 3.1) and the async ORM (4.1); under ASGI,
AsyncGraphQLViewand async resolvers are the default choice now. -
Strawberry did not exist in 2018 (its first release is from 2019), so there is no book-era coverage of the code-first, type-annotation approach used here.
What changed recently
| Version (date) | Change |
|---|---|
Django 5.0 (2023-12-04) |
|
Strawberry-Django 0.35.0 (2024-03-18) |
Filters and ordering rewritten: |
Strawberry-Django 0.58.0 (2025-04-04) |
Official Django 5.2 support. |
Strawberry-Django 0.59.0 (2025-04-30) |
|
Strawberry-Django 0.68.0 (2025-12-03) |
Declares Django 6.0 support. |
Strawberry-Django 0.70.0 (2025-12-06) |
|
Strawberry-Django 0.74.0 (2026-01-17) and 0.85.0 (2026-05-23) |
|
Strawberry 0.316.0 (2026-05-19) |
Passing schema extension instances is deprecated; pass classes or factories. |
Django 6.1 (2026-09-02) |
Fetch modes ( |
Strawberry-Django 0.92.0 (2026-10-01) |
Requires |
The Strawberry-Django rows come from its GitHub release notes, the Strawberry row from its changelog and the Django rows from the release notes.