Changelog¶
All notable changes to this library are documented here. The format is based on Keep a Changelog and the project adheres to Semantic Versioning.
A fresh start
django-graphex is a new, rewritten library — the successor to
graphene-django-extras. Its history starts at 1.0.0 below. If you are
coming from graphene-django-extras 1.x, the Migration Guide
explains every change with before/after examples (install django-graphex,
import django_graphex).
3.1.0 — 2026-09-03¶
Audit-hardening release. This release closes all 24 findings from the 3.0 post-release audit across runtime correctness, security guidance, the test suite, release automation and benchmarks. The public GraphQL API remains compatible, but cache invalidation is deliberately safer by default.
What you need to act on¶
- Shared cached reads now invalidate globally.
CACHE_INVALIDATION_SCOPE="global"is the new default, so a successful mutation advances one shared GraphQL version and no identity keeps stale shared data. Set it to"identity"only when every cached response is truly private and the narrower 3.0 invalidation policy is intentional. Cache keys now carry av2format marker and the selected scope, so old entries cannot reappear after the policy changes. - Cookie-dependent queries bypass the response cache by default. The new
protected
GraphQLView.should_cache_query(request)hook rejects requests with cookies because the standard key cannot represent session, cart, tenant, locale or feature-flag state. An override may re-enable them only if its cache key represents every varying part of that context. Executed mutations still invalidate normally. - CRUD permission hooks remain synchronous. If a
BasePermissioncreate, update or delete hook returns an awaitable, django-graphex now closes it and raisesImproperlyConfiguredbefore any row is written. Subscription authorization continues to support its documented async hooks.
Fixed¶
- A syntactically valid mutation rejected with HTTP 405 or a validation error used to advance the cache version even though no resolver ran. Invalidation now occurs only after execution could have produced durable writes; atomic rollbacks still leave the version unchanged.
- Anonymous requests with different cookies could share a response through the
common
anonidentity. Cookie-bearing queries now skip both cache lookup and storage, while cookie-free public queries can still share entries. - Subscription
on_commitcallbacks captured a mutable model instance. A create followed by update or delete in one transaction could therefore publish the final state twice or publish a create event withpk=None. Callbacks now carry immutable per-save snapshots and emit nothing on rollback. CLEAN_RESPONSEclassified a multi-operation document by its first operation instead ofoperationName; selected introspection responses now preserve their requirednulland empty-list values.- Renamed subclasses of
MultiSelectFieldnow compile to list-shaped enum types in both output and mutation input. - Query-cost analysis no longer treats a singular field as a collection merely
because it has an application argument named
limit; multiplication applies only to list-shaped GraphQL return types.
Documentation and example project¶
- The authenticated-user quickstart is safe to copy: it projects only public
user fields, uses
AuthenticatedGraphQLView, disables response caching in the example, and implements registration withcreate_user()instead of generic writes. Passwords are hashed and callers cannot assign staff, superuser, group or permission state. - Mutation documentation now states that
permission_classesonDjangoModelMutationraisesImproperlyConfigured, describes generated operations as Create/Update/Delete, and uses neutral models for generic CRUD. - The playground reset removes stale generated migrations while preserving
__init__.py, and its test commands consistently avoid local migration drift. WebSockets now ship with explicit localhost/testserver origin defaults rather than an effective allow-all configuration. - The verified 2.x → 3.0 upgrade guide is now published. It documents and reproduces behavior that already shipped in 3.0; it is not a claim that those 3.0 changes were introduced again in 3.1.
Test and release engineering¶
- #146 adds the global CI gate: public APIs require
complete Google-style contracts and type hints without repeated types, every
docstring is free of backticks, and CI combines
--strict-publicwith--strict-contentwithout a changed-owner ratchet. - Channels-free runs keep collecting transaction/savepoint tests, broad
pytest.raises(Exception)contracts were replaced by exact failures, and focused commands use--no-cov. CI enforces branch coverage and changed-line coverage above 95%, while tox tools resolve inside explicit version bounds. - A PostgreSQL 17 gate now exercises foreign-key failures, nested rollbacks, many-to-many rollback, autocommit and connection reuse against the real backend instead of relying solely on SQLite.
- Release automation builds the wheel and sdist once, smoke-tests the installed wheel outside the checkout, audits that exact wheel, records checksums, and carries the same immutable artifact to PyPI and the GitHub Release.
- Tagged publication now waits for the complete test matrix, base install, security/quality, coverage, PostgreSQL, documentation, playground and distribution checks. Manual dispatch targets TestPyPI only; PyPI and Pages publication remain tag-driven.
Benchmarks¶
- The nested workload now enforces the documented 20 authors × 10 posts × 5 comments with exact IDs, order and content across all four libraries.
- Every validation, SQL probe, warmup and timed mutation rolls back, so library order cannot accumulate rows or bias later measurements.
- Direct and transitive benchmark dependencies are pinned, offline replay has explicit cache semantics, and Ariadne's Django adapter is a required input.
python benchmarks/run_publish.py --authors 1000 2000 --runs 3is the sole canonical-results publisher. It recreates each database, rotates library order, validates every run, computes medians and writes results atomically;run_all.shremains a non-publishing diagnostic.
Audit traceability¶
| # | Area | Resolution in 3.1.0 |
|---|---|---|
| 1 | Cache isolation | Cookie-dependent anonymous queries bypass response caching. |
| 2 | Cache invalidation | Rejected or unexecuted mutations no longer advance versions. |
| 3 | Documentation security | The auth.User quickstart uses safe projection, auth and registration. |
| 4 | Release gates | Every validation job blocks tagged PyPI publication. |
| 5 | Immutable artifact | PyPI and GitHub Release consume one checked wheel/sdist artifact. |
| 6 | Subscriptions | Transaction callbacks publish immutable per-save snapshots. |
| 7 | HTTP response cleaning | operationName selects whether introspection data is preserved. |
| 8 | Type compilation | MultiSelectField subclasses stay list-shaped in output and input. |
| 9 | Cache freshness | Global invalidation is default; identity scope remains opt-in. |
| 10 | Query limits | Singular fields named limit no longer receive list multiplication. |
| 11 | Permissions | Async CRUD permission hooks fail closed before writes. |
| 12 | Packaging | A clean external environment tests the exact release wheel. |
| 13 | Test suite | Core savepoint tests run without the optional Channels extra. |
| 14 | Test suite | Exception contracts assert exact classes and messages. |
| 15 | Coverage | Focused tests avoid false global gates; CI checks patch coverage. |
| 16 | Tooling | Tox and development tools use explicit compatible bounds. |
| 17 | PostgreSQL | Real-backend transaction integration is a release gate. |
| 18 | Documentation | Mutation permission and generated-operation contracts are accurate. |
| 19 | Playground | Reset and test workflows recover from stale local migrations. |
| 20 | Playground security | Default WebSocket origins are restricted to local hosts. |
| 21 | Benchmarks | Nested results must satisfy the full 20×10×5 contract. |
| 22 | Benchmarks | Measured mutations roll back and cannot contaminate later runs. |
| 23 | Benchmarks | Environments are pinned and support deterministic offline replay. |
| 24 | Benchmarks | Validated three-run medians are published atomically. |
3.0.0 — 2026-08-27¶
Security release, and the largest set of breaking changes since 2.0. Read this section before upgrading: it names every one of them in one place, and each is expanded in the entries below.
The major bump is what the breaking changes below require, not a rewrite: the API you write is the 2.x API. What changed is what the library refuses. A schema that never answered a question its own projection said it would not answer builds unchanged.
The theme is a single rule finally enforced everywhere it was already written
down: Meta.only_fields / Meta.exclude_fields are a security boundary, not
an output shape. A column a type hides must be unreadable, unorderable and
unfilterable through it. Read it once in
Types › The projection is a security boundary;
every other page links there.
Two new settings ship ON. Both change behaviour for existing clients.
REQUIRE_CSRF_HEADER(True) demands anX-Requested-Withheader on POSTs whose content type a browser can send cross-site with no CORS preflight —application/x-www-form-urlencoded,multipart/form-data,text/plain, and a body-less POST with no content type — on the HTTP views and the SSE subscription endpoint. Without it: HTTP 403 before the body is read. Form-encoded clients break, and so does any multipart upload client written against 2.2.0.application/jsonandapplication/graphqlclients change nothing.MAX_SUBSCRIPTIONS_PER_CONNECTION(50) caps the concurrent operations onegraphql-transport-wssocket may hold. The socket and everything already running on it survive a refusal; SSE is unaffected.Nonerestores the old unbounded behaviour.
Requests that used to succeed are now refused at query time. All on the ordering axis, all closing a read the rule always prohibited:
- ordering by a column the type projects away, by a type's own hidden
primary key, or by a model field name a
resolve_<name>of yours masks; - ordering by a relation's
_idwhen that relation is served by a resolver of your own — the scoping hatch, in either direction; - ordering by a forward FK's
_idwhen the target type hides the key it points at; - an operator-configured
ordering=naming a projected-away column, which now raises on every request throughCursorGraphqlPagination(it echoes the value in the cursor);LimitOffset/Pagekeep their operator exemption; - cursor-paginating a list type whose SDL does not publish its primary key.
Schemas that build today can stop building. That is the point — every one of them was answering a question the projection said it would not:
- a
filter_fieldsentry naming a projected-away column, a masked relation, a hop through either arm of the scoping hatch,pk, or a lookup spelled into the key instead of the value; only_fields/exclude_fields/include_fieldson aDjangoListObjectTypethat reuses a node type already registered for the model (restating that node's own projection is still accepted).
And one that does NOT stop the build, which is worse.
MAX_QUERY_DEPTH / MAX_QUERY_COST set to 0 or a negative value used to
switch the guard off silently, and is now refused — but the refusal fires when
the setting is READ, on the first request, not while the schema builds.
So the schema builds, manage.py check stays green, and the first signal is a
failing request. None remains the only way to disable a guard. Grep your
settings for a literal 0 on either key before you deploy; the value you want
is None.
Four more behaviour changes worth checking before you upgrade:
- Subscriptions are now measured against
MAX_QUERY_DEPTH/MAX_QUERY_COST. Both transports previously validated with graphql-core's default rules, so neither guard ever saw a subscription document. This can only reach a hand-written subscription with a nested payload: the library's own generated event types are flat by construction, so they measure depth 1 and cost 1, and neither setting can legally go below 1. - An
async defsubscribe gate now actually denies. It failed open:authorize_subscription's wrapper discarded the coroutine, so the check never ran. A subscription that used to be granted may now be refused — by the gate you wrote. BaseGraphQLView.format_erroris an instance method taking the request:format_error(self, error, request=None). A subclass overriding the old@staticmethod format_error(error)must be updated.- A chunked multipart body now gets
411instead of a confusing400whenMAX_REQUEST_BODY_SIZEis configured and the request declares noContent-Length. The 413 for an over-sized multipart body is not new — 2.2.0 answered it with a byte-identical message. Only when the setting is configured; theNonedefault is inert on every content type.
Removed. The CAMELCASE_ERRORS setting (it had zero consumers and
changed nothing), plus six internal names — four of which were importable, so
from django_graphex.utils import get_obj / create_obj and
converter.assert_valid_name / convert_choice_name now raise ImportError.
Security¶
- A relation served by a
resolve_method of your own ranked and filtered by the key behind it. The mask stamp — which withdraws a declared attribute that publishes a column's NAME over a resolver serving something else — was carved out for a declared RELATION, on the argument that a relation's name is not a column's and the key behind it is the target type's answer. That argument holds for the AUTO-EXPANDED relation, whose value really is the row's. It does not hold for a declaration served by a resolver:ordering: "authorId"ranks by the raw foreign key on the parent's own row andfilter_fields = {"author__name": …}joins straight past the target type, so aresolve_authorreturning a redacted stand-in — or nothing at all — left a live ranking oracle over a key no type in the schema serves. That shape is indistinguishable at build time from the to-one scoping hatch Types › Custom queryset documents, whose resolver returns a scoped target and still ranks the rows it hides: the difference lives in a resolver body no static analysis can read. Both now fail closed, like every other masked declaration. This rejects orderings that used to succeed and fails a schema that builds today: declaring the hatch withdraws that relation's_idcolumn from the ordering allowlist, and afilter_fieldspath through the declared relation raisesImproperlyConfiguredwhile the schema builds. Both were already the guide's own written remedy for the leak — drop the relation's filter paths, project the key away — so the boundary now does by itself what the reader was being asked to do by hand. A declaration carrying no resolver is untouched: it serves the attribute, so it keeps both axes. - The to-MANY half of that same hatch stayed open. Closing the to-one arm
left its byte-equivalent twin fail-open: a relation declared as
posts = DjangoFilterListField(PostType)— which Types › Custom queryset teaches for the identical purpose, and which carries a resolver of its own by construction — never reached the mask stamp, sofilter_fields = {"posts__title": …}compiled to an ORM join that reaches every row the mounted list field'sget_querysetexists to hide. One user intent, one release, two opposite answers. Both arms now answer the same. The to-MANY arm's cost is strictly smaller than the to-one arm's, because a reverse foreign key or a many-to-many owns no column on the parent row: nothing leaves the ordering allowlist, the relation stays selectable and stays scoped, and what goes is the nestedposts__…filter path — which stops the schema building withImproperlyConfigured. Drop those entries when you declare either arm. - The shared filter input could be widened by a build the guard never saw.
The union of every declaration sharing one
<Model>FilterInputis measured against every type serving it — but the measurement read the cache BEFORE the assertion, and the assertion FORCES compiled field maps, which re-enters the builder for the same model through a nested list field's own thunk. On that path the cache entry was born inside the assertion, and the branch that returned it recorded the narrow type as a server of paths nothing had ever measured: its list field answeredfilter: {bio: {icontains: …}}for a column its own SDL denies. The union is now measured in a loop that re-reads the cache until it stops moving, so the shape being served and the shape being measured cannot differ. - One WebSocket socket could open unbounded subscriptions. The
graphql-transport-wsconsumer registered every acceptedsubscribewith no ceiling, and each live operation joins its own channel-layer group — so a single client turned one connection into hundreds of subscribers (500 concurrent operations on one socket was reproduced) while the HTTP side had bounded its analogous surface withMAX_BATCH_SIZEsince 1.2.1. The newMAX_SUBSCRIPTIONS_PER_CONNECTIONsetting (default50) caps the concurrent operations one socket may hold. This rejects subscriptions that used to be accepted, but only past 50 concurrent operations on a single connection; asubscribepast the cap is answered with the transport's ownerrorframe naming the limit, and the socket plus every subscription already running on it survive untouched. A slot frees itself the moment its operation ends (clientcomplete, stream end, or disconnect). SetMAX_SUBSCRIPTIONS_PER_CONNECTION = Noneto restore the previous unbounded behaviour. The SSE transport is unaffected: one request carries exactly one subscription. See Subscriptions › Per-connection subscription cap. -
A cross-site form could execute mutations under the victim's session. The endpoint is
csrf_exemptandparse_bodyacceptsapplication/x-www-form-urlencodedandmultipart/form-data. Both are CORS-simple content types, so a<form>on any origin posted straight through with no preflight and the browser attached the victim's session cookie — a plain CSRF hole on every mutation. The same hole was open on the SSE subscription endpoint, which iscsrf_exempttoo and reads a form-encodedquerystraight out ofrequest.POST. The newREQUIRE_CSRF_HEADERsetting (defaultTrue) now demands theX-Requested-Withheader on the CORS-simple set — form-encoded, multipart,text/plain, and a body-less POST carrying no content type at all — and answers HTTP 403 before the body is read otherwise, on the HTTP views and on the SSE endpoint (whose refusal is a plain body, not a JSON envelope, because anEventSourceclient does not parse one). The header is not CORS-safelisted, so requiring it forces back the preflight the attacker page cannot pass; the value itself is never inspected. The check runs ahead of every dispatch, wrapped around the view callable, becauseGraphQLView.dispatchowns the whole response-cache interaction and reaches the shared dispatch only throughsuper_call: a guard behind it is bypassed by a warm cache entry, has its own 403 stored and replayed to legitimate callers, and still lets a rejected mutation flush the caller's cache namespace.application/jsonandapplication/graphqlclients change nothing: neither is simple, so both already required a preflight. See Security › Cross-site POST protection.This is a breaking change for two kinds of client, on by default
Who breaks. Anyone POSTing
query=asapplication/x-www-form-urlencoded, and anyone using the multipart file upload shipped in 2.2.0 — that feature works in the published release, so upload clients written against 2.2.0 do break here.What they see. HTTP 403 with
This content type requires the X-Requested-With header. …, before the body is read. The message names the header; there is no silent failure and no partial execution.The fix, one line. Add the header to the request:
headers={"X-Requested-With": "XMLHttpRequest"}(requests),xhr.setRequestHeader(...), or the equivalent. The value is never inspected. Nothing else about the request changes.The opt-out.
REQUIRE_CSRF_HEADER = Falserestores the previous behaviour wholesale. Use it only if a client genuinely cannot set a header and the endpoint is protected another way.Why multipart is not exempted.
multipart/form-datais itself a CORS-simple content type, so a cross-site<form enctype="multipart…">posts to the endpoint with no preflight at all. Exempting it to spare the upload clients would reopen exactly the hole this closes — and would leave the upload mutations, the ones that write files, as the only unprotected surface. -
Disabling introspection still leaked the schema, one guess at a time.
format_errorreturned graphql-core'sformattedmapping verbatim, so theDid you mean 'email'?suggestion appended to an unknown field, type or argument rode out even withDisableIntrospectionMiddlewareinstalled — probing with invented names rebuilt much of a schema the operator believed was hidden. The WS and SSE transports leaked it by the same route, serializing rawerror.formattedof their own, so any deployment exposing subscriptions kept the oracle open on asubscribeframe or a direct POST to the SSE endpoint. The trailing suggestion sentence is now stripped whenever introspection is actually disabled (the middleware installed andALLOW_INTROSPECTION=False), through one shared formatter (django_graphex.security.format_graphql_error) that the HTTP view and both transports call — WS covers its validation errors, its subscribe-entry failures and its in-streamnext{errors}frames. The strip is keyed on the rules that actually leak —FieldsOnCorrectTypeRule,KnownTypeNamesRule,KnownArgumentNames*Rule,ValuesOfCorrectTypeRule/coerce_input_valueand enum-value coercion — by matching the message each one writes ahead of its suggestion, not by matching the shape of a trailingDid you mean …?sentence. Nothing else changes: the rest of the message, thelocationsand thepathall survive;ScalarLeafsRule'sDid you mean 'field { … }'?guidance (built from the name the client typed, not from the schema) and a resolver-raised application error that happens to end in a question keep every word; and with introspection allowed the suggestion is kept because the same names are public anyway. The strip is request-independent by design, so theINTROSPECTION_ALLOW_SUPERUSERbypass does not restore it — an error body reaches the response cache, where a per-user decision would serve one caller's body to another. See Security › "Did you mean" suggestions are stripped too. PERMISSION_SCOPED_SCHEMAtreated a typed GFK union as public. The implicit relation label is derived from the output type'sextensions["gdx"]._meta.model, and aGraphQLUnionTypehas no model — so aMeta.unionsGenericForeignKey field stayed untagged, and untagged means public. The abstract arm of the relation-traversal bypass the 2.2.0 notes describe: a caller whose direct root field to a member type was pruned away still read that member's rows through the union. A union's requirement is now the union of its members' read permissions, so keeping the field takes every member'sview_M. This prunes a field that used to survive: a caller holding one member's permission but not another's now loses the field entirely, because the union can return either member and the requirement applies to the whole field. Expose the members as their own fields if they are meant to be reachable independently. See Permission-scoped schema › A typed GFK union requires every member's permission.orderingwas a read oracle over every column the type hides. The allowlist was built frommodel._meta.concrete_fields— the MODEL's columns, not the TYPE's projection — so a column removed withonly_fields/exclude_fieldswas absent from the SDL, unselectable and unfilterable, and still fully sortable. Sorting by it ranks the visible rows by the hidden value; with a filter narrow enough to isolate a pair of rows the value is recovered exactly, and a hidden boolean was read back for every row of a test table. The docstring of the guard claimed it already rejectedpassword/is_superuser, and there was noordering_fieldsoption anywhere in the package, so a project had no way to mitigate it. The allowlist is now read off the compiled node type that actually servesresults— the type in the SDL, resolved per schema when that schema is built — and enforces it on all three ordering paths: the queryset path, the prefetch-cache (in-memory) path, and the nested window-prefetch optimization — which applied itsORDER BYin SQL and returned pre-sliced rows, so no later guard ever saw the term. ForLimitOffsetGraphqlPaginationandPageGraphqlPaginationthe allowlist gates the client argument only: theordering=you pass when constructing one of those paginators is your own configuration, identical on every request and never echoed back — the response is the rows — so it may name a projected-away column and still serve. A client that repeats that value is still rejected — the check follows the value's provenance, not its text. This rejects orderings that used to succeed: any client sorting by a column its type projects away now getsInvalid ordering field: '<name>'. Affected are projects that hid a column withonly_fields/exclude_fieldswhile still ordering by it — expose the column if the ordering is legitimate, or drop the ordering. A forward FK exposed asauthorkeepsauthor_idorderable only when the type behindauthorpublishes the key it points at — see the shared-predicate entry below — and paginators constructed directly, outside a list type, keep the model-wide allowlist. The prefetch-cache path is stricter too: it previously enforced nothing at all on a type that declared no projection, so the same nested list answered differently depending on whether its rows came from the database or from the prefetch cache. It now refuses the terms the queryset path already refused — a relation-spanningauthor__nameon a nested list raises instead of sorting by nothing. See Pagination › Ordering validation.- Two shapes the ordering allowlist wrongly refused, and one it shared across
schemas. The allowlist used to be built by re-applying the output compiler's
only_fields/exclude_fields/include_fieldsfilters by hand, which is a copy of the compiler held in a second place — and a copy is wrong for every shape it did not anticipate. Three consequences, all closed by reading the compiled type instead of re-deriving it:- A multi-table-inheritance child whose
only_fieldsnamesidwas judged to be hiding its primary key, because a child's own pk is the implicit<parent>_ptrlink and noonly_fieldslist can contain it. That refused cursor pagination on a working configuration, with a message asserting the type hides a key its SDL plainly publishes. Such a type now paginates by cursor again. - An explicitly declared class attribute that re-publishes a column
only_fieldsremoved (bio = CharField()on a type restricted toid/name) made that column appear in the SDL while the allowlist still refused to order by it. It is now orderable, because it is selectable — provided the declaration carries no resolver, so that the field really does serve the column (see the masked-declaration entry below). - Two schemas over one list container class shared a single paginator object, so building the second schema overwrote the first schema's allowlist — the first schema then accepted a hidden column it had refused a moment earlier. The per-schema answer now lives on a per-schema copy; the instance you construct and mount is never mutated.
- A multi-table-inheritance child whose
- A natural primary key hidden by the projection was still sortable. The
allowlist exempted the primary key unconditionally, after the projection
filters ran. That is right for a surrogate
id— ranking rows by an identifier the client already reads gives nothing away — but a natural key (a slug, a code, an email) carries business data and can be projected away like any other column, so the exemption handed the read oracle straight back on every such model:ordering: "slug",ordering: "pk"and the pk's own attname all passed the check, on the queryset path, the in-memory path and the nested window-prefetch path alike. The pk is now added to the allowlist only when the SDL publishes the key's value, and thepkalias rides with it rather than standing on its own. On a multi-table-inherited child that value is the parent'sid, which the SDL does publish, soordering: "pk"and the parent-link column keep working there. This rejects orderings that used to succeed on any type whoseonly_fields/exclude_fieldsremoves its own primary key. Nothing else changes: the paginators' generated pk tiebreak is not client input and is not gated, and where the nested window optimization cannot serve that tiebreak it declines and the plain prefetch path returns the same rows. CursorGraphqlPaginationpublished its ordering column through the cursor. The server-default exemption above rests on the configured ordering never being echoed back.CursorGraphqlPaginationtakes the sameordering=kwarg and does echo it:pageInfo.startCursorandendCursorare base64 ofcursor:<ordering value>\x1f<pk>, so a paginator pointed at a column the node type projects away printed that column verbatim to any client willing to base64-decode the token — a direct read of the hidden value, not merely a ranking of it. The cursor paginator now enforces the projection allowlist on its configured ordering regardless of provenance, at the one seam bothpaginate_querysetandget_page_inforesolve the ordering through. This is a hard failure, not a downgrade: aCursorGraphqlPaginationconfigured to order by a projected-away column now raisesGraphQLError: Invalid ordering field: '<name>'on every request. Point it at a column the node type exposes. See Pagination › Ordering validation.CursorGraphqlPaginationalso published a hidden primary key through the cursor. Gating the ordering column left the other half of the token open: a composite cursor iscursor:<ordering value>\x1f<pk>, and that<pk>is appended unconditionally. A type whoseonly_fields/exclude_fieldsremoves its own natural primary key — a slug, a code, an email — therefore had that key printed verbatim inpageInfo.startCursorandendCursoreven when the ordering named a perfectly public column. The tiebreak is what makes the keyset boundary total, so it cannot simply be dropped: without it avalue > boundarypage silently skips every row tied on the ordering value. Encrypting the cursor would keep both properties at the cost of a key to manage, a rotation story and a wire-format break; tiebreaking on an exposed column instead needs that column to be unique, which nothing can promise. So the configuration is refused: a list type whose SDL does not publish its primary key's value can no longer be paginated by cursor, and every request raisesGraphQLErrornaming the problem without naming the hidden column. That covers anonly_fieldslist that simply omits the key as much as anexclude_fieldsthat names it, and it does not cover a multi-table-inherited child, which publishes the parent'sid. Publish the key, or useLimitOffsetGraphqlPagination/PageGraphqlPagination, which echo nothing. The cursor's wire format is unchanged — existing cursors keep decoding exactly as before. See Pagination › Ordering validation.- A nested list whose child type hides its pk failed on an empty
ordering. The prefetch-cache path substitutes the paginator's own pk ordering when none was resolved, so the in-memory page matches the DB-side window slice. Provenance was read off whether the client sent anorderingargument, and an argument can normalize to nothing (","," ","+"): the projection allowlist then survived while the value did not, and the server's own tiebreak was validated as if the client had asked for it. Any such value answeredInvalid ordering field: 'id'on a nested list whose child type projects its primary key away. The substitution now drops the allowlist along with the value it replaces. A real client term outside the allowlist is still rejected. - A declared field could publish a column's NAME while hiding its value, and
orderingbelieved the name. Reading the allowlist off the compiled type answers "is this name in the SDL", which is not the same question as "does this field serve that column". A declared class attribute wins over the model-derived field of the same name, so a type could dropbiowithonly_fields, declarebio = CharField()with aresolve_bioreturning"[redacted]", and hand every client the redaction whileordering: "bio"ranked the rows by the raw column — the read oracle the projection exists to close, rebuilt out of the type's own declaration. OnCursorGraphqlPaginationit was not even a ranking: the hidden value came back verbatim insidepageInfo.startCursor. A maskediddid the same for the primary key, carryingordering: "pk"in with it. The compiler now marks a declared field whose value does not come from the column — the test is the resolver it compiled, so a field-levelresolver=and a classresolve_<name>are both covered — and the allowlist skips those columns. This rejects orderings that used to succeed on any type that declares a resolver over a model field name; a declaration with no resolver keeps the default attribute resolver, still serves the column, and stays orderable. The rule fails closed: aresolve_<name>that happens to return the real column loses the ordering term too, because no build-time check can read a resolver body. Drop the resolver, or publish the substitute under a different name. See Pagination › Ordering validation. - A pruned schema answered ordering questions with the FULL schema's
allowlist. Under
PERMISSION_SCOPED_SCHEMAthe pruned schema is a clone, and cloning a field carried its resolver through verbatim — including the paginator the allowlist is stamped on, which was derived once against the unpruned node type. A caller deniedview_authorwas served a schema with noauthorfield on the post node and no author type in the type map at all, and could still sendresults(ordering: "-authorId")and rank the rows by that foreign key. Every pruned clone now stamps its own paginator copy from the node type that clone publishes, so the answer belongs to the schema actually serving the request; a caller holding the permission keeps the term on the very same field. Both paginating shapes are covered: the list container'sresultsand a flatDjangoFilterPaginateListField. Schemas built without the flag are untouched. See Permission-scoped schema ›orderingfollows the pruned schema. - An anonymous caller could plant unbounded permanent cache entries.
GraphQLView.cache_key_prefixpartitions the response cache by a hash of theAuthorizationheader when the request is not authenticated — unverified input a client can vary per request — and each identity seeds its own namespace version counter, stored withtimeout=Noneso it never expires (it has to outlive the responses it namespaces). One anonymous client sending a fresh token per request therefore minted a fresh never-expiring cache key per request. The counter's namespace is now bucketed for any identity derived from an unauthenticated request: a fixed 64 buckets, so what an anonymous caller can create is bounded no matter how many credentials it invents. Authenticated identities (bounded by your user table) and the single sharedanonpartition keep their exact namespace. Isolation is unchanged — the response entry still carries the full identity, so two callers sharing a bucket never share a response body. Invalidation granularity is a behaviour change: two token-only callers that land in the same bucket now share a version counter, so a mutation from one makes the other's cached entries unreachable. The counter only moves forward, so that can only turn a cache hit into a miss — the next read re-executes against current data and nothing stale is ever resurrected — but a token-only client that relied on its reads surviving another client's mutation will see extra misses. Authenticate it to get its own counter back. A customcache_key_prefixinherits the bound automatically. The spent property is reachable by an attacker, and that is stated rather than closed: the bucket is a pure function of a caller-chosen header, so an unauthenticated client can hash candidates until one lands in any bucket it likes and evict that bucket's members. Salting would not help — the namespace is small by construction, so a caller that cannot aim can still cover it by volume. The ceiling is misses, never bodies. See Caching › Bucketing for unauthenticated identities and Views › Response caching and cache identity. - A hand-mounted interface field leaked its implementors' rows. The
permission-scoped schema derives a field's implicit label from its output
type's model; an abstract type has none, and the pruner treats an untagged
field as public. 2.2.0 closed this for typed GFK unions but left the sibling
interface arm open, so
field(SomeDjangoInterfaceType)handed a caller rows of every implementor while a direct field to the very same implementor type was pruned away. An interface's label is now the union of the read permissions of every implementor the schema mounts, exactly as a union's is — an AND, because the field can return any of them. A caller holding some but not all of thoseviewpermissions loses the interface field rather than keeping one that can still return a row they may not read; expose those implementors through their own gated fields instead. The implementors are read from the schema's ownget_possible_types, not from the process-wide registry: a registry is populated at class-definition time and is a strict superset of what any one schema mounts, so scoping the label to it would have cost a caller the field over a type no query could ever reach — turning a leak into an outage. The schema-level label set is derived from the same schema, so the two can never disagree. - A multipart POST under ASGI was bounded by nothing but the client's own
claim.
MAX_REQUEST_BODY_SIZEmeasures the body itself precisely becauseContent-Lengthis client-supplied — but that measurement reads the body, which formultipart/form-databreaks the streamed upload and collides with the CSRF check, so multipart was left with the declared length as its only check. Nothing downstream re-imposed that declaration under ASGI:ASGIHandler.read_bodyspools every chunk with no cap and hands the spool straight torequest._stream(noLimitedStream, unlike WSGI), andMultiPartParserbuilds its reader from_chunk_size, consulting_content_lengthonly to shortcut a zero-length body. A multipart POST declaringContent-Length: 100and sending 8 MiB was therefore answered200 OKwith all 8,388,608 bytes parsed, under a cap of 1 KiB. Multipart is now measured without being read:request._streamis seeked to its end and straight back, which allocates nothing, leaves the parser an untouched stream, keeps the upload streaming to disk, and reports the real size — the same measurement Django's ownHttpRequest.bodyperforms on a seekable stream, done one level up so the answer is a 413 rather than a 400. WSGI is unaffected: itsLimitedStreamalready truncates an under-declared body, and where the stream cannot be seeked a multipart POST declaring no length is refused with HTTP 411 Length Required instead. This rejects requests that used to be accepted — an under-declared multipart body now gets a 413, and a chunked one a 411 on WSGI — and only when the cap is configured; projects leaving it at itsNonedefault are unaffected. Note the boundary, now stated plainly in the docs: no view-level setting can stop a multipart body from being received under ASGI, only from being processed. Bound reception at your ASGI server. See Settings › What this cannot do under ASGI. - A column a type hid was still fully filterable — the sharpest read oracle in
the library.
Meta.only_fields/Meta.exclude_fieldsremoved a column from the SDL and, since 2.2.0, fromordering— but the filter input ignored the projection completely. It is compiled fromMeta.filter_fieldsagainst the MODEL, with no idea which columns the type publishes, sofilter: { bio: { exact: "…" } }answered exactly, in one request, for a column the schema said did not exist.icontainsturned the same argument into a prefix walk that recovers the value character by character, and the whole lookup set was published in the SDL as<Model>FilterInput.bio, so the oracle was discoverable by introspection rather than guessed. Every other door led to the same place: a relation-spanningauthor__bioreached it across a join, theand/or/notcombinators composed over it, a nested list filter and a per-fieldfields=override both resolved to the same shared per-model input, and a narrow declaration was silently widened to the model's root paths, so a list that declared onlynamestill servedbio. The projection is now what it was always documented to be — a security boundary, not an output shape — and afilter_fieldsentry naming a column its type projects away raisesImproperlyConfiguredwhile the schema builds, naming the type, the entry and the column. Each hop of a__path is measured against the type that publishes THAT hop, so hidingbioon the author's type also refusesPostType.filter_fields = {"author__bio": …}; widening a cached input is checked on the same path, so the second door is shut with the first. A schema that builds today can therefore fail to build after upgrading — which is the point: every schema that stops building was answering that oracle. The entry is refused rather than silently dropped, following the 2.2.0 precedent, because dropping it would repeat the exact defect 2.2.0 fixed — an option accepted and ignored — and only the operator can say which of the two contradicting options was meant. The fix is one line: publish the column (only_fields/include_fields, or drop it fromexclude_fields), or drop thefilter_fieldsentry. One boundary stays open, deliberately and documented: the BODY of an@filter_fieldmethod, whose argument is an opaque scalar and whose ORM lookup lives in user Python where no build-time analysis can see it — keep those bodies inside your own projection. Its name is checked, so the one-line rename out of a refusal is not a bypass; see the shared-predicate entry below. Subscription filter inputs were already correct (they are built from the projected output field names) and are unchanged. See Filtering › The projection is the outer boundary. - The ordering axis and the filter axis had each invented their own notion of
"hidden", and they contradicted each other. The two entries above were
written against the same rule and implemented twice: ordering read the
compiled type and honoured the compiler's mask stamp, while filtering
re-derived the answer from
Meta. On the identical declaration — a columnonly_fieldsremoved and a declared attribute put back with a resolver — the ordering axis said published and the filter axis said hidden. That is the same drift the ordering axis had already suffered internally, now repeated BETWEEN axes, and a schema whose two guards disagree is a schema whose rule nobody can state. Both axes now ask one predicate,core.output_compiler.publishes_column_value, against the compiled type that will serve the request: does this type hand out the VALUE this column holds? Absence, a projection, a masking declaration and a compiler-dropped relation are one answer, and neither axis can drift from the SDL again. Three behaviour changes fall out of it, all breaking, all in the same direction — closing a read the rule always prohibited:- A forward FK's
_idcolumn now follows the TARGET type's key.ordering: "author_id"was admitted unconditionally whenever the node publishedauthor, justified by "the id is already readable throughauthor { id }" — a claim about the author's type that nobody asked. Where that type projects its own key away,author { id }does not exist either, so the ordering ranked rows by a key nothing in the schema hands out. It is now refused withInvalid ordering field: 'author_id'., andfilter_fields = {"author": ("exact",)}is refused at build time on the same configuration, because a relation named with no tail filters on that same key. Publish the key on the target type if the ordering or the lookup is legitimate. - A
filter_fieldspath through a relation whose target model has no registered type now fails the build. The output compiler drops such a relation, so the schema could never name the rows the nested filter input reached — a substring oracle over a model no query can select. Register a type for the target, or drop the path. - An
@filter_fieldmethod spelled like a projected-away column now fails the build. Its name compiles the very<Model>FilterInputfield afilter_fieldsentry naming it is refused, so the rename out of a refusal is shut. The method body remains the documented open boundary; a method whose name is not a column on the model is untouched.
- A forward FK's
One over-refusal was closed in the same pass, in the opposite direction: the
same-name source= shortcut (bio = CharField(source="bio")) compiles to a
resolver that provably reads that very attribute, so it is no longer stamped
as a mask and the column stays orderable and filterable. A source=
naming a different attribute, and a resolve_<name>, still withdraw the
column. The rule, its one exception and the two boundaries it cannot close are
now stated once, in
Types › The projection is a security boundary;
every other page links there.
- The filter guard measured the projection of a type that was not serving the
request. It resolved the serving type by MODEL through the graphene
registry — a last-wins index a type opts out of with the public
Meta.skip_registry — so the boundary was measured against whichever type
happened to hold the model's slot. A narrow type that left the registry, or
simply lost the slot to a wider sibling declared after it, was checked against
the sibling's projection and its own hidden columns sailed through: the guard
was a no-op for exactly the type about to answer. The compiler path now NAMES
the type it is measuring — core.base.resolved_output_type on the pair being
built, so a permission-scoped clone is measured as itself, and a list-object
field is measured against the node it paginates rather than the container.
- A relation-direct filter entry over a reverse FK or a many-to-many skipped
the boundary entirely. {"posts": ("exact",)} filters on the post's
primary key, but a reverse foreign key and a many-to-many own no column on the
declaring model, so the predicate declined them and the last-hop check let
them through — while the byte-identical {"posts__id": …} spelling of the
same query was refused. Same query, two spellings, opposite answers. The
target's key is now asked of the target type directly, so both spellings agree.
- Two node types over one model shared one <Model>FilterInput, and only one
of them was measured. The input is cached per model and every context
converges on the model's root declaration, so a narrow DjangoObjectType
mounted beside a wide one is served the union of both declarations — while
the guard only ever saw the declaration in front of it. The narrow type's list
field was therefore filterable by a column its own SDL projects away, with no
build failure at all. The boundary is now measured against the union the shared
input will actually serve, and against every type that will serve it.
- A DjangoListObjectType's projection was silently discarded whenever a
DjangoObjectType was already registered for the model — which is the
ordinary documented arrangement. The container builds its node type from
Meta.only_fields / include_fields / exclude_fields only when it mints
it; reusing a registered type dropped all three without a word, so every column
the operator meant to hide stayed readable, orderable and filterable.
Declaring one in that situation now raises ImproperlyConfigured at class
definition, naming the option, the model and the type that registered the node
— the same answer 2.2.0 gave the identical defect on DjangoModelType. This
fails a schema that builds today, deliberately: only the already-leaking
configuration is affected, and the fix is to move the projection to the node
type, where it was always taking effect. See
Types › Configuration Options.
- Under PERMISSION_SCOPED_SCHEMA the two axes disagreed on the same pruned
schema. The ordering allowlist is re-derived from the pruned clone and
refuses a dropped relation's column; the filter argument and its whole nested
<Model>FilterInput rode through the prune verbatim. One schema, two answers —
and the surviving half was a prefix oracle over a model the clone does not
mount. The pruned filter input now narrows with the schema on the same
predicate: a relation the pruned node type no longer publishes is dropped from
it, and a nested input left over an unmounted model falls out of the type map.
The full schema is untouched, and each pruned variant carries its own clone.
See
Permission-scoped schema › filter follows the same prune.
- A filter_fields entry naming nothing was accepted and ignored. "pk" is
an ORM alias _meta.get_field does not answer to, and "id" names no column
on a natural-key model; either one compiled to nothing, so an operator
reading their own Meta believed the list was filterable by its key while
every request returned the unfiltered set. A segment naming no field on the
model that owns it now fails the build, naming the model's real primary key.
A lookup spelled into the KEY ("name__icontains") is refused by the same
guard, and for the same reason: lookups are declared in the entry's value,
so the compound key lands on the model's own leaves where _meta.get_field
does not answer to it and the field thunk dropped it. It compiled to exactly
as much as "pk" — nothing — and exempting it refused one dead spelling while
accepting its byte-equivalent twin. This rejects a declaration that used to
build: move the lookup into the value ({"name": ("icontains",)}), which is
what it was always compiled from.
Changed¶
- Subscriptions are now measured against
MAX_QUERY_DEPTHandMAX_QUERY_COST. Both transports validated with graphql-core's default rules, so the depth and cost guards never saw a subscription document — while Mutations says they are enforced on "query, mutation, and subscription selection sets" and Query optimization says "all GraphQL operation types". The WebSocket and SSE transports now validate with the same settings-driven rule tuple the HTTP view uses. A subscription's selection set is re-executed for every delivered event, so an over-deep or over-costly document was paid for repeatedly rather than once — the guard matters more here than on a one-shot query, not less. Which subscriptions this can reject is narrower than it sounds, and worth stating so nobody raises a limit they did not need to: the library's own generated event types are FLAT by construction — the build-time guard insubscriptions/guard.pyenforces it, and relations render as IDs — so a generated subscription measures depth 1 and cost 1, and neither setting can legally be set below 1. Only a hand-written subscription with a nested payload can trip either guard; that project must raise the limit far enough to cover its documents, or flatten the payload. BaseGraphQLView.format_erroris now an instance method taking the request —format_error(self, error, request=None), previously@staticmethod format_error(error). It has to be: the formatter decides whether to strip the schema suggestion by looking at the middleware chain, and the chain that actually ran comes fromget_middleware(request), the same per-request hook execution asks. A static, request-less formatter could reach neither, and a subclass that resolves middleware per request got a formatter whose verdict disagreed with the chain that ran. Migration: a subclass overriding it asdef format_error(error)(or a caller invokingMyView.format_error(err)on the class) must becomedef format_error(self, error, request=None). Calls through an instance —self.format_error(err)— are unaffected.
Fixed¶
- An SSE subscription sent no bytes at all until its first event. Django's
ASGI handler emits
http.response.startbefore iterating the body, but a server only writes the status line and headers when the first body chunk arrives — daphne stores them onhttp.response.startand flushes on the firstwrite(). So an idle stream left the client with no response whatsoever: a browser'sfetchnever resolved (the bundled client could not show a connected state), and an intermediary proxy times a silent connection out. Every stream now opens with an SSE comment line (:\n\n) before anything else — including before an in-stream denial, so a refused subscribe flushes too. A comment is not an event; conforming clients ignore it, and the bundled browser client was taught to as well — its parser had no comment guard, so it reported "Connection Error" on a healthy stream. Both transports are now verified end to end in a real browser. - The depth-limit refusal named an operation the client never sent. The
budget was labelled
'query'unconditionally, so an over-deep mutation — and, now that both subscription transports validate with the shared rule tuple, an over-deep subscription — was refused for a query that was not in the request. The label is the operation's own kind. - Both transports reported a document they could not SELECT from as the wrong
error.
get_operation_astanswersNonefor three unrelated reasons: the operation is not a subscription, the document carries several and the request named none, and the name matches no operation at all. All three produced "only serves subscriptions", which sends the caller hunting for a query or mutation their document does not contain. The two selection failures now name themselves — one says to pick an operation withoperationName, the other names the operation the document does not define. - A WebSocket subscribe could end in total silence. Two changes in this
release interact: both transports now validate with the shared rule tuple,
whose depth rule READS
MAX_QUERY_DEPTHat validation time, and the settings reader now refuses a limit of0.ImproperlyConfiguredis not aGraphQLError, so it never became a validation error — it escaped the operation task, whose done-callback only logged it. The client received noerror, nocompleteand nothing else, and waited forever: byte-for-byte the shape the malformed-payload fix eliminated, reached by another route, and reachable by a misconfiguration that fails loudly on HTTP while hanging silently here. Every operation is now wrapped so any escaping error is framed aserror{id};CancelledErrorstill propagates untouched, because a clientcompleteor a disconnect is a teardown and not a fault. - The SSE transport answered 500 where the HTTP view answers 400. A
variablesvalue that is not a mapping reached graphql-core, which raises a plainTypeErrorthat nothing wrapped, so it escaped the async view. Two reachable shapes: a JSON body carryingvariablesas an encoded string (which the HTTP view has always decoded), and any form-encoded body, where every value is a string by construction. The transport now decodes and validates it the same way the HTTP view does. - The published sdist could not run its own test suite. The allowlist ships
/tests, and three of those modules load a script from/scriptsby path (spec_from_file_location), which was not shipped — so collection aborted in the tarball. Same class as thepytest_coverage_isolation.pyomission fixed above it. - Two
pytestruns in one checkout reported each other's coverage, then0.00%.pytest-covcollects into a per-process file and then COMBINES every sibling file next to the configured data file. With the data file at the repo root, a second run in the same checkout is a sibling: each run swept up — and deleted — the other's partials, so the loser reported the winner's totals and thenFAIL Required test coverage of 95% not reached. Total coverage: 0.00%and exited 1, with 4120 tests passing above it. Every concurrent verification of this repository was unreliable, and the failure reads exactly like a real coverage regression. Each process now gets its own data-file directory, so the combine sweep can only find its own partials.coverage.xmlandhtmlcov/stay where they were, because CI uploads them from there. - The flat paginated list field ranked by a type its schema does not serve.
Every other ordering-allowlist stamper reads the node type the schema being
built holds;
DjangoFilterPaginateListFieldstamped_type._meta.graphql_output_type— the CLASS-DEF canonical instance — once, while the root class body was still executing. That instance is measurably not the object the schema serves (twelve of the fields this project's own suite builds resolve to a different one), and it agreed only because a fork recompiles the same class from the sameMeta. The allowlist is now derived from the served node type on every mount path, so a schema publishing less can no longer be ranked by a column its SDL denies. - A projection MIRRORING the reused type's own was refused. Declaring a node
type beside its
DjangoListObjectType— the ordinary documented arrangement — and restating the sameonly_fields/exclude_fieldson both failed the build, because the guard fired on the mere PRESENCE of the option rather than on it making a difference. Honouring a mirror and dropping it publish the same columns, so the schema cannot tell them apart and neither can a reader: the refusal cost a defensive restatement an outage, and it broke the "every common option in one place" sample in Types. The guard now compares the SELECTED columns, through the same predicateconverter.construct_fieldsselects with, so an equivalent projection spelled differently stands too. The same allowance applies to the identical guard onDjangoModelType. A projection that would genuinely change what is exposed is refused exactly as before. - A build-time refusal reached the caller as a
TypeError. Every guard in the filter builder raisesImproperlyConfiguredfrom inside a graphql-core fields thunk, and graphql-core rewraps ANYTHING a thunk raises as a bareTypeErrorwhose message chains generated type names. The schema build already dug the real error back out; the two EAGER force sites (compile_all_outputs, which the app-ready hook and every schema module call, and the forkedcompile_outputs_into) did not — so which site happened to force the thunk first decided the exception type the operator saw, and the documented contract was wrong for the most common path. All three now force fields through one helper that surfaces the buried configuration error. - A filter refusal named a
Metathat does not exist. The message opened with<Model>.filter_fields, and a model has noMeta.filter_fields: the declaration lives on a TYPE, and the input is shared per model, so the type that contributed the path is not always the type serving the rows. Each path now carries the name of the class whoseMetadeclared it — the node's, not the container's, when aDjangoListObjectTypeinherited the declaration — and the refusal names it, so "drop the entry" points at a file the reader can open. An entry whose deep hop names nothing is attributed the same way: the entry to the declaration it was written in, the missing segment to the model that fails to hold it. - …and then named a class that exists in no user file. An auto-expanded
to-many gets a container the reader never wrote:
get_or_create_list_object_typemints one and SEEDS itsMetawith the node type's ownfilter_fieldsso the nested list stays filterable. A seeded declaration looked self-declared, so the attribution above handed the blame toGenericListType.Meta.filter_fields— a factory class inside this library, which is the exact defect the fix above set out to close, reintroduced one code path over. The container is now credited with a declaration only when it is not the very object its node type holds, so an inherited entry names the node'sMetawhether the container was written by hand or minted by the compiler. - A relation refused for the wrong reason listed every cause but the likely
one. The traversal refusal offered three explanations — the projection
removed it, a declared attribute publishes the name over a leaf, or the
compiler dropped it for an unregistered target — and none of them fits the
reader who declared the to-one scoping hatch. They read "publish
author" while looking atauthorin their ownonly_fields. The message now names the fourth cause: a declared attribute publishing the name over a resolver of its own. totalCountwas counted whether or not the client asked for it. A list served byDjangoModelTypeissued itsCOUNT(*)eagerly, so a query selecting onlyresultspaid for two round trips where theDjangoListObjectFieldpath paid for one — while Pagination states the count is "only issued when the client actually selectstotalCount". The count is now deferred to first access, matching the sibling path and the documented behaviour. A client that does select it sees the same number as before, including under a filter or a page limit, because the deferred call still closes over the unsliced queryset.- A nested child was fetched and authorized twice on every write. The reverse-relation and many-to-many paths resolved the child by primary key, ran the ownership and scope checks, and then handed the raw payload to the writer, which resolved the same row again and repeated the same checks — two lookups plus two scope queries per host, per child, growing linearly with the payload. The already-resolved row is now passed through. Every check still runs, and runs against the same row: the authorization outcome is unchanged for every relation kind, for a permitted and a denied caller alike.
- A multipart part spelled the way the SDL spells it was silently dropped.
The merge that folds an upload into a mutation payload built its allow-list
from each input field's snake_case model attribute, so
profilePhoto— the only spelling a client can discover from the schema — matched nothing: the part was dropped and the mutation still answeredok: truewith no file written. Both spellings are now accepted, derived from the same compiled input field so neither can name a target the other does not. The projection guard is unchanged: a part naming a field the input does not publish is still ignored under either name. - A request naming one field under BOTH spellings let part order decide which
file landed. Accepting the alias and the attribute made a self-contradicting
request possible —
profilePhotoandprofile_photoin one body, two files, one column — and the merge folded the accepted parts in whatever orderrequest.FILESyielded them, so which file was written depended on how the client happened to serialize the body rather than on the body itself. The alias is now applied first and the model attribute second, so the attribute always wins and the outcome is a property of the request. A body naming a field under exactly one spelling, which is every ordinary body, is unaffected. - A malformed batch entry answered HTTP 500 where the docs promise 400. A
batch body was checked for being a non-empty list but never for what the list
held, so an entry that was not a JSON object reached
get_graphql_params, wheredata.get("query")raised anAttributeErrorthat escaped the view's error handler: posting[1, 2, 3]returned a 500 while Views documents a 400. Every entry is now checked alongside the outer list and a non-object one is rejected with the documented 400. A well-formed batch executes exactly as before. - A misconfigured write host was accepted in silence, three ways.
DjangoModelMutationnever ran the unknown-Meta-option check its siblingDjangoModelTypehas run since 2.0, soexclude_field— a typo forexclude_fields— built a mutation whose input still carried the very column the declaration meant to hide, and aMeta.querysetwas accepted while scoping nothing (this host scopes throughfilter_queryset).permission_classeswas worse than a no-op: the plain assignment raised a raw Pydantic error telling you to annotate itClassVar, and taking that advice bought a class that builds with a permission that never fires, because the class reads the name nowhere. And anested_fieldskey naming no relation was skipped when the input surface was built and skipped again on the write path, yet the generated input type was still named after it — so{'bookz': Book}minted anAuthorCreateNestedBookzTypecarrying no nested field at all, on both hosts. All three now raiseImproperlyConfiguredat class definition, the nested one listing the accessors that would have worked. A schema that builds today can therefore fail to build after upgrading — which is the point: each of these classes was running a configuration its author had written down and the library had thrown away. - An unknown
Metaoption that is real on a sibling class read as a typo.registryis a supported option onDjangoModelMutation, but declared on aDjangoModelTypeit was reported with nothing but "Check for typos", sending you hunting for a misspelling in a word spelled correctly. The message now names the classes that do accept the option, read from their own signatures so the list cannot drift out of date. - …and then told you to look for the typo anyway. The sentence naming the
sibling class was appended to the typo hint rather than replacing it, so the
user who spelled
registrycorrectly still read "Check for typos" and went looking for a misspelling that was not there. The hint is now emitted only when at least one unknown name is aMetaoption on no class at all. A real typo reads exactly as it did. - A write-only
DjangoModelTypeseized the model's read container. Every generated list container claims the model's canonical registry slot on a last-write-wins basis, and that slot is what a reverse to-many relation resolves through — so a host declaringMeta.model_operations = ("create", "update", "delete"), which says in as many words that it does not servelist, still displaced theDjangoListObjectTypethe project had declared for its reads. Attaching a permission to a model's write path (permission_classeslives onDjangoModelType, not onDjangoModelMutation) therefore silently rewrote that model's shape everywhere it appears nested, and a query selecting the declared container'spageInfostopped validating. A host that does not servelistnow hands the slot back to whoever holds it, and still fills an empty one so a project with a single write-only host keeps that host's own pagination. A host that does servelistis unchanged, and_meta.output_list_typeis still populated on every host, solist_object_type()andListField()are unaffected. - Both subscription transports stopped importing without configured Django
settings. Sharing the HTTP view's validation-rule tuple — and, for SSE, its
CSRF guard — put a module-level
from ...views import ...in each transport, andviewsreachescore.permission_signature_cache, which readsDJANGO_GRAPHEXwhile it is being imported. Soimport django_graphex.subscriptions.transports.wsraisedImproperlyConfiguredon the import alone, breaking any ASGI entrypoint that reaches the consumer (directly or through a routing module) before it points the process at a settings module — a dependency neither transport had before. Both imports are deferred into the request/operation path, so the shared rule tuple and the shared CSRF guard are unchanged and the transports import settings-free again. - Two write-only hosts over one model resolve to the first one declared. The
container-slot fix above reverses the old outcome for this case: the first
host fills the empty slot, so the second finds it taken and hands it back,
where last-write-wins used to give the slot to the second. Neither declaration
claims
list, so nothing in either ranks them; first-wins is kept because it is the choice that does not move a model's read shape when a second write concern is added later, and it drops nothing — the losing container is still on its own_meta, and a write-only host'sListField()raises, so it can never reach a schema. Only a project declaring two write-only hosts for one model is affected; one host, or any host that serveslist, is unchanged. - The test suite was order-dependent: some shuffles died with
Schema must contain uniquely named types. No library behaviour changed — the defect was test hygiene. The output registry is keyed by model, so when two unrelated test modules declared a class under the same GraphQL type name for the same model on the global registry, one module kept its own compiled node while the registry handed the other's out through the relation graph, and any schema reaching both died. Nine such names were reachable from a real schema (_PostListType,_PostType,_PostT,_TagT,_AuthorT,_AuthorType,_AuthorList,HookType,ScopedDocType); each is now module-unique. The four node types the six subscription-transport modules each re-declared, and the subscription schema each of them rebuilt on every call, now live once intests/subscriptions/_transport_schema.py. The root cause behind two of the names wastests/test_optimizer_phase_c.pypassing{}asMeta.registry: an empty dict is falsy, soMeta.registryfell back to the global registry and every type built there published itself process-wide. Two further modules now keep the throwaway types they declare only to read_metaoff the global registry entirely, and the twoScalarKindsModelround-trip tests mount only the scalar field they assert instead of the whole compiled type, so neither drags the shared relation graph into a schema. Verified with the full suite under 150 distinct--randomly-seedvalues, including the seeds that used to fail. - The SSE subscription endpoint answered HTTP 500 for a JSON body that was
not an object.
[1,2,3],"x"andnullall decode cleanly and only then broke the transport's mapping assumption (body.get(...)→AttributeError), so SSE was the one surface in the library returning a server error for a bodyGraphQLViewalready classifies as a client error. It now answers400 Bad Requestwith the HTTP view's own message, The received data is not a valid JSON query. The same decode guard now also catchesRecursionError, so a deeply nested JSON body — which is not aValueErrorand used to escape as a 500 — is a plain 400 too. A well-formed request on the endpoint is unaffected. - A WebSocket
subscribewith a non-objectpayloadwas silently dropped. The payload went straight into the operation task, wherepayload.get(...)raised, was logged, and then vanished: noerrorframe, nocomplete, no response of any kind, leaving the client unable to tell the protocol violation from a slow server. The consumer now answers with its own{"type": "error", "id": …}frame — the same shape every other malformedsubscribegets — and the socket plus every operation already running on it keep going. - A limit set to
0did the opposite of what it says.MAX_QUERY_DEPTH: 0andMAX_QUERY_COST: 0were read as falsy by their validation rules, which returned early — so a limit that reads as "allow nothing" allowed everything, with no warning, whileNonewas documented as the way to disable them. A negative value was worse: it enforced a budget no query could meet and named that negative in the error.PERMISSION_SCHEMA_CACHE_MAXSIZE: 0had the same shape in reverse, quietly restoring the default64instead of caching nothing. All three are now validated where every reader routes through — the settings reader itself — and a value below the key's minimum raisesImproperlyConfigurednaming the key, the minimum and the remedy.Noneremains the only off switch for the two query limits, and0is now honored for the cache bound as "cache nothing". Projects on the defaults are unaffected; a project that had written0was already running without the guard it thought it had. See Settings › Query depth & cost. PERMISSION_SCHEMA_CACHE_MAXSIZEwas frozen at import. The bound was captured in the cache's constructor and the cache is a module singleton, so the setting was fixed for the life of the process andoverride_settingscould never reach it. It is now read on every eviction pass instead; nothing else about the cache's identity depends on it, since the bound only decides when to evict, never what a key means.MAX_REQUEST_BODY_SIZEturned every multipart POST after the first into a 500.dispatchcarries@ensure_csrf_cookie, whose token check readsrequest.POSTwhenever the client holds acsrftokencookie — and for multipart that drains the upload stream. The body guard then calledlen(request.body)on the drained request and raisedRawPostDataException. Since the endpoint plants that cookie on every response, only a fresh client's first multipart POST ever worked. The same read also dragged multipart under Django'sDATA_UPLOAD_MAX_MEMORY_SIZE(2.5 MB by default), which a streamed upload otherwise escapes, so a 3 MB file that uploaded fine with the guard off became an opaque Django 400 with the 20 MB cap the docs recommend — and the documented workaround, raisingDATA_UPLOAD_MAX_MEMORY_SIZE, doubled peak memory by pulling the whole body into RAM. The guard now measures multipart without reading it — every other content type is still measured by reading. This fix first fell back to the declaredContent-Lengthalone, on the belief that Django caps the request stream atCONTENT_LENGTH; that is true only under WSGI, so the fallback bounded nothing under ASGI. See A multipart POST under ASGI was bounded by nothing but the client's own claim above for the measurement that replaced it, and Settings › How the guard reads each content type.DOCUMENT_CACHE_MAXSIZE = Nonetook the whole endpoint down.Noneis the documented "no limit" value for every sibling bound in the namespace, but here it reachedint(None)and every single request came back HTTP 400 carrying the leakedTypeErrortext. It now means unbounded, as it reads.- Three malformed bodies answered 500 instead of 400. A deeply nested JSON
body (20 KB is enough) raised
RecursionErrorout ofjson.loads, which is not aValueErrorand so missed the decoder's handler; anapplication/graphqlbody that is not valid UTF-8 raisedUnicodeDecodeErrorfrom adecode()the neighbouring JSON branch already guarded; and withCACHE_ACTIVEaquerythat is not a string, or one holding a literal nested past the parser's recursion limit, escaped theexcept GraphQLSyntaxErrorin the cache's pre-parse. All three are ordinary bad client input and are now reported as such. The pre-parse now matches the sibling call site inexecute_graphql_request, which already treated any parse failure the same way. - The documented
source=shortcut silently cost a column its ordering. The ordering guard treats a declared attribute carrying a resolver as publishing the field's name without its value, andsource="x"is compiled into a resolver — soemail = CharField(source="email"), the no-logic shortcut Types documents, madeemailunsortable on a type that hides nothing, andid = IDField(source="id")took the primary key with it, which is the tiebreak every cursor page needs. A source naming the field's own attribute is now recognized as the passthrough it is. A source naming any other attribute, and aresolve_<name>, are unchanged: both still withdraw the column, because no build-time analysis can read a resolver body. - A filter refusal named the type it asked, not the type you have to change.
A deep path (
author__posts__title) told the reader to publish the missing hop on the root type, which never held it, and a relation the compiler dropped told them to publish a name whose absence has a cause the message did not list — noMetaedit brings back a to-one relation whose target model has no registered type. Each refusal now names the type owning the failing hop, and a dropped relation names the target model that needs aDjangoObjectType. The documentation's standing advice to disregard the message is gone with it. FilterBackend.build_input_typecould not name the serving type, so the public seam fell back toregistry.get_type_for_model— the model-keyed, last-wins index a type opts out of withMeta.skip_registry, and the very lookup this release removed from the compiler path — which made the projection guard a no-op through it. The seam now takesnode_typeand forwards it. The registry fallback is kept for callers that name none.- Three write-only assignments on
DjangoObjectType._meta(only_fields,include_fields,exclude_fields) were removed. Nothing read them: the output compiler takes the projection from the registration entry, and neither projection guard reads a declaration at all. The comment beside them named a reader that does not exist. - Every authenticated SSE subscription died with
SynchronousOnlyOperation.AuthenticationMiddlewareleavesrequest.useran unresolvedSimpleLazyObject, and the SSE view isasync: the first reader ofinfo.context.user— anyauthorize_subscriptiongate, or aschema_providerpruning by permission — therefore fired the session and user query inside the event loop. A gate sawYou cannot call this from an async contextin place of its answer and denied the subscription; a provider raised it straight out of the view, past the in-stream framing, as a 500. Anonymous callers never noticed, because an empty session resolves toAnonymousUserwithout touching the database — which is why every SSE test (all of which injected an already-resolved user object) and the anonymous playground samples stayed green. The view now resolves the user once, in a thread, before anything else reads it; because aSimpleLazyObjectresolves in place, every later reader — hooks, middleware, resolvers reaching back throughcontext.request— sees a plain loaded user. The WebSocket transport was never affected: Channels'AuthMiddlewareawaits the lookup before the consumer runs. Both transports are now pinned to the same contract by one test matrix driven through the real middleware chains — authenticated, anonymous, a session whose user row was deleted, no session at all, and an auth gate that denies as well as one that grants. - An
async defsubscribe gate failed OPEN. The engine awaits an awaitable hook result, andsubscription_scope's wrapper forwards one — butauthorize_subscription's wrapper discarded its return, so a gate declaredasync defproduced a coroutine nobody awaited and the subscribe was granted. Python reports it as nothing louder than aRuntimeWarning: coroutine … was never awaited.async defis the only shape that can reach the ORM from these hooks, because both transports run them on the event loop, so this is the shape a permission check wants. The wrapper now forwards the return, like its sibling. A synchronous gate behaves exactly as before. See Subscriptions › ORM access in the subscribe hooks, whose previous text claimed both hooks were lifted into a thread pool and could query the ORM freely — they never were, and that claim is what made the async-context failure above look impossible. - The bundled subscription client's pre-filled document was a syntax error.
Its editor shipped a
subscription { }whose entire selection set was commented out, so pressing the run button — step 2 of the playground's own walkthrough — answeredSyntax Error: Expected Name, found '}'. The editor now ships a runnable document, and the introspection the client already performs for autocomplete renames its placeholder field to the first subscription the live schema advertises, so the button works on first press against any schema. An editor the reader has already typed into is never rewritten.
Removed¶
- Six internal names that nothing in the package reached, each confirmed
callerless before deletion. None was exported through
django_graphex.__all__or documented anywhere. Four of them were nonetheless importable by name —utils.pyandconverter.pydeclare no__all__, sofrom django_graphex.utils import get_obj,create_obj,converter.assert_valid_nameandconverter.convert_choice_nameresolved on 2.2.0 and raiseImportErrorhere. None had a caller inside the package, andcreate_objreturned an error string rather than raising, so a project depending on it was already handling a value it could not distinguish from success. The names:paginations.utils._nonzero_int(which also still carried the zero passthrough its sibling_positive_intdocuments fixing —_nonzero_int(0, strict=True)returned0instead of raising, and it accepted negatives);Registry._interface_implementors(never written and never read —get_member_modelsderives implementors from_typeson demand, so the second store could only go stale);utils.get_objandutils.create_obj(test-only, andget_obj's wholeexceptchain was unreachable becauseget_Object_or_Nonealready swallows those exceptions); andconverter.assert_valid_name/converter.convert_choice_name, superseded by_is_valid_nameandchoice_enum_name. - The
NullBooleanFieldkey from the two internal-type-keyed field maps (core.fields.FIELD_TYPESand the filter-schema scalar map). Django reports"BooleanField"fromget_internal_type()for aNullBooleanField, so the key was unreachable in both — and the package's third field map had already dropped it, leaving the three to drift apart. ANullBooleanFieldresolves to a boolean exactly as before, now through one key in all three. - The
CAMELCASE_ERRORSsetting, which had zero consumers. It shipped with a documented default ofTrueand a promise to camelCase thefield/pathkeys of error objects; no code in the package ever read it, so setting it — to either value — changed nothing at all. It is removed rather than implemented, because a setting an operator flips while believing something changed is worse than no setting. A project that still passes it now gets thedjango_graphex.W001unknown-key warning frommanage.py checknaming it, and error payloads are byte-identical to 2.2.0.
Documentation¶
- There is an upgrade guide for 2.x → 3.0. The 1.x → 2.0 jump had a
605-line guide in the nav; the largest set of breaking changes since 2.0 had
only this changelog, organised by category — which answers "what changed" and
not the question an upgrader actually brings, "what will break, and when will
I find out?". Upgrade Guide (2.x → 3.0) is organised by
when the failure reaches you: the build stops, nothing fails until a
request arrives, your clients start getting refused, your own code changes
behaviour, your SDL changes. It opens with five
rgcommands that triage a project in about a minute, because three of these changes do not fail the build and one of them is a gate the reader wrote that was never running. Every message in it was produced by executing the code on 3.0.0 and compared against the same code on 2.2.0 — which is how the two corrections above were found. It also names the three diff hunks that look like breaking changes and are not, so nobody edits working code because of them. - Nothing told you to validate the WebSocket handshake's
Origin, and the example project did not. A handshake is an ordinary HTTP request: the browser sends cookies with it and CORS does not apply, so any other site could open a socket to a session-authenticated endpoint as your logged-in visitor and read every subscription that user is entitled to. It routes straight aroundREQUIRE_CSRF_HEADER, which this release turns on for exactly this threat on the HTTP side and never sees this endpoint. The library cannot fix it — it never sees the handshake, your ASGI routing does — so Subscriptions › Validate the WebSocket handshake's Origin now says so under adangeradmonition with the wiring, and the playground shipsAllowedHostsOriginValidatoraround its router, pinned by a test that rebuilds the app under a real host list (the playground's ownALLOWED_HOSTS = ["*"]makes the validator accept everything, so a test that did not rebuild it would pass without the wrapper). - The multipart upload rule was documented as it behaved before this
release.
docs/api/mutations.mdstill said every entry inrequest.FILESis merged under its own form-field name. It is now merged only when the name matches a file field the mutation's input publishes, under either spelling, and a part matching nothing is ignored rather than saved — the projection is the same boundary on the multipart path as on the JSON one. - The example project's public subscription published a column its query
surface hides.
CommentSubscriptionnamesMeta.modeland declared no projection, sointernalNote— described in the model as a moderation scratchpad — was selectable and equality-filterable by an anonymous subscriber on both transports, whileCommentTyperefused it everywhere else. The subscription now restates the exclusion, and the model comment, which claimed the column was "written in the Django admin, never on the wire", says what is actually true: the playground registers no admin at all, and the subscription surface is the fourth place the column has to be hidden because it is the only one that inherits nothing. The library-level gap it compensates for is unchanged and stays pinned by its own isolated test — see Types › The one exception, and the three open boundaries. - An upgrade instruction that was the exact inverse of reality. The
3.0.0 preamble listed "importing either subscription transport
before Django settings are configured" as something that "now raises
ImproperlyConfiguredon the import alone" — under a heading about schemas that stop building. That is what this release fixed, as its ownFixedentry says; no published version ever raised it. An upgrader following the preamble would have rearranged their ASGI entrypoint for a failure that does not exist. The bullet is gone. - Three claims in the example project that a reader could disprove. The
password-hash lesson said the hash reaches anonymous callers without
UserType.exclude_fields;Author.resolve_useralready stops those, so the true statement — every authenticated caller, which is still an incident — is what it says now, with the second wall named rather than conflated. The validation section demonstrated a "failing validation" withtitle: "", which returnsok: trueand creates the row, because validation is Pydantic andblankis a form-level concern Django never enforces onsave(); the sample is now amax_lengthrefusal with its literal message, and both halves are pinned by a test. A pruned-schema refusal was quoted without the tail this release added to it. - A projection rule the code does not implement, and a table that outgrew its
own sentence. Types › What "hidden" means
promised that a relation published over a type bound to another model
measures as unpublished on both axes, on the grounds that no target answers
for the key. It never did, and it must not: such a declaration carries no
resolver, so the default attribute resolver hands out the real target row and
author { id }returns the author's own primary key — refusing to rank by a value the same request returns is precisely the SDL-versus-guard drift the predicate exists to end. The promise is withdrawn rather than implemented, and the shape it described is named for what it is: a schema bug, not a projection boundary. The sentence introducing that table also announced four causes over six rows; the table is now five rows and the count matches. - The example project demonstrated none of the 2.2.0 permission story, and its
nested mutation was the exact shape those release notes call vulnerable.
examples/playgroundimported five permission classes to assign one, and neithercommentCreatenor the nestedpostWithCommentsCreatecarried any — so an anonymous caller created aPostand itsCommentrows through the parent, in a project whose README claimed it showed "every major feature". Comment writes are now served by aCommentModelTypecarrying theIsOwnerOrReadOnlythe module already defined, which is the same gate the nested write runs: the caller denied the child's own mutation is denied it through the parent, and the parent rolls back with the denial.PERMISSION_SCOPED_SCHEMAis switched on so/graphql/secure/serves each caller a schema pruned to their model permissions — including the nestedcommentsinput, which is stamped with the child's permission — and the README's coverage matrix now separates what the playground demonstrates from what it merely imports. See Permission-scoped schema. - Four pages described a codebase that had moved. The playground schema tour
still named a
CommentMutationthat no longer exists and still calledIsOwnerOrReadOnlya pattern no type assigns; the example project's settings claimed subscription connections validate against a pruned schema, when both transports there are wired withschema=and pruning happens only throughschema_provider=; the mutation examples still said a multipart part must be named after the model's snake_case attribute while the guide it links to now says either spelling works; and theDjangoModelMutationMetareference claimed its table was the whole list while the graphene base still acceptsname,_meta,interfaces,possible_types,default_resolverandcontainer. All four now say what the code does. - The multipart upload pages never named the part that carries the document.
Both the guide and the API reference described "the part carrying the JSON
body under whatever key your view reads", which sent readers looking for the
operations/mapenvelope of the graphql-multipart-request spec — an envelope this library does not implement. A multipart body is read straight out ofrequest.POST, so the parts arequeryand (optionally)variables; anything else answersMust provide query string.with HTTP - Both pages now name them, show a working
requests.postcall, and carry theX-Requested-Withrequirement (the API reference had neither). See Mutations › Automatic multipart uploads. - Three more pages contradicted the code, and one contradicted another page.
The directives guide still called the spec bundle three directives in three
places — it is five of thirty (
@skip,@include,@deprecated,@specifiedBy,@oneOf), which the API reference already said; the pagination API reference still promised backward cursor pagination "for a future release" while the guide it links to says the work is not scheduled, andCursorGraphqlPagination.__init__takes nolast/before; theDjangoObjectTypeMetatable was billed as the full set yet omittedunionsandname, so its "every other key is rejected at startup" was false; and the nested-list example named aGroup.usersaccessor Django does not create (user_set, compiled asuserSet) while promising aDjangoListObjectTypethe example never declared. All four now say what the code does. - Two links 404'd and three snippets could not have run. A relative link in
the blog-schema tutorial resolved to a
permissions.mdthat does not exist beside it, and the frontend guide reached four directories above the site root forexamples/playground/. Building both tutorial schemas and validating the guides' queries against them also caught three snippets that fail before the server sees them: a nested M2M selected astags { name }when a nested list is a container (tags { results { name } }), astatusvariable typedStringwhere a field withchoicescompiles to<app_label><Model><field>Enum, and enum values sent as the model's stored'draft'/'published'instead of the schema'sDRAFT/PUBLISHED. DjangoObjectType.get_querysetdid not scope a to-ONE relation, and nothing said so. The to-MANY boundary was documented — an auto-expanded nested list reads the parent's prefetch cache and skips the hook — but the forwardForeignKey/OneToOneFieldarm has the same boundary and was documented nowhere, whileapply_object_type_get_querysetcalled itself "the single choke point … every path". So{ allAuthors { results { name } } }hides a scoped-out row and{ allPosts { results { author { name } } } }serves it. The boundary note now names both directions, spells out thatget_querysetis a field-level scope that must not be relied on to hide relation-reachable rows, and shows the escape hatch for each: an explicitly declared relation field — aDjangoFilterListFieldfor the to-MANY arm, aField(TargetType)plus itsresolve_method for the to-ONE arm. A bareresolve_<relation>method with no declaration does nothing — an auto-expanded relation field is derived from the model, so nothing on the parent class is consulted — and the note now says so, because a mitigation snippet that silently no-ops is worse than the boundary it claims to close. Both snippets are executed by the suite. The docstring names its three real callers instead of claiming every path. The behaviour is unchanged: enforcing the to-ONE arm would trade theselect_relatedjoin for one query per parent row on exactly the types that opted into scoping. See Types › Custom queryset (per-request filtering).- The example project shipped an upload demo its own settings rejected. The
playground advertises a 5 MB
Base64FileInputupload and a 20 MBMAX_REQUEST_BODY_SIZE, but left Django'sDATA_UPLOAD_MAX_MEMORY_SIZEat its 2.5 MB default — and a base64 upload travels inside the JSON body, so a 5 MB file was refused with an opaque HTML 400 long before either library cap saw it. The project now setsDATA_UPLOAD_MAX_MEMORY_SIZEto match, and the settings guide has a table saying which content types that ceiling applies to: base64 and every other in-memory body, never multipart, which streams to disk. The views guide gained the matching per-content-type note on the 413 guard. - Two pages disagreed about
0in one release. The settings reference saysMAX_QUERY_DEPTHandMAX_QUERY_COSTrefuse0and negatives withImproperlyConfigured— but Query depth & cost limits, the page a reader actually lands on for those two settings, still presented them as ordinary values (None (default) = never block, with nothing said about the boundary). It now states thatNoneis the only way to disable either guard, and disambiguates the neighbouring note about a per-typeMeta.max_depth = 0, which is a real value there — that one forbids nested objects rather than disabling anything. - The published benchmark table was measured on 2.0.0, read from gitignored
files, and rested on single runs. Why django-graphex went out in
2.2.0 with a table whose own conditions list said
django-graphex 2.0.0, from result files nobody could open — a reader could neither tell which code had been timed nor check one figure. All eight artifacts are now tracked, were re-measured on this branch's code, and each is the median of three runs, recorded in the file under a newaggregationkey. Three runs matter: repeating identical code minutes apart drifts up to 8 % on the reference hardware, so the old single samples could not resolve any smaller difference — and an earlier re-measurement on this branch was discarded rather than published because latencies rose uniformly across all four libraries, three of which nobody here has touched. Against the 2.0.0-era figures the operations read:nested15.97 → 12.26 ms,create_comment0.62 → 0.58 ms,filtered1.18 → 1.16 ms,flat_list0.81 → 0.79 ms,single0.38 ms, unmoved. The scaling pair behind theO(table)claim holds: graphene'sfilteredreads 3.23 ms at 1,000 authors and 5.17 ms at 2,000, against graphex's 1.19 and 1.16. No SQL count moved on any operation of any library, and nosurfacemoved — the structural story is exactly what it was. - "Schema build" was never measuring schema build. The figure was the wall
time of
import bench_schema, which pays the library's entire dependency tree and the schema construction in one number — and those differ by two orders of magnitude, so their sum answers neither question. Measured apart: strawberry costs ~106 ms to import and ~6 ms to build, ariadne ~49 ms and ~2 ms, graphene-django ~10 ms and ~4 ms, graphex ~9 ms and ~3 ms. So the old claim that "strawberry pays 10× and ariadne 5×" was describing how heavy those libraries are to load, not how fast they compile a schema — and on compilation the ordering is different again. The harness now reportsschema_import_ms(the cold import, the comparable one) andschema_rebuild_samples_ms(five rebuilds with the dependency tree already loaded) separately. - The cold-import row was biased toward graphex; the harness now removes the
bias rather than the page apologising for it.
run_all.shrunsmakemigrations,migrateandseed_benchunder the graphex virtualenv, so graphex was timed warm and the other three cold — on the one row where the libraries are closest. It now warms every virtualenv before the measured loop, and the spread on that row fell from 24–51 % to 3–15 %. Even so, no winner is claimed between graphex and graphene-django: successive revisions of that page named opposite ones — graphene by 0.05 ms, then graphex by 1 ms — and both were beneath the instrument's resolution. - The rebuild series ships as raw samples, because it is a diagnostic and not
a ranking. Re-executing declarations perturbs each library's process state
differently: django-graphex's cost climbs across repeated in-process
rebuilds (
[2.99, 3.38, 3.70, 3.89, 4.34]is typical) where ariadne's is flat, andgc.collect()between rebuilds makes graphex's worse rather than better, so something is retained. It has no effect on a deployment that builds its schema once. It is not a cache hit either — in all four libraries the rebuilt schema object, itsGraphQLSchema, its Author type and that type's fields are all new objects. Part of that build cost is this release's own projection boundary, priced rather than implied:benchmarks/guard_cost.pycounts and times the shared predicate at 46 calls / 0.69–0.71 ms and 17 calls / about 0.015 ms pernestedrequest, 0.13 % of that operation. - The example project demonstrated none of this release. The 2.2.0 headline
— automatic multipart uploads — had no write host to exercise it at all
(
Documentwas read-only, reachable only through a hand-written base64 mutation), so the feature the release is named for could not be run.Document.fileis nowDocument.attached_file(a two-word column, because a one-word one spells both accepted part names identically and therefore cannot show that either works) behind aDocumentMutationservingdocumentCreate/documentUpdate.Commentgained aninternal_notemoderation column that the schema hides on both sides, so one projection can be watched travelling through the output type, the filter input, the write input and the nested child input aMeta.nested_fieldsparent exposes. Both arms of the relation scoping hatch are mounted —CategoryType.postsandAuthorType.user— beside the auto-expandedAuthorType.poststhat is not scoped, so the pair teaches the boundary instead of implying a defect.UserTypenow excludespassword, which any anonymous caller could read throughAuthor.user. The two settings that ship on are named and explained inconfig/settings.pyas comments rather than pinned to their own defaults. And the README's own suggestedsearch: "django"returned zero rows, because no seeded post contained the word: each body now names its tag, so it answers - The suite grew from 23 tests to 49, several asserting the verbatim answer strings the README quotes.
docs/usage/settings.mdtold you to budget for an envelope this library does not implement. Two pages were corrected in this release to say the multipart contract is aquerypart plus an optionalvariablespart — with nooperations/mapenvelope — and the settings page was missed, still telling readers to sizeMAX_REQUEST_BODY_SIZEandDATA_UPLOAD_MAX_MEMORY_SIZEagainst "a multi-thousand-entrymap". Posting that envelope answers400 Must provide query string; the page now names the two parts that do count.- A sample in the types API reference could not run for a reader who followed
the page. The Types API reference under
DjangoModelTypeshowed a read-onlyUserCard(DjangoModelType)carryingonly_fieldsoverUser— and the first code block on that page registers aUserTypeoverUser, which makes exactly that declaration raise theImproperlyConfigureddocumented in the warning ten lines below it. The sample now uses a model no other block on the page registers, and the warning quotes the refusal the old one produced. - The projection boundary had a third open edge nobody had written down. A
hand-written
SubscriptionbindingMeta.modelcompiles its event type and its<Model>SubscriptionFilterInputfrom the model, so it does not inherit the projection of whateverDjangoObjectTypethe schema registered for that model: a column hidden on the node type stays selectable and equality-filterable there. The remedy is one line — repeat the projection in the subscription's ownMeta— and a subscription generated byDjangoModelType.SubscriptionField()needs none, because that host forwards its own projection into the class it builds. Stated once alongside the other two open boundaries in Types › The one exception, and the three open boundaries, and repeated where a reader meets the filter surface. - A build-time guarantee that reads stronger than it is. The filter axis
refuses a
filter_fieldsentry naming a hidden column while<Model>FilterInputcompiles — and that input compiles only because some field mounts a filtered list of the type. A declaration on a type nothing mounts that way is never measured and the schema builds in silence. Filtering › The projection is the outer boundary now says so: a clean build is a statement about the filter surface you serve, not a review of everyMetain the file. - Three pages still described pre-branch behaviour. The sample application said two hosts over one model are allowed "only while neither declares a projection", which stopped being true when this release started accepting a projection that mirrors the registered type's own; the same page and the types guide now say so, and say why restating it is worth doing. The bundled subscription client's editor was described as something you type into, when it now ships a runnable document and renames its placeholder field from introspection — including what happens when introspection is off. And the playground page documented neither the multipart host, nor either arm of the relation hatch, nor the projection boundary, nor the two settings that ship on; it now walks all four with the answers they return.
- The nav never reached the 2.0 upgrade guide.
docs/UPGRADE-2.0.mdis built and linked from three pages, but nonav:entry listed it, so it was reachable only by following a link. It is now under Getting Started.
2.2.0 — 2026-08-24¶
Security release. Upgrade if you use Meta.nested_fields.
Two authorization defects let a caller write child rows they were not permitted
to write, both reproduced over the wire against a real schema before being
fixed. A nested write never consulted the child's own permission_classes, so a
parent a caller could write was a door into a child they could not; and
Meta.nested_fields derived the child's input from a shared registry slot
rather than from the child's declared type, so a column hidden with
exclude_fields — the shape the guides teach for is_staff and friends —
stayed writable through the parent, and through the child's own mutation too.
PERMISSION_SCOPED_SCHEMA made the first one worse rather than better: it
pruned the child's own mutation and left the nested write surface untouched, so
it granted confidence it had not earned. Details in Fixed below.
The rest is a correctness pass over the 2.1.0 audit backlog: confirmed defects, each reproduced before the fix and covered by a regression test.
One behaviour change worth calling out before you upgrade: declaring
only_fields / exclude_fields / include_fields on a type whose output type
is reused from the registry now raises ImproperlyConfigured at class
definition instead of dropping the option silently. A schema that builds today
can therefore fail to build after upgrading — which is the point: every schema
that stops building was silently exposing a field it was told to hide.
One rename is wire-visible: the list container a DjangoModelType generates is
now <Model>ListGenericType instead of <Model>ListType, which was colliding
with the name the guides give your own DjangoListObjectType. Field names and
shapes are unchanged; only a client document that spells the container's type
name out needs updating.
A second rename is wire-visible for the same reason: the child input a nested
field exposes is now built per nesting parent and named
<Child><Op>In<Parent>Type (for example CommentCreateInPostType) instead of
borrowing the child's own <Child><Op>GenericType. Field names and shapes are
unchanged apart from the fix below; only a client document that spells the
nested child input type name out needs updating.
Nested writes now run the child's own permissions, which is a behaviour change
in two directions. A nested payload that used to be accepted can now be denied —
that is the fix. And the child's checks are called with a new nested_parent
keyword argument, which is passed only to a check whose signature can absorb it:
an authorize override or a permission class that spells its arguments out
(def authorize(cls, info, action, data=None)) keeps working and simply never
sees the marker, treating a nested write exactly like a direct one. That holds
for a has_permission override too — the narrowing happens at the call that
lands on your method, not at the outer call site, so the **kwargs on the
built-in has_<action>_permission in between does not leak the marker through.
Accept **kwargs to see it.
Nested pk lookups resolve through get_queryset, which folds in Meta.queryset
— but only for the child hosts that serve the write. Which hosts those are
follows from Meta.model_operations, which DjangoModelType now takes as well
(see below): a host restricted to model_operations = ("create",) has no
update to mirror, so its scope is not applied to a nested update, and a
DjangoModelType declaring model_operations = ("list", "retrieve") is a read
host that leaves the nested write path entirely. Left undeclared, a
DjangoModelType still serves everything, so its Meta.queryset — the queryset
its own update and delete already resolve through — now gates the nested
path as well: a row outside it answers not found. Check any DjangoModelType
whose Meta.queryset is a display default (hiding archived or unpublished rows)
before upgrading, and declare it a read host if a parent should still be able to
update those children inline.
One configuration that used to build now raises ImproperlyConfigured: a host
declaring only_fields / exclude_fields / required_perms for a model whose
nested input has already been built. graphql-core caches an input object's
field map, so such a host could never reach the nested surface, and being
ignored silently is how the leak used to happen. A late host that only repeats a
declaration already contributing is accepted, since the merge is idempotent and
refusing a no-op buys nothing. Otherwise: declare every host for a model before
the first schema build.
Where a child model has more than one host, the two projection axes merge
differently, and together they can leave nothing behind. exclude_fields is a
prohibition and is unioned across every declared host; only_fields is an
allowance and is unioned across the hosts serving the operation; the prohibition
is applied last. A child whose every allowed column is excluded by a sibling host
would therefore end up with a nested input carrying no field, which graphql-core
does not accept as a legal schema — so that configuration is now refused at build
time with an ImproperlyConfigured naming the child, the parent and every
contributing host with both of its axes, rather than returning a schema whose
every request fails validation. Give such a child at least one column no host
forbids, or declare the read host with model_operations = ("list", "retrieve").
Added¶
-
Meta.model_operationsonDjangoModelType. It already existed onDjangoModelMutation; the type class now takes the same option, widened to the five operations it generates —("create", "update", "delete", "list", "retrieve"), which is also the default, so a project that declares nothing behaves exactly as it did before. An operation left out has its*Field()builder raiseAttributeError, andQueryFields()/MutationFields()return only what is enabled.This is the opt-out for the nested-write scoping this release introduces. A
DjangoModelTypeis a write host for the models a parent nests, so itsMeta.querysetand itsonly_fieldsgate a nested write — and in the common read-type-plus-write-mutation split, that queryset is a plain display default, not a policy. Declaringmodel_operations = ("list", "retrieve")says so: the card stops gating the nested write path and stops contributing a write allowance to it, while its read fields work exactly as before. Declaring nothing keeps the option itself inert — but note that is also what leaves the card a write host, and therefore what makes itsMeta.querysetgate nested writes.
Fixed¶
-
A multipart upload to a
FileField/ImageFieldcould never be saved. The merge that foldsrequest.FILESinto the input payload has been there since the first release, but the derived validation schema typed a file column as a plain string, so the uploaded file it merged came straight back as{ ok: false, errors: [{ field: "attachment", messages: ["Input should be a valid string"] }] }— on create and on update, on both mutation hosts, with no way to opt out, since the derived annotation overrides whatever aMeta.pydantic_modelbase declares. A file column is now typed with a marker that accepts exactly the two shapes it can hold: an uploaded file object, and the storage path string a query reads back. Every other shape is still a structured validation error rather than a crash at save time, and the column'smax_lengthstill constrains the string branch. Nothing moves on the wire — the field isStringon input and on output, as before.The merge now honours the input projection, which matters precisely because the value validates: while a file could never be saved, a part named after a column the type hides with
Meta.exclude_fieldswas merged and then thrown out by validation, so nothing landed. Making the value valid would have turned that same merge into a live write reaching a column the wire surface deliberately does not expose. Only a part naming a field the input actually publishes is merged; anything else is ignored, and the part must carry the model's snake_case attribute name rather than its camelCase alias.The merge is flat, so it still reaches only the model the mutation is bound to. A file field on a child declared in
Meta.nested_fieldsremains unreachable, and naming a multipart part after a nested relation still replaces the nested payload and fails in the nested handler. That gap is unchanged by this fix and is now documented where the feature is described; for nested uploads use the base64 input. -
Meta.nested_fieldsdropped the child's declared input projection. The nested child input was never derived from the child's own type — it was whatever object happened to occupy the shared(child model, operation)registry slot, so the outcome depended on which class was declared first. With the parent declared first the slot was empty, so an unprojected input was minted straight from the Django model: every writable column landed on the parent's nested payload,exclude_fieldsand all. Worse, that minted input was registered in the shared slot, so the child's own mutation, declared later, reused it and lost its projection too — the classicexclude_fields = ("is_staff", "is_superuser")became settable through both the parent's nested list and the child's own root field, and the child's required back-referencepost: ID!was silently relaxed topost: IDon its standalone surface. With the child declared first the projection survived, but the parent reused the child's own input, whose required back-reference foreign key graphql-core rejects before any resolver runs — so that order produced a nested field that could not be used at all. The two orders were not symmetric: one leaked, the other was unusable, which means every project whose nested writes actually worked had landed on the leaking one. Each nesting parent now builds its own copy of the child input, inside the parent's field thunk, from the hosts declared for the child in that registry. The two projection axes are merged differently, because they say different things: anonly_fieldsis a positive allowance, so the hosts that serve the operation being built union theirs — a create-only and an update-only mutation for one child no longer poison each other's nested surface, and neither do a read card and a write mutation projecting different columns — while anexclude_fieldsis a prohibition — this column is never client-writable — and is unioned across every declared host and applied last, so a create host's exclusion no longer vanishes from the nested update surface and let a client write, on an existing row through the parent, a column the project's own write mutation refuses. The shared registry slot is never written, and only that copy relaxes the back-reference key — per parent, so a child nested under two parents relaxes each parent's own key and nothing else. Both declaration orders now produce the same, working surface. The remaining way that merge could have failed open is closed with it: a host declared after the nested input was already built raises rather than being silently dropped — the memo is not what freezes the surface, graphql-core's own field-map cache is, so no rebuild or cache clear could have recovered it. - A nested write never ran the child's own permissions.
createandupdateauthorized the parent once and then handed the whole payload — children included — to the nested writer, whose only call was into the child's validation backend, a seam with no permission concept. A caller allowed to write the parent could create and update rows of a child whose own type denied it:postCreateansweredPERMISSION_DENIEDand wrote nothing, while the identical write smuggled insideauthorCreate's nested list returnedok: trueand persisted the row. UnderPERMISSION_SCOPED_SCHEMAthis was worse than a plain gap: the pruner correctly removed the child's ownpostCreateroot and cloned the parent's input type verbatim, so the feature closed the front door, left the back door open, and granted false confidence while doing it. Every declared host for the child model is now authorized before a nested create or update — all of them, so two hosts for one model fail closed — and the nested input field is pruned by the child's write permissions, whose labels now reach the schema label-set even for a child that has no root field of its own. Those permissions cover what the field can actually do rather than what the parent's verb suggests: a nested payload'sidis optional on an update input, so omitting it creates a row, and the field therefore requires the child'saddandchangethere — otherwise a caller holdingchange_childalone kept, inside the parent's update payload, the exact create the child's own pruned-away root refused. Therequired_permsof every child host that serves one of those verbs is then unioned onto that default, never substituted for it, so an override can only ever add a requirement: a write host declaring something stricter genuinely reaches the nested field — otherwise a caller who has the child's own roots pruned away keeps the identical write inside the parent's payload — while a read label is not silently read as a licence to write. Being a union term is not free, though: aDjangoModelTypeserves every operation unless it says otherwise, so a read card carryingrequired_permsalso gates the nested field, and a caller lacking that label loses it even where a differently-hostedchildCreatestill accepts the same write. Declare such a host withmodel_operations = ("list", "retrieve")so its label stays on the read side. And an input object every field of which is pruned is no longer emitted unfiltered — the mutation field that takes it is pruned instead, so a parent whoseMeta.only_fieldsnames nothing but the nested relation loses the root rather than regaining an ungated write. A denial is the samePERMISSION_DENIED/ 403 the direct mutation returns and rolls the whole write back. Checks receivenested_parent, the parent model class, so a child that must be writable only inside its parent is now expressible (under a permission-scoped schema that pattern needs no label of its own — the caller doing the nested write holds the child's write permission, and simply not mounting the child's own root leaves the pruner nothing to prune). The link paths — attaching an existing forward-FK or M2M row by pk — stay ungated by design: they write nothing on the child and offer exactly the reachability the plaincategory: IDsurface always had. - A nested upsert could reach a row the child's own host hides. The nested
writer resolved the target row against the bare model while the top-level
update/deleteboth scope theirs throughget_queryset/filter_queryset. Naming another tenant's primary key in a nested payload therefore rewrote that row in place, because Django'ssave()with a primary key issues anUPDATE. Nested pk lookups now resolve through the scope of the child hosts that serve the write, and answer a hidden row with the same not found the child's own mutation returns — never a create at that key, never an update of the row the scope was hiding. The scope decision is taken before the reverse-ownership guard, which resolves the pk unscoped: a hidden row that happened to belong to another parent used to be answered with does not belong to this <Parent>, confirming its existence, while an ownerless hidden row got the not-found. Both now answer identically. - A second registry's host rewrote the first registry's nested surface. The
per-parent input memo and the materialization record that refuses a late host
both live on the registry, but the host list they read from did not: it was a
process-wide table keyed by model alone. Every nested decision reads that list
— the projection merge, the permission stamp, the write-time row scoping and
the write-time
authorizeloop — so a host bound to a second registry throughMeta.registry, the documented multi-schema hatch, silently narrowed the first registry's nested payload, added its own label to it, and applied itsfilter_querysetto writes in a schema it has nothing to do with. Merely importing the other schema's module could turn every nested update in the first into a not found. The list now lives on the registry beside the two memos, and dies with it — but it is read as the parent's registry unioned with the global one, becauseMeta.registryis an option onDjangoModelMutationalone: aDjangoModelType, the only host class carryingpermission_classes, can only ever register globally, so reading the parent's registry by itself left a parent declared withMeta.registryfinding no hosts at all and the nested permission gate silent — the same front-door / back-door shape, reopened for multi-schema projects. A host bound to a non-global registry still cannot reach any other registry's parents. - A projected
only_fieldson a nested child stripped the pk from the update input. The nested update input is what makes the documented upsert-by-id work, and a child write host whoseonly_fieldsdid not happen to list the primary key silently removedidfrom it — so the payload the guide's worked example shows was rejected over the wire, and a client that dropped the rejectedidgot a duplicate create instead of an update. The same omission reached the host's own update root, whose resolver also takes the pk from inside the input — soonly_fields = ("headline",)shipped an update mutation no client could address at all. The pk is not a projectable column on an update surface — it is how the row is identified — and it now survives both projection axes, on the nested input and on the host's own. The create input still carries no pk at all, so creates stay create-only. - A host's
only_fieldswas silently dropped when another host had already registered the model's input. The(model, operation)input slot holds one type per model, so the first host to reach it decided the wire surface for every later one: aDjangoModelMutationdeclaringonly_fields = ("headline",)behind an already-declared display card kept accepting every writable column on its own root — the exact leakonly_fieldsis documented to close — and the reverse order narrowed the card's input to the mutation's projection. This lot made it worse by reading the declaration rather than the built type when merging a nested surface, so the nested payload could end up wider than every mutation root in the schema. A host that declares a projection now gets its own input type, named after that projection and memoized so two hosts declaring the same one share it. Hosts that declare no projection keep the shared slot and are unaffected; a projected input's GraphQL type name gains a short_p<hash>suffix. - A child host declared after its parent had only half of it honoured. The
nested field's permission stamp was frozen when the parent's input class was
defined, while the projection it must match is resolved later, inside the
parent's field thunk. A child write host declared after the parent but before
the schema build — the ordinary parent app imports the child app later
order — therefore had its
exclude_fieldsapplied and itsrequired_permsdropped, and the guard that exists to make a too-late host loud never fired, because the record it keys off is only written at thunk time. A caller with the child's own roots pruned away kept the identical write inside the parent's payload. The stamp is now resolved in the same thunk, from the same host list, as the projection. In the same pass it became operation-aware: a host'srequired_permsis read only for the verbs that host serves, so a delete-only host's label no longer removes the nested create field. - The comment defending the ungated link paths argued something untrue. It
claimed a linkable row belongs to nobody, so no scope hid it either, which
is false whenever the child has a host with a
filter_querysetorMeta.querysetscope. The real reason is the one the guide gives: a link writes nothing on the child and offers exactly the reachability the plaincategory: IDrelation input already has, unscoped, so gating only the nested spelling would make two surfaces for one operation disagree. Behaviour is unchanged; only the reasoning is now the true one. required_permscould not be written on aDjangoModelType. The guide documents the plain class attribute for the mutation fields aDjangoModelTypegenerates, and the field builder reads it — but onlyDjangoModelMutationdeclared it as aClassVar, and these classes are Pydantic-backed, so the documented form raisedPydanticUserErrorat class-definition time.DjangoModelTypenow declares it too, exactly as it already declarespermission_classesand for the same reason. Nothing changes for a class that spelled the annotation out by hand.- A permission-scoped schema could be served for the wrong schema. The
pruned-schema cache keys on
id(full)and stores a weak reference to detect address reuse, but a reference that had gone dead — the exact state anidrecycle leaves behind, since the original schema must be collected before its address can be reused — was treated as "still valid" and the collected schema's pruned variant was served to the new schema. Only a reference resolving to the requested schema (or an entry stored without one, for a schema that cannot be weakly referenced) is now trusted. - Every validator-free type ran pydantic's deprecated
validateon each write. The inline-validator collector walked the whole MRO, so it picked uppydantic.BaseModel.validate— inherited by every host — as if it were a user-declared object-level hook.Meta.pydantic_modelstopped passing through, a synthetic validator model was built for hosts that declared nothing, and each save re-validated the payload against the host type itself (aPydanticDeprecatedSince20warning per write, and a hard rejection for any host with required fields). The walk now stops atBaseModel. - The optimizer returned wrong rows for aliased nested lists. Selecting the
same relation twice under different filters made both aliases return the
unfiltered set, and adding a filtered or paginated alias next to a plain one
silently corrupted the plain one. The dedup guard counted only selections that
emitted a prefetch, so mismatched siblings were never detected. It now records
a row-set signature for every nested-list selection, keeps one shared prefetch
when the signatures agree, and drops the ambiguous lookup (and its
descendants) from
prefetch_relatedso each alias resolves its own rows. - A nested window under a windowed parent was paginated twice, truncating
the inner page and reporting the page size as
totalCount. The re-rooted child prefetch now carries itsto_attr, so the page is no longer sliced a second time in memory. optimize_<field>crashed on a reverse FK declared withoutrelated_name. The accessor (<model>_set) was looked up in a map keyed by the relation name, and the bareexceptsubstituted the owner model, producing a prefetch that failed at query time. Both walk sites now resolve through the accessor-indexed helper and leave an unresolvable segment untouched.- Filtering a list by a to-many primary-key set returned duplicate parent
rows. The
to_manyflag was only set on the nested-relation branch, so a direct pk lookup never triggered.distinct(). - Grouped
choiceswith a repeated label produced a broken enum. The duplicate-name guard was re-created on every recursive call, so two groups sharing a label collapsed into one member: the enum lost a value, rows holding the losing value became unserialisable, and writing that member stored the wrong value. Colliding names are now disambiguated deterministically; enums without a collision are unchanged. - Subscriptions were broken for any model whose primary key is not named
id. The broadcast payload hardcoded the key"id", so every event failed the non-null check on the real pk field — in the defaultid_onlymode. - Subscriptions delivered only errors to clients using GraphQL variables.
The per-event execution received neither
variable_valuesnoroperation_name, so subscribing succeeded and every delivered event failed. This affected every standard client (Apollo, urql, graphql-ws) and the bundled browser client. - Every read of a populated
DurationFieldreturnednullplus a field error, becausetimedeltacannot coerce toFloat. The value is now resolved throughtotal_seconds(), and the input side accepts it back. - The
idreturned by a query could not be sent back to an update mutation. Output emittedID!(a JSON string) while the update input declared the raw pk type, so echoing it back raised a coercion error — the sibling delete mutation already usedID!. ordering: "+field"returned a 500 that enumerated every column of the model. The allowlist stripped every leading-/+while the term handed to the ORM kept them. Prefixes are now normalised before validation, so the validated term is the one executed; a malformed--fieldis rejected with a clean error instead of leaking the field list.- With
CACHE_ACTIVE, a malformed request body raised an unhandled exception instead of returning 400. The pre-cache preamble parsed the body outside the handler that turns those errors into responses. - A multi-operation document with
operationNamesilently discarded the mutation and replayed a cached success payload without executing it. The operation name is now honoured when classifying the request, and an undeterminable operation bypasses the cache. orderingdid nothing on any list that used the default paginator. With the shipped defaults (nopagination=on the list type, noDEFAULT_PAGE_SIZEorMAX_PAGE_SIZE) the paginator is unbounded, and it returned the queryset from its unbounded early-return before readingordering— so the argument was advertised in the schema, autocompleted in GraphiQL, and silently ignored:ordering: "name"andordering: "-name"both returned insertion order, and an invalid ordering field was not even rejected. Ordering is now resolved and applied independently of the page-size decision, so it works on bounded and unbounded lists alike, acrossDjangoListObjectField,DjangoFilterPaginateListFieldand nested lists. Explicitly configured paginators are unchanged.- An unset variable inside a
JSONliteral aborted the whole operation.createOdd(newOdd: {payload: {a: $v, b: 1}})sent without$vembedded theUndefinedsentinel in the parsed value and failed withObject of type UndefinedType is not JSON serializable— so leaving an optional variable unset broke the request instead of omitting the key. The literal parser now drops such a field, exactly as graphql-core does for input-object fields. See Types ›JSONField→JSON. - A
JSONStringvalue could not be read back by its own scalar. A resolver returning a plain string ("hello") put it on the wire verbatim, which is not valid JSON, so sending the same value back raisedJSONString cannot parse. Anything that is not already valid JSON text is nowjson.dumps-encoded; text that already parses as JSON is still passed through unchanged. See Types ›JSONField→JSON. - An input model using a Pydantic 2.10+ validated-data
default_factorycould not build a schema at all.Field(default_factory=lambda data: ...)was invoked with no argument at compile time, so the type died with<Input> fields cannot be resolved. Such a factory has no compile-time value: the field now renders without an SDL default and Pydantic applies it per instance. See Types API ›DjangoInputObjectType. - A wrapped root field made a per-schema registry pair unbuildable. A root
declaring
field(NativeList(SomeType))orfield(NativeNonNull(SomeType))fell through to the scalar branch of the root compiler, and that branch was the only one that did not carry the schema's registry pair — so the inner type resolved against the process-global one and the build aborted withassert_schema_pair_isolation. A barefield(SomeType)was unaffected, which is why the wrapper looked like the culprit. - A type implementing an interface could not be used in a second schema. The
per-schema copy of the type kept the interface instance compiled for the
default schema, while a root
field(SomeInterface)compiled a fresh one, so two same-named interfaces reached one schema and it failed to build withSchema must contain uniquely named types. Interfaces are now recompiled against the schema that owns the type. name=was honoured on the root only. A field declared asdate_ = field(GdxDate, name="date")rendereddateon the root anddate_everywhere else — mutation payloads and nested object types camelCased the attribute name instead, leaking the keyword-dodging underscore onto the wire. See Fields › The unifiedField.- A nested list ignored the
@filter_fieldfilters it advertised. The nestedfilter:argument mounts the same<Model>FilterInputas the root list, custom filters included, but every nested path applied only the standard lookups — soposts(filter: {search: "…"})returned the unfiltered set (and an unfilteredtotalCount) while the identical root query filtered correctly. All the nested paths — the plain prefetch, the DB-side window, its count subquery, and the resolver's own branches — now run both filter stages through one choke point. See Nested Lists and Filtering › Composition order. - A nested list reported a different
totalCounton its last page. The count for an empty or past-the-end window page was rebuilt from the child's default manager, dropping theoptimize_<field>hook that had scoped the page itself: a pagination UI reading the total off the overshoot page saw the unscoped row count. The count now reuses the queryset the page was computed from, and the per-parent fallback re-applies the hook. See Nested Lists › Per-field optimize hook. - A declared
Meta.querysetcould freeze at the first request. The queryset is built once at class definition and bound as the resolver base; it was handed to the resolver verbatim, so withOPTIMIZE_QUERYSET = False(nothing else clones it) the first request filled its result cache and every later request in that process replayed it — rows created afterwards stayed invisible until a restart. The base queryset is now cloned per request. See Types and theDjangoListObjectTypeoptions. - With
CACHE_ACTIVEandATOMIC_MUTATIONS, the cache-invalidation bump fired before the mutation ran. It was scheduled throughtransaction.on_commitfrom the pre-dispatch preamble, but the atomic blockATOMIC_MUTATIONSopens lives inside the execution of the mutation — so at scheduling time there was no open transaction and Django ran the bump immediately, leaving the TOCTOU window it was written to close wide open: a concurrent reader could cache pre-mutation data under the post-mutation version. The bump is now scheduled after the mutation has run. See Caching › Post-commit invalidation. - With
CACHE_ACTIVE, a cached GraphiQL page was served to API clients. The response-cache key ignores content negotiation, so the HTML render and the JSON answer for the same query shared one slot: whichever arrived first decided what everyone else received — a browser could warm the page and the nextAccept: application/jsonrequest gottext/htmlback. A request that would render GraphiQL now bypasses the cache entirely. See Caching › Requests that bypass the cache. - A batch endpoint answered HTTP 500 for any non-JSON body. Only an
application/jsonbody was checked for being a list;application/graphql, form-encoded and multipart bodies were iterated as-is and died withAttributeError: 'str' object has no attribute 'get'. Every batch request now goes through one non-list guard and gets the documented HTTP 400. See Views › Batch endpoints. - A quality value written with a space (
Accept: text/html; q=0.1) was ignored, and the entry was ranked asq=1— so a JSON client that de-prioritised HTML the perfectly legal way was served the GraphiQL page. Whitespace after the semicolon is now tolerated. See Views › GraphiQL. - A nested write skipped the child type's own validation. The nested path
built its child validator from the child MODEL alone, so the inline
validate_<field>/validatemethods and theMeta.pydantic_modeldeclared on that child's ownDjangoModelType/DjangoModelMutationnever ran: the exact payload the child's own mutation rejected was accepted — and written — through a parent'snested_fields. A rule expressed once, in the documented place, was enforced on one of the two routes to the same table. The nested path now reuses the child host's backend, so both routes run the same validator. See Mutations. - A malformed primary key answered with Django's internals instead of the
error envelope.
updateanddeleteon bothDjangoModelTypeandDjangoModelMutationpassed the client'sidstraight to the ORM, soid: "abc"surfacedField 'id' expected a number but got 'abc'.(and a malformed UUID surfaced aValidationError) rather than the documentedok: false/<Model> with id <pk> does not exist.. A value no row can hold matches no row, so it is now reported exactly as an absent one, at the single lookup helper all four resolvers share. - A delete input could not be declared for a model whose primary key is not
named
id.input_for = "delete"looked the pk up under the literal key"id", so a model on aUUIDField, aSlugFieldor any renamed pk raisedKeyError: 'id'at class-definition time — the module simply would not import, with no workaround. The delete branch now readsmodel._meta.pkand keys the field on the real primary-key name. See Types API ›DjangoInputObjectType. - An inherited
class Argumentslost every inherited argument. The helper behind the mutation argument compiler read only the class body, so factoring shared arguments into a base class (class Arguments(CommonArgs)) dropped them from the compiled SDL with no error at all — a requiredtenant: String!scope key simply disappeared. The whole MRO is now read, base classes first, with the most-derived declaration winning a name clash. See Mutations › Custom Arguments. - A
@filter_fieldnamed after afilter_fieldskey silently ate that field's lookups. The custom-filter loop ran last and overwrote the compiled<Model><Field>Lookupsentry, so the field became unfilterable in both shapes and the only symptom was a raw'str' object has no attribute 'items'from the query-timeQbuilder. Such a collision — including one with anand/or/notcombinator — now raisesImproperlyConfiguredwhen the filter input is built, mirroring the existing reserved-name check. See Filtering › Reserved argument names. - Declaring a relation and a path through it dropped the nested filter.
{"author": ("exact",), "author__name": ("icontains",)}putauthorin both the relation and the plain-pk bucket, and the two compile loops wrote the same wire key, so theAuthorFilterInputnever mounted and the declared nested filter vanished from the schema. The plain-pk lookups now fold onto the nested input under the related model's primary-key name, keeping both halves. See Filtering › Filtering across relations. - An explicit nested lookup was replaced wholesale by the related type's
own. The canonical-shape check tested only whether the requested path
existed on the related model's root declaration, then returned that root and
discarded the requested lookups — so
PostType.filter_fields = {"author__name": ("icontains",)}compiled to the root's("exact",)and{author: {name: {icontains: "…"}}}was rejected by validation. The short-circuit now requires the root's lookups to actually cover the request; otherwise the two are unioned, as they already were for divergent paths. See Filtering › Filtering across relations. - Every string directive rendered a null field as the literal text
"None".@uppercaseanswered"NONE",@slugifyanswered"none", and so on for the whole family, because the shared coercion helper calledstr(value)unconditionally — while the numeric directives already returnednull. All of them now passnullstraight through. See Directives API › String Directives. @defaultreplaced any falsy value, not just null and the empty string. A legitimate0,falseor[]was substituted with thetostring and then failed serialization outright (Expected Iterable, but did not find one,Int cannot represent non-integer value). Onlynulland""are substituted now. See Directives API ›DefaultGraphQLDirective.@numberand@currencynulled the very fields they are documented for. Both always returned a formatted string, soviewCount @number(as: ",.0f")on anIntfield andprice @currencyon aFloatfield answerednullplus an opaqueInt cannot represent non-integer value: '1,234'. On a field that cannot serialize a string the raw value is now returned unchanged; the format-spec width/precision cap still applies on every field type. See Directives.- A subscription filter the schema accepted could crash the live stream. A
to-many field declares lookups the ORM then refuses across the join
(
filter: { tags: { iexact: 3 } }), so the key passed the subscribe gate and raisedFieldErrorat delivery — escaping the SSE generator after the200was committed, and killing the WebSocket operation task with no frame at all. Client filters are now validated against the ORM itself at subscribe time, and a delivery-time failure on either transport is framed asnext{errors}followed bycompleteinstead of tearing the connection down silently. See Subscriptions › Filter key validation. - One malformed WebSocket frame leaked every live subscription on the socket.
A body that is not valid JSON, or a non-string operation
id, raised out of the consumer, so Channels never randisconnect()and every running operation kept its task and its channel-layer group. Any frame the consumer cannot dispatch now closes with4400after the full teardown. - An SSE response that was never iterated leaked its groups permanently.
The groups are joined before the response is returned, but teardown lived only
in the streaming generator's
finally— so a client that aborts during the subscribe handshake left a ghost group member every future broadcast fanned out to. Teardown is now registered on the response, which Django closes whether or not the body was read. - Two subscriptions on the same model cross-delivered each other's events.
Group names carried the model and the action but not
Meta.stream, while the signal bindings registered per stream — so both fanned out into the identical groups and afull-payload subscriber received a duplicate, all-null event from theid_onlyone on every change.Meta.streamis now part of the group name. subscription_scopeandauthorize_subscriptionwere dead on both transports. The subscribe hooks receive the transport context as theirinfo, and it had no.contextattribute — so the documentedinfo.context.userraisedAttributeErrorand the subscribe failed closed, with no source started. The transport contexts now expose.contextalongside.user, so both spellings resolve. See Subscriptions › Authorization and row-scoping.- A subscription on a model with a
FileFieldorBinaryFieldcrashed on every save. The broadcast payload is JSON-encoded, and the serializer handed it a rawFieldFile/bytes. File fields now serialize as their storage name and binary fields as base64 — matching theStringboth already render as on the event type. - The bundled subscription client pointed its SSE field at the JSON
endpoint. There was no
sse_path, so the SSE input was seeded fromhttp_path; the first-run playground POSTed a subscription toGraphQLView, got a200 application/jsonbody with noevent:line, and showed a connected stream with zero data and zero errors.SubscriptionClientViewnow has ansse_path(default/graphql/stream), and a frame the client cannot recognise is logged as an error instead of discarded. See Subscriptions › Browser client view. - An
AnnotatedFieldreached through two chained forward foreign keys resolved tonull. The select→prefetch promotion pass never recursed, so it only ever saw the first hop from the root:{ comments { post { author { postCount } } } }leftpost__authorinselect_related, where the annotation cannot ride along, and the field came back empty with no error. The pass now descends the whole chain carrying the dotted lookup, so the hop that actually owns the annotated child is the one promoted. See Query Optimization › Selection-driven annotations. - An
... on <Interface>fragment inside a prefetched child cost one query per row. The.only()walkers were given the list container's identity and no source class at all, so the guard could neither match the interface (only the source class carriesMeta.interfaces) nor recognise the row type — every interface fragment was dropped from the projection and its columns were reloaded row by row. Both walkers now carry the GraphQL type through theresultswrapper down to the row type. Two further walk sites read the source class off agraphene_typeattribute that a natively compiled type never has, which silently made the same guard inert there; both now resolve it properly. - A named fragment spread on a
GenericForeignKeyunion member was ignored. The per-content-type bucket collector only looked at inline fragments, so a selection mixing... on AccountType { balance }with...Moneynarrowed the member queryset to the inline fragment's columns and fetched the rest one query per row. Spreads are now resolved against the document's fragments and merged into the same bucket. See Query Optimization › Typed GenericForeignKey unions. - A nested list on a child with two relations back to its parent always
resolved empty. Every relation pointing at the parent was collected into one
filter mapping, and a mapping is a conjunction — so a child with
created_byandupdated_byforeign keys (or a foreign key beside a many-to-many) was scoped to the rows matching both, which is nothing. Which relation is meant cannot be inferred, so the ambiguity now raisesImproperlyConfigurednaming the relations instead of silently returning[]; mount such a list through its relation accessor. See Fields › DjangoFilterPaginateListField. - A manual
prefetch_relatedinget_querysetcollided with the optimizer's own. The derived lookups were appended without checking what the base queryset already carried, and Django rejects two lookups on the same path — so the documentedreturn queryset.prefetch_related('posts')failed the whole field with'posts' lookup was already seen with a different queryset. A manual lookup the optimizer is about to re-derive is now dropped first, which is the "replaced" behaviour the docs already promised; manual prefetches of other relations are untouched. See Query Optimization › Custom resolvers.
Security¶
updateanddeleteignored the queryset scoping that protected reads. Both resolved their target row from the bare model, whileretrieveandlistwent throughget_queryset→filter_queryset— so the documented multi-tenant pattern (qs.filter(tenant=info.context.user.tenant)) hid another tenant's row from a query and still let any caller overwrite or delete it by primary key, returningok: true. That is the worst possible shape: a developer verifying the scope on the read path concludes it works. Both write methods now resolve the row through the same scoped queryset, and a row outside the scope answers exactly as a missing one (ok: falsewith<Model> with id <pk> does not exist.), so the response cannot be used to probe which primary keys exist. See Filtering ›filter_queryset.- A permission returning
None,0or""was treated as allowed. The check compared the result with theFalsesingleton (is False), so only a literalFalsedenied:return user and user.is_staff— the single most idiomatic way to write it — returnsNonefor an anonymous caller and granted every action, reads and writes alike. Any falsy value now denies. See Permissions › Writing a custom permission. only_fields/exclude_fieldswere silently dropped, keeping a sensitive column exposed. When aDjangoObjectTypewas already registered for the model, theDjangoModelTypereused that output type and discarded its own projection without a word (the existing warning only fired for custom fields), soexclude_fields = ("secret",)built a schema that still servedsecret— defeating the control this library documents as the way to keep a column out. Declaringonly_fields/include_fields/exclude_fieldsin that situation now raisesImproperlyConfiguredat class definition, naming the option, the model and the type that registered the output type. This fails the build for a schema that builds today, deliberately: a warning is filterable and would leave the leak live in production, and the only configurations affected are the ones already leaking. Move the projection to the registeredDjangoObjectType, or drop the option. See Types › Custom output fields.DjangoObjectType.get_nodeignoredget_queryset. The sibling of the item above on the plain-type hierarchy: it resolved its row on the bare manager instead of the documented scoping choke point every other row-serving path uses, so a caller passing a primary key straight to it got back exactly the rows the scope exists to hide. It now runs throughget_queryset, and an excluded row is reported as missing.- A nested reverse
OneToOneFieldchild could be stolen from its owner. The ownership guard on the nested-write path only tested the reverse-FK kind, so updating your own parent row with{id: <yours>, profile: {id: <theirs>, ...}}silently re-pointed another tenant's one-to-one child at you and returnedok: true— while the same payload on a reverse FK was correctly rejected. The guard now covers both reverse kinds with the identical error, so the two paths are indistinguishable to a client. Forward FK/O2O and M2M children are unaffected: those rows are not owned by the parent, so no ownership check applies. See Mutations. - Setting
root_valuesilently disabled every private field.AuthenticatedFieldsMiddlewareused "the root value is notNone" as its proxy for "this is a nested field", butroot_valueis a public seam (aGraphQLViewkwarg, a class attribute and an overridableget_root_value()). Any view configured with one served every protected field to anonymous callers, and the gate was skipped on each delivered subscription event too — the event payload is the root value there. The top level is now read from the resolve path, which is also correct for a field reached through an inline fragment and for one nested inside a list element; an unreadable path is treated as top level, so the gate fails closed. See Security. - A deactivated superuser could still introspect the schema. The
INTROSPECTION_ALLOW_SUPERUSERbypass testedis_superuseralone, unlike every sibling superuser check in the library, so anis_active=Falseaccount kept full__schemaaccess on backends that do not run Django'suser_can_authenticate(token / JWT). The bypass now requires an active superuser. See Security. - Row-level scoping leaked through a parent relation. A
DjangoFilterListFieldmounted on a parent type read the rows straight off the relation accessor, a shortcut that skipped the node type'sget_queryset. A type that hid rows correctly at the top level exposed all of them when reached through the parent —{ authors { createdPosts { title } } }returned the rows{ posts { title } }withheld. The hook is now applied on that path too, from a single shared choke point; it also rejects a hook that returns a non-QuerySetinstead of quietly serving unscoped rows. Types that declare no scope keep the prefetch cache and the single query; a scoped type costs one query per parent on that field. Auto-expanded nested lists (DjangoNestedListObjectField) remain the documented boundary. See Types. - A misspelled
DJANGO_GRAPHEXkey was dropped without a signal. An unknown key never reaches the reader, so the setting it was meant to configure keeps its default:"MAX_PAGE_SIZ": 10leftMAX_PAGE_SIZEatNone(no page-size cap) and"CACHE_ACTIV": Trueleft the cache off, with nothing reported bymanage.py check. A Django system check (django_graphex.W001,Tags.compatibility, registered from the app config) now compares your keys against the known settings and names the closest match. It is a warning, never an exception, so an app that starts today keeps starting. See Settings. - The playground's "private" note subscription had no gate on the type.
examples/playgrounddocumentednoteSubscriptionas auth-gated, but the only thing denying an anonymous subscriber was the schema-root wiring:"subscribe"is a READ action, soIsAuthenticatedOrReadOnlyallowed it, andsubscription_scopereturnedNone— no scope, i.e. every user's notes — for a caller with no user. Copied into a project that does not mountAuthenticatedFieldsMiddleware, the type leaked the whole stream.NoteModelTypenow denies the anonymoussubscribeinauthorize(before any Channels group is joined) and fails closed insubscription_scope. See Subscriptions. - A negative page size bought budget for a sibling field, bypassing
MAX_QUERY_COST. The cost estimator used a list's page size as its multiplier verbatim, solimit: -1000multiplied that subtree by a negative number and subtracted from the operation total: a query rejected on its own (requestedCost 1001against a budget of50) sailed through with one extra aliased field whose limit was negative, and the expensive sibling executed in full. Page sizes are now clamped to0before theMAX_PAGE_SIZEcap, so a field can only ever add cost. See Query limits › Query cost analysis. - A variable default declared in the document defeated
MAX_QUERY_COST. Variables are not bound during validation, so the enforcing rule fell back to the operation's own$n: Int = 1default — a value written by the same client that then sends{"n": 1000}at execution time. The query executed, and withEXPOSE_QUERY_COSTthe response even reported the realrequestedCostover itsmaxCost. The rule no longer reads document defaults: a variabled page size is costed atMAX_PAGE_SIZE(elseDEFAULT_PAGE_SIZE/DEFAULT_LIST_MULTIPLIER), the same conservative fallback an unknown variable already used. The reporting path, which does receive the request's real variables, is unchanged. See Query limits › Query cost analysis. - A customized
perms_mapwas ignored for subscriptions.DjangoModelPermissions.has_subscribe_permissionresolved a forwarded subscribe action against the library's hardcoded permission table instead of the class's ownperms_map, and the generated subscription always forwards one. A subclass tightening thesubscriberow ("subscribe": ("{app_label}.stream_{model_name}",)) was silently not enforced — a user without that codename could still subscribe — and a loosened row was silently denied. Both halves of the composite check now resolve throughget_required_permissions, so the required codenames are the union of thesubscriberow and the row the action maps to. The default mapping is unchanged:viewplus the action's write verb. See Permissions › Customizing the codenames. - A per-request
validation_rules=could be skipped entirely. The validation cache keyed its verdicts onid(rules), and an address is unique only while the object is alive: once a rules tuple built for one request was freed, CPython handed the same address to the next one, and the previous rule set's verdict was replayed — a stricter rule set silently never ran. The key is now derived from the rules themselves (their dotted names, in order). Views using the shipped class-attribute tuple were never affected. extensions.costwas a schema-existence oracle. WithEXPOSE_QUERY_COST, the cost payload was computed against the FULL schema and attached even to a failed validation — so underPERMISSION_SCOPED_SCHEMAa caller could tell a field pruned out of their schema (requestedCost: 1) from one that does not exist (requestedCost: 0), although both answer with the sameCannot query fielderror. That undoes the point of the pruned schema. The cost is now computed against the schema the request is actually served, and is not attached to a response that carries errors and no data. See Query limits › Query cost analysis.- A nested forward FK or M2M payload could rewrite any row of the related
table. Declaring
nested_fields = {"category": Category}was read by the writer as clients may edit any row of the Category table: naming a pk in the nested payload updated that row, whatever it was. The ownership guard did not apply (a forward or M2M target is not owned by the parent) and no scope did either — a shared lookup row belongs to nobody, so nothing hid it. Updating your own document with{ id: <yours>, category: { id: <any pk>, name: "x" } }returnedok: trueand renamed a category every tenant reads, while the same payload on a reverse FK was correctly rejected. A pk the parent is not already attached to is now a link: the row is set on the parent (or.add()-ed) and the payload's other fields are ignored. The row the parent is already attached to is still updated in place, so the documented use — change the category attached to this document — is unchanged, as is attaching an existing row. See Mutations. DjangoModelMutationhad no row-scoping hook at all. Its siblingDjangoModelTyperesolvesupdateanddeletethroughget_queryset→filter_queryset; the mutation host went straight to the bare model and had no such hook to override, so afilter_querysetwritten on it — spelled exactly as the documented one, with no error, no warning — was never called and every row stayed writable by any caller. Both hooks now exist onDjangoModelMutationwith the same names and signatures, and itsupdateanddeleteresolve through them. Authorization stays asymmetric and is now documented as such:permission_classes/authorizeremainDjangoModelType-only. See Mutations › Row scoping.- A multi-table-inheritance child could not be put in a schema at all. The
native compilers walked
model._meta.get_fields(include_parents=False)— correct for an abstract base, which copies its columns onto the child, and wrong for multi-table inheritance, where everything inherited lives in the parent's table. An inherited reverse relation was therefore still listed on the type but had no compiled counterpart, and the schema build died withRestaurantType fields cannot be resolved. Cannot convert None to a graphql-core type— an error naming nothing that would let you find the cause. The field walk intypes.pynow goes through one helper that enumerates parents too, so an inherited reverse relation renders as its usual<Model>ListTypecontainer. Models that do not use multi-table inheritance are provably untouched (include_parentsis a no-op for abstract and proxy inheritance). The output compiler walks parents too, so the inherited columns render as well — a multi-table child used to reach the schema without so much as anid, because every column it inherits (its primary key included) lives on the parent. The implicit<parent>_ptrlink is hidden: the child now exposes each inherited column directly, so the link would only offer a redundant hop back to a copy of the same row. See Types › Model inheritance. editable=Falserelations were accepted in mutation input and silently dropped. The scalar path already honouredfield.editable, but the relation path did not, so a server-managedForeignKey/OneToOneField/ManyToManyField(created_by,tenant, anything a customsave()owns) advertised itself as writable, returnedok: true, and wrote nothing. Such relations are now excluded from the create and update inputs. The guard is restricted to concrete fields on purpose: Django hardcodeseditable = Falseon every reverse relation object, so applying it there would have deleted the reverse-relation injection wholesale. The validation-model layer honourseditableon many-to-many too, so a non-editableManyToManyFieldno longer leaks back in as a raw primary-key list.- A populated
BinaryFieldalways read asnull. The output compiler mapped it to a bareStringwith no resolver, so thebytesthe column yields reached graphql-core's string serializer, which cannot represent them. It now resolves through the same shape theDurationFieldprecedent uses, and delivers the column as base64 — unconditionally, matching what the subscription payload encoder already sends for the same field, so a client is never left guessing whether it received text or base64. DjangoModelType's generated list container took the name the docs teach you to use. The container was minted as<Model>ListType— the exact name the guides give your ownDjangoListObjectType— so declaring both over one model put two different types with one name into a schema and the build failed withSchema must contain uniquely named types. It is now<Model>ListGenericType, matching theGenericname-space the same type already mints its node (<Model>GenericType) and inputs (<Model>CreateGenericType) into. This is wire-visible: update any client document that spells the container's type name out. Reusing a registered container instead was rejected — aDjangoModelTypecarries its ownpagination/results_field_name/ projection, which a container built from someone else'sMetawould silently discard.- A
DjangoModelTypemutation could not be mixed into a forked schema. The generated field stamped onlygdx_required_perms, while the compiler keys its forked-schema payload re-compile offextensions["gdx_mutation_source"]— the keyDjangoModelMutationhas always stamped. Without it the field opted out of the re-fork and every schema built withregistries=shared the payload instance pinned at class-definition time. The field now records its source class, so a forked schema compiles its own payload; args, resolver, permissions and field shape are unchanged.
Documentation¶
- The main tutorial (
Sample Application), the first example on the Fields page, the file-upload guide and several Query Recipes did not compile or taught a pattern that crashes at runtime. Every snippet on the affected pages was executed against a real schema and corrected.
2.1.0 — 2026-08-23¶
Security release — upgrade is strongly recommended. This release closes five defects confirmed in 2.0.0 by a line-by-line audit of the published code (see Security below), and unifies the subscription filter argument with the query one.
The subscription filter argument is now a real typed input object. Queries
took filter as a generated <Model>FilterInput; subscriptions took filters
as a plain String carrying JSON — two names, two shapes, and no schema
validation or autocompletion on the subscription side. That inconsistency is why
the documented subscription filter syntax was wrong for two releases. This
release unifies them.
Changed (BREAKING)¶
-
filters→filter, typed<Model>SubscriptionFilterInput. The subscription argument is renamed to the singular query term and takes a generated input object with the same nested{field: {lookup: value}}shape queries use. There is nofiltersalias — 2.0.0 shipped one day before 2.1.0 and the documented syntax never worked, so the exposed surface is minimal.Before (2.0.x):
After (2.1.0):
subscription { commentSubscription(action: ALL_ACTIONS, filter: { post: { exact: 7 } }) { id text } }Combining lookups works the same way:
filter: { post: { exact: 7 }, status: { in: ["open", "urgent"] } }. -
The schema is now the filter boundary. The generated input type is deliberately not the query's
<Model>FilterInput— reusing it would re-expose every lookup and reopen the extraction oracle closed in this release. It declares exactly the subscription's projected output fields (honouringMeta.only_fields/Meta.exclude_fields) and exactly the four allowed lookups (exact,iexact,in,isnull). It is flat, so relation traversal is unexpressible. A banned lookup, an excluded column or a relation path is now a GraphQL validation error instead of a runtime rejection. The runtime check remains as defence in depth for anything reaching the engine without schema coercion.
Both input types coexist in one schema; the query-side
<Model>FilterInput SDL is unchanged.
Security¶
The five items below were prepared as 2.0.1; that patch was never published — they ship here in 2.1.0.
- Relation traversal bypassed permission scoping. Only generated root fields
carried the permission label the pruner reads, so a user could read a model
they had no
view_<model>permission for by traversing a relation from a parent they could read (post { comments { ... } }). Untagged fields now fall back to the implicit label of their output type, and the schema label set is widened accordingly, soPERMISSION_SCOPED_SCHEMAremoves those relation fields. See Permission-Scoped Access. - Subscription client filters were an extraction oracle. The filter
whitelist was built from the unprojected model, and only the segment before
the first
__was validated — so a subscriber could probe any column (includingpassword) one comparison at a time via lookups such asstartswith, and could traverse into related models.Meta.only_fields/Meta.exclude_fieldsare now honoured by the subscription backend, relation traversal is rejected, and client filters accept onlyexact,iexact,inandisnull. Ordered and pattern lookups (startswith,icontains,gt,regex,range, date parts) are refused — move that logic tosubscription_scope. See Subscriptions. private_subscriptionwas not enforced on either transport.DJANGO_GRAPHEX['MIDDLEWARE']was read only byGraphQLView, while subscriptions are served exclusively by the SSE view and the WebSocket consumer — soAuthenticatedFieldsMiddlewarenever ran and an anonymous subscriber received protected events. Both transports now build and apply the configured middleware chain for the subscribe and delivery paths.- Sub-kilobyte documents could pin a worker (DoS). The bundled depth and
cost validation rules re-walked each fragment once per reachable path, so a
~1 KB document with fan-out fragments took over ten seconds of CPU — before
authentication, on any stock
GraphQLView. Both rules now memoize each fragment's contribution; reported depth and cost values are unchanged.
Fixed¶
<Model>FilterInputname collision refused to build the schema. Filtering a relation by a column the related type does not itself declare (for examplefilter_fields = {"author__email": ("exact",)}) produced two input types with the same name and a hardTypeErrorat schema construction — the application did not start. One canonical instance per model is now cached and widened in place, converging regardless of build order.- Documented
filterssyntax could never work. Every snippet showedfilters: { post: 7 }, but the argument is aStringcarrying JSON, so the object literal failed input coercion. Docs and the playground now show the JSON-encoded form.
2.0.0 — 2026-06-17¶
graphene removed — django-graphex now runs on native graphql-core + Pydantic
alone. The legacy graphene backend is deleted; graphene is no longer a
dependency and is never imported, even on a full build with mutations,
subscriptions and pagination. See the Upgrade Guide for
before/after snippets and a migration codemod (scripts/migrate_2_0.py).
Changed (BREAKING)¶
- graphene backend removed entirely. The
GDX_BACKENDenvironment variable that selected the legacy graphene path is gone; the native graphql-core path is the only path. graphene (and graphene-django) are no longer required and can be uninstalled. GRAPHENEsettings namespace →DJANGO_GRAPHEX(single namespace). Schema/middleware settings (SCHEMA,MIDDLEWARE,SUBSCRIPTION_PATH, …) are merged into the singleDJANGO_GRAPHEXDjango-setting dict alongside this package's own settings (pagination, caching, query limits, …); there is no longer a separate schema-settings namespace, and the legacyGRAPHENEnamespace is no longer consulted. The codemod--applyfolds an existingGRAPHENEdict intoDJANGO_GRAPHEX(no key collisions).graphene.ObjectTypeschema roots → nativeObjectType. Import the root base fromdjango_graphex.core(from django_graphex.core import ObjectType).graphene.Schema(...)→DjangoGraphQLSchema(...). Build the schema with the publicdjango_graphex.schema.DjangoGraphQLSchemaclass.- graphene field descriptors → native
field(...). Hand-declared (non-model) fields usefield(GraphQLString)/field(GraphQLList(...))with graphql-core types instead ofgraphene.String()/graphene.Field(...). graphene.Argument(...)in a Mutationclass Arguments→GraphQLArgument(...). Mutation arguments are declared with native graphql-coreGraphQLArgument(a bare graphql-core type is auto-wrapped) inside theclass Argumentsinner class. This is a clean break: a non-native value left inclass Argumentsnow raisesTypeErrorinstead of being silently dropped.choicesfields now render as a GraphQL enum on both output and input. A model field withchoicesis exposed as a realGraphQLEnumTypeon the output type and on filter/input types (previously a choices field could render as a plainStringon the native path). This is an observable wire-format change — review clients that send/read choices values as raw strings.- Update mutations now honour an explicit
null(GraphQL-spec-correct: omitted ≠ null). Previouslyupdate()stripped everyNonefrom the input and treatednullas "not provided", so a nullable field or an M2M could not be cleared over the wire. Now an omitted field is left unchanged (partial update), while an explicitnullsets a nullable scalar/FK column toNULLand clears a top-level (ID-list) M2M —tags: nullis equivalent totags: []. Anullon a required field returns a clean validationerrors[]payload (ok: false), never a 500. Nested inputs (Meta.nested_fields) still treatnull/[]/{}as a no-op (related children are never deleted). This is an observable behavior change — review clients that relied onnullbeing silently ignored on update. Both mutation surfaces (DjangoModelMutationandDjangoModelType) behave identically. See Mutations → Explicit-null semantics. - Public-API renames. Five names were renamed for clarity and consistency
(no deprecated aliases — update call sites directly):
Meta.max_deep→Meta.max_depthonDjangoObjectType,DjangoListObjectType, andDjangoModelType(aligns with theMAX_QUERY_DEPTHsetting). The unknown-option guard names the new spelling.@filter_field(graphene_type=…)→@filter_field(graphql_type=…)(graphene is gone; the argument type must be a graphql-coreGraphQLType).DjangoUnionTypeMeta.gfk_types→Meta.types. Declaring the oldgfk_typesnow raisesImproperlyConfiguredwith a rename hint.- GFK-owner
Meta.gfk_unions→Meta.unions(the key that maps aGenericForeignKeyto a typed union on aDjangoObjectType). Declaring the oldgfk_unionsnow raisesImproperlyConfiguredwith a rename hint (gfk_unions was renamed to unions in v2.0). - Mutation argument container
class args→class Argumentson the native hand-writtenMutationbase (unifies withDjangoModelMutation/DjangoModelType, which already usedArguments). A subclass that still declares the legacy innerclass args(and noArguments) raisesTypeErrorwith a rename hint atField()build time. - Subscription
Meta.serialize_data→Meta.payload_modeand theSUBSCRIPTION_SERIALIZE_DATAsetting →SUBSCRIPTION_PAYLOAD_MODE. The boolean flag becomes a string mode:serialize_data=True→payload_mode= "full",serialize_data=False→payload_mode="id_only"(the new global default),Nonestill inherits the setting. Semantics are unchanged (id-only broadcasts{"id": <pk>}; full broadcasts a flat serialization). Declaring the oldMeta.serialize_datakey or the oldSUBSCRIPTION_SERIALIZE_DATAsetting key raisesImproperlyConfiguredwith a rename hint, and an invalidpayload_modevalue names both valid values.
InputFieldand the 12*InputFieldtwins removed — one unifiedField. TheField/InputFieldsplit is gone: the singleFielddescriptor (and 11 typed shortcuts —IntField,CharField,FloatField,BooleanField,IDField,DateField,DateTimeField,TimeField,DecimalField,UUIDField,JSONField) now works in both an output body and aclass Argumentsbody.InputFieldand every*InputFieldtwin (IntInputField,CharInputField, …) are removed with no alias — port with a search-and-replace that drops theInputinfix (InputField→Field,IntInputField→IntField, …).resolver=/source=/args=are output-only (aTypeErrorin an argument position) anddefault=is input-only (aTypeErrorat output compile). The rawGraphQLArgumentidiom is unaffected. See Upgrade Guide → §8.JSONFieldnow carries raw JSON on the wire (SDL scalarJSON). A modelmodels.JSONFieldand theJSONField()descriptor are transported as raw, structured JSON (SDL scalarJSON) across all three paths — output, mutation input, and filters — instead of the JSON-encoded string wire (SDL scalarJSONString) used before. Objects, lists, and scalars pass through structurally, and inline object / list literals are accepted directly in a query. Clients thatJSON.parse()d the oldJSONStringwire (orJSON.stringify()d it on input) must stop. The escape hatchJSONField(as_str=True)keeps the oldJSONString(string-encoded) wire for a single descriptor field.GenericJSONFieldand theGenericScalarSDL scalar name are removed (the raw scalar is exported asGdxJSON, SDL nameJSON; the legacy grapheneGenericScalarclass still resolves to it for backward compatibility, andGdxJSONStringstays). This is an observable SDL change. See Upgrade Guide → §9.- Cursor pagination cursor format is now composite (
value+pk).CursorGraphqlPaginationencodes the boundary row's ordering value plus its primary key as a deterministic tiebreak, so tied ordering values page correctly (rows are never dropped or duplicated across a boundary). Any stored v1 / beta cursor string is no longer valid and raises a cleanGraphQLError("Invalid cursor")— re-fetch from the first page after upgrading. Thefirstargument is unchanged. See Upgrade Guide → §11. - Output camelCase digit parity —
phone_1→phone1(bugfix, wire-visible). A field name with a trailing digit component was camelCased incorrectly on the output side (phone_1stayedphone_1), diverging from the input side (Pydantic), which already producedphone1. Output now routes through the same canonical camelCase, sophone_1→phone1,address_2→address2,iso_8601_date→iso8601Date— restoring output/input wire parity (and v1 graphene parity). Fields without a digit component (created_at→createdAt) are unchanged. Review clients that selected the oldphone_1-style output name. See Upgrade Guide → §12. - Filter input SDL name
<Model>Filterinput→<Model>FilterInput(bugfix, wire-visible). The generated filter input type name is now capitalized idiomatically (UserFilterInput, notUserFilterinput). Inlinefilter: { ... }usage is unaffected; update any named-variable reference ($filter: UserFilterInput) or SDL snapshot / codegen artifact that pinned the old spelling. See Upgrade Guide → §13.
Removed¶
- The graphene backend producer code, the
GDX_BACKEND/ dual-backend switch, and the graphene dependency frompyproject.toml.
Added¶
- Django-style field descriptor API (
django_graphex.core), unified across positions. One capitalized, Django-model-field-styleFielddescriptor declares custom (non-model) fields in both anObjectType/Mutationpayload body and aclass Argumentsbody — direction is inferred from the declaration site, not the descriptor. It is sugar over thefield()/GraphQLArgumentsubstrate (every descriptor compiles byte-identical, no wire/SDL change).Field(type, *, source=None, required=False, default=_UNSET, description=None, name=None, resolver=None, args=None, deprecation_reason=None):source=/resolver=/args=are output-only (a clearTypeErrorif left on an argument-position field) anddefault=is input-only (aTypeErrorat output compile). Ships 11 typed scalar shortcuts usable in both positions —IntField,CharField,FloatField,BooleanField,IDField,DateField,DateTimeField,TimeField,DecimalField,UUIDField,JSONField— plus a collision guard that raises a loudTypeErrornaming the likely import mistake if adjango.db.models.Fieldis used where a descriptor is expected. The low-levelfield()helper and the rawGraphQLArgument/ lambda-thunk idiom stay public and unchanged. See Declaring fields: the descriptor API. - Ordering values accept camelCase (GraphQL-consistency). Because every field
name is exposed in camelCase on the wire, the
orderingvalue now accepts camelCase too: each term is normalized to its snake_case attname (preserving the-/+direction prefix) at one canonical point, soordering: "createdAt"behaves exactly likeordering: "created_at"— on the DB path, the in-memory (prefetch-cache) path, and the nested window-prefetch optimization (which no longer declines for a camelCase term). snake_case keeps working unchanged; an invalid camelCase field still raises the sameInvalid ordering fieldGraphQLError, and relation-spanning terms are still rejected.CursorGraphqlPagination(server-configured ordering) is unaffected. This also fixes a latent bug where a camelCase ordering term silently degraded on the in-memory path (it sorted by a missing attribute — a no-op) instead of matching the DB path. - Permission-scoped schema (
PERMISSION_SCOPED_SCHEMAsetting, defaultFalse— fully inert until enabled).AuthenticatedGraphQLViewcan serve each authenticated request a schema pruned to the caller's permissions: a field whose required perms the caller lacks is absent from validation, so selecting it reads as a nativeCannot query field(a not-found, never an authorization error — no existence leak). Both validation and execution run against the pruned schema; a caller whose entireQueryroot is pruned away gets the endpoint's generic403; an active superuser always gets the full schema; the publicGraphQLViewis never pruned. Includes:field(required_perms=…)and aMutation.required_permsclass attribute to label fields explicitly, on top of the automatic labels stamped on every generated CRUD field (extensions["gdx_required_perms"]); an unlabeled field is treated as public.- a revised composite
DjangoModelPermissions.perms_map— because a write payload returns instance data,create/update/deletenow require the write verb andview(read actions stayview-only; overrideperms_mapto restore write-only behavior). - per-action subscription authorization —
authorize_subscriptionnow forwards the requestedaction, so subscribe permissions are enforced per CRUD action (defense in depth: the action's enum value is pruned at validation and denied at runtime). - a per-connection
schema_provideron the WS/SSE subscription transports, so subscriptions prune to the same schema as HTTP for a user. - cache-key hardening — pruned schemas are memoized in a bounded
in-process LRU keyed by the caller's permission signature
(
perms ∩ schema label-set, never by user id, revoke-safe;PERMISSION_SCHEMA_CACHE_MAXSIZE, benchmark-calibrated default64), and the HTTP response cache folds the signature into its key so a low-permission caller can never read a high-permission caller's cached response body.
Requires a labeled DjangoGraphQLSchema; with the flag False behavior is
byte-identical to today. See
Views → Permission-scoped schema,
Security → Permission-scoped schema,
and Settings → Security.
- API_ACCESS_GROUP setting (DJANGO_GRAPHEX). Restricts the authenticated
endpoint (AuthenticatedGraphQLView) to members of a single Django auth
Group (by name). "" (default) disables the gate — zero impact. When set,
non-members are rejected with a generic HTTP 403 before any GraphQL
parsing/execution (the message never leaks the group requirement), an active
superuser always bypasses the gate (hardcoded invariant), and a
missing/anonymous user is denied (fail-closed, independent of
permission_classes). The public GraphQLView is not affected. See
Views → Endpoint-level auth
and Settings → Security.
- DjangoModelPermissions permission class (django_graphex.permissions).
A DRF-style permission that maps each CRUD action to Django's built-in model
permissions (add/change/delete/view) and checks them with
user.has_perms. Fail-closed (anonymous users, a missing model, and unknown
actions are denied), superusers pass automatically, and the per-action
codenames are customizable via the perms_map class attribute or by
overriding get_required_permissions. Intended for
DjangoModelType.permission_classes. See
Permissions → DjangoModelPermissions.
- graphql_schema management command (introspection JSON / SDL export).
Mirrors graphene-django's command of the same name (a drop-in for migrating
users) but is built on graphql-core with no graphene import. Writes
introspection JSON wrapped as {"data": ...} (matching graphene-django's
shape) to DJANGO_GRAPHEX["SCHEMA_OUTPUT"] (default schema.json), or SDL
when the output path ends in .graphql / .gql. Supports --out/-o
(incl. - for stdout), --indent/-i, and --schema <dotted.path>. New
settings SCHEMA_OUTPUT and SCHEMA_INDENT back the command's defaults. See
Settings → Exporting the schema.
- deprecation_reason= across the descriptor API and every Django field/mutation
builder. field(), Field, and all 11 typed shortcuts accept a
deprecation_reason=, and so do every Django mounting builder —
DjangoObjectField, DjangoListObjectField, DjangoFilterListField,
DjangoFilterPaginateListField, RetrieveField, QueryFields, and the mutation
builders (CreateField / DeleteField / UpdateField / MutationFields). When
set, the reason is wired into the compiled field / argument so the SDL renders
@deprecated(reason: "…").
- Pydantic InputType list[...] fields render as GraphQL lists with defaults.
A list[...]-annotated field on an InputType now compiles to a GraphQL list
input ([T]), and a Python field default is carried into the SDL as the
argument's default value.
Performance¶
Five behavior-preserving optimizations (response shape is unchanged in all
five; SQL query counts and per-request timings improve). A reproducible
cross-library benchmark harness lives at benchmarks/ if you want to measure
these against your own workload.
totalCountis computed lazily. TheCOUNTquery is only issued when the client actually selectstotalCount, and reuses an already-materialized results list (len()) instead of a fresh query when one is available. Skips oneCOUNTquery per request for any list operation that doesn't selecttotalCount.- Parse + validate document cache (
DOCUMENT_CACHE_MAXSIZEsetting, default128). graphql-core'sparse()andvalidate()are memoized in two bounded LRUs — a global parse cache keyed on the query string (the AST is immutable and schema-independent) and a per-schema validation cache keyed by the schema object (aWeakKeyDictionary, so a permission-pruned schema never shares a verdict with another schema). Shaves the re-parse/re-validate cost (each on the order of a fraction of a millisecond) on every repeated document. See Settings → Document cache. - Cached Pydantic validator build.
build_model_schema(the dynamically generated Pydantic model backing create/update validation) is now memoized per(model, partial, base, exclude)instead of rebuilt on every mutation call — the derived class is a pure function of that key, so rebuilding it per request was wasted work. - FK existence check moves to the mutation failure path. A valid
create/update mutation issues a single
INSERT/UPDATE— the per-FK existence pre-check no longer runs on the happy path. A bad FK still returns the same structurederrors[]envelope; the diagnostics that produce it now run only after anIntegrityError. Saves oneSELECTper FK field on every successful mutation. - Nested-write savepoint opens only when nested work is present. A
create/update mutation with
Meta.nested_fieldsno longer opens atransaction.atomic()savepoint when the request has no nested child payload — a plain, parent-only mutation pays no savepoint overhead.
See Mutations → performance note and
Pagination → totalCount is computed lazily
for the user-facing behavior these changes preserve.
Fixed¶
- Subscription transports'
schema_providernow respectsPERMISSION_SCOPED_SCHEMA. Once aschema_providerwas wired onsubscription_ws_consumer/subscription_sse_view, the bundledpruned_schema_forhelper pruned unconditionally — the provider path never readPERMISSION_SCOPED_SCHEMAeven though the HTTPAuthenticatedGraphQLViewis gated by it, so the transports diverged from HTTP. The bundled helper now reads the flag per connection (never at import): it returns the full schema when the flag is off (default) and the pruned schema when on, so one flag rules the feature across HTTP, SSE, and WebSocket. The active-superuser invariant is unchanged (always the full schema), and a custom provider callable that does not route throughpruned_schema_foris honored as-is (the flag gates the bundled helper only). - Model-derived
JSONFieldmutation inputs now store a real Python object. A model-derivedJSONField(andHStoreField) was rendered as a plainStringon the mutation input type, so the submitted JSON text was assigned to the column verbatim — a double-encoded string instead of the parsed value. ModelJSONField/HStoreFieldinputs now render as the rawJSONscalar (see the JSON-flip breaking change above) and store a dict or a list as a real Python object on both create and update — output, input, and filter paths all agree on theJSONscalar. The custom scalars are exported for reuse:from django_graphex.core import GdxJSON(alsoGdxJSONString,GdxDate,GdxDateTime,GdxTime,GdxDecimal,GdxUUID). - Pagination
limit/firstguards — zero and negative now raise a cleanGraphQLError. Alimit: 0/first: 0or a negativelimit/firstpreviously slipped through (or produced a negative-offset slice); both now raise a structuredGraphQLError(e.g.Invalid limit: -5. Limit must be a positive integer.) instead of an HTTP 500 or an empty page. Negativeoffsetalready raised. PageGraphqlPaginationsupports backward (negative-page) access.page: -1returns the lastpage_sizerows in truelist[-N:]order,page: -2the window before it, and an overshoot clamps to the first rows. A negative page costs oneCOUNT(needed to compute the count-relative offset) and opts out of the nested window-prefetch optimization;page: 0still raises aGraphQLError.prune_schemaforwards interface implementers (PERMISSION_SCOPED_SCHEMApolymorphic fix). A surviving object type that implemented a surviving interface but was only ever returned via the interface used to fall out of the pruned schema'stype_map, leavingpossible_typesempty and breaking inline-fragment queries. The pruner now forwards those implementers, so an interface keeps its implementers in the pruned schema exactly as in the full one.- SSE subscription transport hardening. The bundled SSE view is now
csrf_exempt(the event-stream endpoint no longer trips CSRF), and the SSE client's request path building and value escaping were corrected. - GenericForeignKey custom-PK resolution uses
root.pk. A GFK whose owner has a non-default primary key name is now resolved viaroot.pk(rather than a hard-codedid), so custom-PK owners resolve their GFK target correctly. - Inline JSON literals are accepted on
JSONarguments. TheJSONscalar's literal parser recursesObjectValueNode/ListValueNode(and resolves nestedVariableNodereferences), so an inline object / list literal (payload: { a: 1, tags: [1, 2] }) is parsed into a real Pythondict/listinstead of being rejected.
1.3.0 — 2026-06-13¶
Added¶
-
@filter_fielddecorator (#26) — declare custom per-field GraphQL filter arguments directly on aDjangoObjectTypeorDjangoModelType, co-located with the resolver logic. The method name becomes the GraphQL argument name; the graphene type (defaultgraphene.String) and an optional description are configurable.filter_fieldscontinues to work for model-field lookups; custom logic is now exclusively via@filter_field. Composition order at query time:get_queryset/filter_querysetscoping first, then standard lookups, then@filter_fieldmethods (declaration order). (#26) Erratum: this entry originally describedfilter_querysetas running last — the scoping hook has always run first. See Filtering → Composition order. -
Base64FileInput— opt-in base64 file uploads (#25) —Base64FileInput(graphene.InputObjectType)withfilename(required),data(required, base64-encoded), andcontent_type(optional, defaultapplication/octet-stream). Call.to_uploaded_file(max_size=None)in any mutation resolver to obtain a DjangoSimpleUploadedFileready forFileField.save(). Also importable asdecode_base64_file(value, *, max_size=None)for cases where you hold the raw dict.
Two new settings enforce memory safety:
MAX_UPLOAD_SIZE(int bytes, required whenBase64FileInputis used) — global decoded-size cap. A pre-check fires before base64 decoding to avoid the allocation. Per-fieldmax_sizeoverrides this. RaisesImproperlyConfiguredif absent and no per-field override is given.MAX_REQUEST_BODY_SIZE(int bytes,None= disabled) — HTTP body-size guard inBaseGraphQLView.dispatch, checked before JSON parsing. Rejects oversized bodies with HTTP 413. This is the primary memory cap (the base64 string is already in RAM once the body is parsed).
Malformed base64 raises GraphQLError (never HTTP 500). Content-type
validation (magic bytes) is out of scope — use FileField validators.
Batch requests: MAX_BATCH_SIZE (op count) + MAX_REQUEST_BODY_SIZE
(bytes) compose; no special upload-batch logic. Upload mutations bypass the
response cache automatically (mutations are never cached). See
Mutations → File Upload Support and
Settings → File uploads. (#25)
Changed¶
-
BREAKING — minimum Django raised to 5.2 (#75) — Django 4.2, 5.0, and 5.1 are no longer supported (all end-of-life). The supported matrix is now Django 5.2 (LTS) + 6.0 × Python 3.12–3.14. Tooling:
pytest-asynciopinned to>=1.0,ruffconstraint updated, CI usessetup-uvv8. -
filter_fields = {"field": None}now raisesImproperlyConfigured(#26) — TheNonesentinel (which previously applied the default lookup set and crashed withTypeError) was un-Pythonic and has been replaced by the@filter_fielddecorator. The error message directs users to the new API.
Fixed¶
-
Docs accuracy (#71) — Five follow-up fixes: migration guide import corrected (
graphene_django_extras→django_graphex); fragile changelog anchor removed from query-optimization guide;Metavalidation options table added to types docs;!!! warningcallout added to API types reference;BaseGraphQLViewviews docstring reworded to match the actual tuple-store/reconstruct caching mechanism. -
TTL and CSRF-replay tests now have teeth (#72) — The TTL test previously read
_cache[raw_key](LocMemCache stores expiry in_expire_info, not in the value tuple), making the assertion vacuous. Replaced with acache.get()presence check plus_expire_infoTTL assertion. The CSRF-replay test'sif-gatedassertNotEqualwas replaced with an unconditionalassertNotIn('Set-Cookie', resp2)plus a super-call count assertion that verifies the cache hit. -
Test infrastructure hardened (#73) — Multi-backend cache fixture covering both
LocMemCacheandDatabaseCache; concurrency test for parallel mutation invalidation; deadreset_global_registrycall removed; fixture deduplication. -
Playground updated (#74) —
PostType.get_querysetscoping example (anonymous → published only, authenticated → all); safe-ordering comment onPostListType; version signal markers updated; README banner added ("Targets django-graphex v1.3.x") with safe-ordering table.
New settings¶
| Setting | Default | Description |
|---|---|---|
MAX_UPLOAD_SIZE |
None |
Global decoded-size cap for Base64FileInput (bytes). Required when using uploads unless a per-field max_size is set. |
MAX_REQUEST_BODY_SIZE |
None |
HTTP body-size guard (bytes). Requests exceeding this limit are rejected with HTTP 413 before JSON parsing. |
Behavior changes¶
Review before upgrading
Review each item below before upgrading.
-
Django < 5.2 is no longer supported — Upgrade Django to ≥ 5.2 before upgrading
django-graphexto 1.3.0. Projects on Django 4.2, 5.0, or 5.1 must stay ondjango-graphex1.2.x. (#75) -
filter_fields = {"field": None}now raisesImproperlyConfigured— Previously this crashed withTypeError; the error is now explicit and directive. ReplaceNoneentries with a@filter_field-decorated method on the same type. (#26) -
Custom filters are now declared exclusively via
@filter_field— Passing callable values throughfilter_fieldsis no longer supported. Use the@filter_fielddecorator instead; it co-locates the filter logic with the declaration. (#26) -
Base64FileInputrequiresMAX_UPLOAD_SIZEor a per-fieldmax_size— UsingBase64FileInputwithout either a globalMAX_UPLOAD_SIZEsetting or a per-fieldmax_sizekwarg raisesImproperlyConfiguredat resolution time. (#25)
1.2.3 — 2026-06-13¶
Hotfix
Corrective release only — no behavior changes beyond restoring the two pre-1.2.2 behaviors described below.
Fixed¶
- Subscription delete broadcasts no longer send
pk=None—_on_deletenow snapshots the pk (and the serialized payload inserialize_data=Truemode) at signal time before deferring viatransaction.on_commit, instead of capturing the live instance whose pk Django nulls before the callback runs. Fixes misrouted per-object delete notifications in the default id-only mode and aValueErrorthat escaped the user's transaction inserialize_data=Truemode. Regression from 1.2.2 (#63). (#69) - Pagination
orderingnow accepts the idiomaticpk/-pkalias — The 1.2.2 ordering allowlist (#59) rejected it, breaking any paginator configured withordering="pk"on every request. Relation-spanning (__) ordering remains rejected. Regression from 1.2.2. (#70)
1.2.2 — 2026-06-13¶
Security¶
DjangoObjectType.get_querysetnow actually applied on object, list, paginated, and list-object fields — The hook was documented but never invoked on those field types, making every override a silent no-op. It is now called forDjangoObjectField,DjangoFilterListField,DjangoFilterPaginateListField, andDjangoListObjectField(results + totalCount). (#58)- Pagination
orderingvalidated against the model's concrete columns — Client-suppliedorderingvalues are now checked before they reach the ORM. An unknown field name can no longer trigger aFieldErrorthat discloses the model's full field list, nor a relation-traversalJOINthat enables a DoS via index-missing foreign keys. (#59) Corrected: this entry originally said "validated against exposed columns". It was not — the allowlist came from the model, so a column the type projected away stayed sortable. See the 3.0.0 Security section. - Response cache skips cookie-bearing and multipart requests — The
CACHE_ACTIVEcache no longer stores responses to requests that carry cookies or aremultipart/form-data. Previously such responses could be replayed to unrelated clients (CSRF-cookie replay) or cause a 500 on multipart uploads. (#53)
Fixed¶
- Directives and pagination raise
GraphQLErrorinstead of HTTP 500 on bad client input —@base64,@currency,@floor,@ceil,@round,@abs,@center, and negative-offset pagination now return a structuredGraphQLErrorfor invalid arguments rather than an unhandled exception that propagates as HTTP 500. (#50) - Nested-write integrity — M2M writes with a bad primary key now return a
structured error instead of crashing; the reverse-FK ownership guard prevents
cross-owner row "stealing"; to-one fields that receive a list are rejected at
input validation;
pk=0upsert correctly routes to the update path; enum values inside a list input are properly unwrapped before persistence. (#62, #51) - Subscriptions run
authorize/scopeand registry I/O viasync_to_async— ResolvesSynchronousOnlyOperationerrors that appeared when authenticated subscriptions called ORM-backed permission or scope logic from an async context. Thedisconnecthandler is also now safe to call without a priorconnect. (#61) - Subscription broadcasts fire on transaction commit — Notifications are now
deferred to
transaction.on_commitrather than being sent atsave()time. Rows inserted in a rolled-back transaction no longer emit phantom notifications to subscribers. (#63) - Enum registry keyed by model class — The enum registry now uses the model
class as the key, preventing collisions between apps that define a model with the
same name. Self-referential
OneToOneFieldrelations are no longer silently dropped during schema construction. (#52) Meta-option hygiene — Unknown or mistypedMetaoptions (e.g.filter_Filedinstead offilter_fields) now raiseImproperlyConfiguredinstead of being silently ignored.include_fieldsis honored on input and list types; anid-excludedonly_fieldsno longer breaks the update mutation;DjangoListObjectType.Meta.querysetis now consumed as the base queryset. (#65)- Cache version counter uses
transaction.on_commit— The version-bump that invalidates a user's cache is now deferred to commit, closing the pre-commit stale window. Non-expiring version keys no longer resurrect after a cache flush; a cold key correctly heals to1rather than0. (#60)
Performance¶
- Empty window-sliced nested pages skip per-parent
COUNT— When a window-sliced nested list returns zero rows for a parent, no additionalCOUNTquery is issued for that parent. (#64) - Request-scoped field-map cache threaded through filtered-prefetch descent and
.only()-narrowing pass — The per-request_cachedict is now correctly propagated through the filtered-prefetch and column-narrowing code paths, preventing redundant_meta.get_fields()calls on queries with nested filtering or.only()projections. (#57, #66)
Packaging / Docs¶
all_directivesstatic type matches runtime instances — The type annotation was broadened to match the actual runtime list of directive instances returned by the function. (#66)- Install "verify" snippet works on a clean install —
docs/installation.mdnow usesimportlib.metadata.version('django-graphex')(nodjango.setup()required) and carries an accurate Python × Django compatibility statement. Development Status classifier set to "5 - Production/Stable". (#67) - README and migration link fixes — Migration guide URL corrected from
.../migration.htmlto.../migration/; stale#18tracking links removed fromdocs/usage/mutations.md. mypy dev dependency pinned to>=2.1,<3to match CI;daphneupper bound<5.0restored in tox test environments;filter_fields={"field": None}no longer raises at schema build. (#55)
Behavior changes¶
Review before upgrading
Review each item below before upgrading. Items marked action required need a one-time change in your project.
- Subscription broadcasts now deliver on COMMIT, not at
save()time — Downstream tests that use@pytest.mark.django_db(non-transactional) will not observe broadcasts unless the test database wraps the write in a real transaction. Action required for test authors: switch to@pytest.mark.django_db(transaction=True)in any test that asserts on subscription notifications. (#63) - Response cache stores a
(body, status, content_type)tuple, not a rawHttpResponse— Deployments that introspect or inject into the response cache directly (e.g. custom cache backends that serialise the value) must account for the new tuple shape. Cookie-bearing and multipart requests are never cached. (#53) - Unknown
Metaoptions now raiseImproperlyConfigured— A previously silent typo (e.g.filter_Filed) will now surface at server startup rather than being ignored. This surfaces real bugs; review yourDjangoObjectTypeandDjangoListObjectTypesubclasses for typos before upgrading. (#65) DjangoObjectType.get_querysetis now actually invoked — Anyget_querysetoverride that was previously dormant (documented as active but never called on list/paginated/list-object fields) will now execute on every relevant query. Action required: review yourget_querysetoverrides to confirm they are safe to apply globally on those field types, then upgrade. (#58)- mypy dev dependency moved to
>=2.1,<3— Projects that pin mypy via thedjango-graphexdev extras should update their own mypy constraint accordingly. (#55)
1.2.1 — 2026-06-12¶
Security¶
- Response-cache cross-user isolation (
CACHE_ACTIVE) — Cache keys now incorporate a per-identity token (u{pk}for authenticated users,t{header_hash}for token-auth,anonfor anonymous). User A's cached response is never served to User B. Mutation invalidation advances a per-user version counter instead ofcache.clear(), so one user's cache miss never evicts another user's valid entries. A malformed query now returns HTTP 400 even whenCACHE_ACTIVE=True(parse guard). (#27, closes #11) - Subscriptions hardening — Channel ownership is validated before a
subscriber joins any group (prevents channel hijacking). Client-supplied
filterskeys are now checked against declared output fields (rejects arbitrary ORM field probing such aspassword__contains). Broadcast signal handlers are ASGI-safe (no deadlock under a running event loop). Percent-encoded index group names prevent ambiguous separators in filter values. A newSUBSCRIPTIONS_CHANNEL_GUARDsetting (defaultTrue) controls the ownership check; set toFalseonly when every worker shares the"default"cache backend. The channel registry is backed bycaches["default"]so multi-worker deployments with a shared cache work without configuration. (#30, closes #14) MAX_BATCH_SIZE(default10) — Batch requests with more entries than this limit receive HTTP 400 before any query executes, capping amplification attacks.Nonerestores the previous unlimited behavior. (#31, closes #15)- GraphiQL SRI pinning — CDN assets (react@18.3.1, react-dom@18.3.1,
graphiql@3.7.1) are pinned with SHA-384
integrity=andcrossorigin="anonymous"attributes. A CDN compromise or silent version bump can no longer inject JavaScript to GraphiQL users. (#31, closes #15) @numberformat-spec cap — Client-supplied specs with width or precision100 raise a
GraphQLErrorinstead of allocating gigabytes of output. Normal specs (.2f,,.2f,+.1%) are unaffected. (#32, closes #16)
Fixed¶
- UniqueConstraint validation —
model._meta.constraintsis now iterated for unconditionalUniqueConstraintentries; violations return a structuredErrorTyperesponse instead of propagating an unhandledIntegrityErrorHTTP 500. Deduplication prevents double messages when a field has bothunique=Trueand aUniqueConstraint. Conditional and expression-based constraints are intentionally left to DB enforcement. MTIparent_linkfields are excluded from FK constraint checks, mirroring the guard already present in the type converter. (#29, closes #13) @skip/@includehonored by cost, depth, and optimizer — Fields marked@skip(if: true)or@include(if: false)are excluded from cost counting, depth counting, and queryset optimization (no more false-positiveQUERY_TOO_COMPLEX/QUERY_TOO_DEEPerrors; skipped subtrees are not over-fetched). When the directive argument is an unbound variable, the selection is treated as included (conservative fallback). (#28, closes #12)- Pagination —
page=0now raises an explicitGraphQLErrorin all Python execution modes (the previousassertwas a no-op underpython -O). Tampered or corrupted cursors raise a cleanGraphQLError("Invalid cursor")instead of HTTP 500. TheCOUNTquery inPageGraphqlPaginationis now conditional — it runs only for last-page navigation, removing one DB round-trip from every forward-paginated request. (#33, closes #17) @date(format: "iso")— Now emits real ISO 8601 (2023-12-01T14:30:00) instead of the previous locale-dependent month abbreviation format (2023-Dec-01T14:30:00) that was unparseable bydatetime.fromisoformat. (#32, closes #16)@datetime-ago DST awareness —_format_time_agonow computes "now" usingtimezone.get_current_timezone()instead of the fixed, DST-unawaretime.timezoneoffset. Daylight-saving transitions no longer produce off-by-one-hour relative timestamps. (#32, closes #16)@base64UTF-8 support —.encode("ascii")raisedUnicodeEncodeErroron non-ASCII input; replaced with.encode("utf-8")/.decode("utf-8"). (#32, closes #16)- Custom-pk
deletereturns the correctid—mutation.pyandtypes.pynow resolve viaold_obj._meta.pk.attnameinstead of the hard-codedold_obj.id, so models with a custom primary key name (pk_id,uuid, etc.) return the right identifier in the delete payload. (#34, closes #18) - Enum unwrap in nested writes —
_unwrap_enumsnow usesisinstance(value, enum.Enum)instead of the fragile"Enum" in type(value).__name__substring check, matching the guard innative/backend.py. A class whose name contains "Enum" but is not an actual enum is no longer incorrectly unwrapped. (#34, closes #18) - Deterministic SDL field order —
construct_fieldsnow sorts fields unconditionally. The previoussettings.DEBUGgate meant dev and prod SDLs had different field orders, breaking snapshot tests, federation registries, and SDL diff tools. (#35, closes #19) - Introspection detection by AST — Replaces the fragile
startswith("\n query IntrospectionQuery")string check with a proper AST walk (_is_introspection_document). Any query whose top-level selections are exclusively__schemaor__typeis detected as introspection regardless of formatting — fixing compact inline queries and__typevariants. (#31, closes #15)
Performance¶
- Request-scoped field-map memoization —
_relation_field_mapand_concrete_field_mapaccept a per-invocation_cachedict threaded through all walker functions by_apply_optimizations. Each(model, map-kind)pair calls_meta.get_fields()at most once per optimizer run (down from 6+ for a typical two-model query). (#36, closes #20) - Selection-aware mutation re-read —
DjangoModelType.perform_mutatelocates the output-field sub-node in the mutation selection set and passes it through_apply_optimizationsbefore the DB re-read, so forward-FK relations present in the mutation response are pre-joined viaselect_related. (#36, closes #20) - Single parse with
EXPOSE_QUERY_COST—execute_graphql_requestaccepts an optionaldocument=kwarg;GraphQLView.get_responseparses once and threads the document to both the executor and cost checker. WithEXPOSE_QUERY_COST=True,parse()is called exactly once per request. (#31, closes #15) - One fewer
COUNTper forward page —PageGraphqlPaginationnow runs theCOUNTquery only when navigating to the last page (see Fixed above). (#33, closes #17)
Packaging¶
py.typedshipped — PEP 561 marker file added; mypy/pyright now resolve types from the installed package without additional configuration. (#37, closes #21)- sdist hygiene —
[tool.hatch.build.targets.sdist]allowlist ensures onlydjango_graphex/,tests/,docs/,README.md,LICENSE, andpyproject.tomlare included in the source distribution;.claude/andspecs/are provably absent from the tarball. (#37, closes #21) __version__fromimportlib.metadata— Single source of truth: the installed package version is derived at import time; aget_version(VERSION)fallback handles editable/source installs. (#37, closes #21)- Dependency caps —
channels-redis>=4.2,<5.0anddaphne>=4.0,<5.0prevent accidental upgrades to as-yet-untested major versions. (#37, closes #21) - Metadata fixes —
DocumentationURL corrected; PyPy classifier removed (untested in CI); staleREADME.rstreference removed fromMANIFEST.in. (#37, closes #21)
Documentation¶
- Overhaul — All verified doc/code drift fixed:
only_fields/exclude_fieldskwarg names,ListField()AttributeErrorwarning,Registrypublic import note,DjangoUnionType/DjangoInterfaceTypeAPI reference sections,CACHE_ACTIVEdefault clarification. Pydantic validation docs consolidated tobackends.md(single home). Nested UPDATE worked examples, mutation complete example with client tab,DjangoNestedListObjectFielddocumented infields.md, paginationpage_size_query_paramsemantics and cursor single-field design noted. Directive middleware required banner relocated.docs/api/utils.mdremoved. (#39, closes #23) - New caching guide —
docs/usage/caching.mddocuments key anatomy, per-user isolation, mutation invalidation, malformed-query handling, andcache_key_prefixcustomisation. Settings page expanded with security semantics. (#27, closes #11)
Behavior changes¶
Upgrade actions required
Review each item below before upgrading. Most require no action; the ones marked action required need a one-time change in your project.
MAX_BATCH_SIZEdefault is now10— Batch requests longer than 10 entries are rejected with HTTP 400. Deployments that intentionally send large batches must setDJANGO_GRAPHEX["MAX_BATCH_SIZE"] = None(unlimited) or a higher integer. (#31, closes #15)@date(format: "iso")output format changed — The old output ("2023-Dec-01T14:30:00") was locale-dependent and unparseable; the new output is real ISO 8601 ("2023-12-01T14:30:00"). If you have clients or tests that match the old locale-abbreviation format, update them. (#32, closes #16)- Schema SDL field order is now deterministic everywhere — Previously
DEBUG=Truesorted fields butDEBUG=Falsedid not, causing dev/prod divergence. The order is now consistently alphabetical in both modes. Action required: regenerate any SDL snapshot files once after upgrading. (#35, closes #19) - Cache key format changed — The per-identity token added for cross-user isolation changes the shape of every cache key. The cache will be cold after upgrading; no stale cross-user entries will survive. No action required beyond accepting a one-time warm-up cost. (#27, closes #11)
page=0now raises aGraphQLError— Previouslypage=0silently returned an empty result set (or crashed underpython -O). Clients that sentpage=0expecting an empty page must sendpage=1instead. (#33, closes #17)- Subscriptions guard is fail-closed —
SUBSCRIPTIONS_CHANNEL_GUARD=True(default) requires that the HTTP subscribe request and the WebSocket connect reach the same worker, or that all workers share the"default"cache backend (e.g. Redis). Single-worker / single-process deployments are unaffected. Multi-worker deployments must configure a shared cache or setSUBSCRIPTIONS_CHANNEL_GUARD=Falseto disable the guard. (#30, closes #14) - New settings introduced:
MAX_BATCH_SIZE(default10),SUBSCRIPTIONS_CHANNEL_GUARD(defaultTrue). Both live under theDJANGO_GRAPHEXdict insettings.py. (#31, closes #15; #30, closes #14)
1.2.0¶
Added¶
DjangoUnionType— a base for a GraphQLUnionwhose members are explicitDjangoObjectTypes (Meta.gfk_types = (MemberAType, MemberBType)). Its main use is exposing aGenericForeignKeyas a typed union instead of the flatGenericForeignKeyType, so clients select per-member fields via inline fragments. A GFK owner opts in withMeta.gfk_unions = {"<fk_name>": TheUnion}. Members are explicitly enumerated — thedjango_content_typetable is never queried to discover them. A mandatory, providedresolve_typemaps each row to its registered type (raising a descriptiveTypeErroron an unregistered model). Declaration order is load-bearing: members → union → owner LAST; a mis-ordered declaration logs aWARNINGand falls back toGenericForeignKeyType. See Types — DjangoUnionType.DjangoInterfaceType— a base for a GraphQLInterfacethat shares field declarations across multipleDjangoObjectTypeimplementors (via the existingMeta.interfaceskwarg). Schema-level field sharing only — no new fetch path. See Types — DjangoInterfaceType.- Per-content-type column narrowing for union GFKs (Django 5.0+) — when
OPTIMIZE_ONLY_FIELDSis on, a union-typed GFK is prefetched viaGenericPrefetchwith one narrowed queryset per content type, each.only()-restricted to that member's selected columns, batched across all parents (no N+1). On Django < 5.0 the optimizer degrades gracefully to a single bare full-loadPrefetch— it never importsGenericPrefetch, never narrows columns, and is never slower than before. Each distinct content type yields exactly one queryset; two members over one shared table are collapsed (merged.only()columns). See Types — per-content-type narrowing.
Fixed¶
- Inline-fragment type-condition guard. The query optimizer no longer
descends into an inline fragment whose
type_conditionnames a different concrete type than the one being walked, preventing field mis-attribution against the wrong model's relation map. Inert before this release (no polymorphic output types were exposed) but the correctness foundation the union/interface routing above builds on. - Nested writes now expose object inputs in the GraphQL schema. A
DjangoModelMutation(orDjangoModelType) declaringMeta.nested_fieldsreused the model's generic cached input type, so its nested relations were exposed as[ID!]and a client could not create children inline — even though the backend supported it. The mutation now builds a distinct nested-aware input (e.g.PostCreateNestedCommentsTypewithcomments: [CommentCreateInput!]) while a sibling generic mutation on the same model keeps its[ID!]input unchanged, regardless of declaration order.
Documentation¶
- Optimizer docs brought up to date with the 1.1.0 internals: documented
AnnotatedField, theOPTIMIZE_NESTED_PAGINATION/OPTIMIZER_SAFE_MODE/OPTIMIZE_ANNOTATED_FIELDSsettings, GenericForeignKey prefetch, and the safe-mode degrade. Corrected the nested-list pagination section, which previously said nested lists were sliced "in memory" — by default they are now sliced DB-side viaROW_NUMBER()window functions.
1.1.0¶
Added¶
OPTIMIZER_SAFE_MODE(defaultFalse) — opt-in coarse fail-safe: any exception raised inside the queryset-optimization block degrades the whole resolve to the un-optimized queryset and logs aWARNINGinstead of surfacing a 500. Default is fail-loud. See Query Optimization — OPTIMIZER_SAFE_MODE.- GenericForeignKey / GenericRelation prefetch —
GenericForeignKeytargets are added toprefetch_related(the parent's content-type-id and object-id columns are retained so the second query resolves) andGenericRelationreverse sides are prefetched and.only()-narrowed (their content-type / object-id attnames kept). See Query Optimization. OPTIMIZE_NESTED_PAGINATION(defaultTrue) — DB-sideROW_NUMBER() OVER (PARTITION BY fk)window slicing for reverse-FK nested paginated lists (LimitOffsetGraphqlPagination/PageGraphqlPagination), fetching only the requested page rows per parent in a single query, with a filter-awaretotalCountcarried per partition. SetFalseto fall back to the in-memory order+slice path. See Nested Lists — Performance (N+1).AnnotatedField— a public, declarative GraphQL field backed by a Django ORM annotation (e.g.comment_count = AnnotatedField(graphene.Int, Count("comments"))), injected only when the field is selected in the query; gated byOPTIMIZE_ANNOTATED_FIELDS(defaultTrue). Forward-FK relations whose child selection contains anAnnotatedFieldare auto-promoted fromselect_relatedtoprefetch_related(annotations can't cross a SQL JOIN). See Fields — AnnotatedField.- Per-field optimize hook (
optimize_<field>) on parent graphene types. Declare anoptimize_<snake_field>(queryset, info, **kwargs)static method on the parentDjangoObjectTypeto customize the child queryset for a specificDjangoNestedListObjectField. The hook is applied after the optimizer builds the child queryset and before Django executes it — allowing you to addselect_related, annotations, ordering, or any other queryset modification without disabling the global optimizer. Hook kwargs includefilter_value(the filter input orNone) andis_window(Trueon the window-sliced path,Falseon all plain paths). When no hook is declared, behavior is byte-identical to the pre-1.1 baseline (purely additive). See Nested Lists — Per-field optimize hook for a worked example.
Changed¶
- BREAKING — minimum Django is now 4.2. Django 4.0 and 4.1 (both end-of-life)
are no longer supported. Projects pinned to those versions should stay on
django-graphex1.0.x. - BREAKING — public classes renamed (the
Extraprefix is dropped).ExtraGraphQLSchema→DjangoGraphQLSchemaandExtraGraphQLDirectiveMiddleware→GraphQLDirectiveMiddleware. Update your imports and theGRAPHENE["MIDDLEWARE"]dotted path todjango_graphex.middleware.GraphQLDirectiveMiddleware.DjangoGraphQLSchemaalso avoids the name clash withgraphql.GraphQLSchemafrom graphql-core.
1.0.0¶
The first release. A GraphQL + Django toolkit built directly on graphene
(graphene-core) and Pydantic v2 — no graphene-django, no
djangorestframework, no django-filter.
Model types, mutations & the native backend¶
DjangoModelType/DjangoModelMutation— define a Django model once and get query, list and create/update/delete operations. Backed byMeta.modeland a native Pydantic v2SerializerBackendthat validates and persists with the Django ORM (field types,max_length,Decimalprecision, required/nullable/ defaults,choices→Enum, FK pk types, M2M as a list of pks), and runs the DB-level checks Pydantic can't — FK existence, uniqueness andunique_together. Supports partial updates.- Custom validation without a serializer: declare DRF-style inline
validate_<field>(self, value)and an object-levelvalidate(self, data)directly on the class, and/or pass aMeta.pydantic_modelwith validators (they compose). - Atomic, relation-aware nested writes —
Meta.nested_fields = {field: Model}for forward FK, reverse FK and M2M children, written in one transaction (parent or child failure rolls everything back) withfield.subfielderror paths. - Custom output fields declared on a
DjangoModelTypeare honored, including theirresolve_<field>resolver methods (andsource=), inherited/overridable down the MRO.
Filtering¶
- Native
and/or/notfiltering through a single nestedfilter:argument (a generated<Model>FilterInput), built on Django's ORM lookups +Qobjects. Per-field lookups, relation descent (auto.distinct()on to-many joins), plain-pk /UUIDFieldfiltering (id: { exact/in }), andchoicesfiltered via theirEnum. Declared withMeta.filter_fields(list or dict form); the common base lookup set is configurable viaCOMMON_FILTER_LOOKUPS. AFilterBackendseam keeps it swappable. - Custom filtering logic via the
get_queryset/filter_querysethooks.
Lists, pagination & performance¶
- Three paginators —
LimitOffsetGraphqlPagination,PageGraphqlPaginationandCursorGraphqlPagination(keyset cursor with a non-opaquepageInfo). Pagination/ordering live on theresults(...)subfield; lists expose the uniformresults/totalCountshape, including nested related lists. - Automatic N+1 query optimization —
select_related/prefetch_related/.only()derived from the GraphQL selection (incl. filtered nested-list prefetches), tunable withOPTIMIZE_QUERYSET/OPTIMIZE_ONLY_FIELDS. - An effective
MAX_PAGE_SIZEceiling applied even when no page-size arg is sent.
Subscriptions¶
- Real-time GraphQL subscriptions over Django Channels 4 (optional
[subscriptions]extra) with an in-house signal →group_sendengine; configurable notification payload (id-only vs full) and value-scoped "indexed" groups.
Permissions & security¶
- DRF-style permission classes (
BasePermission,IsAuthenticated,IsAdmin,IsAuthenticatedOrReadOnly, …) usable on types, subscriptions and views. - Query depth limiting (
MAX_QUERY_DEPTH/Meta.max_depth) and query cost analysis (MAX_QUERY_COST/Meta.complexity, optionalextensions.cost). - Security middlewares —
DisableIntrospectionMiddleware,AuthenticatedFieldsMiddleware— andExtraGraphQLSchemafor declaring private fields. Every execution error carries a machine-readableextensions.code.
Views¶
GraphQLView(response caching + depth/cost rules +extensions.cost),BaseGraphQLView(minimal, self-contained — nographene-django), andAuthenticatedGraphQLView(endpoint-level auth gate via the library's own permission classes, no DRF). GraphiQL is served from a self-contained CDN page, overridable withgraphiql_templatefor offline / strict-CSP setups.
Directives¶
- Schema directives for string/number/list/date transforms (
@title_case,@snake_case,@slugify,@truncate,@round,@abs,@unique,@date, …; custom directive names are snake_case), with directive arguments as GraphQL variables.
Requirements¶
- Python 3.12–3.14, Django 4.2–6.0 (LTS 4.2 and 5.2 recommended for production), graphene >=3.3,<4, pydantic >=2,<3.
Django range clarification
The 1.0.0 release initially listed Django 4.0–6.0 in its requirements. The true minimum is Django 4.2 — Django 4.0 and 4.1 are end-of-life and were never tested. From 1.1.0 onward the supported range is explicitly 4.2–6.0.