Skip to content

Response Caching

GraphQLView can cache successful HTTP responses to avoid re-executing identical queries. Caching is off by default; enable it with a single setting:

# settings.py
DJANGO_GRAPHEX = {
    "CACHE_ACTIVE": True,
    "CACHE_TIMEOUT": 300,  # seconds, default 5 min
}

The view uses Django's "default" cache backend. Any backend Django supports (local-memory, Redis, Memcached, database, …) works out of the box.

With permission-scoped schemas, both the schema-variant cache and this HTTP response cache include the caller's permission signature. Normally only schema-labeled grants contribute. A schema containing the native flat GFK fallback uses all grants for both keys because an unregistered target's read permission cannot be known from static schema labels. A target grant or revoke takes effect on the next request; a target row-scope hook still runs on each fresh execution, so ordinary response-cache lifetime and invalidation rules remain relevant. See flat GFK authorization.

The document-validation cache is separate from this HTTP response cache. Its per-schema entries also distinguish validation rules, error and query limits, and suggestion visibility. GraphQL-core 3.3 can omit schema suggestions during validation; a cached public error cannot be reused for a validation call that requests hidden suggestions. GraphQL-core 3.2 does not provide that native option, so the migration adapter preserves its existing validation behavior. This cache isolation alone does not configure HTTP or subscription error visibility; those call sites must request the native option separately.


Cache key anatomy

Each cached entry is stored under a key of the form:

_graphql_v2_{scope}_{version_identity}_{version}_{identity}_{body_hash}
Component Source Purpose
_graphql_v2_ fixed, versioned prefix Namespaces GraphQL entries and prevents reuse of keys written by the 3.0 policy
{scope} CACHE_INVALIDATION_SCOPE Records whether invalidation is global or identity
{version_identity} selected counter namespace global by default; the identity/bucket namespace in legacy mode
{version} current counter value Makes older responses unreachable after invalidation
{identity} cache_key_prefix(request) Isolates responses by user identity (see below)
{body_hash} fetch_cache_key(request) — SHA-256 of the tagged, length-framed raw request body and query, variables, operationName GET parameters, plus the pretty flag when set Distinguishes request components without ambiguous concatenation

The raw body contributes to the key even for an unusual GET request carrying one: identical GET parameters with different body bytes produce different keys. This describes cache-key construction, not a recommendation to send GET bodies. Use ordinary GET query parameters or POST JSON for GraphQL requests.

The default body digest changes with this framing, so previously cached GraphQL responses have a cold miss and expire under the configured cache policy. The 64-character hexadecimal digest and the override hook are unchanged. Missing and empty optional GET parameters still have the same value.


Per-user isolation

Responses are partitioned by request identity so that one user's cached result is never served to another user.

Request type Identity token Sharing
Authenticated (request.user.is_authenticated) u{user.pk} Per-user (isolated)
Token-auth only (Authorization header, no request.user) t{sha256(header)[:16]} Per-token (isolated)
Anonymous (no credentials or cookies) anon Shared

Cookies are context even when the request is anonymous. A session-backed cart, tenant, locale or feature flag can change a resolver result without changing the GraphQL document, so the default should_cache_query(request) hook returns False whenever request.COOKIES is non-empty. Those queries execute normally and are neither read from nor written to the response cache.

This partitioning applies to the response entry, which always carries the full identity. Invalidation is grouped more coarsely for unauthenticated identities — see Bucketing for unauthenticated identities.


Mutation invalidation

When a mutation is detected, the view increments a version counter stored in the cache rather than calling cache.clear().

By default, CACHE_INVALIDATION_SCOPE="global": every GraphQL response identity reads the same counter, so one mutation invalidates user, anonymous and token reads. The response key still includes full identity; global invalidation never shares bodies.

Set CACHE_INVALIDATION_SCOPE="identity" to preserve the narrower 3.0 policy:

DJANGO_GRAPHEX = {
    "CACHE_ACTIVE": True,
    "CACHE_INVALIDATION_SCOPE": "identity",
}

Identity scope is only safe when mutations affect identity-private data; shared models can otherwise remain stale for other callers until their TTL expires.

Both policies have two important properties:

  1. Response isolation remains per identity. A caller never receives another caller's body, regardless of invalidation scope.
  2. Unrelated cache entries survive. Keys set by other parts of your application (sessions, page fragments, etc.) are not touched.

Bucketing for unauthenticated identities in identity scope

This subsection applies only when CACHE_INVALIDATION_SCOPE="identity". The version counter is stored permanently (see Version-counter key TTL), and the t{hash} identity is derived from a caller-supplied Authorization header that nothing has verified at that point. A client rotating that header per request would otherwise mint one permanent cache key per request. So every identity an unauthenticated caller can vary is mapped onto one of a fixed number of invalidation buckets, and identities that land in the same bucket share a version counter.

That is a real behaviour change, and it is worth being exact about what it costs:

