GraphQL in Django: queries, mutations, and permissions
Build a Django GraphQL API with Graphene-Django, bounded queries, exact decimal prices, staff-only mutations, and explicit authorization.
GraphQL lets a client select the fields it needs and traverse related data through one schema. In Django, Graphene-Django connects that schema to models and querysets. This can reduce unnecessary response fields and extra client round trips, but it does not automatically reduce database queries or grant access safely.
This example builds a public book catalog with staff-only creation. It uses Django and Graphene-Django 3; the public-read policy is intentional. A private or tenant-scoped catalog needs additional queryset restrictions on every access path.
Decide whether GraphQL fits the API
A mobile list may need three fields while a detail view needs a book, its author, and related information. GraphQL allows those clients to request different shapes without creating a new REST endpoint for every combination.
The server still owns the schema, resolvers, permissions, and cost controls. A client cannot request a field the schema does not expose, and a new business capability still needs server work. REST remains a straightforward choice for simple resource operations. Many applications use both.
Set up Graphene-Django
In an existing Django project with an application named blog, install and lock compatible dependency versions:
python -m pip install graphene-django
Add the integration without replacing the standard Django apps:
# settings.py: append to the existing list.
INSTALLED_APPS += ["graphene_django"]
GRAPHENE = {"SCHEMA": "blog.schema.schema"}
Ensure blog is also installed. Keep Django's session, authentication, and CSRF middleware. In the project's URL configuration:
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)),
]
Merge this route into existing urlpatterns. GraphiQL is an interactive development UI, not an access-control mechanism. Disable it in production and apply the API's authentication and authorization policies separately. Session-authenticated POST requests need a valid CSRF token; do not exempt the endpoint merely to make mutations work. See Graphene-Django installation.
Define the models and exposed fields
The example uses these models in blog/models.py:
from decimal import Decimal
from django.core.validators import MinValueValidator
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=200)
class Book(models.Model):
title = models.CharField(max_length=200)
price = models.DecimalField(
max_digits=10, decimal_places=2,
validators=[MinValueValidator(Decimal("0.00"))],
)
author = models.ForeignKey(Author, on_delete=models.PROTECT)
Create and apply the migration in your development database, then add at least one author before creating a book:
python manage.py makemigrations blog
python manage.py migrate
In blog/schema.py, explicitly allow the fields clients may see:
import graphene
from graphene_django import DjangoObjectType
from .models import Author, Book
class AuthorType(DjangoObjectType):
class Meta:
model = Author
fields = ("id", "name")
class BookType(DjangoObjectType):
class Meta:
model = Book
fields = ("id", "title", "price", "author")
class Query(graphene.ObjectType):
all_books = graphene.List(BookType)
book = graphene.Field(BookType, id=graphene.Int(required=True))
books = graphene.List(
BookType,
title_contains=graphene.String(),
max_price=graphene.Decimal(),
)
def resolve_all_books(root, info):
return Book.objects.select_related("author").order_by("id")[:100]
def resolve_book(root, info, id):
return Book.objects.select_related("author").filter(pk=id).first()
def resolve_books(root, info, title_contains=None, max_price=None):
qs = Book.objects.select_related("author").order_by("id")
if title_contains:
qs = qs.filter(title__icontains=title_contains)
if max_price is not None:
if not max_price.is_finite() or max_price < 0:
from graphql import GraphQLError
raise GraphQLError("Invalid maximum price")
qs = qs.filter(price__lte=max_price)
return qs[:100]
schema = graphene.Schema(query=Query)
Graphene's default field naming exposes all_books as allBooks and title_contains as titleContains. A client can select only the title and author name:
query Catalog {
allBooks {
title
author { name }
}
}
select_related("author") loads the foreign-key relationship in the same SQL query for this resolver. It addresses this specific N+1 pattern; it does not optimize every nested relationship elsewhere in the schema. Use prefetch_related or a suitable batching loader when the relationship and execution model require it.
Filter a bounded query
The books field in the same Query class combines optional filters:
query AffordableBooks {
books(titleContains: "Django", maxPrice: "60.00") {
id
title
price
author { name }
}
}
Money uses a decimal model field and Graphene's Decimal scalar, with a string literal at the GraphQL boundary. This avoids converting the price through a binary floating-point argument. See Graphene scalar types.
The 100-row cap is a demonstration boundary, not a complete pagination design. Add stable, bounded pagination for larger catalogs. django-filter can help define filters, but generated filters still need review for access and query cost.
In a private application, apply the same visibility policy to lists, individual lookups, mutations, and any Relay node access. Hiding a field is different from restricting which objects a caller can fetch. See Graphene-Django authorization.
Add a staff-only mutation
Append the following classes to blog/schema.py, and replace the final schema assignment with the one shown here:
from django.core.exceptions import ValidationError
from graphql import GraphQLError
class CreateBook(graphene.Mutation):
class Arguments:
title = graphene.String(required=True)
price = graphene.Decimal(required=True)
author_id = graphene.Int(required=True)
book = graphene.Field(BookType)
def mutate(root, info, title, price, author_id):
user = info.context.user
if not user.is_authenticated or not user.is_staff:
raise GraphQLError("You do not have permission to create books")
if not price.is_finite():
raise GraphQLError("Invalid book fields")
book = Book(title=title, price=price, author_id=author_id)
try:
book.full_clean()
except ValidationError:
raise GraphQLError("Invalid book fields")
book.save()
return CreateBook(book=book)
class Mutation(graphene.ObjectType):
create_book = CreateBook.Field()
schema = graphene.Schema(query=Query, mutation=Mutation)
An authenticated staff client can submit:
mutation CreateCatalogBook {
createBook(title: "Learning Django", price: "59.90", authorId: 1) {
book { id title price }
}
}
Use an author ID that exists. full_clean() checks model validation, including the price constraints and foreign-key value, before saving. It is not called automatically by save(), nor does it replace database constraints or eliminate concurrent-write races. Add the transaction and error policy required by the real data model.
The staff flag is this example's creation rule. Production systems may need a specific permission and restrictions on which author or tenant the caller may modify. Do not expose raw validation or database details to clients. Also inspect GraphQL's errors array: an HTTP response can contain execution errors and partial data, so transport status alone is insufficient for application-error monitoring.
Control query cost and observe the result
Measure SQL count, result count, and latency for a named operation before and after resolver changes. A single HTTP request can still issue many queries or return a large nested result. Apply depth and complexity controls appropriate to your GraphQL implementation, plus pagination, request-size, timeout, and rate limits. A list cap on one field does not bound every possible query.
Authentication identifies the caller; resolvers and domain services must enforce access to the requested records. For instrumentation, record the operation name and useful timing/error metadata rather than indiscriminately logging variables that may contain personal data.
When using Guance, collect application errors through DataKit logging and verify that GraphQL execution failures remain visible even when the HTTP layer reports success. Use tracing instrumentation compatible with the Django deployment to connect resolver work to database calls. The presence of a /graphql request span alone does not establish resolver-level coverage.
Common questions
Will GraphQL replace REST?
It need not. REST can be simpler for predictable resource operations; GraphQL is useful when clients need different shapes of related data. Choose according to access patterns and operating requirements.
Should a new Django project use Graphene or Strawberry?
Graphene provides the model-oriented integration used here; Strawberry builds schemas around Python type annotations. Compare current Django integration, permissions, async behavior, pagination, and ecosystem compatibility using a representative operation rather than choosing on syntax alone.
How should file uploads work?
File upload is not part of the core GraphQL transport contract. A multipart-upload extension requires compatible client/server libraries and deliberate CSRF and upload limits. A separate authenticated upload endpoint or direct object-storage upload flow can be simpler; pass the resulting authorized file reference into a mutation.
For related examples, the Flask WebSocket guide and FastAPI GraphQL guide are currently in Chinese.
Contact us
Join the community
to join the community
Try Guance
Start online and pay only for what you use.
Get startedChoose a Guance plan