GraphQL Subscriptions¶
django-graphex ships an optional GraphQL subscriptions engine built
on Django Channels 4. It is the modern
successor of the standalone graphene-django-subscriptions package, merged here
as an opt-in extra so that the base install never depends on channels.
Optional extra
Subscriptions are not installed by default. The base package never
imports channels. You opt in explicitly:
Importing django_graphex.subscriptions without the extra raises a
friendly error telling you to install it.
Subscriptions are native-only in v2.0
The v2.0 subscription engine runs exclusively on the native graphql-core
backend — there is nothing to configure (graphene is gone, so the old
GDX_BACKEND toggle no longer exists). The legacy graphene transport (the HTTP
channelId handshake, the demultiplexer consumer, and
SubscriptionGraphQLView) was removed in v2.0 and replaced by two
standards-based transports — see
Serve subscriptions.
How it works¶
v2.0 ships one serialize-once engine behind two standards-based transports — you pick the transport at routing time, the engine is the same:
- SSE (Server-Sent Events) —
a single async HTTP
text/event-streamresponse. Simplest to deploy; great for a one-way notification feed. - WebSocket (
graphql-transport-ws) — the modern bidirectional protocol used bygraphql-wsclients; multiplexes many subscriptions over one socket.
The data path:
- A client sends a GraphQL
subscription { ... }operation over the chosen transport (an SSE HTTP request or a WSsubscribemessage). Authentication is the transport's own request/scope — there is no separatechannelIdhandshake. - The engine runs the subscription's authorize/scope hooks before joining any broadcast group, then streams events.
- When a model instance changes, an in-house signal binding serializes it once and broadcasts the payload to the group. Every subscriber receives the notification, projected to the fields it requested.
Broadcast groups are named after the model, the subscription's Meta.stream and
the action, so two subscriptions on the same model under different streams
never receive each other's events — including when they disagree on
payload_mode. Group names are an internal detail; nothing in your code should
depend on their spelling.
A cross-process channel layer (Redis) is required when the producer (the process running model writes) and the subscriber processes are separate; the in-memory layer is fine for development.
Try it interactively
Add the browser client view to your URLConf to subscribe and watch notifications stream in — straight from the browser, served from your own origin.
1. Define a subscription¶
A Subscription is declared like a DjangoModelType, through Meta:
from django_graphex.subscriptions import Subscription
from myapp.models import User
class UserSubscription(Subscription):
class Meta:
model = User # required, a Django model class
stream = "users" # required, a str
queryset = None # optional
description = "User Subscription" # optional
payload_mode = None # optional, see "Notification payload"
The notification payload is serialized through the native (Pydantic) backend.
This generates a real GraphQL subscription field whose output type is the
model's projected fields (the serialized instance) — a true streaming
subscription, not the legacy one-shot confirmation object. It exposes:
- Arguments:
action(required, see the enum below),id(optional — scope to one instance by pk), andfilter(optional — see Filtering notifications). The legacychannelIdandoperationarguments are gone (the transport handles connection lifecycle). - Enum:
ActionSubscriptionEnum {CREATE, UPDATE, DELETE, ALL_ACTIONS}.
2. Mount it on the schema¶
from django_graphex.core import ObjectType
from django_graphex.schema import DjangoGraphQLSchema
from myapp.subscriptions import UserSubscription
class Subscription(ObjectType):
user_subscription = UserSubscription.Field()
schema = DjangoGraphQLSchema(query=Query, subscription=Subscription)
3. Serve subscriptions over SSE or WebSocket¶
Both transports are factories that take the live native schema and return a Django view (SSE) or a Channels consumer class (WebSocket). Mount either, or both.
SSE (HTTP text/event-stream)¶
subscription_sse_view(schema=...) returns an async Django view (requires
Django >= 5.2). It parses the subscription operation, runs the engine's
authorize/scope hooks before joining any group, and streams next / complete
frames.
# urls.py
from django.urls import path
from django_graphex.subscriptions.transports.sse import subscription_sse_view
from myapp.schema import schema
urlpatterns = [
path("graphql/stream", subscription_sse_view(schema=schema.graphql_schema)),
]
WebSocket (graphql-transport-ws)¶
subscription_ws_consumer(schema=...) returns a Channels
AsyncJsonWebsocketConsumer subclass speaking the graphql-transport-ws
protocol (connection_init/ack, multiplexed subscribe, ping/pong,
per-id complete). Route it via your ASGI app:
# project/asgi.py
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
django_asgi_app = get_asgi_application()
from channels.routing import ProtocolTypeRouter, URLRouter
from django.urls import path
from django_graphex.subscriptions.transports.ws import subscription_ws_consumer
from myapp.schema import schema
application = ProtocolTypeRouter(
{
"http": django_asgi_app,
"websocket": URLRouter(
[path("ws/graphql/", subscription_ws_consumer(schema=schema.graphql_schema).as_asgi())]
),
}
)
# settings.py
ASGI_APPLICATION = "project.asgi.application"
# Development: in-memory layer. Production: use a Redis layer.
CHANNEL_LAYERS = {
"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"},
}
# Production example:
# CHANNEL_LAYERS = {
# "default": {
# "BACKEND": "channels_redis.core.RedisChannelLayer",
# "CONFIG": {"hosts": [("127.0.0.1", 6379)]},
# },
# }
Per-connection schema (permission-scoped subscriptions)¶
Both transports accept an optional schema_provider in addition to the plain
schema=. A schema_provider is a callable provider(user) -> GraphQLSchema
resolved per connection with the connection's user (request.user for SSE,
scope["user"] for WebSocket). When given, it wins over schema=.
This lets a subscription connection use the same pruned schema as HTTP: wire
the provider to the bundled pruned_schema_for helper so a subscription
action the user is not permitted to observe is absent from the connection's
schema and a subscribe naming it fails at validation (Cannot query
field), exactly like the HTTP endpoint.
The bundled pruned_schema_for helper is gated by
PERMISSION_SCOPED_SCHEMA — the same one flag that gates the
HTTP AuthenticatedGraphQLView — read per connection (never at import):
PERMISSION_SCOPED_SCHEMA = False(the default): the helper returns the full schema, so a provider wired to it is inert and every field validates.PERMISSION_SCOPED_SCHEMA = True: the helper returns the schema pruned to the connection user's permissions. An active superuser always receives the full schema.
from django_graphex.core.permission_signature_cache import pruned_schema_for
from django_graphex.subscriptions.transports.sse import subscription_sse_view
from django_graphex.subscriptions.transports.ws import subscription_ws_consumer
from myapp.schema import schema
_full = schema.graphql_schema
# SSE: the provider is resolved with request.user.
sse_view = subscription_sse_view(
schema_provider=lambda user: pruned_schema_for(user, _full),
)
# WebSocket: the provider is resolved once per socket with scope["user"].
ws_consumer = subscription_ws_consumer(
schema_provider=lambda user: pruned_schema_for(user, _full),
)
Prerequisites for pruning
Pruning via pruned_schema_for requires a labeled DjangoGraphQLSchema
(the schema carries a gdx_label_set and per-field gdx_required_perms, the
same labeling the HTTP endpoint uses). It also needs a real user on the
connection: SSE reads request.user, WebSocket reads scope["user"] — so the
WS routing MUST be wrapped in Channels' AuthMiddlewareStack (or an equivalent
that populates scope["user"]), otherwise the provider sees AnonymousUser.
Per-socket resolution and staleness
For WebSocket, the provider is resolved once per socket (on the first
subscribe) and cached for the life of the connection, so every multiplexed
operation on that socket shares one schema. A permission change (or a
PERMISSION_SCOPED_SCHEMA toggle) therefore only takes effect on the next
connection, not mid-socket. For SSE (one request = one stream) the provider
is resolved per request, so each new stream picks up the current state.
Passing a plain schema= (no schema_provider) is fully backward
compatible — the transport behaves exactly as before, so existing wiring such
as subscription_ws_consumer(schema=schema.graphql_schema) keeps working
unchanged. You must pass at least one of schema= or schema_provider=.
A custom schema_provider callable that does not route through
pruned_schema_for is your own code and is not gated by the setting — the
flag gates the bundled helper only, so whatever schema your callable returns is
used as-is.
From a DjangoModelType (one definition)¶
If you already use a DjangoModelType for
queries and mutations, you can get its subscription from the same class — no
separate Subscription subclass. Add stream (and optionally payload_mode)
to its Meta:
from django_graphex.types import DjangoModelType
from myapp.models import User
class UserModelType(DjangoModelType):
class Meta:
model = User
stream = "users" # enables the subscription
payload_mode = "full" # optional; defaults to the global setting
Mount it on the schema — then serve it through either transport (see Serve subscriptions):
# schema.py
from django_graphex.core import ObjectType
from django_graphex.schema import DjangoGraphQLSchema
class Subscription(ObjectType):
user_subscription = UserModelType.SubscriptionField()
schema = DjangoGraphQLSchema(query=Query, subscription=Subscription)
UserModelType.subscription_type() builds (and caches) the Subscription
lazily, so the base install stays Channels-free until you actually wire a
subscription. The generated subscription supports the same arguments — including
filter. Setting Meta.stream is required to use
SubscriptionField() / subscription_type(). The transport (SSE or WS) is chosen
at routing time; the subscription class is transport-agnostic.
Authorization and row-scoping¶
The generated subscription honors the type's authorization and scoping:
permission_classes/authorizegate the subscribe itself. Authorize runs at registration (the HTTP request, soinfo.context.useris available) for the read-like"subscribe"action; a denial yieldsok: False/errorand no group is joined.IsAuthenticatedOrReadOnlytherefore lets anyone subscribe to a public stream, whileIsAuthenticatedrequires a login.- Per-action check (defense in depth). The requested subscription
action(CREATE/UPDATE/DELETE/ALL_ACTIONS) is forwarded to the permission check, so a class can gate each action independently. Because a subscription payload returns instance data,DjangoModelPermissionstreats a subscribe action as composite: it requires the codenames of itsperms_mapsubscriberow plus those of the row the action maps to — with the default mapping, the model'sviewpermission plus the action's write verb (ALL_ACTIONSrequires every write verb). Because both halves are resolved throughget_required_permissions, a subclass that customizesperms_mapforsubscribe(or forcreate/update/delete) is honored here too. A user permitted onlyCREATEis therefore denied asubscribeforUPDATEat runtime, even if the request reaches the full schema — this is the runtime half of the same model thatPERMISSION_SCOPED_SCHEMAenforces at the schema layer (the action's enum value is pruned away). Custompermission_classesread the action viahas_subscribe_permission(info, model, **kwargs)(the action arrives underkwargs["subscription_action"]). subscription_scope(info, **kwargs)returns a server-forced filter mapping (e.g.{"owner": info.context.user.pk}). Theinfoa subscribe hook receives is the transport's own context object, and it exposes bothinfo.userandinfo.context.user— the second is the spelling a resolver uses, so hook code reads the same either way. It is evaluated at subscribe time and enforced per event at delivery, merged over the clientfilterwith server precedence — the client can neither widen nor drop it. Equality scopes on a serialized field (likeowner) are decided in memory, so there is no per-event query.
from django_graphex.permissions import IsAuthenticated
from django_graphex.types import DjangoModelType
from myapp.models import Note
class NoteModelType(DjangoModelType):
permission_classes = [IsAuthenticated]
class Meta:
model = Note
stream = "notes"
payload_mode = "full"
@classmethod
def subscription_scope(cls, info, **kwargs):
return {"owner": info.context.user.pk} # only my notes
Make subscription_scope fail closed
None means no scoping, not "no notifications". A scope written as
return {"owner": user.pk} if user.is_authenticated else None therefore
serves an anonymous subscriber every row on the stream. Pair it with a
permission_classes / authorize gate that denies the anonymous
subscribe — remember "subscribe" is a READ action, so
IsAuthenticatedOrReadOnly does not deny it — and raise from the scope
itself when there is no user to scope by. examples/playground
(NoteModelType) does both.
Why not filter_queryset?
filter_queryset is an opaque queryset transform used by the query/list
resolvers; it cannot be applied to a single changed instance without a
per-event query (and needs the request at delivery time). subscription_scope
returns a plain mapping instead, so scoping is enforced in memory and the
WebSocket connection itself does not need to be authenticated.
Indexed groups (optional, for high fan-out)¶
With only subscription_scope, a change is broadcast to one group per
model+action and every subscriber's connection is woken to evaluate the filter
in memory (cheap, but O(subscribers)). For streams with very many concurrent
subscribers partitioned by a value (per owner, per tenant, per room), declare
subscription_index_fields to route each change to a value-scoped group so
only the matching subscribers are woken:
from django_graphex.types import DjangoModelType
from myapp.models import Message
class MessageModelType(DjangoModelType):
class Meta:
model = Message
stream = "messages"
payload_mode = "full"
subscription_index_fields = ("tenant", "room") # compound (AND) index
@classmethod
def subscription_scope(cls, info, **kwargs):
return {"tenant": info.context.tenant_id, "room": kwargs.get("room")}
How it works: at subscribe the group name gets a canonical suffix built from the
scope (e.g. messages-create:room=42&tenant=7); at broadcast the same suffix
is built from the changed instance (reading each field's attname, so a foreign
key yields its raw id without a query). The two names match by construction, so
Channels delivers only to that group -- no group enumeration is involved.
- Opt-in and additive. Omit it and everything works exactly as before.
- Must be a subset of the scope. Every index field has to be present in what
subscription_scopereturns; if any is missing the subscriber transparently falls back to the coarse group (still correct, just not narrowed). - Good index fields are high-cardinality equality keys (FKs like
owner,tenant,room). A low-cardinality boolean would only create two groups and buy nothing. - Independent of
payload_mode. The index is captured from the live instance at signal time, not derived from the payload, so it works in id-only mode too. - The full filter is still applied on delivery, so indexing is purely a routing optimization -- correctness never depends on it.
Browser client view¶
SubscriptionClientView serves a self-contained HTML page to try subscriptions
live. It speaks the same standards as the transports: graphql-transport-ws over
WebSocket and graphql-sse over a streamed fetch() POST — the browser
EventSource API is not used to start the subscription, since EventSource
cannot carry the operation (see the SSE wire protocol).
The response it reads back is a standard text/event-stream, though — see
the tip below for watching it live in DevTools. Add it to your URLConf like the
admin — because it is served from your own origin, there is no CORS to
configure:
# urls.py
from django_graphex.subscriptions import SubscriptionClientView
urlpatterns = [
...,
path("graphql/client/", SubscriptionClientView.as_view()),
]
Open /graphql/client/: it connects to the chosen transport, lets you send a
subscription { … } operation, and streams the notifications back. The editor
ships a runnable document, not a commented-out example:
yourSubscription is a placeholder, and the introspection the client already
runs for autocomplete rewrites it to the first field your subscription root
advertises — so on a schema with any generated subscription, pressing ▶ works
on first press. Every generated subscription field takes that same required
action argument and exposes id, which is why one placeholder fits every
schema. The rewrite only ever touches an editor still holding the shipped
default; once you type, the client leaves your document alone.
The editor also supports Tab to indent (Shift+Tab to outdent) and schema-aware autocomplete — it introspects the configured endpoint and suggests types, fields, arguments and enum values as you type (Ctrl+Space to trigger, Enter/Tab to accept).
With introspection off, the placeholder stays a placeholder
Both the autocomplete and the field rename come from the same
introspection query. With ALLOW_INTROSPECTION = False (the default) and
DisableIntrospectionMiddleware installed, autocomplete falls back to
GraphQL keywords only and the document still says yourSubscription,
so pressing ▶ answers Cannot query field 'yourSubscription' on type
'Subscription' until you type your own field name. An active superuser
bypasses the block (INTROSPECTION_ALLOW_SUPERUSER), so the same page can
behave differently for two logged-in users.
The endpoints default to the page's own origin with the
routes /ws/graphql/ (WS), /graphql/stream (SSE) and /graphql/ (HTTP);
override them if yours differ:
path(
"graphql/client/",
SubscriptionClientView.as_view(
ws_path="/ws/graphql/",
sse_path="/graphql/stream",
http_path="/graphql/",
),
),
sse_path is a separate route from http_path
The SSE transport is its own view (subscription_sse_view) returning
text/event-stream; http_path is the JSON GraphQLView. They are
different URLs, so the client has a sse_path of its own. Point it at
the JSON endpoint and the POST still returns 200 application/json — the
client shows a connected stream and no data at all, because the body carries
no event: line. A frame the client cannot recognise is now logged as an
error instead of being dropped, so that misconfiguration is visible.
5. Subscribe from a client¶
Send a normal GraphQL subscription operation over the SSE or WebSocket
transport. The selection set is the model's projected fields — each event
delivers the (optionally filtered) serialized instance:
action: ALL_ACTIONSsubscribes toCREATE,UPDATEandDELETE.- Omit
idto subscribe to every instance for that action. - Unsubscribing is the transport's own lifecycle — see
Unsubscribing. There is no
operation: UNSUBSCRIBEargument anymore. - Which fields are deliverable depends on the payload mode — in id-only mode only
idis present (see Notification payload). - Relations are delivered as flat pks (
post: ID,tags: [ID]) — selecting their subfields is a validation error (see Relations are flat IDs).
SSE: the wire protocol¶
The SSE transport implements the server side of
graphql-sse's distinct
connections mode: one HTTP request carries one subscription. The operation
travels in a POST body — a JSON object with query, variables and
operationName — and the response is a text/event-stream that stays open:
curl -N -X POST http://localhost:8000/graphql/stream \
-H "Content-Type: application/json" \
-d '{"query": "subscription { userSubscription(action: UPDATE) { id username } }"}'
Each delivered event is a next frame carrying a standard GraphQL result; the
terminal complete frame ends the stream (its empty data: line is part of the
protocol — an EventSource-style listener never fires complete without it):
event: next
data: {"data": {"userSubscription": {"id": "5", "username": "neo"}}}
event: complete
data:
Posting the document form-encoded needs X-Requested-With
The endpoint also accepts query as application/x-www-form-urlencoded or
multipart/form-data. Both are CORS-simple, and the endpoint is
csrf_exempt, so such a POST must carry the X-Requested-With header or it
is answered with HTTP 403 before the stream starts — see
Security → Cross-site POST protection.
The JSON POST above (and the bundled client) is unaffected.
The browser EventSource API cannot start a subscription
The limitation is in the EventSource JavaScript API, not the wire
format: EventSource only issues GET requests and cannot send a body,
so it has no way to deliver the GraphQL document — the view answers such a
query-less request with HTTP 400. Subscribe with a streamed fetch()
POST instead (exactly what the bundled
browser client view does), or use the
graphql-sse client library,
which speaks this protocol out of the box. The response itself is a
standard text/event-stream either way — see the tip below to watch it in
DevTools.
Watching the raw stream in DevTools
Because the response really is text/event-stream, you can watch the
next / complete frames arrive live without any client library: open
your browser's DevTools → Network tab, find the streaming request, and
open its EventStream tab (Chrome/Edge) or Response tab (Firefox).
Each frame appears as it is flushed, even though the request itself was
sent as a POST (not via EventSource).
CSRF is exempt on this view
subscription_sse_view ships csrf_exempt, mirroring GraphQLView: the
GraphQL document is the request payload and the engine's own
authorize/scope hooks gate the subscription, so session-cookie CSRF adds no
protection to a same-origin JSON POST. This means a plain fetch() POST or
a curl command (like the one above) reaches the view without needing a
CSRF token.
Error surfacing splits at the moment the stream is committed:
- Pre-stream (HTTP 400) — a missing
query, a GraphQL syntax error, a non-subscription operation, or a JSON body that is not an object ([1,2,3],"x",null) is rejected as a plain400 Bad Requestbefore any stream starts — the same classificationGraphQLViewgives those bodies. - In-stream — everything after that (validation errors and
authorize-denials) is delivered inside the committed
200 text/event-streamresponse as a singlenextframe carrying{"errors": [...]}followed by thecompleteframe — never an HTTP 4xx, because a status code cannot change once the streaming response has begun.
WebSocket: the wire protocol¶
The consumer speaks
graphql-transport-ws —
your client must request that subprotocol when opening the socket. One socket
multiplexes any number of operations, each identified by a client-chosen id:
| # | Direction | Message |
|---|---|---|
| 1 | client → server | {"type": "connection_init"} — must be the first message (the auth boundary) |
| 2 | server → client | {"type": "connection_ack"} |
| 3 | client → server | {"type": "subscribe", "id": "1", "payload": {"query": "subscription { … }"}} |
| 4 | server → client | {"type": "next", "id": "1", "payload": {"data": {…}}} — one frame per delivered event |
| 5 | server → client | {"type": "complete", "id": "1"} — only when the stream itself ends |
The subscribe payload carries the same query / variables /
operationName keys as an HTTP POST. A subscribe-time failure (parse error,
validation error, authorize-deny, a payload that is not a JSON object, or the
per-connection cap) is answered with
{"type": "error", "id": "1", "payload": [...]} instead of next frames, and
the server answers a client {"type": "ping"} with {"type": "pong"}. The
graphql-ws JavaScript client
speaks this protocol out of the box.
Protocol violations close the socket with the spec's codes: 4400 malformed
message, 4401 subscribe before the ack, 4408 no connection_init within
the init timeout, 4409 duplicate operation id, 4429 a second
connection_init. 4400 covers any frame the consumer cannot dispatch —
including a body that is not valid JSON and a non-string id — and the close
tears down every live operation on that socket first, so a malformed frame can
never leave a task or a joined group behind.
A failure that happens after delivery started is not a close and not an
error frame: it arrives as {"type": "next", "payload": {"errors": [...]}}
followed by the terminal complete, so the client always gets a protocol signal
instead of a stream that goes quiet forever. The SSE transport does the same
thing with its own framing (event: next carrying errors, then
event: complete) — once the 200 text/event-stream response is committed an
HTTP error is impossible.
Unsubscribing¶
Both transports guarantee that a gone client is a gone subscriber — teardown discards every channel-layer group the operation had joined.
WebSocket — send a complete frame for the operation id:
This cancels only operation "1": its task is cancelled and its groups are
discarded, while the other operations multiplexed on the same socket keep
streaming. Per the protocol the server does not echo a complete back for
a client-initiated one — a server complete means "this stream ended on its
own". Closing the socket cancels all operations and discards every joined
group.
SSE — one request is one stream, so unsubscribing means closing the HTTP
connection: abort the fetch() (AbortController.abort()), dispose the
graphql-sse client subscription, or Ctrl-C the curl. Django ≥ 5.2
guarantees the disconnect cancels the streaming generator, and the transport's
cleanup then leaves every joined group — no ghost subscribers. Teardown is also
registered on the response itself, so a client that aborts during the
subscribe handshake — before the response body is ever read — releases its
groups too.
Filtering notifications¶
id scopes by the changed object's own primary key. To scope by field
values instead — e.g. a post-detail page that should only receive the comments
of that post — pass the optional filter argument. It is a real generated
input object, <Model>SubscriptionFilterInput, with the same nested shape
queries use:
subscription {
commentSubscription(
action: ALL_ACTIONS
filter: { post: { exact: 7 } } # only comments whose post == 7
) {
id
text
}
}
The type exposes exactly the subscription's projected output fields (see
only_fields / exclude_fields), each with exactly four lookups —
exact, iexact, in, isnull. Nothing else is expressible: the schema
itself is the boundary now, so your IDE autocompletes the valid keys and an
invalid one fails validation before the request ever reaches the engine.
Filters are evaluated per connection at delivery time:
exactis decided in memory against the serialized payload — no extra query. This is the fast path the whole serialize-once engine is built around, so prefer it for scoping.- The other three lookups fall back to a single-row database check.
- Combine them:
filter: { post: { exact: 7 }, status: { in: ["open", "urgent"] } }. - Omitting
filterdelivers every event in the group.
Breaking change in 2.1.0 — filters is now filter
Through 2.0.x the argument was filters, typed String, carrying a
JSON-encoded object (filters: "{\"post\": 7}"). There is no alias: the
old name and the old string form are gone. Rewrite
as
See the changelog for the full before/after.
Ordered and pattern lookups are rejected
startswith, icontains, regex, gt/gte/lt/lte, range and the
date-part transforms (year, month, day, …) are not declared on the
generated input type, so they fail schema validation — on every field,
including declared ones. Delivery evaluates a filter as an ORM lookup and
whether the event arrives is observable, so a comparison lookup is a boolean
oracle an attacker can walk one character (or one bisection) at a time.
Equality and membership only answer "is it exactly this value?", which
forces a whole-value guess.
2.0.0 documented text__icontains as usable. 2.1.0 closed it twice over:
the runtime gate rejects the lookup at subscribe time, and the generated
input type does not declare it, so it fails schema validation before the
runtime ever sees it. Move the substring match to subscription_scope
(server code, exempt from this check) or filter client-side on the
delivered payload.
Delete + lookup filters
On a delete the row no longer exists, so only the in-memory (equality)
path applies. With payload_mode = "full" the payload still carries the
field values, so equality filters work on deletes; with id-only payloads,
non-pk filters cannot be evaluated on delete and the notification is dropped.
Scoping vs. security
A client-supplied filter is a convenience scope, not an authorization
boundary. To enforce row-level access (e.g. "only my records"), gate the
subscription with private_subscription / your auth layer rather than
relying on a client-provided filter.
Client filters can still test equality against any declared field
A filter key is evaluated as an ORM lookup at delivery time, and whether the
event arrives is observable — so a client filter remains a boolean oracle
over every field the subscription declares as output. Restricting the
lookups to equality and membership (see
Filter key validation) removes incremental
extraction, not the oracle itself: a client can still ask "is this field
exactly X?" about any declared field, one guess at a time. That is fine
for a value with a large space (a password hash) and not fine for one
with a small space (a boolean flag, a status enum, a short token).
Keep sensitive columns out of that surface with only_fields /
exclude_fields on the subscription's Meta — they gate the event payload
and the declared filter set in one move:
What the client receives is a normal GraphQL result — its own selection set
projected from the event (wrapped in the transport's frame: an SSE next
event or a WS next{id, payload} message). Subscribing with
{ id username email } in the default id-only mode delivers:
…and in full-payload mode, the serialized values:
The broadcast envelope is internal
The {"stream": ..., "payload": {"action": ..., "model": ..., "data": ...}}
message you may see in channel-layer captures is the internal broadcast
between the signal binding and the subscriber processes — it never reaches
the client. Clients only see the transport frames above.
Notification payload¶
On every model change the binding builds the notification data. By default it
is id-only — {"id": <pk>} — which skips serialization entirely (the
fastest option for hot models, where clients typically refetch on notification).
Set it to full to serialize the whole instance through the native (Pydantic)
backend.
Two controls, in order of precedence:
| Control | Values | Effect |
|---|---|---|
Meta.payload_mode |
None (default), "full", "id_only" |
Per-subscription. None inherits the global setting; "full" = full; "id_only" = id-only. |
DJANGO_GRAPHEX["SUBSCRIPTION_PAYLOAD_MODE"] |
"id_only" (default), "full" |
Global default for subscriptions that don't override it. |
# settings.py — make every subscription serialize the full instance by default
DJANGO_GRAPHEX = {
"SUBSCRIPTION_PAYLOAD_MODE": "full",
}
# ...or decide per subscription, regardless of the global default
class UserSubscription(Subscription):
class Meta:
model = User
stream = "users"
payload_mode = "full" # full payload for this one; "full"/"id_only"/None
File and binary columns
The payload is JSON-encoded before it crosses the channel layer, so the two
field kinds whose Python value is not JSON-encodable are converted: a
FileField / ImageField is delivered as its storage name (the string
its GraphQL String output already carries) and a BinaryField as
base64 text. Both render as String on the event type. Before this,
payload_mode = "full" on a model carrying either column raised
TypeError: Object of type FieldFile is not JSON serializable on every
save.
id-only is the default
graphene-django-subscriptions always sent the full serialized instance.
django-graphex defaults to id-only for performance. In id-only mode the
payload carries only {"id": <pk>}, so a subscription selection set can
deliver only id — to receive other fields, opt into full mode via
Meta.payload_mode = "full" / the global setting. The payload mode is fixed
when the subscription class is defined.
Relations are flat IDs (no nested selections)¶
The generated event type — <Model>SubscriptionEvent — renders every relation
flat: a ForeignKey / OneToOneField field is the related object's pk
scalar (ID for the usual auto pk, otherwise the pk field's own scalar), and a
ManyToManyField is a list of pks ([ID]). This is the serialize-once
design working as intended: a change is serialized once (FK → pk,
M2M → [pk]) and that single flat payload feeds every subscriber with zero
per-subscriber database queries — resolving a nested object per subscriber
would reintroduce exactly the per-event N+1 the engine exists to avoid.
Select relations as scalars:
subscription {
commentSubscription(action: CREATE) {
id
text
post # ID — the related post's pk, not an object
tags # [ID] — the related pks
}
}
Selecting subfields on a relation fails GraphQL validation — before any event is delivered:
(On SSE the error arrives in-stream; on WebSocket as an error frame — see
the wire protocols.)
The payload mode does not change this schema — payload_mode only decides
which keys the broadcast payload carries, and the flat resolvers simply read
their key from it:
| Client selects… | payload_mode = "id_only" (default) |
payload_mode = "full" |
|---|---|---|
id |
the pk | the pk |
a concrete scalar (text, username, …) |
null — no error |
the serialized value |
a FK / O2O (post) |
null — no error |
the related pk (ID) |
a M2M (tags) |
null — no error |
the related pks ([ID]) |
nested subfields (post { id }) |
validation error | validation error |
In id-only mode the payload is just {"id": <pk>}, so any selected non-id
field resolves null — the selection still validates against the same schema,
and nothing errors at delivery. In full mode the broadcast carries all
concrete fields plus the M2M pk lists, and each subscriber's selection projects
from that single dict.
Per-connection field selection¶
Each subscriber's GraphQL selection set is its own projection: two clients on the same stream requesting different fields each receive only the fields they asked for, resolved per event from the single serialized payload (no shared serializer state, no re-serialization).
Security hardening¶
Validate the WebSocket handshake's Origin¶
A session-authenticated socket without an Origin check is cross-site reachable
A WebSocket handshake is an ordinary HTTP request: the browser sends your cookies with it, and CORS does not apply. So a page on any other site can open a socket to your endpoint as your logged-in visitor and read every subscription that user is entitled to. Nothing in this library can stop it, because the library never sees the handshake — your ASGI routing does.
This is the WebSocket counterpart of
REQUIRE_CSRF_HEADER, which
guards the HTTP endpoint and never sees this one.
Wrap the routed WebSocket application in Channels'
AllowedHostsOriginValidator, outside the auth stack:
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from channels.sessions import SessionMiddlewareStack
from channels.security.websocket import AllowedHostsOriginValidator
application = ProtocolTypeRouter({
"http": django_asgi_app,
"websocket": AllowedHostsOriginValidator( # <- the guard
SessionMiddlewareStack(
AuthMiddlewareStack(
URLRouter([path("ws/graphql/", MyWSConsumer.as_asgi())])
)
)
),
})
It checks the handshake's Origin against ALLOWED_HOSTS, so a foreign origin
never completes the handshake and never reaches connection_init. Note that
ALLOWED_HOSTS = ["*"] makes it accept everything — convenient in development,
and worth remembering when you read a test that seems to pass without it. The
playground ships this wiring, and pins it with a test
that rebuilds the app under a real host list.
Transport-level authentication¶
v2.0 has no separate channelId handshake or channel-ownership cache to
guard — the legacy two-channel protocol (and its SUBSCRIPTIONS_CHANNEL_GUARD
setting) was removed. Authentication is now the transport's own request/scope:
- SSE authenticates the HTTP request (
request.user/ session / middleware) before the engine joins any group. - WebSocket authenticates the connection scope at
connection_init(the auth boundary) before any subscription is accepted.
The subscription's authorize_subscription / permission_classes hooks run
before any group_add, so a denial short-circuits before the source is
created — there is no window where an unauthorized subscriber is joined.
Per-connection subscription cap¶
One graphql-transport-ws socket multiplexes any number of operations, and every
live operation joins its own channel-layer group — so an unbounded socket lets a
single client amplify one connection into hundreds of subscribers.
MAX_SUBSCRIPTIONS_PER_CONNECTION (default 50) bounds it, the same way
MAX_BATCH_SIZE bounds a batch request:
A subscribe past the cap is rejected, not fatal: the server answers that
operation id with an error frame naming the limit, and every subscription
already running on the socket keeps streaming. The slot frees itself as soon as
an operation ends — a client complete, a stream that ends on its own, or a
disconnect — so a client that stays under the cap never notices it.
None removes the protection
MAX_SUBSCRIPTIONS_PER_CONNECTION = None allows any number of concurrent
subscriptions per socket. Use it only when every client is trusted and you
bound connections elsewhere (gateway/proxy rate limiting).
The SSE transport needs no equivalent: one text/event-stream request carries
exactly one subscription, so its concurrency is bounded by your server's
connection limits rather than by an in-process registry.
GraphQL execution middleware¶
Both transports build the DJANGO_GRAPHEX['MIDDLEWARE'] chain once per
connection and run it on the subscribe entry and on every per-event delivery
execute — the same chain GraphQLView runs for queries and mutations. So
AuthenticatedFieldsMiddleware (the enforcement half of private_subscription,
see Security) actually protects
subscription fields: an unauthenticated subscriber is denied before any
group_add, and no event is ever delivered to it.
Fixed in 2.1.0
In 2.0.0 the setting was read only by GraphQLView, and subscriptions are
served only by the SSE / WS transports — so every configured GraphQL
middleware was inert on subscriptions and private_subscription protected
nothing there. Upgrade to 2.1.0 if you rely on it.
Filter key validation¶
Since 2.1.0 the schema is the first gate: <Model>SubscriptionFilterInput
declares only the projected output fields, each with only the four allowed
lookups, so anything else fails GraphQL validation before the subscribe
resolver runs. A second, runtime check (_validate_client_filters) still
validates the flattened key as defence in depth for anything that reaches the
engine without going through schema coercion:
- the root (everything before the first
__) must be a declared output field of the subscription's serialized payload; - every remaining segment must be a Django lookup or transform registered on that field;
- every remaining segment must also be one of the four allowed lookups:
exact,iexact,in,isnull; - the whole mapping must be a query the ORM itself can build.
| Filter | Verdict |
|---|---|
{ post: { exact: 7 } } |
accepted — equality, decided in memory |
{ post: { in: [7, 9] } } |
accepted — membership |
{ username: { iexact: "neo" } } |
accepted |
{ deletedAt: { isnull: true } } |
accepted |
{ authToken: { exact: "x" } } |
rejected — undeclared field, not in the input type |
{ groups: { name: { startswith: "adm" } } } |
rejected — relation traversal is unexpressible (the input is flat) |
{ password: { startswith: "pbkdf2" } } |
rejected — pattern lookup, not declared |
{ created: { gte: "2024-01-01" } } |
rejected — ordered lookup, not declared |
{ created: { year: { gte: 2024 } } } |
rejected — date-part transform, not declared |
{ text: { icontains: "urgent" } } |
rejected — pattern lookup, not declared |
{ tags: { iexact: 3 } } |
rejected — the ORM refuses iexact across a to-many join |
Schema rejections arrive as a normal GraphQL validation error and no group is joined; a runtime rejection likewise raises before any group is joined, and its message names the offending lookup alongside the allowed ones.
Step 4 exists because a Django field's declared lookup registry is wider than
what the query compiler accepts once a join is involved. A to-many field
(tags) declares iexact and then refuses it, so { tags: { iexact: 3 } }
passed steps 1–3 and blew up at delivery instead — deep inside the stream, after
the SSE 200 was already committed. The engine now builds the same query at
subscribe time (no SQL is issued, only the lookup resolution), so an
unsatisfiable filter is a clean denial with no group joined.
Why: the filter argument executes as ORM lookups at event-delivery time
and delivery is observable, so a filter key is a boolean oracle. Rooting it on a
declared field bounds which column it can probe; restricting the lookup bounds
how much each probe reveals. An ordered or pattern lookup answers a
comparison, so a few hundred probes recover a password hash character by
character; equality and membership answer only "is it exactly this value?",
which forces a whole-value guess.
What remains: a client can still test equality against any declared
output field. That is a weak oracle for a high-entropy value and a real one for
a low-entropy value — a boolean flag falls in two probes. Declared fields
are the names returned by the subscription's backend.output_field_names()
after the Meta.only_fields / Meta.exclude_fields projection, so use that
projection to keep sensitive columns out of both the payload and the filter
surface. If you need tighter control still, override subscription_scope to
enforce server-side filters that cannot be widened or removed by the client.
The projection that gates this is this class's Meta
A hand-written Subscription binds Meta.model and compiles its event
type and <Model>SubscriptionFilterInput from that model. It does not
read whatever DjangoObjectType the schema registered for the same model,
so a column that type hides with exclude_fields is still selectable
and equality-filterable here. Repeat the projection:
class CommentSubscription(Subscription):
"""Comment change notifications."""
class Meta:
model = Comment
stream = "comments"
payload_mode = "full"
exclude_fields = ("internal_note",) # <- not inherited from CommentType
A subscription generated by DjangoModelType.SubscriptionField() needs no
repetition: that host forwards its own only_fields / exclude_fields
into the subscription class it builds. This is the third of the
open boundaries the projection rule states.
Relation and comparison lookups are server-side only
Multi-hop keys such as {"author__name": "alice"} and comparison keys such
as {"text__icontains": "urgent"} are not accepted from a client — since
2.1.0 they cannot even be written, because the generated input type is flat
and declares only the four allowed lookups. Both remain available from
subscription_scope, which is server code and therefore exempt from
filter-key validation.
Percent-encoded index group names¶
Subscription index fields (see subscription_index_fields) use = and &
as delimiters in the group name suffix. Field values are percent-encoded
(urllib.parse.quote) before the suffix is assembled, so a value like "a=b"
no longer produces a name that is ambiguous with two separate key-value pairs.
Asserts replaced with explicit raises¶
The validation guards in Subscription.__init_subclass_with_meta__ that were
expressed as assert statements are now raise TypeError(...) calls. Python's
-O flag strips assert at compile time; the explicit raises survive.
Safe async_to_sync in signal handlers¶
The signal binding (SubscriptionBinding) calls channel_layer.group_send
from a synchronous Django signal handler. Under an ASGI server a running event
loop is already present on the calling thread; a naive async_to_sync(…)()
call in that context deadlocks.
bindings.py detects whether a loop is running (asyncio.get_running_loop).
On the ASGI path (loop present), the coroutine is scheduled as a
fire-and-forget task via loop.create_task — the call returns immediately
without blocking the loop thread, and a done-callback logs any delivery failure
via the module logger. On the WSGI / sync path (no running loop),
async_to_sync is used and errors propagate synchronously as before.
Commit-time broadcast delivery¶
Subscription broadcasts (_on_save, _on_delete) are now deferred via
django.db.transaction.on_commit. This means:
- No phantom notifications: if a nested write runs inside
transaction.atomic()and a later child save raises anIntegrityError(triggering a rollback), the broadcast callbacks are discarded — subscribers never receive events for rows that were rolled back. - Auto-commit path unchanged: when no explicit transaction is open, Django's
auto-commit mode causes
on_committo call the callback immediately, so broadcast delivery is instant for standalone saves. - Nested
atomic()blocks: callbacks accumulate until the outermost transaction commits, matching Django's standardon_commitsemantics. - Signal-time snapshots: each
post_save/post_deletecaptures its primary key, routing index and payload before registering the callback. Saving the same Python object again — or deleting it, which clears its pk — cannot rewrite an earlier event while the transaction is still open. Everypost_saveremains a separate event; changes are not coalesced.
Test setup
Django's TestCase wraps every test in a transaction that is rolled back
after the test; on_commit callbacks never execute inside that wrapper.
If your subscription tests assert on captured_group_sends, mark them with
@pytest.mark.django_db(transaction=True) so real commits occur.
ORM access in the subscribe hooks¶
authorize_subscription and subscription_scope are synchronous classmethods
(user-overridable), and both transports run them on the event loop — not in
a thread. A blocking ORM query inside either one therefore raises
SynchronousOnlyOperation ("You cannot call this from an async context"), which
the engine reports as a subscribe denial, not as a crash. Reading
info.context.user is free: both transports resolve the user before the hooks
run. Anything past it — a related row, a permission lookup, a
Model.objects call — must be lifted by you:
from asgiref.sync import sync_to_async
from graphql import GraphQLError
class PostSubscription(Subscription):
class Meta:
model = Post
@classmethod
async def authorize_subscription(cls, info, **kwargs):
user = info.context.user
if not user.is_authenticated:
raise GraphQLError("authentication required")
allowed = await sync_to_async(user.has_perm)("blog.view_post")
if not allowed:
raise GraphQLError("not allowed")
Both hooks may be declared async def (as above) or stay synchronous; an
awaitable return is awaited either way. Keep a synchronous hook free of
blocking ORM access.
JavaScript path escaping¶
SubscriptionClientView injects ws_path and http_path into inline
JavaScript via json.dumps, which escapes double quotes and backslashes.
Injecting raw strings would allow XSS if a path contained a double-quote
(") that could close the surrounding JS string literal.
Migrating from graphene-django-subscriptions / v1.x¶
Full v2.0 upgrade guide
A comprehensive GRAPHENE → DJANGO_GRAPHEX upgrade guide (including the subscription transport cutover) ships with the v2.0 release notes. The steps below cover the subscription-specific moves.
The transport changed in v2.0: the HTTP channelId handshake, the
GraphqlAPIDemultiplexer consumer, and SubscriptionGraphQLView were removed in
favor of native SSE and graphql-transport-ws. To migrate:
- Install the extra:
uv add "django-graphex[subscriptions]"(orpip install "django-graphex[subscriptions]"). - Update imports to
django_graphex.subscriptions. - Replace the demultiplexer consumer +
SubscriptionGraphQLViewURL with the native transports: routesubscription_ws_consumer(schema=...)for WebSocket and/or mountsubscription_sse_view(schema=...)for SSE (see Serve subscriptions). - Update clients: drop the
channelId/operationarguments and the{ok, error, stream, operation, action}confirmation selection — select the model's fields instead, and rely on the transport for unsubscribe (abort the SSE request or send agraphql-transport-wscomplete— see Unsubscribing). - Notifications are id-only by default. If your clients relied on the full
serialized payload, set
Meta.payload_mode = "full"on those subscriptions orDJANGO_GRAPHEX["SUBSCRIPTION_PAYLOAD_MODE"] = "full"globally. See Notification payload. - Configure a Redis channel layer for multi-process deployments.