Property Status Consequence
Response isolation Kept The response entry is keyed by the full identity, never by the bucket. Two callers sharing a bucket never share a response slot, so one caller's body is never served to another.
Invalidation locality Spent A mutation from one bucket member advances the counter its bucket-mates read, so their cached entries become unreachable and their next read re-executes.

The counter only ever moves forward, so the spent property can only turn a cache hit into a miss — the reader then answers from current data. No entry is resurrected and nothing stale is served. Authenticated identities (bounded by your user table) and the single anon partition are not bucketed and keep their exact namespace.

The spent property is reachable by an attacker

Be blunt about this one. The bucket is a pure function of a header the caller chooses, so an unauthenticated client can hash candidate credentials offline until one lands in whichever bucket it wants, run a mutation, and send that bucket's members back to the database. Salting the digest would not help: the namespace is small by construction, so a caller that cannot aim can still cover every bucket by volume.

What that buys an attacker is cache misses and nothing else. The counter only moves forward, and the response entry is keyed by the full identity, so no body ever crosses callers. Treat it as a throughput consideration on a public token-only endpoint: put a request limit in front of your GraphQL view, or authenticate the client.

If a token-only client is latency-sensitive in identity scope, authenticate it: an authenticated identity gets its own counter and is not bucketed at all.

Post-commit invalidation (TOCTOU safety)

The bump is scheduled after the mutation has been executed, and deferred via transaction.on_commit so it only fires after the mutation's database write is durable. A concurrent query that arrives between the start of the mutation and its commit therefore sees the pre-mutation version key and correctly hits (or misses) pre-mutation cache entries — it never caches pre-mutation data at the new, post-mutation version.

The cache layer does not invalidate merely because a document parses as a mutation. A mutation rejected by the transport (for example, a mutation sent by GET with HTTP 405) or by GraphQL validation never reaches execution and does not advance the counter.

If an ATOMIC_MUTATIONS transaction is explicitly rolled back, the execution marker is not published and the counter is not advanced. In non-atomic mode, reaching execution is sufficient to invalidate even when a later resolver returns an error: an earlier resolver may already have committed a write. transaction.on_commit still defers the bump when the application wraps the whole request in a transaction, so a request-level rollback discards it.

When ATOMIC_MUTATIONS is off (no open transaction), Django executes on_commit immediately after the current statement, so behaviour is unchanged for non-transactional deployments.

Ordering matters

ATOMIC_MUTATIONS opens its atomic block inside the execution of the mutation, so scheduling the bump before running the mutation would find no open transaction and fire it immediately — advancing the counter while the mutation body was still running. The bump is therefore always scheduled after the mutation has run.

Version-counter key TTL

The version counter key is stored with timeout=None (never expires independently). If CACHE_TIMEOUT is set higher than your cache backend's own default TTL (e.g. 600 s vs. Redis's 300 s default), the counter would otherwise expire first, causing the version to reset and old response entries to be reused as if they were fresh. The permanent timeout prevents this.

Cold-start initialisation

On a cold cache (first request or after the version key expires on a backend that ignores timeout=None), the counter is initialised to 1 (not 0). This ensures:

  • The first mutation bump reaches 2, not 1.
  • Version 0 is never used as a live cache key, eliminating an ambiguous state where a concurrent request could cache a response at v0 and that entry would remain permanently unreachable after the first bump.

