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.

Choosing a library

Expose a model

@strawberry_django.type(Model) with auto annotations; list the fields you expose, never "all".

Types from models

List, filter, sort, page

strawberry_django.field() on the root type; filter_type, order_type and pagination=True on the type.

Queries: lists, single objects, filters, ordering and pagination

Change data

mutations.create/update for plain CRUD, @strawberry_django.mutation(handle_django_errors=True) for logic.

Mutations

Restrict access

Permission extensions (IsAuthenticated, HasPerm, …​) on fields and mutations; the user comes from the session.

Permissions and the current user

Serve it

AsyncGraphQLView under ASGI; GraphiQL only when DEBUG; keep CSRF on for cookie sessions.

Serving the schema: views, GraphiQL and CSRF

Stop N+1

Always add DjangoOptimizerExtension; DataLoaders for computed fields; FETCH_RAISE as a guard.

The N+1 problem with the ORM

Test

strawberry_django.test.client.TestClient plus assertNumQueries for every list query.

Testing

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.

sequenceDiagram autonumber participant C as Client participant M as Middleware stack participant V as GraphQL view participant S as Schema and resolvers participant D as Database C->>M: POST /graphql/ with query and variables M->>M: sessions, CSRF check, request.user M->>V: HttpRequest V->>V: parse and validate the document V->>S: execute with context (request, response) S->>S: optimizer reads the selection set S->>D: one QuerySet per root field (JOINs and prefetches) D-->>S: rows S-->>V: data and errors V-->>C: 200 JSON {"data": ..., "errors": ...}
Figure 1. A GraphQL request through Django

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.

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

strawberry-graphql

strawberry-graphql-django (installs Strawberry)

graphene-django (installs Graphene)

Schema style

Code-first, type annotations

Code-first, type annotations plus model introspection (auto)

Code-first, DjangoObjectType subclasses with an inner Meta

Views

GraphQLView and AsyncGraphQLView

The same views

GraphQLView (sync)

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:

config/settings.py (excerpt)
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.

catalog/models.py
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.

catalog/types.py (object types)
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.

catalog/types.py (input types)
@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.

catalog/schema.py (queries)
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.

catalog/types.py (filters and ordering)
@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.

catalog/schema.py (mutations)
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

IsAuthenticated()

request.user is authenticated and active.

IsStaff() / IsSuperuser()

user.is_staff / user.is_superuser.

HasPerm("app.codename")

The user has the model-level permission (any_perm, with_superuser and with_anonymous tune the check).

HasSourcePerm(…​)

The user has the permission on the parent object of the field.

HasRetvalPerm(…​)

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.

catalog/schema.py (schema)
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

schema

required

The strawberry.Schema.

graphql_ide

"graphiql"

The in-browser IDE served to GET requests that accept HTML: "graphiql", "apollo-sandbox", "pathfinder", or None to disable it.

allow_queries_via_get

True

Accept queries (never mutations) in the query string of a GET.

multipart_uploads_enabled

False

Accept file uploads via the GraphQL multipart request spec; read the spec’s security notes before turning it on.

config/urls.py (first version)
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:

config/urls.py
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.

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:

catalog/loaders.py
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)
    )
catalog/views.py
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:

catalog/types.py (BookType, continued)
    @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.

config/settings.py and config/urls.py (Graphene-Django)
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)),
]
catalog/schema_graphene.py
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, auto from the model

DjangoObjectType with Meta.model and Meta.fields

Filtering, ordering, pagination

Built in (filter_type, order_type, pagination=True)

django-filter via DjangoFilterConnectionField (Relay connections)

Mutations

CRUD helpers and @strawberry_django.mutation, OperationInfo errors

graphene.Mutation classes; DjangoModelFormMutation and DRF serializer mutations

N+1

DjangoOptimizerExtension, hints, DataLoaders

Hand-written select_related/prefetch_related in resolvers; no optimizer

Async

AsyncGraphQLView, async resolvers

Synchronous view

Testing

strawberry_django.test.client.TestClient/AsyncTestClient

graphene_django.utils.testing.GraphQLTestCase

The Graphene test case posts to GRAPHQL_URL and offers assertResponseNoErrors:

catalog/tests_graphene.py
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.

catalog/tests.py
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=None unless DEBUG), and consider disabling introspection for public APIs; see Introspection.

  • Cap work per request: keep PAGINATION_MAX_LIMIT low, and add Strawberry’s QueryDepthLimiter extension (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 DjangoOptimizerExtension in 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.

Adding a depth limit to the schema
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.author or a deferred column silently adds queries per row, even with the optimizer; declare only/select_related/prefetch_related.

  • Plain @strawberry.field resolvers calling the sync ORM under the async view raise SynchronousOnlyOperation; use @strawberry_django.field or an async def resolver with the async ORM.

  • A module-level DataLoader. Its cache outlives the request and leaks data between users; create loaders in get_context.

  • csrf_exempt copied 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 of get_current_user(info) lets clients act as anybody.

  • Relying on HTTP status codes. GraphQL errors arrive with HTTP 200; log and alert on the errors key and on OperationInfo payloads.

  • Unbounded lists. Before 0.85.0 (2026-05-23) PAGINATION_MAX_LIMIT defaulted to None, so limit: null returned whole tables; projects pinned to older releases must set it explicitly.

  • Deprecated decorator names. strawberry_django.filter (renamed filter_type in 0.59.0) and strawberry_django.order (superseded by order_type, also 0.59.0) still work but emit deprecation warnings, which python -W error turns 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 promise library, so promise-based DataLoaders from that era do not carry over.

  • DjangoObjectType classes without fields or exclude exposed 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, AsyncGraphQLView and 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

Table 1. GraphQL-with-Django changes by version
Version (date) Change

Django 5.0 (2023-12-04)

ensure_csrf_cookie, csrf_exempt and the other view decorators accept async views, so they can wrap AsyncGraphQLView.

Strawberry-Django 0.35.0 (2024-03-18)

Filters and ordering rewritten: AND/OR/NOT/DISTINCT on every filter, custom filter_field methods; the old behaviour stays behind USE_DEPRECATED_FILTERS.

Strawberry-Django 0.58.0 (2025-04-04)

Official Django 5.2 support.

Strawberry-Django 0.59.0 (2025-04-30)

@strawberry_django.order_type (a list of criteria, order preserved); filter renamed filter_type; DjangoCursorConnection for cursor-based Relay pagination.

Strawberry-Django 0.68.0 (2025-12-03)

Declares Django 6.0 support.

Strawberry-Django 0.70.0 (2025-12-06)

strawberry.Maybe in mutation inputs and filters, to tell "not provided" from "set to null".

Strawberry-Django 0.74.0 (2026-01-17) and 0.85.0 (2026-05-23)

PAGINATION_MAX_LIMIT added (default None), then its default changed to 100 — a breaking change for clients that relied on limit: null.

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 (FETCH_ONE, FETCH_PEERS, FETCH_RAISE), usable as an N+1 guard in get_queryset (Guarding against regressions with FETCH_RAISE (Django 6.1)).

Strawberry-Django 0.92.0 (2026-10-01)

Requires strawberry-graphql 0.328.0 and graphql-core 3.3; list fields return an unevaluated QuerySet that graphql-core iterates with async for in async execution.

The Strawberry-Django rows come from its GitHub release notes, the Strawberry row from its changelog and the Django rows from the release notes.