Types¶
django-graphex provides enhanced type classes built directly on graphql-core.
Model choices → GraphQL enum
A model field with choices is converted to a GraphQL enum automatically.
Every declaration form is supported, including the Django 5.0 forms — an
enumeration type (TextChoices / IntegerChoices), a mapping, or a callable —
on all supported Django versions (the converter normalizes them). See
choices → GraphQL enums for how member names are
derived.
DjangoObjectType¶
The base node type: it maps a Django model to a GraphQL object type. Declare a
model and the library converts its fields (including choices → enums and
relations → nested list types). It is the building block every other type and
field resolves to.
from django.contrib.auth import get_user_model
from django_graphex.types import DjangoObjectType
User = get_user_model()
class UserType(DjangoObjectType):
class Meta:
description = "Type definition for a single user"
model = User
# The projection. It is a security boundary, not cosmetics — see
# [the rule](#projection-security-boundary) — and it is the ONE
# projection for `User` on this page: every later sample that lists,
# filters or orders users is bound by exactly these columns.
only_fields = (
"id", "username", "first_name", "last_name",
)
# Enables `filter:` when this type is used as a (nested) list.
filter_fields = {"username": ("exact", "icontains")}
Use it directly with DjangoObjectField to fetch a
single object by id, as the node of a DjangoListObjectType,
or as the output of a mutation. Relations on the model are exposed
as nested lists with the uniform results / totalCount shape — see
Nested lists.
Django accounts are not generic CRUD resources
Keep the four-field projection above behind an authenticated endpoint and leave response caching disabled unless its key includes every identity and tenant dependency:
from django.urls import path
from django_graphex.views import AuthenticatedGraphQLView
urlpatterns = [
path("graphql/", AuthenticatedGraphQLView.as_view(schema=schema)),
]
DJANGO_GRAPHEX = {"CACHE_ACTIVE": False}
Registration is a separate, purpose-built mutation. Accept only the credentials your flow needs and call Django's manager so the password is hashed; do not expose privilege flags through a generated input:
See the Quick Start for the complete executable schema.
Custom per-field filter arguments — @filter_field¶
Added in v1.3.0. Use the @filter_field decorator to expose a custom
GraphQL filter argument directly on the type, co-located with its logic:
from graphql import GraphQLString
from django.db.models import Q
from django_graphex.filtering import filter_field
from django_graphex.types import DjangoObjectType
class PostType(DjangoObjectType):
class Meta:
model = Post
filter_fields = {"title": ("exact", "icontains")}
@filter_field(GraphQLString, description="Full-text search")
def search(cls, queryset, info, value):
return queryset.filter(
Q(title__icontains=value) | Q(body__icontains=value)
)
See Filtering — @filter_field
for the full reference including type override, composition order, and reserved
argument names.
Custom queryset (per-request filtering)¶
Override get_queryset(cls, queryset, info) to scope what a type exposes on a
per-request basis — for example, to restrict rows to the current user's data:
class ArticleType(DjangoObjectType):
class Meta:
model = Article
filter_fields = {"title": ("icontains",)}
@classmethod
def get_queryset(cls, queryset, info):
# Only expose articles owned by the requesting user.
return queryset.filter(owner=info.context.user)
The hook is called by all four top-level field types before the query
optimizer runs, so select_related / prefetch_related are applied on top of
the already-narrowed queryset — the same interplay described for
DjangoModelType.Meta.queryset in the optimizer docs:
| Field type | get_queryset applied? |
|---|---|
DjangoObjectField |
✅ yes |
DjangoFilterListField |
✅ yes |
DjangoFilterPaginateListField |
✅ yes |
DjangoListObjectField (results + totalCount) |
✅ yes |
Both results and totalCount reflect the hook — the hook is applied
before the COUNT query is issued.
The hook applies wherever the field is mounted, root or nested: a
DjangoFilterListField you mount by hand on a parent type (e.g.
created_posts = DjangoFilterListField(PostType) on AuthorType) scopes its
rows even when it is reached through the parent's relation, not only at the top
level.
The hook must return a QuerySet
Returning anything else (most often a missing return, so None) raises
TypeError. The request is denied rather than served with the scope
silently skipped.
Cost of scoping a hand-mounted relation field
A DjangoFilterListField mounted on a parent type normally reads the rows
straight out of the parent's prefetch cache. When the child type declares a
get_queryset scope, that scope has to be applied to the relation, which
issues one query per parent instead. Types that declare no scope keep the
cache and the single query.
Remaining boundary: auto-expanded relation fields
The hook is a field-level scope, not a model-level one. Relation fields
that django-graphex auto-expands on a parent type do not call the child
type's get_queryset, in both directions:
- to-MANY — a reverse FK or M2M exposed as
allAuthors { results { posts { results { title } } } }(resolved byDjangoNestedListObjectField) reads out of the parent's prefetch cache. - to-ONE — a forward
ForeignKeyorOneToOneFieldexposed asallPosts { results { author { name } } }is a plain attribute read off the already-fetched parent (select_related).
So { allAuthors { results { name } } } correctly hides a row the scope
excludes, while { allPosts { results { author { name } } } } still serves
that same row through the relation. Do not rely on get_queryset alone
to hide rows that are reachable through a relation.
This is intentional, and the cost is the reason: the to-MANY arm would have
to rebuild the prefetch queryset inside the resolver, which conflicts with
the window-pagination and prefetch optimizations, and the to-ONE arm would
have to re-fetch the target per parent row, replacing one select_related
join with one query per row.
If you need per-relation row scoping, declare the relation field explicitly on the parent type. A declared field of the same name replaces the auto-expanded one, and only then is a resolver wired at all.
-
to-MANY — mount it as a
DjangoFilterListField, which applies the hook on its own (see the cost note above):from django_graphex.fields import DjangoFilterListField class AuthorType(DjangoObjectType): # Replaces the auto-expanded PostListType container; the hook runs. posts = DjangoFilterListField(PostType) class Meta: model = AuthorThis changes the field's wire shape from the
PostListTypecontainer (posts { results { … } }) to a plain[PostType](posts { … }), so existing clients selectingresultsneed updating. It also withdrawspostsfrom the filter axis — see the warning below. -
to-ONE — declare the field and scope it in its
resolve_method:from django_graphex.core import Field class PostType(DjangoObjectType): # REQUIRED: without this declaration the resolver below never runs. author = Field(AuthorType) class Meta: model = Post def resolve_author(self, info): # The auto-expanded FK read would bypass AuthorType.get_queryset. return AuthorType.get_queryset( Author.objects.filter(pk=self.author_id), info ).first()
Declaring the relation withdraws it from the other axes — in both directions
A relation served by a resolver of your own is treated as a mask,
exactly like a column served by one: what the client can read is whatever
the callable returns, not what the row holds. One rule, both arms of the
hatch — a to-one declaration plus its resolve_ method, and a to-many
DjangoFilterListField / DjangoFilterPaginateListField, which carries a
resolver of its own by construction. Their cost differs only because a
to-one relation owns a column on the parent row and a to-many one does not:
| Axis | to-ONE author = Field(AuthorType) + resolve_author |
to-MANY posts = DjangoFilterListField(PostType) |
|---|---|---|
ordering |
ordering: "authorId" refused — GraphQLError: Invalid ordering field. |
Unaffected: a reverse FK or M2M owns no column here, so nothing leaves the allowlist. |
filter |
filter_fields = {"author__name": …} stops the schema building. |
filter_fields = {"posts__title": …} stops the schema building. |
The build failure is ImproperlyConfigured, the same
contradiction-between-two-Meta-options refusal every hidden path gets.
Drop those entries when you declare either arm of the hatch.
The cost to a reader who already followed this guide is exactly that:
a nested posts__… filter on the parent type now has to go. Nothing about
the output changes — the relation is still selectable, still scoped, and
still returns the rows its get_queryset allows. What goes is the ORM
join through it, which never ran that get_queryset and therefore
answered, one lookup at a time, the very question the hatch was mounted to
refuse. If you need the join, the parent type has to serve the relation
itself: drop the declaration and scope the parent's own queryset instead.
This is not a cost the boundary could have avoided. Both axes read the
relation unscoped: ordering ranks by the raw foreign-key column on the
parent's own row, and a nested filter compiles to an ORM join that never
passes through AuthorType.get_queryset. Left open,
{ posts(filter: { author: { name: { icontains: "a" } } }) } narrows posts
by an author the hatch exists to hide — the row's name stays out of the
response while the filter confirms, one substring at a time, that such an
author exists behind that post. And a resolve_author that returns no
author at all — a stand-in, a None — would still have published a ranking
over author_id, a key no type in the schema serves.
Nothing readable at build time separates those two resolvers; the difference is in a body no static analysis can read. The boundary therefore fails closed for both. If you need the key orderable or the relation filterable, the parent type has to serve the relation itself — drop the declaration and scope the parent's own queryset instead.
A declaration standing under a relation's name that compiles to a scalar publishes no relation at all, and fails closed for the same reason. A declaration rendering the relation with a type bound to another model does not: the value behind it is still the real target row, so the key stays published. That is a schema bug, not a projection boundary. See What "hidden" means.
A bare resolve_<relation> method is silently ignored
An auto-expanded relation field is derived from the model, not from the
parent type, so nothing looks for a resolve_<relation> method on the class
— in either direction. Writing one without the matching field
declaration shown above leaves the relation served exactly as before, with
no error and no warning. Declare the field.
DjangoModelType uses a different hook
DjangoModelType has its own queryset-scoping API (get_queryset +
filter_queryset) with a different signature (manager, info, **kwargs).
That hook is called earlier, at the CRUD-method level, and is unaffected by
this change. Do not mix the two APIs.
Meta options are validated¶
Since v1.2.2 (#65), unknown or mistyped Meta options raise
ImproperlyConfigured at server startup instead of being silently ignored.
For example, a typo like filter_Filed (capital F) is caught immediately.
The full set of recognised options for DjangoObjectType.Meta is:
| Option | Description |
|---|---|
model |
Django model class (required) |
registry |
Type registry instance |
skip_registry |
Skip automatic registration |
only_fields |
Include only the listed model fields |
exclude_fields |
Exclude the listed model fields |
include_fields |
Additional fields to include |
filter_fields |
Field filtering configuration |
interfaces |
GraphQL interfaces to implement |
max_depth |
Max nested-object depth below this type |
complexity |
Cost weight for query cost analysis |
unions |
Mapping of GenericForeignKey field name to a DjangoUnionType |
name |
Type name in the schema; defaults to the class name |
description |
Type description exposed in the schema |
Graphene's own base options ride along on top of that list and are accepted
too: possible_types, default_resolver and container. Every other public
key is rejected at startup. Review your DjangoObjectType and
DjangoListObjectType subclasses for typos before upgrading from pre-1.2.2.
The projection is a security boundary¶
This is the canonical statement of the rule. Every other page links here.
only_fields / exclude_fields are a security boundary, on every axis
A column your type projects away is not merely absent from the output. It must not be readable, orderable, or filterable through that type — one rule, three axes:
| Axis | What a hidden column does | Enforced |
|---|---|---|
| Output | Absent from the SDL, unselectable. | Schema build |
ordering |
GraphQLError: Invalid ordering field: '<name>' — see Ordering validation. |
Query time |
filter |
ImproperlyConfigured — the schema stops building; see The projection is the outer boundary. |
Schema build |
The filter axis refuses rather than drops because a filter_fields entry
naming a hidden column is a contradiction between two Meta options on
the same type, and the operator is the only one who can say which half
was meant. Accepting the option and ignoring it is the defect 2.2.0 fixed;
doing it here would have left an exact lookup returning the hidden value
in one request. A schema that builds today can stop building after
upgrading, and that is the point — every schema that stops building was
answering that lookup.
What "hidden" means — one predicate, both axes¶
Both axes ask one question of one function: does the type that will serve this
request hand out the value this column holds? It is answered against the
compiled type — the type in the SDL — and never by re-reading Meta, so
orderable, filterable and selectable cannot drift apart. Five different
causes therefore give one answer:
| The type … | Column's value published? |
|---|---|
never listed the column (only_fields / exclude_fields) |
No |
publishes the column's name over a resolver of its own (resolve_<name>, or a field-level resolver=) |
No — a published name is not a published value |
publishes a relation over a resolver of its own — a declared to-one plus its resolve_, or a declared to-many DjangoFilterListField / DjangoFilterPaginateListField (the scoping hatch, in either direction) |
No for the relation's _id column, and the relation stops being traversable — the client reads whatever the resolver returns, so the key behind it is served by nothing |
| publishes a relation whose target type hides its own key | No for the relation's _id column |
| lost a relation because the compiler dropped it (its target model has no registered type) | No |
Two consequences worth spelling out, because both break code that used to work:
- A forward FK's
_idcolumn follows the target type's key.author_idis orderable and filterable only whenPostTypepublishesauthorand the type behindauthorpublishes the keyauthorpoints at. A target that projects its own primary key away takesauthor_iddown with it — the id was never readable throughauthor { id }in that schema, so nothing about it was already public. - A multi-table-inheritance parent link rides on the inherited key. The
compiler drops the
<parent>_ptrlink as plumbing and publishes the parent'sidin its place, so the child'spkfollows thatid.
The rule fails closed: a resolver body is user Python that no build-time
check can read, so a resolve_<name> that happens to return the real column
loses the column anyway. Drop the resolver, or publish the substitute under a
different name. The one shortcut the compiler can read is exempt — a
same-name source=, as in bio = CharField(source="bio"), provably reads that
very attribute, so it keeps the column. A source= naming anything else does
not.
The one exception, and the three open boundaries¶
There is exactly one exception, and it is about provenance, not about the column:
An operator-configured ordering= default is not client input
The ordering= you pass when constructing LimitOffsetGraphqlPagination
or PageGraphqlPagination may name a column the type projects away. That
is a deliberate decision, not a claim that the default leaks nothing: a
server-configured ordering ranks by exactly as much as a client term does,
but it is your own deployment choice, applied identically to every request,
and turning it into a runtime outage would punish a configuration only you
can see. A client repeating that same value is still rejected.
CursorGraphqlPagination gets no exemption, because it echoes the
ordering value: pageInfo.startCursor is base64 of
cursor:<ordering value>\x1f<pk>. Printing the column back is reading it
out, not ranking by it, and no operator-intent argument survives that. See
Ordering validation.
Three boundaries the rule cannot close, stated rather than hidden:
- A
@filter_fieldmethod body. Its name is checked — a method spelled like a hidden column compiles the very<Model>FilterInputfield afilter_fieldsentry naming it is refused, so the one-line rename is shut. The body is opaque user Python; keep your own ORM lookups inside your own projection. See Custom per-field filters. - Per-type narrowing on the filter axis. There is one
<Model>FilterInputper model, shared by every context, so a second, narrower type over the same model cannot get a narrower filter input — it gets a build failure instead. The boundary is measured against the union the shared input ends up serving, and against every type that will serve it, so the narrow type cannot inherit the wide one's filters in silence. See Two node types over one model share one input. - A hand-written
Subscriptionthat namesMeta.model. It builds 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 on that subscription. The subscription honours a projection — its ownMeta.only_fields/Meta.exclude_fields— so repeat it there. A subscription generated from aDjangoModelType(Meta.streamplusSubscriptionField()) is not affected: that host forwards its own projection into the subscription class it builds. See Subscriptions › Filter key validation.
Underscore-prefixed names are not checked
The unknown-option check skips a key starting with _: an
from app.models import Foo as _Foo inside the Meta body leaks as a class
attribute, and rejecting it would fail a build for an import alias. This is
not a promise that every underscored key is harmless — _meta, for
instance, is consumed elsewhere and raises on its own.
Model inheritance¶
Abstract base — Django copies the parent's columns onto the child, so they appear on the child's type exactly once. Nothing special to do.
Multi-table inheritance — the parent keeps its own table and the child
holds a parent link. A type over the child picks up everything it inherits: the
parent's columns render alongside the child's own, and the parent's reverse
relations render as their usual <Model>ListType containers.
class Place(models.Model):
name = models.CharField(max_length=100)
class Review(models.Model):
place = models.ForeignKey(Place, related_name="reviews", on_delete=models.CASCADE)
class Restaurant(Place): # multi-table inheritance
serves_pizza = models.BooleanField(default=False)
type RestaurantType {
id: ID! # inherited from Place
name: String # inherited from Place
servesPizza: Boolean
reviews: ReviewListType # inherited from Place
}
Before this, an inherited reverse relation had no compiled counterpart and the
whole schema build failed with RestaurantType fields cannot be resolved, while
the inherited columns — the primary key among them — were absent from the type.
The implicit parent link (placePtr) is deliberately not exposed: the child
already carries every inherited column, so the link would only offer a redundant
hop back to a copy of the same row.
DjangoListObjectType¶
Recommended for Types
Extends DjangoObjectType with built-in pagination and filtering support.
from django_graphex.types import DjangoListObjectType
from django_graphex.paginations import LimitOffsetGraphqlPagination
from django.contrib.auth.models import User
class UserListType(DjangoListObjectType):
class Meta:
description = "Type definition for user list"
model = User
pagination = LimitOffsetGraphqlPagination(
default_limit=25,
ordering="username"
)
filter_fields = {
"username": ("exact", "icontains"),
"first_name": ("exact", "icontains"),
}
Features¶
- Built-in Pagination: Automatic pagination with configurable settings
- Filtering: Built on Django's ORM lookups +
Qobjects (no django-filter) - Ordering: Custom ordering options
- Caching: Optional query result caching
- Custom Queryset: Override default queryset behavior
Configuration Options¶
A DjangoListObjectType concentrates its list behavior in Meta: the paginator
(with its default/max page size and ordering), the filter_fields lookups
exposed on the filter: argument, and a custom base queryset. The field
restrictions are the one thing that does not belong here when a node type is
already registered for the model — they live on that node type, for the reason
the box under the sample gives. The example below continues the UserType
declared at the top of this page:
class UserListType(DjangoListObjectType):
class Meta:
model = User
description = "User list with advanced features"
# Pagination
pagination = LimitOffsetGraphqlPagination(
default_limit=20,
max_limit=100,
ordering=("username", "first_name")
)
# Filtering
filter_fields = {
"username": ("exact", "icontains", "istartswith"),
"first_name": ("exact", "icontains"),
"last_name": ("exact", "icontains"),
# No "groups" entry. Filtering through a relation needs the TARGET
# to be published too, and `Group` has no registered
# `DjangoObjectType` here, so the compiler drops the relation and
# the entry would refuse the build. Register a `GroupType` first if
# you want it.
}
# Custom queryset
queryset = User.objects.all()
# Field restrictions: NOT here. `UserType` is already registered for
# `User` (top of this page), so this container reuses that node type and
# a projection of its own would be dropped — declaring one raises, see
# the box below. The restriction lives on `UserType`, and every column
# the two options above name is published there. It has to be: a
# projection is a security boundary, so filtering or ordering by a
# column the node type hides fails the build (see
# [the rule](#projection-security-boundary)).
The projection only applies when the container mints its node type
A DjangoListObjectType builds its node type from these options only
when no DjangoObjectType is registered for the model yet. Declare the two
side by side — the ordinary arrangement — and the container reuses the
registered type, which was built from its own Meta.
Until this release the container's only_fields / include_fields /
exclude_fields were then dropped without a word, so every column the
projection was 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:
UserListType.Meta.exclude_fields cannot be honored: the node type for User
is reused from UserType, which was built from its own Meta, so the
projection would be silently dropped and any field it hides would stay
exposed. Declare exclude_fields on UserType instead, or remove the option.
The fix is to move the projection to the node type, which is where it
was always taking effect. This is the same answer 2.2.0 gave the identical
defect on DjangoModelType: a warning is filterable and would leave the
leak live in production, and the only configurations affected are the ones
already leaking.
Restating the node's own projection is not affected. The refusal is
about a declaration being dropped into a DIFFERENCE, so the guard compares
the columns the two projections SELECT, not the options they spell. Adding
only_fields = ("id", "username", "email", "first_name", "last_name",
"date_joined", "is_active") to the sample above still builds, because that
is exactly what UserType publishes; so does
exclude_fields = ("password",) on top of it, and so does any other
spelling that selects the same set. Only a projection that would change what
the schema publishes is refused. The sample omits it anyway, because a
restatement is a second place to update when the real projection moves.
Helper Methods¶
To mount a DjangoListObjectType on your Query, wrap it in a
DjangoListObjectField — that produces the
paginated results / totalCount field with the declared filters. The type
also provides a RetrieveField() classmethod that builds a single-object
lookup (by id) from the same node type:
from django_graphex.fields import DjangoListObjectField
from django_graphex.core import ObjectType
class Query(ObjectType):
# Preferred: use DjangoListObjectField directly
users = DjangoListObjectField(UserListType, description="List all users")
# Or use the RetrieveField shorthand for a single object
user = UserListType.RetrieveField(description="Get single user")
ListField() belongs to DjangoModelType, not DjangoListObjectType
UserListType.ListField() raises AttributeError — ListField is a
classmethod on DjangoModelType, not on DjangoListObjectType. Use
DjangoListObjectField(UserListType) instead.
Declaring fields: the descriptor API¶
When a model doesn't already provide a field — a computed value, a hand-written
mutation payload, a custom argument — declare it with a capitalized field
descriptor imported from django_graphex.core. This is the primary idiom for
custom (non-model) fields:
from django_graphex.core import CharField, IntField, Field, ObjectType
class UserType(ObjectType):
full_name = CharField(description="First + last") # -> String
post_count = IntField(required=True) # -> Int!
email = CharField(source="user_email") # reads root.user_email
def resolve_full_name(self, info):
return f"{self.first_name} {self.last_name}"
Every descriptor compiles to the exact same graphql-core type as the
low-level field() substrate it wraps — the SDL is byte-identical. The
descriptors just give you a Django-model-field-style surface (source=,
required=, typed shortcuts) with real import-time names your IDE can resolve.
Scalar shortcuts¶
Each shortcut binds one GraphQL scalar. There are 11 of them, and every one
is position-agnostic — the same shortcut works in an ObjectType body
(output) and in a class Arguments body (input). Use them anywhere you'd
hand-write Field(<scalar>):
| Shortcut | GraphQL type |
|---|---|
IDField |
ID |
IntField |
Int |
FloatField |
Float |
BooleanField |
Boolean |
CharField |
String |
DateField |
CustomDate |
DateTimeField |
CustomDateTime |
TimeField |
CustomTime |
DecimalField |
Decimal |
UUIDField |
UUID |
JSONField |
JSON (or JSONString with as_str=True) |
The Custom* date/time scalar names
The output/argument date/time scalars render as CustomDate /
CustomDateTime / CustomTime, matching the v1 (graphene-django) SDL for
drop-in schema parity. The plain Date / DateTime / Time names are
reserved for the filter-lookup scalars — see
Accepted date/time input formats.
Every shortcut accepts the same keyword surface (the same one the unified
Field accepts):
| Keyword | Effect |
|---|---|
required=True |
Wrap the type in non-null (T!). |
source="attr" |
Output only. Resolve by reading attr off the root object (or calling it if it's a method). |
default=value |
Input only. GraphQL default for the argument (an explicit default=None declares a real null default). |
description="..." |
Field / argument description in the SDL. |
name="wireName" |
Explicit wire name (skips the automatic camelCase pass). |
resolver=fn |
Output only. Field-level resolver (wins over the parent resolver). |
deprecation_reason="..." |
Marks the field / argument @deprecated(reason: ...). |
Setting an output-only keyword (source= / resolver=) on a field used in an
argument position raises a clear TypeError; setting the input-only default=
in an output position raises a TypeError at output compile.
For a type with no named shortcut (a DjangoObjectType reference, a
GraphQLList/GraphQLNonNull wrapper, a custom scalar), use the general
Field descriptor — it takes any type positionally and offers the same surface:
from graphql import GraphQLList, GraphQLString
from django_graphex.core import Field
class Query(ObjectType):
me = Field(UserType) # a DjangoObjectType reference
tags = Field(GraphQLList(GraphQLString)) # list via the graphql-core wrapper
slug = Field(GraphQLString, required=True) # -> String!
Custom fields with resolvers¶
Descriptors work the same on a DjangoObjectType: declare the field on the
class body and it is merged with the model-derived fields. When source= isn't
enough, back the field with a resolve_<name> method — the contract is:
- The method name is
resolve_+ the snake_case attribute name of a field declared on the class (full_name→resolve_full_name). - It is called as
resolve_<name>(self, info, **args):selfis the object being serialized (the model instance on aDjangoObjectType),info.contextis the request, and the field's declared arguments arrive as keyword arguments. - Precedence: an explicit
resolver=callable on the descriptor wins over the method;source=is the no-logic shortcut (it reads the named attribute off the instance, calling it if it's a method).
from django.contrib.auth.models import User
from graphql import GraphQLArgument, GraphQLInt, GraphQLString
from django_graphex.core import CharField, Field
from django_graphex.types import DjangoObjectType
class UserType(DjangoObjectType):
# source= shortcut — reads (or calls) `get_short_name` on the instance.
initials = CharField(source="get_short_name")
# Computed field backed by the resolve_full_name method below.
full_name = CharField(description="First + last name")
# A field with its own GraphQL arguments; the resolver reads them as kwargs.
greeting = Field(
GraphQLString,
args={"width": GraphQLArgument(GraphQLInt)},
)
class Meta:
model = User
only_fields = ("id", "username", "first_name", "last_name")
def resolve_full_name(self, info):
return f"{self.first_name} {self.last_name}".strip()
def resolve_greeting(self, info, width=None):
text = f"Hello, {self.username}!"
return text.center(width) if width else text
Argument dict keys are used verbatim as the wire names (declare a multi-word
argument in camelCase, e.g. args={"maxWidth": ...}); each argument reaches the
resolver as its snake_case keyword (max_width). The same contract applies
on a plain ObjectType root (see the Query examples above) and on a
DjangoModelType, where the resolver is forwarded to
the generated output type.
Arguments use the same descriptors¶
There is one Field for both positions. Arguments on a hand-written
Mutation (or inside Field(args={...})) are declared with the very same
Field / scalar shortcuts you use in an ObjectType body — the direction comes
from the declaration site, never from the descriptor. Field accepts
either a DjangoInputObjectType / InputType CLASS (resolved lazily to its
compiled input type) or a bare graphql-core scalar, so one descriptor covers
both cases:
from django_graphex.core import CharField, Field, Mutation
class CreateUser(Mutation):
class Arguments:
new_user = Field(UserCreateInput, required=True) # an input-object CLASS
note = CharField() # a bare String arg
user = Field(UserType)
...
Field(SomeInput, required=True) replaces the older
lambda: GraphQLArgument(GraphQLNonNull(SomeInput._meta.graphql_input_type))
thunk — same lazy timing, one readable line. In an argument position, a scalar
shortcut accepts required=, default=, description=, name=, and
deprecation_reason=; the output-only source= / resolver= / args= raise a
clear TypeError if set there.
No more *InputField twins (2.0)
In v1 the 12 typed shortcuts each had an *InputField sibling
(CharInputField, IntInputField, …) and a separate InputField
descriptor for arguments. Those are gone. The single Field and the 11
surviving scalar shortcuts (CharField, IntField, JSONField, … —
GenericJSONField was folded into JSONField(as_str=...)) work in both
positions. Replace every InputField(X, ...) with Field(X, ...) and every
CharInputField() with CharField().
Don't import field classes from django.db.models
django.db.models also exports CharField, IntegerField, and friends. If
you accidentally import a model field class and declare it where a GraphQL
descriptor is expected, django-graphex raises a loud TypeError instead of
silently mis-compiling:
'name': got a django.db.models.Field (<django.db.models.CharField>). Did you
import CharField from django.db.models instead of django_graphex.core? Use
django_graphex.core.CharField (or Field) for GraphQL fields.
The guard fires on both the type-body path and the mutation-argument path.
The fix is always the same: import from django_graphex.core.
field() is the low-level substrate
The capitalized descriptors are sugar over field(), which stays public and
unchanged. Reach for field() directly only when you want the raw
graphql-core-typed primitive with no Django-style surface:
from graphql import GraphQLString
from django_graphex.core import field
server_time = field(GraphQLString, description="ISO timestamp")
field() takes description=, args=, resolver=, name=, and
required_perms= — but not source= or required= (those live on the
descriptors). Prefer CharField(...) / Field(...) in new code.
DjangoInputObjectType¶
Creates input types for mutations based on Django models.
from django_graphex.types import DjangoInputObjectType
from myapp.models import Article
class ArticleInput(DjangoInputObjectType):
class Meta:
description = "Article input for mutations"
model = Article
only_fields = ("title", "body", "status")
# or exclude specific fields
# exclude_fields = ("internal_notes",)
Advanced Configuration¶
A DjangoInputObjectType derives its input fields from the model. Shape them
with only_fields / exclude_fields and a description:
from django_graphex.types import DjangoInputObjectType
class ArticleCreateInput(DjangoInputObjectType):
"""Input for creating an article."""
class Meta:
model = Article
only_fields = ("title", "body", "status")
description = "Input type for article creation"
class ArticleUpdateInput(DjangoInputObjectType):
"""Input for updating an existing article."""
class Meta:
model = Article
only_fields = ("title", "body", "status")
description = "Input type for article updates"
Input bodies are model-derived
A DjangoInputObjectType compiles its input fields from the model — custom
descriptors declared on the class body are not added to the input type.
For a bespoke input field that isn't backed by a model column, declare a
hand-written Mutation and pass the extra argument with Field / a scalar
shortcut (e.g. CharField) in its class Arguments (see
the descriptor API and
Mutations). Custom
output fields — with source= or a resolve_<name> method — belong on
the object types instead: see
Custom fields with resolvers.
Usage in Mutations¶
A DjangoInputObjectType is most often wired through a
DjangoModelMutation, which consumes it automatically. For a
hand-written Mutation, pass the input-type class to Field — it
resolves the compiled input type lazily at schema-build time, so you reference
the class directly with no thunk boilerplate:
from django_graphex.core import BooleanField, Field, Mutation
class CreateUserMutation(Mutation):
class Arguments:
new_user = Field(UserCreateInput, required=True)
user = Field(UserType)
success = BooleanField()
@classmethod
def mutate(cls, root, info, **kwargs):
# The input object arrives as a dict of the declared fields.
new_user = kwargs["new_user"]
username = new_user["username"]
email = new_user["email"]
# ... mutation logic
return cls(user=..., success=True)
Field(UserCreateInput, required=True) compiles to exactly the same
GraphQLArgument(GraphQLNonNull(...)) the older lambda thunk produced. The thunk
form still works as a low-level substrate — see
Declaring fields: the descriptor API.
Prefer DjangoModelMutation for model inputs
For ordinary create/update/delete against a model, use
DjangoModelMutation — it builds the input type, validates,
and persists for you. Hand-written Mutation classes are for bespoke logic.
DjangoModelType¶
Recommended for Quick Setup
Automatically generates types, queries, and mutations from a Django model.
All writable model fields are covered, choices become enums, FK fields
accept a pk, and M2M fields accept a list of pks. Partial updates and DB
integrity checks (FK existence, uniqueness, unique_together) are handled
automatically.
Setting Meta.stream also auto-generates a subscription via
SubscriptionField(). This requires the [subscriptions] extra
(pip install "django-graphex[subscriptions]"). See
Subscriptions.
from myapp.models import Article
from django_graphex.types import DjangoModelType
from django_graphex.paginations import LimitOffsetGraphqlPagination
class ArticleType(DjangoModelType):
class Meta:
description = "Article type with auto-generated operations"
model = Article
pagination = LimitOffsetGraphqlPagination(
default_limit=25,
ordering="-published_at"
)
filter_fields = {
"title": ("exact", "icontains"),
"status": ("exact",),
}
Custom base queryset
Pass a Meta.queryset to scope every retrieve/list to a base queryset
(e.g. queryset = Article.objects.filter(status="published")). It is honored by the
generated RetrieveField() / ListField().
The declared queryset is a template: it is evaluated once, at class
definition, and every request runs a fresh clone of it. It never
accumulates a result cache, so it cannot serve stale rows — regardless of
the OPTIMIZE_QUERYSET setting.
Optimizer and Meta.queryset interplay
The query optimizer applies select_related / prefetch_related / .only()
on top of the Meta.queryset (or the value returned by get_queryset).
A manual prefetch_related for a relation the optimizer also derives is
replaced by the derived version — this is intentional and typically reduces
queries further; manual prefetches of other relations are kept as they are.
(The replacement is what keeps the two from colliding: Django rejects two
lookups on the same path, so the query used to fail outright.) If you rely on
specific prefetch options (e.g. a custom Prefetch queryset), use a
per-field optimize_<field> hook on the parent type instead of embedding them
in Meta.queryset. See Query Optimization.
Custom queryset & per-request filtering¶
For anything beyond a static Meta.queryset, override two hooks. info.context
is the request:
from django.db.models import Count, F
from myapp.models import Author
class AuthorType(DjangoModelType):
class Meta:
model = Author
@classmethod
def get_queryset(cls, manager, info, **kwargs):
# custom base queryset for retrieve/list (and mutation responses)
return Author.objects.select_related("user").annotate(
email=F("user__email"),
post_count=Count("posts"),
)
@classmethod
def filter_queryset(cls, qs, info, **kwargs):
# per-request scoping; default returns `qs` unchanged
user = info.context.user
if user.is_superuser:
return qs
return qs.filter(user=user)
get_queryset(cls, manager, info, **kwargs)supplies the base queryset and appliesfilter_queryset; the default usesMeta.queryset(else the model manager).filter_queryset(cls, qs, info, **kwargs)is the scoping hook; the default is a no-op. It scopes every CRUD operation:updateanddeleteresolve the row they are about to write through it too, and a row outside the scope is reported as not found instead of being written.
Mutation responses
create / update re-read the mutated object through get_queryset so
annotated/related fields resolve in the response (one extra query). If
filter_queryset would exclude it, the response falls back to the saved
object — a mutation never returns null for what it just wrote.
Custom output fields¶
To expose a field that isn't a plain model column (a model @property, an
annotated value, a computed URL…), declare it directly on the
DjangoModelType.
It is added to the generated output type, so it shows up in both
RetrieveField() and ListField() — no separate DjangoObjectType required:
from django_graphex.core import CharField, IntField
class AuthorType(DjangoModelType):
# Typed descriptors, resolved from the instance (here from the annotations
# added in get_queryset above, and a model property). `source=` reads the
# named attribute off the instance.
post_count = IntField(source="post_count")
email = CharField(source="email")
avatar_url = CharField(source="avatar_url") # a model @property
class Meta:
model = Author
- The field is resolved like any descriptor:
source="x"readsgetattr(instance, "x"), or add aresolve_<name>method for custom logic. - It appears in the detail and the list, because the list reuses the same item type.
resolve_<field> methods¶
A custom field can also be backed by a resolve_<field> method declared on
the DjangoModelType — not just source=. The resolver is forwarded onto the
generated output type, so it runs for both the retrieve and the list. This lets a
custom field return another GraphQL type with arbitrary logic:
from django_graphex.core import Field
from django_graphex.types import DjangoModelType
from myapp.types import CommentType
from myapp.models import Post
class PostType(DjangoModelType):
# A computed object field, resolved by the method below.
featured_comment = Field(CommentType)
class Meta:
model = Post
def resolve_featured_comment(self, info):
# `self` is the Post instance being serialized; return any object
# CommentType can resolve (here, the first approved comment).
return self.comments.filter(is_approved=True).first()
- The method name must be
resolve_<field>where<field>is the attribute name of a custom field declared on the class. It receives(self, info), withselfbound to the model instance andinfo.contextthe request. - The most-derived
resolve_<field>wins, so a subclass can override an inherited resolver by redeclaring the method. - A
resolve_<x>without a matching custom field is ignored (it is not forwarded to the output type).
Custom fields are inherited like normal class attributes, so shared fields can live on an abstract base and a subclass may override one by redeclaring it:
from django_graphex.core import CharField, IntField
class TimestampedType(DjangoModelType):
age = CharField(source="age_display") # shared by subclasses
class Meta:
abstract = True
class InvoiceType(TimestampedType):
total = IntField(source="total_cents") # adds its own
class Meta:
model = Invoice # gets `age` + `total`
Don't mix with a hand-written DjangoObjectType
These two ways are mutually exclusive per model. If a DjangoObjectType is
already registered for the model, the DjangoModelType reuses it and fields
declared here are ignored with a warning — put them on that
DjangoObjectType instead.
The same applies to the projection, but it fails the build rather than
warning: only_fields / include_fields / exclude_fields declared on a
DjangoModelType whose output type comes from the registry raise
ImproperlyConfigured at class definition, naming the option, the model and
the registered type. A silent no-op there would leave a column you excluded
for security reasons queryable.
Restating the registered type's own projection is accepted, exactly as
it is for DjangoListObjectType — the guard
compares the columns the two projections select, not the options they
spell. Restating it is worth doing: this host forwards its projection
into the subscription it generates from Meta.stream, so the repetition is
what keeps a hidden column off the subscription surface too.
Limiting the generated operations — model_operations¶
A DjangoModelType serves all five operations it can generate, so a type that
declares nothing behaves exactly as it always has. Narrow that with
Meta.model_operations:
| Option | Default | Description |
|---|---|---|
model_operations |
("create", "update", "delete", "list", "retrieve") |
The operations this type serves; any subset of the default. The *Field() builder of an excluded operation raises AttributeError, and QueryFields() / MutationFields() return only what is enabled. An excluded write also stops this type counting as a write host for the model it is bound to. |
That last clause is the opt-out for nested-write scoping: a DjangoModelType
is a write host for the models a parent nests through
Meta.nested_fields, so its
Meta.queryset and its only_fields gate that nested write. In the common
read-type-plus-write-mutation split that queryset is a display default, not a
policy — declaring the type a read host says so:
class UserCard(DjangoModelType):
class Meta:
model = User
# A display default, NOT a policy: hide deactivated users from the card.
queryset = User.objects.filter(is_active=True)
model_operations = ("list", "retrieve")
class Query(ObjectType):
user_retrieve, user_list = UserCard.QueryFields() # both still generated
UserCard.CreateField() now raises AttributeError, MutationFields() returns
an empty tuple, and the card's queryset no longer decides whether a parent may
write a User inline. Its read fields are unchanged. See the
Meta reference for the full option table.
Auto-generated Query Fields¶
A DjangoModelType builds its read operations for you: RetrieveField()
returns a single-object lookup (by id, routed through the type's
get_queryset / filter_queryset hooks) and ListField() returns the
paginated + filtered list with the uniform results / totalCount shape.
QueryFields() is the shorthand that returns both at once. The GraphQL field
names come from the attribute names you assign them to (article_retrieve →
articleRetrieve):
from django_graphex.core import ObjectType
class Query(ObjectType):
# Generate both retrieve and list queries automatically
article_retrieve, article_list = ArticleType.QueryFields(
description='Article queries'
)
# Or define them separately
article_detail = ArticleType.RetrieveField(
description='Get one article by ID'
)
article_list_custom = ArticleType.ListField(
description='List articles with filtering and pagination'
)
Generated type names
A DjangoModelType mints its types into a Generic name-space that no
hand-written type claims: the node is <Model>GenericType, the mutation
inputs are <Model>CreateGenericType / <Model>UpdateGenericType, and the
list container ListField() returns is <Model>ListGenericType.
That last name changed: the container used to be called <Model>ListType,
which is exactly the name this guide gives your own
DjangoListObjectType. Declaring both over one model therefore put two
different types with one name into a single schema and the build failed
with "Schema must contain uniquely named types". Update any client
document that spells the container out (an inline fragment, a
__typename assertion); field names and shapes are unchanged.
Auto-generated Mutation Fields¶
The write side is generated the same way: CreateField(), DeleteField() and
UpdateField() each return a complete mutation — input type derived from the
model, validation and DB integrity checks included — whose payload carries the
mutated object plus ok / errors. MutationFields() is the shorthand that
returns them in create, delete, update order:
from django_graphex.core import ObjectType
class Mutation(ObjectType):
# Generate all CRUD mutations
article_create, article_delete, article_update = ArticleType.MutationFields(
description='Article write operations'
)
# Or define them separately
create_article = ArticleType.CreateField(description='Create an article')
delete_article = ArticleType.DeleteField(description='Delete an article')
update_article = ArticleType.UpdateField(description='Update an article')
Both shorthands return only the operations
Meta.model_operations enables, so the three-name unpack
above is what the default — every operation — produces. Narrow the option and
the tuple shrinks with it: a type declaring
model_operations = ("list", "retrieve") returns an empty tuple from
MutationFields(), and the unpack raises ValueError: not enough values to
unpack. Unpack as many names as the type serves, or call the individual
*Field() builders.
Custom mutation arguments — class Arguments¶
The generated mutations accept extra arguments beyond the auto-derived input
object. Declare a class Arguments on the DjangoModelType — its members are
added to every generated mutation (create / update / delete)
alongside new_<model>, using the same
argument descriptors as a hand-written
Mutation (a raw GraphQLArgument also works):
from django_graphex.core import BooleanField
from django_graphex.types import DjangoModelType
class ArticleType(DjangoModelType):
class Arguments:
dry_run = BooleanField(description="Validate only; do not save")
class Meta:
model = Article
The extra argument reaches the resolver as a snake_case kwarg — consume it by
overriding the operation classmethod and delegating to super():
@classmethod
def create(cls, root, info, dry_run=False, **kwargs):
if dry_run:
return cls(ok=True)
return super().create(root, info, **kwargs)
Custom validation¶
For field-level validation beyond the automatic DB checks, see
Model backend (Pydantic) — the authoritative reference for
inline validate_<field>() validators and Meta.pydantic_model. All
validation patterns are documented there in one place.
Validation errors¶
When a create/update fails validation, the mutation returns ok: false and an
errors list of { field, messages }:
- Errors from nested writes (
Meta.nested_fields) are reported with the nested field name as a prefix —field: "addresses.zip_code"— including nested list children. non_field_errorsis surfaced with an emptyfield(or just the nested model name).
DjangoUnionType — typed GenericForeignKey targets¶
DjangoUnionType is the base for a GraphQL Union whose members are concrete
DjangoObjectTypes. Its primary use is to expose a Django
GenericForeignKey (GFK) as a typed union instead of the flat
GenericForeignKeyType scalar — so a client can select concrete fields per
member with inline fragments (... on AccountType { balance }).
Members are explicitly enumerated via Meta.types; the library never
inspects the django_content_type table to discover them.
Required declaration order¶
The declaration order is load-bearing (no lazy string forward-references in this release):
- Declare the member
DjangoObjectTypes first. - Declare the
DjangoUnionTypewithMeta.types = (MemberAType, MemberBType). - Declare the owner
DjangoObjectTypeLAST, naming its GFK union viaMeta.unions = {"<gfk_field_name>": TheUnion}.
gfk_unions was renamed to unions (2.0)
The Meta key is now unions. Declaring the old gfk_unions key raises
django.core.exceptions.ImproperlyConfigured at server startup — rename it.
from django_graphex.types import DjangoObjectType, DjangoUnionType
# 1. Members first.
class AccountType(DjangoObjectType):
class Meta:
model = Account
class InvoiceType(DjangoObjectType):
class Meta:
model = Invoice
# 2. The union, enumerating members explicitly.
class CommentTargetUnion(DjangoUnionType):
class Meta:
types = (AccountType, InvoiceType)
# 3. The GFK owner LAST, mapping the GFK field name -> the union.
class CommentType(DjangoObjectType):
class Meta:
model = Comment # has `target = GenericForeignKey(...)`
unions = {"target": CommentTargetUnion}
Querying it:
If the union is declared after the owner (mis-ordered), the converter emits
a WARNING and falls back to the flat GenericForeignKeyType — the schema
still builds, but the field is not a union.
resolve_type is mandatory (and provided)¶
DjangoUnionType.resolve_type(instance, info) maps a plain Django row to its
registered DjangoObjectType via registry.get_type_for_model(type(instance)).
It raises a descriptive TypeError if a row's model has no registered type
(rather than returning None, which would surface GraphQL's opaque
"Abstract type must resolve to an Object type"). You do not override it.
Per-content-type column narrowing (Django 5.0+)¶
Optimizer hub
For how this fits the rest of the N+1 optimizer (with the inline-fragment query), see Query Optimization → Typed GenericForeignKey unions.
When OPTIMIZE_ONLY_FIELDS is on and Django is 5.0 or newer, the
optimizer routes the union GFK through a
GenericPrefetch,
building one narrowed queryset per content type — each .only()-restricted
to exactly the columns that member's inline fragment selected (e.g. the
Account queryset fetches balance, the Invoice queryset fetches amount).
This batches all parents into one query per content type (no N+1).
- Django < 5.0: the optimizer degrades gracefully to a single bare,
full-load
Prefetch— it never importsGenericPrefetch, never narrows columns, and is never slower than the pre-union behaviour. - Per-content-type uniqueness: each distinct content type gets exactly one
queryset. Two members backed by the same concrete table (e.g. proxy
models) are collapsed into one queryset whose
.only()columns are the union of both members' selections. Divergent per-row narrowing on a single shared table is out of scope; that bucket safely degrades to full-load.
DjangoInterfaceType — shared fields across types¶
DjangoInterfaceType is the base for a GraphQL Interface that declares fields
shared by several DjangoObjectType implementors (typically backed by a shared
abstract Django base model). It is schema-level field sharing only — it
introduces no new queryset/fetch path; each implementor's own model drives its
column narrowing.
from django_graphex.core import CharField
from django_graphex.types import DjangoInterfaceType, DjangoObjectType
class ProductInterface(DjangoInterfaceType):
name = CharField()
class Meta:
pass
class BookType(DjangoObjectType):
class Meta:
model = Book
interfaces = (ProductInterface,)
class MagazineType(DjangoObjectType):
class Meta:
model = Magazine
interfaces = (ProductInterface,)
Implementors declare membership with the Meta.interfaces
kwarg. Like DjangoUnionType, DjangoInterfaceType provides a mandatory
resolve_type that maps each row to its concrete implementor.
Limiting query shape: max_depth & complexity¶
Every type above accepts two optional Meta options that protect your API from
abusive queries. They are enforced before execution by the validation rules
the library's GraphQLView enables by default. See
Query depth & cost limits for the full reference; the
mini-examples below are the gist.
max_depth — caps how many nested object levels may be selected below a field
returning this type (scalars don't count):
class CategoryType(DjangoModelType):
class Meta:
model = Category
max_depth = 2 # category -> posts -> comments OK; one level deeper is rejected
{
category(id: 1) {
posts { # level 1 ✅
comments { # level 2 ✅
author { username } # level 3 ❌ "Query exceeds the maximum nesting depth of 2 ..."
}
}
}
}
complexity — the cost weight of a field returning this type, used by cost
analysis (MAX_QUERY_COST). Make expensive types cost more so a single page of
them eats more of the budget:
class ReportType(DjangoObjectType):
class Meta:
model = Report
complexity = 50 # one report is worth 50; default object weight is 1
Both work on DjangoObjectType, DjangoListObjectType and DjangoModelType
(forwarded to its generated output type). A global depth cap (MAX_QUERY_DEPTH)
and cost budget (MAX_QUERY_COST) are configured in settings — see
Query depth & cost limits.
choices → GraphQL enums¶
Any model field with choices becomes a GraphQL enum automatically — on
every type above (DjangoObjectType, DjangoListObjectType, DjangoModelType).
The interesting part is how each enum member name is chosen, because GraphQL
enum names must be valid identifiers (letters, digits, underscores; not starting
with a digit) and should stay readable and stable across locales.
For each (value, label) choice the name is picked by this cascade:
- The value, if it is already a valid GraphQL name — e.g. a Django
TextChoicesvalue"draft"becomesDRAFT. -
Otherwise the label, resolved as its source msgid with translations off, so the schema is the same in every locale. This is why numeric values with human labels surface readably:
-
Otherwise
A_<value>as a last resort — e.g. a numeric value whose label is empty or also non-identifier-safe yieldsA_1,A_2, …
from django.db import models
from django.utils.translation import gettext_lazy as _
class Profile(models.Model):
# value -> member name
status = models.CharField( # "draft" -> DRAFT (from the value)
max_length=20,
choices=(("draft", "Draft"), ("published", "Published")),
)
gender = models.CharField( # "1"/"2" -> MALE/FEMALE (from the label)
max_length=1,
choices=(("1", _("Male")), ("2", _("Female"))),
)
The enum member's description carries the original label, so the human-readable text is never lost.
MultiSelectField¶
When the optional django-multiselectfield package is installed, a
MultiSelectField is a list of the generated choice enum, not one enum
value. Detection uses isinstance, so a renamed subclass keeps the same shape
in output and generated create/update input:
from multiselectfield import MultiSelectField
class FeatureFlagsField(MultiSelectField):
pass
class Account(models.Model):
flags = FeatureFlagsField(choices=(("beta", "Beta"), ("dark", "Dark")))
type AccountType { flags: [AccountFlagsEnum] }
input AccountCreateInput { flags: [AccountFlagsEnum] }
input AccountUpdateInput { flags: [AccountFlagsEnum] }
django-multiselectfield remains optional: install it only when the model uses
that field. Without the package, importing django-graphex adds no runtime
dependency. The development and integration environments install it only to
exercise this optional adapter.
Field type conversion reference¶
How Django model fields map to GraphQL output types:
| Django field | GraphQL output |
|---|---|
CharField / TextField / SlugField / … |
String |
IntegerField / AutoField / BigIntegerField |
Int |
FloatField / DecimalField |
Float |
BooleanField |
Boolean |
DateField / DateTimeField / TimeField |
CustomDate / CustomDateTime / CustomTime (see input formats) |
any field with choices |
a generated Enum (see above) |
ForeignKey / OneToOneField |
the related object type |
reverse FK / ManyToManyField |
a <Model>ListType container (results + totalCount) |
ArrayField(inner) |
[<inner>] — nested arrays as [[<inner>]]; a choices base as [<Enum>] |
*RangeField (Integer/BigInteger/Decimal/Date/DateTime) |
a { lower, upper } composite typed by the bound scalar |
JSONField |
the JSON scalar — raw structured JSON on the wire (see below) |
GenericForeignKey |
a typed union when declared in Meta.unions, otherwise a flat GenericForeignKeyType |
FileField / ImageField |
String — the storage name on output; on input, a storage path or the file a multipart part carries (see Mutations › Automatic multipart uploads) |
HStoreField, GIS geometry |
a permissive scalar (no native modeling — see Backends) |
Worked example — ArrayField (incl. a choices base) and a range field:
from django.contrib.postgres.fields import ArrayField, IntegerRangeField
from django.db import models
class Article(models.Model):
tags = ArrayField(models.CharField(max_length=50)) # -> tags: [String]
grid = ArrayField(ArrayField(models.IntegerField())) # -> grid: [[Int]]
statuses = ArrayField( # -> statuses: [ArticleStatusesEnum]
models.CharField(max_length=10, choices=(("draft", "Draft"), ("pub", "Published"))),
)
span = IntegerRangeField() # -> span: { lower: Int, upper: Int }
Accepted date/time input formats¶
DateField / DateTimeField / TimeField (both the model-derived output
fields and the DateField() / DateTimeField() / TimeField() shortcuts)
parse input with Python's datetime.fromisoformat, and serialize output with
.isoformat(). What that means in practice:
| Input on the wire | Result |
|---|---|
"2024-01-15T10:30:00Z" |
Accepted — the trailing Z is honored (UTC). |
"2024-01-15T10:30:00+05:00" |
Accepted — the offset is preserved (no conversion). |
"2024-01-15T10:30:00" |
Accepted — naive passthrough; the library does not call make_aware. |
"2024-01-15 10:30:00" |
Accepted — a space separator works in place of T. |
"2024-01-15" (into a DateTime) |
Accepted — a bare date becomes midnight (00:00:00). |
1700000000 (a numeric timestamp) |
Rejected — the scalar only parses ISO-8601 strings, never numbers. |
Output is always .isoformat(), so an aware datetime renders with an explicit
offset (2024-01-15T10:30:00+00:00) — never the shorthand Z.
Why CustomDate / CustomDateTime / CustomTime?
The output/argument scalars use these Custom* names for SDL parity with
v1 (graphene-django), so a v2 schema is a drop-in match. The plain Date /
DateTime / Time names are not free — they belong to the
filter-lookup scalars (the exact / gte / … values inside a
<Model><Field>Lookups input). Keeping the two families distinct is what
lets both render in one schema without a name clash.
USE_TZ interactions on the write path
Because parsing is a plain fromisoformat (no make_aware), what you send
interacts with Django's timezone handling on save:
USE_TZ = True, naive input ("2024-01-15T10:30:00"): Django emits its ownRuntimeWarning("received a naive datetime … while time zone support is active") and interprets the value in yourTIME_ZONE. A naive10:30:00underTIME_ZONE = "America/New_York"is stored (and re-read) as the aware2024-01-15T15:30:00+00:00— so the value the client reads back differs from the naive string it sent. Send an offset-qualified string (orZ) to avoid the ambiguity.USE_TZ = False, aware input ("…+05:00") on SQLite: Django raises a top-level error — "SQLite backend does not support timezone-aware datetimes when USE_TZ is False." — surfaced as aGraphQLError. Send a naive string whenUSE_TZis off.
JSONField → JSON¶
A model JSONField is exposed as the JSON scalar in every direction. JSON
carries raw structured JSON on the wire: objects, lists, and scalars pass
through as-is, with no string-encoding step and no client-side JSON.parse.
| Direction | GraphQL type | Behavior |
|---|---|---|
| Query output | JSON |
The stored value is sent structurally — a client selecting specs gets a real object { "ram": 16 }, not a string. |
| Mutation input | JSON |
A real dict / list (or scalar) reaches the model field. Inline object/list literals in the query document are accepted, and so are variables. |
| Filters | JSON |
filter_fields = {"specs": ("exact",)} compiles the lookup input with a JSON value. |
from django.db import models
class Product(models.Model):
name = models.CharField(max_length=100)
specs = models.JSONField(null=True, blank=True)
# Inline object literal — accepted (no variables required):
mutation {
productCreate(newProduct: { name: "Laptop", specs: { ram: 16, tags: ["a", "b"] } }) {
product {
specs # -> { "ram": 16, "tags": ["a", "b"] } (a real object; no parsing)
}
ok
}
}
# Variables work too:
mutation CreateProduct($specs: JSON) {
productCreate(newProduct: { name: "Laptop", specs: $specs }) {
product { specs }
ok
}
}
# variables: { "specs": { "ram": 16 } }
Omitting specs leaves the column untouched; passing specs: null writes SQL
NULL — the usual omit-vs-null semantics, unchanged.
Custom JSON fields: the JSONField() descriptor and as_str=
For a hand-declared (non-model) field, the
JSONField() shortcut covers both JSON
styles through one as_str flag. JSONField() (the default) binds the raw
JSON scalar — structured objects/lists on the wire, exactly like a model
JSONField. JSONField(as_str=True) is the escape hatch: it binds the
string-encoded JSONString scalar, whose wire value is a JSON-encoded
string the server decodes with json.loads on input and encodes with
json.dumps on output.
from django_graphex.core import JSONField, ObjectType
class Query(ObjectType):
config = JSONField() # -> JSON: { "theme": "dark" }
raw_config = JSONField(as_str=True) # -> JSONString: "{\"theme\": \"dark\"}"
def resolve_config(self, info):
return {"theme": "dark"} # sent as a JSON object
def resolve_raw_config(self, info):
return {"theme": "dark"} # serialized to a JSON string
A JSONString value is always valid JSON on the wire: a resolver
returning text that already parses as JSON ('{"theme": "dark"}') is sent
verbatim, while any other Python value — including a plain string such as
"dark" — is json.dumps-encoded ("\"dark\""), so every value the field
emits round-trips back through the same scalar on input.
Which one do I use? Reach for the default JSONField() (raw JSON)
unless a client specifically expects a JSON string — e.g. it stores the
value verbatim, or you need wire parity with a legacy graphene-django schema
that used JSONString. In that case use JSONField(as_str=True).
The scalar singletons themselves are importable for use with Field /
field() / GraphQLArgument:
Inline literals are accepted
The raw JSON scalar parses an object or list literal written inline in
the query document (echo(data: { a: [1, 2] }) reaches the resolver as a
real dict) — the literal parser recurses through nested objects, lists, and
variable references. You do not need to route structured values through
variables (though variables work identically):
A variable used inside an object literal that the request leaves unset
simply drops its key — echo(data: { a: $unset, b: 1 }) reaches the
resolver as {"b": 1} (the same rule graphql-core applies to input-object
fields), not as an error.
Type Comparison¶
| Feature | DjangoListObjectType | DjangoInputObjectType | DjangoModelType |
|---|---|---|---|
| Purpose | List queries with pagination | Input for mutations | Complete CRUD operations |
| Pagination | ✅ Built-in | ❌ N/A | ✅ Built-in |
| Filtering | ✅ Built-in | ❌ N/A | ✅ Built-in |
| Auto Queries | Manual setup | ❌ N/A | ✅ Auto-generated |
| Auto Mutations | ❌ No | ❌ N/A | ✅ Auto-generated |
| Auto-derived Schema | ❌ No | ❌ No | ✅ Full (from model) |
| Customization | High | High | Medium |
| Setup Complexity | Medium | Low | Low |
Best Practices¶
1. Choose the Right Type¶
# ✅ For list queries with custom logic
class UserListType(DjangoListObjectType):
class Meta:
model = User
# ✅ For input validation on an ordinary application model
class ArticleInput(DjangoInputObjectType):
class Meta:
model = Article
only_fields = ("title", "body")
# ✅ For rapid prototyping
class ArticleType(DjangoModelType):
class Meta:
model = Article
2. Use Descriptive Names¶
# ✅ Clear naming
class UserListType(DjangoListObjectType): pass
class CreateUserInput(DjangoInputObjectType): pass
class UserCRUDType(DjangoModelType): pass
# ❌ Confusing naming
class UserType(DjangoListObjectType): pass # Is it single or list?
class UserInput(DjangoModelType): pass # Not an input type
3. Optimize Performance¶
class UserListType(DjangoListObjectType):
class Meta:
model = User
# Optimize database queries
queryset = User.objects.select_related('profile').prefetch_related('groups')
# Enable caching for expensive queries
# (Configure in settings)
Limit the exposed fields with only_fields / exclude_fields (never fields /
exclude) on the node type, not on the container: a container that reuses a
registered DjangoObjectType cannot honour a projection of its own and refuses
to build with one — see
the projection is a security boundary. Narrowing
the surface is a security decision before it is a performance one, which is
precisely why it has exactly one home.
4. Combine Types Strategically¶
# Use DjangoModelType for basic CRUD on ordinary application models
class ArticleType(DjangoModelType):
class Meta:
model = Article
# Use DjangoListObjectType for complex list logic
class UserAnalyticsType(DjangoListObjectType):
total_posts = IntField() # a custom, computed output field
class Meta:
model = User
def resolve_total_posts(self, info):
return self.posts.count()
# Use DjangoInputObjectType for model-derived input; shape it with only_fields.
# For a bespoke, non-model input argument, use a hand-written Mutation with a
# Field / CharField argument instead (input bodies are model-derived).
class ArticleImportInput(DjangoInputObjectType):
class Meta:
model = Article
only_fields = ("title", "body", "status")
For Django accounts, use the read-only projection and purpose-built
create_user() registration shown in the Quick Start;
generic model persistence does not hash passwords.
These examples assume the descriptor imports at the top of the module: