Deploy a Django GraphQL API to Production
Prepare a Django GraphQL API (Strawberry or Graphene) for production: disable the IDE and introspection, limit depth and aliases, mask errors, batch queries, and log operations.
Problem statement
A GraphQL endpoint is one URL that accepts arbitrary queries. That flexibility is the point for API clients, and a risk in production: a single deeply nested or heavily aliased query can trigger thousands of database queries, the schema is fully discoverable by anyone, error messages can leak internals, and all traffic looks like POST /graphql/ in access logs.
Deploying the Django side (Gunicorn, a proxy, PostgreSQL, HTTPS) is the same as for any API. This guide covers what is different for GraphQL: locking down the development conveniences, limiting query cost, fixing N+1 queries with batching, and making the endpoint observable. Examples use Strawberry, with Graphene equivalents noted.
- Turn off the in-browser IDE (GraphiQL) in production.
- Disable schema introspection for public APIs, or restrict it to authenticated internal clients.
- Add validation limits: maximum query depth, maximum aliases, and a request body size limit at the proxy.
- Mask unexpected errors so clients see a generic message while details go to logs and error tracking.
- Use DataLoaders or query optimisation (
select_related/prefetch_related) to avoid N+1 queries. - Log the operation name and duration for every request, and rate limit the endpoint.
- Handle CSRF correctly: session-authenticated browser clients need tokens; token-authenticated clients can use a CSRF-exempt view.
Step-by-step solution
1. Configure the view for production
# config/urls.py
from django.conf import settings
from django.urls import path
from django.views.decorators.csrf import csrf_exempt
from strawberry.django.views import GraphQLView
from api.schema import schema
graphql_view = GraphQLView.as_view(
schema=schema,
graphql_ide="graphiql" if settings.DEBUG else None,
)
urlpatterns = [
path("graphql/", csrf_exempt(graphql_view)),
]
Only wrap the view in csrf_exempt if clients authenticate with tokens in headers (for example Authorization: Bearer). If the API relies on Django session cookies from a browser, keep CSRF protection and send the X-CSRFToken header from the frontend. Older Strawberry versions use graphiql=False instead of graphql_ide=None; Graphene-Django uses GraphQLView.as_view(graphiql=False).
2. Add validation limits and disable introspection
# api/schema.py
import strawberry
from graphql.validation import NoSchemaIntrospectionCustomRule
from strawberry.extensions import AddValidationRules, MaskErrors, MaxAliasesLimiter, QueryDepthLimiter
from django.conf import settings
extensions = [
QueryDepthLimiter(max_depth=8),
MaxAliasesLimiter(max_alias_count=15),
MaskErrors(),
]
if not settings.DEBUG:
extensions.append(AddValidationRules([NoSchemaIntrospectionCustomRule]))
schema = strawberry.Schema(query=Query, mutation=Mutation, extensions=extensions)
- Depth limit stops queries like
author { posts { author { posts { ... } } } }that multiply work at each level. - Alias limit stops a single request from asking for the same expensive field hundreds of times under different names.
- Introspection off means clients cannot download the full schema. Public APIs with published documentation may keep it on; internal APIs used by your own frontend usually should not expose it.
- MaskErrors replaces unexpected exception messages with a generic error while the original is still logged.
For Graphene, use graphene.validation.depth_limit_validator and DisableIntrospection through the view's validation_rules.
Choose limits from the real queries your clients send: log operations for a week, find the deepest legitimate query, and set the limit slightly above it.
3. Limit request size and rate at the proxy
limit_req_zone $binary_remote_addr zone=graphql:10m rate=10r/s;
location /graphql/ {
client_max_body_size 256k;
limit_req zone=graphql burst=20 nodelay;
proxy_pass http://unix:/run/gunicorn/gunicorn.sock;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
GraphQL queries are small. A 256 KB body limit blocks absurd payloads, including huge batched arrays, before Django parses them. File uploads through GraphQL multipart requests need a separate, larger limit on a dedicated path.
4. Fix N+1 queries
A list of 50 posts each resolving author naively runs 51 queries. Options:
- strawberry-django types can optimise querysets automatically with its query optimiser extension, adding
select_relatedandprefetch_relatedbased on the fields requested. - DataLoaders batch lookups for the same request:
from strawberry.dataloader import DataLoader
from asgiref.sync import sync_to_async
from blog.models import Author
async def load_authors(keys):
authors = await sync_to_async(lambda: {a.id: a for a in Author.objects.filter(id__in=keys)})()
return [authors.get(k) for k in keys]
Create the loader per request (in the context getter), never globally, so cached results do not leak between users.
Measure with the query count per operation in development (Django Debug Toolbar or assertNumQueries in tests) for your most common operations.
5. Log operations and timings
Access logs show only POST /graphql/. Log the operation name and duration from inside the app:
import logging
import time
from strawberry.extensions import SchemaExtension
logger = logging.getLogger("api.graphql")
class OperationLogger(SchemaExtension):
def on_operation(self):
start = time.monotonic()
yield
ctx = self.execution_context
logger.info(
"graphql operation=%s type=%s duration_ms=%.1f errors=%d",
ctx.operation_name or "anonymous",
ctx.operation_type.value if ctx.operation_type else "unknown",
(time.monotonic() - start) * 1000,
len(ctx.result.errors or []) if ctx.result else 0,
)
Add OperationLogger to the schema extensions. Require clients to send named operations so logs and APM traces group correctly.
6. Deploy
Deployment is otherwise a standard Django API deployment: Gunicorn behind Nginx, DEBUG = False, secure cookies, and check --deploy. For async resolvers, serve the app over ASGI (Uvicorn workers or another ASGI server); synchronous resolvers work fine under WSGI.
Verification check:
# IDE must not be served
curl -s https://api.example.com/graphql/ -H "Accept: text/html" | head -c 200
# Introspection must be rejected
curl -s https://api.example.com/graphql/ -H "Content-Type: application/json" \
-d '{"query":"{ __schema { types { name } } }"}'
# A too-deep query must be rejected
curl -s https://api.example.com/graphql/ -H "Content-Type: application/json" \
-d '{"query":"{ posts { author { posts { author { posts { author { posts { author { posts { title } } } } } } } } } }"}'
Expect validation errors, not data.
Explanation
GraphQL moves query design from the server to the client. The server's job in production is to define an envelope of acceptable queries: validation rules reject shapes that are too deep or too wide before execution, proxy limits cap raw size and rate, batching keeps the database cost of valid queries proportional to the data returned, and logging by operation name restores the visibility that URL-based APIs provide for free.
Edge cases / notes
Persisted queries
If only your own clients use the API, persisted queries (clients send a hash, the server looks up a pre-approved query) remove arbitrary queries entirely. That is the strongest cost control for first-party APIs.
Caching
GraphQL POST responses are not cached by HTTP caches. Cache at the resolver or data layer (Redis) instead, keyed by the inputs and user permissions.
Timeouts
Set a database statement_timeout and a proxy read timeout so a query that slips through validation cannot hold a worker indefinitely.
When to automate this
Add tests that assert the production schema rejects introspection and over-deep queries, and CI checks that detect breaking schema changes by comparing the exported schema with the previous release. Those checks catch regressions that manual review misses.
Next steps
- Compare with REST deployments in Deploy a Django REST Framework API to Production.
- Review general API requirements with the Django REST API Production Checklist.
- Hunt down query explosions with Find and Fix N+1 Queries in Django.
- Add proxy-level limits with Rate Limit Django Endpoints with Nginx.
FAQ
Should I disable GraphQL introspection in production?
For internal or first-party APIs, yes. For public APIs, you may keep it enabled for client tooling, but still enforce depth, alias, and rate limits.
Does a Django GraphQL endpoint need CSRF protection?
If browsers call it with session cookies, yes. If clients authenticate with tokens in headers, a CSRF-exempt view is acceptable.
How do I stop N+1 queries in a Django GraphQL API?
Use a query optimiser that applies select_related and prefetch_related based on requested fields, or DataLoaders that batch lookups per request.
Why are all my GraphQL requests the same line in the access log?
Every request is POST /graphql/. Log operation names and durations from a schema extension or middleware.