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.

GraphQL request guardsProxysize, rate limitsValidationdepth, aliasesResolversbatched queriesResponsemasked errorsReject expensive queries during validation, before any resolver touches the database.
  1. Turn off the in-browser IDE (GraphiQL) in production.
  2. Disable schema introspection for public APIs, or restrict it to authenticated internal clients.
  3. Add validation limits: maximum query depth, maximum aliases, and a request body size limit at the proxy.
  4. Mask unexpected errors so clients see a generic message while details go to logs and error tracking.
  5. Use DataLoaders or query optimisation (select_related/prefetch_related) to avoid N+1 queries.
  6. Log the operation name and duration for every request, and rate limit the endpoint.
  7. 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)
cap what one request can ask forMaximum query depthstops deeply nested cyclesAlias and token limitsstops one field repeated 1,000 timesIntrospection off in productionunless the schema is publicRequest body size at the proxyclient_max_body_sizeRate limit per clientlimit_req in NginxIn GraphQL the client decides the cost; these limits give it a ceilingdjango-deployment.com
Validation rules run before any resolver, so rejected queries cost almost nothing.
  • 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_related and prefetch_related based 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]
one list, very different query countsNaive resolvers101 queriesDataLoader batching2 queriesselect_related1 query0255075100queriesNested fields multiply: add comments and the naive count explodes againdjango-deployment.com
Log the query count per operation so a regression shows up before users notice.

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.

who decides how expensive a request isREST endpointserver fixes the shape of each response✓cost is known per URL✓cache by URL at the edgeGraphQL endpointclient chooses fields and nesting✕one URL, any cost✕POST bodies bypass URL cachesPersisted queries turn GraphQL back into a fixed menudjango-deployment.com
That is why depth limits, query logging, and timeouts matter more here than in REST.

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

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.

Keep going