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:
| 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:
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:
- Response isolation remains per identity. A caller never receives another caller's body, regardless of invalidation scope.
- 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, not1. - Version
0is never used as a live cache key, eliminating an ambiguous state where a concurrent request could cache a response atv0and 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:
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).
Browser cookie round trip¶
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}).