Backends that support atomic incr (Redis, Memcached) use it; backends that do not (Django's local-memory cache when the key is absent) fall back to setting the heal value to 1 with timeout=None.


Malformed query handling

If the request body contains syntactically invalid GraphQL, get_operation_ast catches the GraphQLSyntaxError and returns None. The request falls through to the normal execution path, which returns HTTP 400 with a structured error body. The cache is not consulted or written for invalid documents.


Customising the cache key

Body hash (fetch_cache_key)

Override fetch_cache_key on a subclass to derive the body hash differently (e.g. normalise whitespace, extract the operation name, or mix in query variables):

from django_graphex.views import GraphQLView

class MyView(GraphQLView):
    @staticmethod
    def fetch_cache_key(request):
        import hashlib, json
        if request.method == "GET":
            return GraphQLView.fetch_cache_key(request)
        data = json.loads(request.body or b"{}")
        canonical = json.dumps(
            {"query": data.get("query", ""), "variables": data.get("variables")},
            sort_keys=True,
        ).encode()
        return hashlib.sha256(canonical).hexdigest()

An override that also customises GET requests must account for any raw body and distinguish the query, variables, and operation-name boundaries itself; joining their text without framing can make different requests share a cached response.

Identity prefix (cache_key_prefix)

Override cache_key_prefix to use a different identity source (e.g. a tenant ID, a session key, or a custom header):

class MyView(GraphQLView):
    @staticmethod
    def cache_key_prefix(request):
        tenant = getattr(request, "tenant_id", "default")
        user_pk = getattr(getattr(request, "user", None), "pk", "anon")
        return f"{tenant}_{user_pk}"

The two overrides are composed independently in dispatch; overriding either one does not break the other.

Eligibility (should_cache_query)

Override should_cache_query(request) to add stricter application-specific rules. The default rejects every cookie-bearing query:

class PublicOnlyView(GraphQLView):
    def should_cache_query(self, request):
        return super().should_cache_query(request) and request.tenant.is_public

Returning True for a request that the default rejects is an advanced opt-in. If a response depends on a cookie, session, locale or tenant, include every such input in cache_key_prefix or fetch_cache_key; otherwise two contexts can share a response body.


Successful responses only

The HTTP response cache stores and reuses only HTTP 200 GraphQL data responses without an errors field. An execution error can still produce HTTP 200 when another field succeeds; that partial result is returned to the current caller but is not cached. Validation failures and other non-200 responses are not cached either. Repeating a failed request executes it again, so a transient resolver failure can recover:

{"data":{"ready":"ok","fragile":null},"errors":[{"message":"temporarily unavailable"}]}

Once the same query returns {"data":{"ready":"ok","fragile":"recovered"}} with HTTP 200, later identical eligible requests can hit the cache. Responses already stored under the existing key by older versions are checked on read: error bodies, failure statuses, and malformed GraphQL envelopes are ignored. A later success replaces only that response entry; the cache is not cleared, and unrelated application keys are untouched. Existing successful entries, identity partitioning, key hooks, cookie bypass, TTL and mutation invalidation remain unchanged. The separate document-validation cache may still reuse its validation verdicts; this policy concerns only HTTP response bodies.

Applications that relied on repeated errors being replayed from the cache should expect fresh execution instead. The HTTP error status and payload for each actual execution are unchanged.


Requests that bypass the cache

Not all requests are eligible for caching even when CACHE_ACTIVE=True. The following are always passed through to the underlying view uncached:

Reason Detail
Batch requests A batch body is a JSON list; individual responses are not cached. If any operation executes a potentially durable mutation, the namespace is still invalidated once after the batch.
Multipart/form-data parse_body reads request.POST for this content type, consuming the WSGI stream. A subsequent read of request.body (needed to compute the cache key) raises RawPostDataException. Responses bypass the cache, but an executed upload mutation still invalidates the namespace.
Mutations Mutations advance the version counter instead of being cached.
Queries with cookies The default key does not encode session, cart, tenant, locale or feature-flag cookies, so should_cache_query rejects the request. Executed mutations with cookies still invalidate normally.
GraphiQL renders A request the view would answer with the GraphiQL page (graphiql=True and the client prefers text/html) is never cached. The cache key is built from the request body / query-string parameters, not from content negotiation, so a cached HTML page and the JSON answer for the same query would otherwise share one slot and whoever warmed it would decide what every later client received. The GraphiQL page is a static render, so caching it buys nothing.

CSRF cookies and cached responses

BaseGraphQLView.dispatch is decorated with @ensure_csrf_cookie. An eligible cookie-free query on a server response-cache miss reaches this dispatch and can receive a new Set-Cookie: csrftoken header. A cache hit returns earlier and does not issue a new CSRF cookie.

To prevent one client's CSRF secret from being replayed to other clients in the same identity namespace, the cache stores only the response body, HTTP status code, and content type — not the whole HttpResponse object. On a cache hit a fresh HttpResponse is constructed from those stored values; ensure_csrf_cookie is not re-applied (the GraphQL endpoint is csrf_exempt so this has no security impact on the endpoint itself).

On a browser's first request without cookies, an eligible public query that is a cache miss executes the decorated base view. In that path, ensure_csrf_cookie issues csrftoken in the response. If the browser returns that cookie on the second request, the default should_cache_query bypasses the response cache. This is the approved any cookie privacy boundary, not a CSRF failure; keep CSRF protection enabled.

A cache hit instead returns a fresh response before the decorated base dispatch; it does not add a new CSRF Set-Cookie header. Without another source of cookies, that cookie-free client can remain eligible on its next request. If an application opts into caching cookie-bearing queries with a custom hook, its key must fully partition every cookie-dependent identity and response input; the default deliberately does not.

Server response-cache eligibility is distinct from browser HTTP caching. Set-Cookie alone does not prohibit HTTP caching (RFC 9111, Section 7.3). When a response has Vary: Cookie, a conforming HTTP cache compares the request's Cookie field with the original request before reuse (Section 4.1). This guide does not claim that a particular browser reuses these responses; assess the deployment's HTTP cache headers separately.


CACHE_ACTIVE=False (default)

When CACHE_ACTIVE is False, the dispatch method bypasses all caching logic immediately and every request is executed fresh. The setting can be toggled per-test with @override_settings(DJANGO_GRAPHEX={"CACHE_ACTIVE": True}).