Mutations¶
Generated inputs for BigIntegerField and PositiveBigIntegerField use
BigInt decimal strings, not GraphQL Int numbers. This preserves the full
64-bit value through validation and the ORM; see
64-bit integers for the wire contract and
client migration.
Relation IDs retain their generated ID input (including relations to regular
BigInteger primary keys), accepting string and integer ID values. A positive
BigInteger field with choices retains its generated Enum input.
GraphQL mutations allow you to modify data on your server. django-graphex provides powerful tools to create mutations from Django models, making CRUD operations simple and consistent.
DjangoModelMutation¶
DjangoModelMutation generates create, update, and delete operations directly
from an ordinary Django model. Read fields belong on DjangoObjectType /
DjangoModelType; they are never generated by this mutation host.
Features¶
- Automatic CRUD Operations: Generates create, update, and delete mutations
- Built-in Validation: Validates all writable model fields, FK existence, uniqueness, and
unique_togetherconstraints - File Upload Support: Handles multipart/form-data requests
- Nested Relationships: Supports nested field creation and updates
- Error Handling: Returns structured error responses
Basic Usage¶
from django_graphex.core import ObjectType
from django_graphex.schema import DjangoGraphQLSchema
from .mutations import CustomerMutation
class Mutation(ObjectType):
# Get all mutation fields (create, update, delete)
customer_create, customer_delete, customer_update = CustomerMutation.MutationFields()
schema = DjangoGraphQLSchema(query=Query, mutation=Mutation)
from django_graphex.core import ObjectType
from django_graphex.schema import DjangoGraphQLSchema
from .mutations import CustomerMutation
class Mutation(ObjectType):
# Individual mutation fields
create_customer = CustomerMutation.CreateField()
update_customer = CustomerMutation.UpdateField()
delete_customer = CustomerMutation.DeleteField()
schema = DjangoGraphQLSchema(query=Query, mutation=Mutation)
Native mutation field identity
Generated native mutation fields are recognized by their live field objects, including an earlier field when a later mutation class replaces the same model-and-operation registry slot. An expired field leaves no lasting integer identity marker that could match an unrelated raw GraphQL field. This internal correction does not change mutation SDL or root declarations; it is not a guarantee that all schema or registry objects are released.
Deprecating a mutation field
Every mutation-field builder (CreateField, UpdateField, DeleteField,
and MutationFields) accepts deprecation_reason=, wired straight into the
compiled field so the SDL renders @deprecated(reason: ...):
Configuration Options¶
The DjangoModelMutation supports several configuration options:
Meta Configuration¶
from .models import Customer
from .models import Address, Profile
class CustomerMutation(DjangoModelMutation):
class Meta:
model = Customer
only_fields = ('name', 'email', 'phone')
input_field_name = 'customer_data' # Default: 'new_{model_name}'
output_field_name = 'customer' # Default: '{model_name}'
description = "Custom description for the mutation"
# map each nested field to its related Django model
nested_fields = {'profile': Profile, 'addresses': Address}
Field Filtering¶
Field Control
Use only_fields to include specific fields, or exclude_fields to exclude certain fields from mutations.
editable=False fields are never input¶
A model field declared editable=False is server-managed, so it is left out of
the generated create and update inputs — you do not have to list it in
exclude_fields. This covers every concrete field, relations included: a
created_by / tenant ForeignKey, a OneToOneField or a ManyToManyField
set inside save() no longer advertises itself as writable.
class Document(models.Model):
title = models.CharField(max_length=200)
owner = models.ForeignKey(Customer, on_delete=models.CASCADE, editable=False)
reviewers = models.ManyToManyField(Customer, related_name="reviews", editable=False)
input DocumentCreateGenericType {
title: String!
# no "owner", no "reviewers" — the server owns them
}
Reverse relations are untouched by this: Django sets editable = False on every
reverse relation object, so honoring it there would delete the reverse-relation
input surface wholesale.
Custom Arguments with Field¶
You can add custom arguments to your mutations. Declare them in a nested
class Arguments with the unified Field descriptor — the same descriptor
used in output position — or one of the typed shortcuts (BooleanField,
CharField, IntField, …) for a bare scalar:
from .models import Customer
from django_graphex.core import BooleanField
from django_graphex.mutation import DjangoModelMutation
class CustomerMutation(DjangoModelMutation):
class Meta:
model = Customer
class Arguments:
send_email = BooleanField(
default=False,
description="Send welcome email after customer creation",
)
@classmethod
def create(cls, root, info, **kwargs):
send_email = kwargs.pop('send_email', False)
response = super().create(root, info, **kwargs)
if response.ok and send_email:
send_welcome_email(getattr(response, cls._meta.output_field_name).email)
return response
class Arguments may inherit
Shared arguments can be factored into a base class; the compiler walks the whole MRO, so inherited attributes are compiled alongside the ones declared in the class body and the most-derived declaration wins on a name clash.
Nested Fields Support¶
Handle related models with nested fields:
from .models import Customer
from .models import Address, Profile
class CustomerMutation(DjangoModelMutation):
class Meta:
model = Customer
# each nested field maps to its related Django model
nested_fields = {'profile': Profile, 'addresses': Address}
Nested Fields Behavior
- For single objects: The created object's ID is assigned to the field
- For lists: Objects are added to the many-to-many relationship
Row scoping: get_queryset / filter_queryset¶
update and delete resolve their target row through the same two hooks
DjangoModelType uses, with the same names and signatures, so an override
moves between the two hosts unchanged:
from django_graphex.mutation import DjangoModelMutation
class DocumentMutation(DjangoModelMutation):
class Meta:
model = Document
@classmethod
def filter_queryset(cls, qs, info, **kwargs):
return qs.filter(tenant=info.context.user.tenant)
A row outside the scope answers exactly as a missing one (ok: false,
<Model> with id <pk> does not exist.), so the response cannot be used to probe
which primary keys exist. create has no target row, so nothing is scoped there.
The model's registered public output type is a second scope. When the request
schema registers a DjangoObjectType for the model that overrides
get_queryset, update and delete (on both DjangoModelMutation and
DjangoModelType) also require the target row to be visible there: the host
scope and the output type scope are intersected in a single query. A row the
output type hides on reads can therefore no longer be edited or deleted by id;
it answers exactly like a missing one. An output type that keeps the default
hook, or a model with no registered output type, adds no query. The same rule
applies to a nested child payload that writes fields of an existing child
(reverse ForeignKey, reverse OneToOneField, many-to-many, or a forward
relation carrying the current target's id plus field data): the child's
output type scope is intersected with the child host scope, and a hidden child
answers like a missing one. An id-only re-send of a forward relation's
current target writes nothing and is not re-scoped.
Permissions are DjangoModelType-only¶
The two hosts are not symmetric on authorization. permission_classes /
authorize — the per-action checks described under
Permissions — are honored by DjangoModelType only, because
DjangoModelMutation reads the name nowhere.
Declaring permission_classes on a DjangoModelMutation therefore raises
ImproperlyConfigured at class definition without registering a model output
type. It used to be worse than a no-op:
the plain assignment tripped a raw Pydantic error telling you to annotate the
attribute ClassVar, and taking that advice produced a class that builds fine
with a permission that never fires. Reach for DjangoModelType when you need
per-action authorization, or gate the mutation field at the schema root.
Unknown Meta options are refused too
DjangoModelMutation rejects any Meta key its signature does not name —
the same check DjangoModelType has run since 2.0. An exclude_field typo
used to build a mutation whose input still carried the column the
declaration meant to hide, and a Meta.queryset was accepted while scoping
nothing (this host scopes through filter_queryset, above). Both raise now.
So does a nested_fields key naming no relation on the model: it was
skipped when the input was built and skipped again on the write path, while
the generated input type was still named after it. The full list of accepted
keys is the Meta options table.
Automatic multipart uploads¶
When the request content type is multipart/form-data, a part named after a
FileField / ImageField the mutation input exposes is merged into the
payload and saved to that field. Both hosts do it, on create and on update, and
no extra configuration is needed:
from .models import Profile
# A multipart part named "avatar" lands on Profile.avatar (an ImageField).
class ProfileMutation(DjangoModelMutation):
class Meta:
model = Profile
The GraphQL input field stays String — the file itself never travels through
the GraphQL variables. Send the operation and the file in one
multipart/form-data request: a query part carrying the document, an
optional variables part carrying its variables as JSON, plus one part per
file named after the model field. (The view reads a multipart body straight out
of request.POST, so those are the two part names it looks for — there is no
operations / map envelope.) The
same field also accepts a plain storage path string, which is what a query
returns for it, so a value read back can be written back unchanged. Anything
else — a number, a list, an object — comes back as a normal validation error,
and a path longer than the column's max_length is rejected before it reaches
the database.
If MAX_UPLOAD_SIZE is configured, every
received file part must fit that per-file byte limit, including unused parts
and repeated parts with the same name. An oversized part returns JSON HTTP 413
before any resolver runs; a file exactly at the limit is allowed. None
leaves multipart files without this per-file cap. The separate
MAX_REQUEST_BODY_SIZE still limits the whole request and can reject earlier.
A multipart POST must carry X-Requested-With
multipart/form-data is a CORS-simple content type: a browser posts it
cross-site with no preflight, so the endpoint — which is csrf_exempt —
would otherwise execute a forged <form> submit under the victim's session
cookie. Every multipart (and form-encoded) request therefore has to send the
X-Requested-With header, or it is refused with HTTP 403 before the body
is read. The value is not inspected; XMLHttpRequest is the conventional
one.
requests.post(
"https://app.example.com/graphql/",
files={"avatar": open("avatar.png", "rb")},
data={
"query": "mutation Create($doc: ProfileCreateGenericType!) { … }",
"variables": json.dumps({"doc": {"bio": "hi"}}),
},
headers={"X-Requested-With": "XMLHttpRequest"}, # <- required
)
JSON clients are unaffected. See
Security → Cross-site POST protection
for the REQUIRE_CSRF_HEADER opt-out.
Either spelling of the field name works
The part name is matched against both spellings of the field: the
camelCase alias it is published under — the one the SDL shows — and the
model's snake_case attribute. profilePhoto and profile_photo both land
on Profile.profile_photo, so there is nothing to remember. A part
matching no exposed input field is ignored — the mutation still answers
ok: true and simply saves no file — so a misspelled part name looks like
success with nothing written when the file fits the configured size limit.
An oversized unmatched part is rejected before input mapping.
A field the type projects away with Meta.exclude_fields, or leaves out of
Meta.only_fields, is not an exposed input field: a part named after it is
ignored under either spelling, so a projection cannot be walked around
through the multipart body.
The match is against every field the input exposes, not only its file columns. A part sharing a name with an ordinary column replaces that column's JSON value with the upload, which then fails that column's validation — name parts after file fields only.
Top-level fields only
The merge is flat and keyed by the bare form-field name, so it can only
address a field on the model the mutation is bound to. A file field on a
child declared in Meta.nested_fields is not
reachable: no part name addresses one. Naming a part after the relation
itself is worse than useless — the upload replaces the nested payload and
the nested handler then fails on it, so do not do it. Write the child first
through its own mutation, or use the
base64 upload input below, which travels inside the
GraphQL variables and therefore nests.
Error Handling¶
All mutations return a consistent response structure:
{
"ok": Boolean, # True if successful, False if errors
"errors": [ErrorType], # List of validation errors
"{model_name}": Object # The created/updated/deleted object (null if errors)
}
Example error response:
{
"data": {
"createCustomer": {
"ok": false,
"errors": [
{
"field": "email",
"messages": ["This field is required."]
},
{
"field": "username",
"messages": ["A user with that username already exists."]
}
],
"customer": null
}
}
}
An ok: false result is structured mutation data, not a GraphQL execution
error. It rolls back that generated mutation's own failed write, but does not
undo an earlier successful sibling root mutation. With ATOMIC_MUTATIONS=True,
a GraphQL execution error in any sibling root field instead rolls back the
entire mutation operation on the default database. Django's ATOMIC_REQUESTS
also rolls back the default-database request transaction on GraphQL execution
errors. The response can retain partial data for writes that were rolled back;
do not treat partial response data as proof that a row was committed.
For generated PositiveIntegerField and PositiveSmallIntegerField inputs, a
supplied negative value now returns ok: false with an error on that field
before SQL persistence. This also changes the transaction result for an earlier
successful sibling root: even with ATOMIC_MUTATIONS=True, the earlier write
commits because this is structured mutation data, not a GraphQL execution
error. For example, createA remains saved when a later createB returns
{ ok: false, errors: [{ field: "quantity", ... }] }. If clients require the
two root writes to succeed or fail together, they must coordinate that business
operation explicitly; ATOMIC_MUTATIONS still rolls back on actual GraphQL
execution errors. Zero, positive values, GraphQL Int bounds, and existing
choice Enum names are unchanged.
Generated DjangoModelMutation.DeleteField() and
DjangoModelType.DeleteField() return a GraphQL execution error when
deletion is blocked by a Django PROTECT or RESTRICT relation. The message is
Deletion blocked by related objects.; it does not expose related model names
or row identifiers. For example, the delete field is null and the error has
the field path rather than an ok: false mutation payload:
{
"data": {"deleteCategory": null},
"errors": [{
"message": "Deletion blocked by related objects.",
"locations": [{"line": 2, "column": 3}],
"path": ["deleteCategory"]
}]
}
The location reflects the caller's query. The HTTP status remains the normal
GraphQL execution-response status (200 for a valid request). A previous sibling
write still commits with ATOMIC_MUTATIONS=False and rolls back with
ATOMIC_MUTATIONS=True or a request-wide ATOMIC_REQUESTS transaction. A
missing or out-of-scope row continues to use the structured ok: false
not-found response. If clients previously inspected Django's blocked-delete
collector message, migrate to the generic message and GraphQL error path;
neither the protected relation nor its row IDs are disclosed.
Generated create, update, and delete fields also hide an unclassified Django
IntegrityError that reaches GraphQL execution, including one from a nested
child. Instead of exposing SQL constraint, table, or column names, the field is
null and the top-level error message is
A database constraint prevented this operation. The error keeps its normal
GraphQL path and location, and a valid request retains the normal execution
response status (typically HTTP 200). For example, a database-only check that
previously surfaced CHECK constraint failed: private_constraint now returns:
{
"data": {"createItem": null},
"errors": [{
"message": "A database constraint prevented this operation.",
"locations": [{"line": 2, "column": 3}],
"path": ["createItem"]
}]
}
Nested parent/child writes still roll back together, and an earlier sibling
write rolls back when ATOMIC_MUTATIONS=True (or a request-wide
ATOMIC_REQUESTS transaction applies). With both off, that sibling can still
commit. This is not an ok: false conversion: supported field-level
unique, foreign-key, and many-to-many conflicts still use structured
errors[], while an unclassified integrity failure does not. Direct backend
calls still raise the original exception. Model field validators run on written fields
(see Model field validation), but no
Model.clean(), constraint pre-scan, or automatic upsert is added. Clients
inspecting raw database messages must migrate to the fixed message and
GraphQL error path.
An unhandled database exception outside this generated-field adapter
(including a hand-written mutation) is different: the default HTTP formatter
with DEBUG=False returns DATA_ACCESS_ERROR, a safe category message, and
an opaque errorId for restricted server-log correlation. It does not turn
the failure into ok: false or change transaction behavior. See
Production execution errors.
FK checks stay inside the mutation's rollback boundary
In true autocommit, a valid plain mutation issues a single INSERT or
UPDATE; it does not pre-check each FK. Within an outer transaction,
SQLite validates constrained FKs on directly saved model rows, including
concrete inheritance parent rows, and directly updated M2M links after
writing, inside the recovery savepoint. A symmetric self-relation checks
both its forward and mirror links, including constrained custom-through
fields, even when accessed through a proxy or concrete child inheriting
the relation. It does not scan unrelated rows
in those tables for ordinary single-column primary keys;
composite primary keys retain the previous table-scoped fallback rather
than silently omitting a constraint check. Invalid FK or M2M input still
rolls back and returns the structured errors[] envelope. An earlier,
unrelated deferred violation can remain until the outer transaction
commits; that commit still fails if the violation is not repaired.
PostgreSQL and other
backends retain their existing deferred-constraint checks. The scoped
SQLite check covers direct model and relation writes, not arbitrary writes
from custom save methods or signals. Nested child work still has its own
atomic boundary; a plain autocommit mutation does not acquire one.
Custom Mutation Logic¶
Override methods to add custom logic:
Override create / update and call super() to run logic around the save
(there is no separate save hook — validation and persistence happen inside
create/update):
from .models import Customer
from django_graphex.mutation import DjangoModelMutation
class CustomerMutation(DjangoModelMutation):
class Meta:
model = Customer
@classmethod
def create(cls, root, info, **kwargs):
response = super().create(root, info, **kwargs)
# Custom logic after a successful save
if response.ok:
send_welcome_email(getattr(response, cls._meta.output_field_name).email)
return response
Complete Example¶
Here's a complete example showing all features:
from django.db import models
from .models import Customer
class Profile(models.Model):
user = models.OneToOneField(Customer, on_delete=models.CASCADE)
bio = models.TextField(blank=True)
avatar = models.ImageField(upload_to='avatars/', blank=True)
birth_date = models.DateField(null=True, blank=True)
class Address(models.Model):
user = models.ForeignKey(Customer, on_delete=models.CASCADE)
street = models.CharField(max_length=255)
city = models.CharField(max_length=100)
country = models.CharField(max_length=100)
from .models import Customer
from django_graphex.core import BooleanField
from django_graphex.mutation import DjangoModelMutation
from .models import Address, Profile
class CustomerMutation(DjangoModelMutation):
class Meta:
model = Customer
exclude_fields = ('internal_notes', 'credit_limit')
nested_fields = {'profile': Profile, 'addresses': Address}
class Arguments:
send_welcome_email = BooleanField(default=True)
from django_graphex.core import ObjectType
from django_graphex.schema import DjangoGraphQLSchema
from .mutations import CustomerMutation
class Mutation(ObjectType):
create_customer, delete_customer, update_customer = CustomerMutation.MutationFields()
schema = DjangoGraphQLSchema(query=Query, mutation=Mutation)
{
"customerData": {
"username": "jane_doe",
"email": "jane@example.com",
"firstName": "Jane",
"lastName": "Doe",
"profile": {
"bio": "Django + GraphQL enthusiast",
"birthDate": "1990-06-15"
},
"addresses": [
{ "street": "123 Main St", "city": "Springfield", "country": "US" }
]
},
"sendEmail": true
}
How nested writes work¶
nested_fields = {field_name: RelatedModel} lets a single create/update write
related objects alongside the parent. The same engine backs both
DjangoModelMutation and DjangoModelType.
- Atomic on the selected alias with the included Django backend — when
nested work is present, a create prepares one populated, unsaved parent
before the first write. The router receives that instance, so a rule such
as
instance.region == "east"can select the secondary alias. Native parent and child saves use that alias within one transaction; a later child validation failure rolls back earlier same-alias writes and suppresses their commit callbacks. An update keeps the existing parent's database alias. The routing hint is populated from supplied scalar fields and model defaults before backend validation; validation and model construction are not replayed. A create without nested work retains its direct-save behavior. Custom serializer backends keep their existingsave_object(instance=None)create contract. For M2M links, an explicitly divergent through-model route is rejected before the first write. If the route diverges only after a generated parent ID, a second check rejects it before linking and rolls back preceding selected-alias writes and commit callbacks. Route through models to the parent's alias; transactions do not coordinate database aliases, and a stateful router that changes between the check and Django's link operation is outside this bounded guarantee. -
Relation-aware — the relation is introspected from the model:
Relation Order Behavior Forward ForeignKey/OneToOneFieldchild saved first its pk is set on the parent Reverse FK / reverse OneToOneparent first each child is linked to the parent ManyToManyField(either side)parent first children are saved and .add()-ed -
Upsert — a child payload that carries its
idupdates that row; without anidit creates a new one. (The nested input only exposesidon the parent's update input, so nested creates stay create-only.) On a relation whose rows the parent does not own this is narrowed by the link rule below. - Link, don't rewrite — on a forward
ForeignKey/OneToOneFieldor aManyToManyField, a payload carrying anidthe parent is not already attached to only links that row: it 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, which is the documented use — change the category attached to this document, not edit any row of the category table. Without the rule,{ id: <mine>, category: { id: <any pk>, name: "x" } }rewrote a shared lookup row that no scope hid and no ownership guard covered. - Child validation — a nested child is validated with the same rules as
its own mutation: when a
DjangoModelType/DjangoModelMutationfor the child model declares inlinevalidate_<field>/validatemethods or aMeta.pydantic_model, the nested write runs them too. (When more than one host declares validation for the same model, the last one defined wins.) -
Child projection — the nested child input is derived from the child's own hosts:
only_fields/exclude_fieldson aDjangoModelType/DjangoModelMutationfor the child model apply to the parent's nested payload too. The two axes are merged differently, because they say different things.An
exclude_fieldsis a prohibition — this column is never client-writable — so every declared host's exclusions are unioned, whether or not that host serves the operation being built, and they are applied last. Otherwise a create-only mutation's exclusion would vanish from the nested update surface, and a client would write, on an existing row through the parent, a column the project's own write mutation refuses.An
only_fieldsis a positive allowance, so only the hosts that serve the operation union theirs: the result is what some declared host would permit, and it is meaningful only for that operation (a host declaringmodel_operations = ("update",)does not narrow the nested create, just as it does not narrow the child's own). Splitting the read and write surfaces — a display card projecting("id", "slug")and a write mutation projecting("name",)— is an ordinary configuration, not a contradiction, and both columns reach the nested input.No allowance restriction is applied when that union comes out empty, and there are exactly two ways to get there. The child may have no declared host at all — the ordinary
nested_fieldscase, where it is a plain related model — and then the nested input is the unprojected surface minus the prohibitions, which is what the library has always built. Or the child's hosts may all have declared, throughMeta.model_operations, that they do not serve this operation; both host classes default that option to every operation they can generate, so the branch cannot be reached without the project saying so.The primary key is not subject to either axis on the update surface. It is not a projectable column there — it is how the row is identified — so a write host projecting
only_fields = ("headline",)still leavesidon the nested update input and the documented upsert-by-id keeps working.A projection whose every allowed column is excluded by a sibling would leave the nested input with no field at all, which graphql-core does not consider a legal schema. That is refused at build time with an
ImproperlyConfigurednaming the child model, the parent, and every contributing host with both of its projection axes — shipping a schema whose every request fails validation is worse than a build error. Widen one of the two declarations, or mark the read host withmodel_operations = ("list", "retrieve")so its allowance leaves the write path.Declare every host for a model before the first schema build: graphql-core caches an input object's field map, so a projection (or a
required_perms) declared afterwards can never reach an already-built nested surface, and the library refuses it rather than ignore it. A late host that merely repeats a declaration already contributing is accepted: the merge is idempotent, so refusing a no-op would buy nothing. - Child permissions — a nested create or update runs the child's ownpermission_classes/authorize, exactly as the child's own mutation does. A denial is the samePERMISSION_DENIED/ HTTP 403 the direct mutation returns, and it rolls the whole write back — parent included, no orphan rows. EveryDjangoModelType/DjangoModelMutationdeclared for the child model is consulted, so two hosts for one model fail closed: all of them must allow the write. ADjangoModelMutationhas nopermission_classesat all, so aDjangoModelMutation-only child gets the scoping below and nothing else.The hosts are read from the parent's registry unioned with the global one.
Meta.registryis an option onDjangoModelMutationonly, so a child'spermission_classescan only ever live in the global registry; reading the parent's registry alone left a parent declared withMeta.registryfinding no hosts for its children and the gate went silent. A host bound to a non-global registry still describes that schema's surface alone and cannot reach another registry's parents. - Child scoping — a nested upsert naming a pk resolves it through theget_queryset/filter_queryset(andMeta.queryset) of the child hosts that serve the write, so it mirrors what those hosts' ownupdate/deletedo. A row they hide is a clean not found, never a silent update of the hidden row nor a create at that key — and it is the same not found whether or not the hidden row happens to belong to another parent, so the answer never confirms that a row you cannot see exists.Which hosts serve a write follows from
Meta.model_operations, and both host classes take it: a mutation narrowed tomodel_operations = ("create",)has noupdateto mirror, so its scope is never applied to a nested update, and aDjangoModelTypedeclaringmodel_operations = ("list", "retrieve")is a read host whoseMeta.querysetleaves the nested write path entirely. That option is the remedy to reach for before narrowingMeta.queryseton a display type (hiding archived or unpublished rows): without it, that display default also stops the parent updating those children inline, because the type's ownupdateanddeleteapply the same scope. - Additive & safe — M2M/reverse children are only added, never removed; an empty[]/{}payload is a no-op (the relation is left untouched). - Errors are prefixed — a child error is reported asfield.subfield(e.g.addresses.zip_code). - M2M pk validation — when M2M pks are passed directly to the top-level mutation (not vianested_fields), non-existent pks are caught before the DB write and returned as a structuredErrorType(field name + message), never as anIntegrityError500. - To-one list guard — supplying a list to a forwardForeignKey/OneToOneFieldnested field raises a clean error if the list contains more than one item. A single-element list is accepted as a convenience. - Reverse-O2O list guard — supplying a list of more than one item to a reverseOneToOneFieldnested field raises a clean error instead of hitting the DB UNIQUE constraint. - Reverse ownership guard — upsert of a reverse child by pk is rejected if that child currently belongs to a different parent. This prevents a client from silently re-parenting (stealing) rows owned by another object. The error message is"Object <pk> does not belong to this <Model>.". The guard covers both reverse kinds — reverseForeignKeyand reverseOneToOneField— identically, so the two are indistinguishable to a client. It does not apply to forwardForeignKey/OneToOneFieldor toManyToManyFieldchildren: those rows are not owned by the parent (many parents may legitimately point at the same one), so there is no owner to compare against. Those two relations are protected by the link rule above instead — the row is attached, never rewritten.
Adopting an existing reverse-FK or reverse-OneToOne orphan by ID¶
An existing child whose nullable reverse foreign key or one-to-one key is null is different
from a new child. Naming that row's id in a generated parent update
adopts and writes the child. The operation now requires at least one registered
child DjangoModelType or DjangoModelMutation that serves update. Each
applicable host's existing row scope and authorization must admit the child.
The parent mutation's registry and the intentional global registry contribute
hosts; an unrelated private registry cannot authorize this write. A plain
read-only output type or a create-only child host does not serve update.
For example, assume Address.customer is nullable, Address.tenant_id exists,
and CustomerMutation declares nested_fields = {"addresses": Address}. This
child write host lets a tenant adopt only its visible orphans:
from typing import Any
from django.db.models import QuerySet
from graphql import GraphQLResolveInfo
from django_graphex.mutation import DjangoModelMutation
from .models import Address
class AddressMutation(DjangoModelMutation):
class Meta:
model = Address
model_operations = ("update",)
@classmethod
def filter_queryset(
cls,
qs: QuerySet[Address],
info: GraphQLResolveInfo,
**kwargs: Any,
) -> QuerySet[Address]:
"""Limit child updates to the request tenant.
Args:
qs: Candidate address rows.
info: Current GraphQL request.
**kwargs: Additional filtering context.
Returns:
Addresses in the request tenant.
"""
return qs.filter(tenant_id=info.context.user.tenant_id)
Before this guard, the following existing-orphan ID could be adopted with no child update host. Now, with no eligible host, it returns a structured error and rolls back the entire parent mutation, including earlier sibling writes:
mutation {
updateCustomer(newCustomer: {
id: "1"
addresses: [{ id: "3", street: "789 Elm St" }]
}) {
ok
errors { field messages }
}
}
{
"data": {
"updateCustomer": {
"ok": false,
"errors": [{
"field": "addresses",
"messages": ["Adopting an existing child by ID requires a registered child update host."]
}]
}
}
}
Migration: find parent update mutations that name an existing unowned child
by id. Register a child update-serving host before schema construction, then
make its queryset/permission checks express the intended visible and writable
rows. Review callers that intentionally adopted read-hidden children; they
must use an explicitly authorized child workflow instead. Registering a host
does not infer tenant ownership or grant blanket access. A child hidden by its
host still receives the usual not-found response; a permission refusal retains
the child's existing permission error. No host is needed for creating a new
child without an existing ID (including generated parent create), or updating a
child already owned by this parent. The generated nested-create input does not
accept an existing child ID.
Adoption from a different parent remains forbidden. A nested child payload
that names an id which does not resolve to a visible child (hidden by its
host or output scope, or nonexistent) is refused with the same
<Model> with id <pk> does not exist. error and writes nothing, so the
response cannot reveal which ids exist; only a payload without an id
creates a new child. This applies to reverse ForeignKey, reverse
OneToOneField and many-to-many nested payloads. A many-to-many
or forward foreign-key id link (an existing row the parent is not yet linked
to) is scoped the same way: an id hidden only by the child's update host is
refused exactly like a nonexistent one, so it cannot be probed. Re-sending only
the id of a forward relation's current target writes nothing (no save(), no
signals). The rule is the same for a
reverse OneToOneField (for example parent = OneToOneField(..., null=True,
related_name="child")) as for a reverse ForeignKey. Generic relations are
outside this rule. Unlike the separate FK/M2M ID-link scope,
this is a child write and its host checks apply; automatic relation reads
and relation-filter joins retain separate boundaries; nested forward-FK
filters have their own destination-hook rule.
Reverse child re-parenting
Attempting to assign an existing reverse child (by pk) to a new parent via the nested write path will fail with a validation error if the child's FK or O2O key already points to a different parent. To genuinely re-parent a row, issue a direct update mutation on the child model instead.
Editing a forward FK / M2M row through the parent
A nested payload can only edit the forward-FK or M2M row the parent is
already attached to. Naming a different row's id attaches it and
silently ignores the rest of the payload — ok is still true and that
row is unchanged. To edit a row the parent is not attached to yet, issue a
mutation on the child model directly, or attach it first and edit it in a
second call.
ID links use the destination's public row scope
A generated create or update now checks forward ForeignKey and
OneToOneField IDs and ManyToManyField ID lists against the destination
DjangoObjectType.get_queryset for the current request schema. This
includes nested link operations such as {"id": "7"}. When the existing
link rule ignores extra child attributes, that operation is checked too;
the attributes still do not write the child. A hidden ID and
a nonexistent ID both return the ordinary structured relation error:
Invalid pk "7" - object does not exist. The parent and its other nested
objects are not saved on that validation failure. The link still does not
WRITE the child and does not run child add/change permissions.
This is an output-type row-scope check, not global model row-level
security. If the request schema has no registered destination output type
with an overridden get_queryset, the existing ID-link path is unchanged
and adds no lookup. The hook must synchronously return a Django
QuerySet for the destination's concrete model; a proxy queryset for the
same table is valid, but a multi-table-inheritance parent queryset is not.
Class methods, static methods, plain functions, and callable-object hooks
follow the same rule. A broader returned queryset cannot authorize an ID
absent from it. A JOIN may repeat a visible row, and an ordered slice only
authorizes rows inside its window: both remain valid hook results. All
distinct IDs requested for one relation under an active hook are checked
with a single membership query.
Omitted relations stay untouched;
nullable null and empty nested inputs keep their existing meanings.
Generic relations and payloads that actually CREATE or UPDATE a child retain their separate existing rules. A nested M2M payload that names an ID is never a creation: an ID that is not visible (hidden or missing) gets the error above, and only a payload without an ID creates a child. This does not change automatic relation expansion on reads or relation-filter joins; automatic FK/reverse-FK output reads and nested forward-FK filters have their own bounded destination-hook rules; see row-scope boundaries.
Migrating ID links hidden by a destination scope¶
Suppose AuthorType.get_queryset excludes the author with ID 7, while
PostType generates mutations for Post.author and Post.tags:
from django.db.models import QuerySet
from graphql import GraphQLResolveInfo
from django_graphex.types import DjangoModelType, DjangoObjectType
from .models import Author, Post
class AuthorType(DjangoObjectType):
class Meta:
model = Author
@classmethod
def get_queryset(
cls, queryset: QuerySet, info: GraphQLResolveInfo
) -> QuerySet:
return queryset.exclude(name="HiddenAuthor")
class PostType(DjangoModelType):
class Meta:
model = Post
Previously, a generated link to that hidden row could succeed. Now both generated operations reject its ID before saving the parent:
mutation {
postCreate(newPost: {title: "Draft", body: "", author: "7"}) {
ok
errors { field messages }
}
}
mutation {
postUpdate(newPost: {id: "12", author: "7"}) {
ok
errors { field messages }
}
}
For either operation, the relation error has the same shape as an absent row:
{"ok": false, "errors": [{"field": "author", "messages": ["Invalid pk \"7\" - object does not exist."]}]}
An ID-only tags: ["8"] input follows the same rule for a registered
TagType: its error uses "field": "tags". Repeated IDs are checked once
per distinct value, and the existing input order is otherwise preserved.
Before upgrading:
- Identify generated create/update clients that link FK or M2M rows hidden from their callers, including nested link payloads whose extra attributes were already ignored by the link rule.
- Make authorized rows available through the destination type's
get_querysetfor those requests, or expose a separate, explicitly authorized workflow. Do not broaden a tenant scope merely to keep an old ID-link request succeeding. - Register the destination public type in the same schema
Registrythat serves the mutation. A type in an unrelated Registry does not supply that request's scope. Hand-written mutations must enforce their own relation policy.
The nested child input type¶
Each nesting parent gets its own copy of the child's input, named
<Child><Op>In<Parent>Type (e.g. CommentCreateInPostType). It is never
registered as the child's canonical input, so declaring a parent that nests a
model can no longer change what that model's own mutation accepts, and the order
in which you declare the two makes no difference to either surface.
The one difference from the child's own input is the back-reference foreign key. Inside a nested payload the parent does not exist yet (or is already known), and the writer injects the key at save time, so the parent's own column is optional on this copy:
input CommentCreateGenericType { # the child's own mutation
post: ID!
body: String!
}
input CommentCreateInPostType { # the copy inside postCreate
post: ID # injected by the writer
body: String!
}
A child nested under two parents gets one copy per parent, each relaxing its own foreign key and nothing else. The child's Pydantic validation model still requires the key, so a standalone create that genuinely omits it fails cleanly.
Type names in client documents
Only a client document that spells the nested child input type name out (an explicit variable declaration, for instance) is affected by this naming. Field names and shapes are unchanged.
Writable only through its parent¶
The child's permission checks receive a nested_parent keyword argument: the
parent model class when the write arrives through a nested payload, and
nothing at all on the child's own mutation. That is enough to express this
model may only be written inside its parent:
from django_graphex.permissions import BasePermission
class OnlyViaParent(BasePermission):
"""Allow creates only when they arrive through a nesting parent."""
def has_create_permission(self, info, model, **kwargs):
return kwargs.get("nested_parent") is not None
class CommentType(DjangoModelType):
permission_classes = (OnlyViaParent,)
class Meta:
model = Comment
commentCreate now answers PERMISSION_DENIED, while
postCreate(newPost: { title: "...", comments: [{ body: "..." }] }) succeeds.
Under a permission-scoped schema, don't mount the child's root
A permission_classes policy runs at write time; the
permission-scoped schema prunes at build
time and cannot evaluate one. It labels the comments field with the
child's write permissions — and that is exactly right: the caller writing
a comment through its post genuinely holds add_comment. What the hatch
withholds is the child's own root, so simply leave commentCreate
off your Mutation; a root that is never mounted gives the pruner nothing
to prune, and the nested field survives for every caller who may write the
child.
required_perms on a child host can only add to the nested field's
label, never replace it: the field always requires the child's composite
write permissions, and the override of every host that serves one of
the verbs the nested field enables is unioned on top. A write host's
stricter label therefore genuinely reaches the nested surface, and a
delete-only host's label stays off a nested create.
It is an extra requirement, not a free one. A DjangoModelType serves every
operation unless it says otherwise, so a read card labelled
required_perms = ["blog.read_comment_card"] labels the nested field too,
and a caller without that label loses comments from the parent's payload —
even though another host's commentCreate still accepts the same write.
That is fail-closed and matches what the label does to the card's own
create/update roots, but if you meant the label as a read gate only, declare
the card with model_operations = ("list", "retrieve"), or drop the label
and gate reads with permission_classes.
Permission checks with a closed signature
nested_parent is only passed to a check that can accept it. A policy or
an authorize override that spells its arguments out
(def has_create_permission(self, info, model, data=None)) keeps working —
it simply never sees the marker, and therefore reads a nested write exactly
as it reads a direct one. Accept **kwargs to see it.
Worked examples — UPDATE + nested create and UPDATE + nested update (upsert)¶
# Add a brand-new address to an existing customer (no `id` on the address payload
# → creates a new Address row and links it to the customer).
mutation {
updateCustomer(newCustomer: {
id: "1"
addresses: [
{ street: "456 Oak Ave", city: "Shelbyville", country: "US" }
]
}) {
ok
customer {
addresses {
results { id street city country }
totalCount
}
}
errors { field messages }
}
}
The new address is added to the customer's existing addresses (M2M/reverse-FK
children use .add() semantics — existing links are kept). totalCount will
increase by 1.
# Update an existing address: include its `id` in the payload.
# Without an id → creates; with an id → updates that row in place.
mutation {
updateCustomer(newCustomer: {
id: "1"
addresses: [
{ id: "3", street: "789 Elm St", city: "Springfield", country: "US" }
]
}) {
ok
customer {
addresses {
results { id street city country }
}
}
errors { field messages }
}
}
A child payload with id updates that existing row. A child payload
without id creates a new one. Both can appear in the same addresses
list in a single mutation call.
Removing M2M/reverse-FK children
Nested writes are additive only (.add() — existing links are kept).
To remove links, issue a separate mutation that targets the child directly or
override the top-level mutation. See
M2M write semantics below.
Reverse-FK nested fields
For a reverse relation (the child holds the FK to the parent), the child's
FK back to the parent is injected automatically at save time — you do not need
to supply it in the mutation input. Use exclude_fields on the child's
DjangoModelMutation (or only_fields) to keep it out of the input:
M2M write semantics: nested path vs. top-level path¶
The two write paths use different M2M semantics — this is intentional and the two paths are not yet unified:
| Write path | M2M operation | Effect |
|---|---|---|
Nested (nested_fields) |
.add(*children) |
Additive — existing links are kept; the submitted items are appended. Submitting an empty list (or null) is a no-op. |
| Top-level native backend | .set(pks) |
Replace — existing links are removed and replaced with exactly the submitted list. Submitting an empty list [] or an explicit null clears all links. Omitting the field entirely leaves the relation untouched. |
Practical implication: to remove M2M links via the nested path you currently cannot — use the
top-level mutation instead (send tags: [] or tags: null to clear), or issue a separate mutation
that clears and re-adds.
Not implemented
There is no per-field m2m_behavior = "set" | "add" option to pick the semantics on the
nested path, and none is scheduled. The nested path is additive; reach for the top-level
path when you need .set.
Explicit-null semantics in update mutations¶
update() follows the GraphQL specification: an omitted field is not the same as an explicit
null. On a partial update:
| Input | Effect |
|---|---|
| Field omitted from the input | Value is left unchanged (partial-update semantics). |
Field sent as explicit null (nullable field / FK) |
Column is set to NULL. |
Field sent as explicit null (required field) |
A clean validation error (ok: false, errors[]) — never a 500. |
M2M sent as null or [] (top-level path) |
Relation is cleared. |
| M2M omitted (top-level path) | Relation is left unchanged. |
# Clear the nullable ``bio`` field:
mutation {
updateCustomer(newCustomer: { id: 1, bio: null }) {
ok
customer { id bio } # -> bio is now null
}
}
# Leave ``bio`` untouched (it is simply omitted):
mutation {
updateCustomer(newCustomer: { id: 1, firstName: "Ada" }) {
ok
customer { id bio } # -> bio unchanged
}
}
# Clear an M2M (``tags: null`` is equivalent to ``tags: []``):
mutation {
updatePost(newPost: { id: 1, tags: null }) {
ok
post { id }
}
}
Nested inputs treat null as a no-op
Explicit-null semantics apply to scalar fields, foreign keys, and the top-level
(ID-list) M2M surface. For nested inputs (Meta.nested_fields) a null / [] /
{} payload is a deliberate no-op — the related children are left untouched, never
deleted. Interpreting null as "delete every related child" would be dangerous and
irreversible, so nested writes never clear on null. To remove nested children, target the
child model directly.
Partial input is not a concurrency guarantee¶
The mutation validates and assigns only the supplied fields, so an
omitted field retains its value on the model instance loaded for this
request. The backend then performs a full save: the resulting SQL UPDATE
can write columns that were absent from the GraphQL input. If another writer
changes an omitted column after this request loads a stale instance, this
save can overwrite that separately changed value. Partial input does not
provide changed-columns SQL or optimistic concurrency protection.
Do not assume a blanket update_fields change would solve the contract:
restricting saved fields can also alter auto_now, custom save hooks, and
derived fields. If concurrent updates matter, coordinate them in your
application's transaction and model workflow; this library does not add a
locking or merge API for partial mutations.
perform_mutate response shape¶
DjangoModelMutation.perform_mutate and DjangoModelType.perform_mutate intentionally differ in
how they produce the response object after a successful write:
| Class | Response object | Optimizer applied? | Extra DB query? |
|---|---|---|---|
DjangoModelType |
Re-reads via get_queryset() — picks up DB defaults, signals, annotations |
Yes — the re-read passes through queryset_factory so select_related / prefetch_related / .only() are derived from the mutation's response selection |
Yes (one re-read) |
DjangoModelMutation |
Returns the in-memory object that was just saved | No re-read, so no optimizer pass | No |
Practical implication: if your mutation response selects nested relations
(e.g. customer { profile { bio } }), DjangoModelType will resolve them via the
re-read queryset — which the optimizer makes efficient. DjangoModelMutation
returns the in-memory object, so relations are resolved lazily (one per field),
unless you refresh_from_db() in a custom perform_mutate.
To avoid N+1 on DjangoModelMutation responses with nested relations, or when you
need DB defaults or signals to be reflected, override perform_mutate:
class MyMutation(DjangoModelMutation):
class Meta:
model = MyModel
@classmethod
def perform_mutate(cls, obj, info):
obj.refresh_from_db()
return super().perform_mutate(obj, info)
Mutation response: depth limits and optimizer¶
MAX_QUERY_DEPTH and Meta.max_depth apply to mutations¶
Query depth limiting (MAX_QUERY_DEPTH global setting and Meta.max_depth per-type)
is enforced on all operation types — including mutation response selection sets.
The selection set validation runs before execution, so a mutation that requests
deeper nesting than the limit permits is rejected with a validation error before
any database writes occur.
DJANGO_GRAPHEX = {
"MAX_QUERY_DEPTH": 5, # enforced on query, mutation, and subscription selection sets
}
The per-type attribute is Meta.max_depth; the global setting key is
MAX_QUERY_DEPTH:
class CustomerType(DjangoModelType):
class Meta:
model = Customer
max_depth = 3 # overrides the global MAX_QUERY_DEPTH for this type
See Query depth & cost limits for the full reference.
Traditional GraphQL Mutations¶
While DjangoModelMutation covers most use cases, you can still create traditional GraphQL mutations for custom logic:
from django_graphex.core import BooleanField, CharField, Field, Mutation
from django_graphex.types import DjangoObjectType
from django.contrib.auth.models import User
class UserType(DjangoObjectType):
class Meta:
model = User
only_fields = ("id", "username", "first_name", "last_name")
class CreateUser(Mutation):
class Arguments:
username = CharField(required=True)
email = CharField(required=True)
password = CharField(required=True)
ok = BooleanField()
user = Field(UserType)
@staticmethod
def mutate(root, info, username, email, password):
user = User.objects.create_user(
username=username,
email=email,
password=password
)
return CreateUser(ok=True, user=user)
from django_graphex.core import BooleanField, CharField, Field, Mutation
from django_graphex.errors import ErrorType
# ErrorType is a native ObjectType (a Python class), so wrap it in the
# lazy NativeList rather than graphql-core's GraphQLList.
from django_graphex.core.descriptors import NativeList
class CreateUser(Mutation):
class Arguments:
username = CharField(required=True)
email = CharField(required=True)
password = CharField(required=True)
ok = BooleanField()
user = Field(UserType)
errors = Field(NativeList(ErrorType))
@staticmethod
def mutate(root, info, username, email, password):
# Validation
errors = []
if User.objects.filter(username=username).exists():
errors.append(ErrorType(
field="username",
messages=["Username already exists"]
))
if User.objects.filter(email=email).exists():
errors.append(ErrorType(
field="email",
messages=["Email already registered"]
))
if errors:
return CreateUser(ok=False, errors=errors, user=None)
# Create user
user = User.objects.create_user(
username=username,
email=email,
password=password
)
return CreateUser(ok=True, user=user, errors=None)
Best Practices¶
Mutation Best Practices
- Use DjangoModelMutation: Drive mutations from
Meta.modelfor consistency - Validate Input: Always validate input data before processing
- Handle Errors Gracefully: Provide clear, actionable error messages
- Test Thoroughly: Write tests for all mutation scenarios
- Document Fields: Use descriptions for all mutation fields and arguments
- Security First: Implement proper authentication and authorization
Authentication & Permissions¶
from graphql import GraphQLError
from .models import Customer
from django_graphex.mutation import DjangoModelMutation
class CustomerMutation(DjangoModelMutation):
class Meta:
model = Customer
@classmethod
def create(cls, root, info, **kwargs):
if not info.context.user.is_authenticated:
raise GraphQLError("Authentication required")
if not info.context.user.has_perm('crm.add_customer'):
raise GraphQLError("Permission denied")
return super().create(root, info, **kwargs)
Custom Validation¶
For field-level validation beyond automatic DB checks (inline validate_<field>
methods, object-level validate(), and Meta.pydantic_model), see the
authoritative reference:
Model backend (Pydantic) — Custom validation.
Testing Mutations¶
import pytest
from graphql import graphql_sync
from .schema import schema # a DjangoGraphQLSchema
@pytest.mark.django_db
def test_create_customer_mutation():
mutation = """
mutation CreateCustomer($customerData: CustomerInput!) {
createCustomer(newCustomer: $customerData) {
ok
customer {
id
username
email
}
errors {
field
messages
}
}
}
"""
variables = {
"customerData": {
"username": "testuser",
"email": "test@example.com",
"password": "secretpass123"
}
}
result = graphql_sync(schema.graphql_schema, mutation, variable_values=variables)
assert result.errors is None
assert result.data['createCustomer']['ok'] is True
assert result.data['createCustomer']['customer']['username'] == 'testuser'
from graphql import graphql_sync
@pytest.mark.django_db
def test_create_customer_validation_error():
mutation = """
mutation CreateCustomer($customerData: CustomerInput!) {
createCustomer(newCustomer: $customerData) {
ok
errors {
field
messages
}
}
}
"""
# Missing required email
variables = {
"customerData": {
"username": "testuser",
"password": "secretpass123"
}
}
result = graphql_sync(schema.graphql_schema, mutation, variable_values=variables)
assert result.data['createCustomer']['ok'] is False
assert len(result.data['createCustomer']['errors']) > 0
The mutation system in django-graphex provides a robust foundation for handling data modifications in your GraphQL API, with built-in validation, error handling, and support for complex operations.
File Upload Support¶
Base64FileInput¶
django-graphex ships an opt-in Base64FileInput for sending files through the GraphQL body as base64-encoded strings. Import it explicitly — it is not wired into DjangoModelMutation automatically.
from django_graphex.uploads import Base64FileInput
from django_graphex.uploads import Base64FileInput, decode_base64_file
Shape¶
input Base64FileInput {
filename: String! # Original filename (stored / used as the key by FileField)
data: String! # Base64-encoded file content
contentType: String # MIME type — defaults to "application/octet-stream"
}
Resolver usage¶
from django_graphex.core import BooleanField, Field, Mutation
from django_graphex.core.base import compile_all_inputs
from django_graphex.uploads import Base64FileInput
# Compile the imported InputType subclasses before any Field(...) reads them.
# See the note below for why importing the module is not enough on its own.
compile_all_inputs()
class UploadAvatarMutation(Mutation):
class Arguments:
# Pass the input-object CLASS directly — no thunk boilerplate.
avatar = Field(Base64FileInput, required=True)
ok = BooleanField()
@classmethod
def mutate(cls, root, info, **kwargs):
# The input object arrives as a dict (snake-cased keys); rehydrate it into a
# Base64FileInput so .to_uploaded_file() is available.
avatar = Base64FileInput(**kwargs["avatar"])
# Pass max_size to override the global MAX_UPLOAD_SIZE for this field:
uploaded = avatar.to_uploaded_file(max_size=512 * 1024) # 512 KB cap
profile = info.context.user.profile
profile.avatar.save(uploaded.name, uploaded, save=True)
return cls(ok=True)
The avatar argument arrives in the resolver as a plain dict of the input
fields; rehydrate it with Base64FileInput(**kwargs["avatar"]) to get a validated
instance with .filename, .data, .content_type attributes and a
.to_uploaded_file(*, max_size=None) method that returns a SimpleUploadedFile.
For hand-authored InputType fields, a zero-argument Pydantic
default_factory runs for each new coerced input object when that field is
omitted. It does not run while the schema is built and puts no fixed value in
the SDL. For example, a uid factory renders uid: String, and each
separately coerced {} input gets a fresh UID. Plain scalar and enum
defaults render as SDL defaults; fixed JSON defaults have the representability
rule below. Explicit values and null bypass a zero-argument factory. A resolver
still receives a snake-keyed dict, not an automatically validated Pydantic
instance. See the input type default contract.
String Literal input enums and unsupported annotations¶
An all-string Literal on a hand-authored input compiles to a GraphQL
enum. For ArticleInput.status: Literal["draft", "published"], the generated
type is ArticleInputStatusEnum with DRAFT and PUBLISHED members. The
compiler uses the compiled input type name and public field alias;
for example, CustomArticleInput.publishState generates
CustomArticleInputPublishStateEnum. A member collision after name
normalization rejects the schema instead of assigning a positional suffix.
A value starting with __ (for example Literal["__x"]) is rejected too,
because GraphQL reserves member names with that prefix; rename the value.
Optional, list, nested, and inherited fields keep their existing wrapping
and resolver names.
Clients send inline values as enum members (status: DRAFT) and JSON
variables by member name ("status": "DRAFT"); a string such as
status: "draft" is not an enum member and fails input validation. A
mutation resolver still receives the original string value ("draft") in
its plain input dictionary. A value outside the enum fails GraphQL input
validation before the resolver. Fixed defaults render as enum members in SDL;
factories still run for each newly coerced input object.
An input field annotated dict[str, int] or typing.Dict[str, int] uses
the existing raw GraphQL JSON scalar. Both spellings have the same strict
input behavior. Send an object, not JSON-encoded text: inline mapping: {count: 3} or the JSON variable
"mapping": {"count": 3}. Every key must be a string and every value must
be a plain integer; true, 3.5, "3", nested containers, and non-object
values fail before the mutation resolver. An empty object is accepted, and
values follow Python int and JSON semantics, so integers beyond GraphQL
Int's 32-bit range are accepted. Error messages name the declared
field without echoing supplied keys or values. Optionality, omission, list
wrapping, and fixed or factory defaults follow the field's existing input
contract. A bare dict remains permissive raw JSON.
Exact dict[str, str] and typing.Dict[str, str] input fields also use the
shared JSON scalar. Send an object such as
"mapping": {"name": "Ada"} in variables or mapping: {name: "Ada"}
inline. Object string keys and string values are required: numbers, booleans, null, nested
containers, non-object values, and non-string keys fail before mutation
resolution without conversion. Empty objects are valid. The same
optional, list, nested, omission, fixed-default, factory-default, and
permission-pruned input behavior applies; bare dict stays permissive.
Exact dict[str, bool] and typing.Dict[str, bool] direct mutation inputs
use the same JSON scalar. Send a JSON object such as
"mapping": {"enabled": true} or inline mapping: {enabled: true};
encoded JSON strings do not match the wire type. Every key must be a
string and every value must be an exact Boolean (true or false): 1,
0, "true", null and nested values fail before the resolver or omitted
sibling factories run. Empty objects are valid. Outer Optional, alias and
fixed/factory-default behavior, including malformed omitted legacy defaults,
is unchanged. This is field-local validation, not a change to bare dict,
other maps, list-wrapped maps or the global JSON scalar.
Fixed JSON defaults and schema metadata
A fixed JSON default whose object keys are valid GraphQL Names (for
example, {"views": 0}) prints as mapping: JSON = { views: 0 }
and appears in default-value introspection. It still supplies the same
value to an omitted input, without JSON string encoding. Nested objects,
lists, and finite scalar values inside that JSON value follow the same
rule. This applies to JSON scalar fields, including those nested in an input
object. A fixed default for a list of JSON values also prints when the
outer value is a concrete list, every element is representable, and null
elements match the field type. The omitted input receives an equal outer
list copy with the same JSON elements. Malformed list defaults retain their
legacy runtime and metadata behavior.
Omitted fixed defaults remain separate from explicitly supplied values under supported graphql-core 3.3 versions, including after a permission-scoped schema clone. Mutable defaults are copied for each execution; cloning does not restore hidden input fields or reuse a coerced default when its nested input type has changed.
JSON objects may also contain keys that cannot be GraphQL Names, such as
"x-y". Those existing runtime defaults remain accepted, but
print_schema and default-value introspection cannot render them as
valid GraphQL object literals. If schema metadata must render for such a
value, use Field(default_factory=lambda: {"views": 0}) with a suitable
object instead. A factory supplies each omitted input without publishing
a fixed SDL default. Cyclic or non-JSON-native fixed values likewise gain
no new metadata support. Strings containing unpaired Unicode surrogates or
integers beyond Python's configured integer-to-string limit likewise keep
their legacy runtime defaults without new SDL metadata. A nullable None
default is unaffected.
Exact set[int] and typing.Set[int] input fields expose [Int!]!.
Send an integer list such as "numbers": [2, 1, 2] rather than an encoded
string. GraphQL also accepts a singleton integer such as "numbers": 3
and wraps it as a list. The resolver receives a Python set, so both inputs
deduplicate ({1, 2} and {3} respectively) and iteration order is not
part of the contract. Native GraphQLInt rejects booleans and numeric strings
before the resolver; this does not add Pydantic validation or custom casts.
Optional, required, and defaulted outer fields keep their existing behavior.
Both spellings have the same resolver behavior. Other set leaves and sets
inside lists remain unsupported.
Exact set[str] and typing.Set[str] mutation inputs expose
[String!]!. Send a string list such as
"colors": ["red", "red", "blue"]; GraphQL also accepts the singleton
"colors": "ab". The resolver receives an actual Python set:
{"red", "blue"} or {"ab"}, never the characters of "ab".
Duplicates are removed and iteration order is not part of the contract.
Native GraphQLString rejects non-string values before resolution without
custom casting. Optional, required, fixed-default and fresh-factory behavior
remains unchanged. Other unapproved set leaves and sets inside lists still use the
legacy warning/String fallback.
Exact set[bool] and typing.Set[bool] mutation inputs expose
[Boolean!]!. Send a boolean list such as
"flags": [true, false, true]; GraphQL also accepts the singleton
"flags": false. The resolver receives a Python set ({True, False}
or {False}), with duplicates removed and no ordering guarantee.
Native GraphQLBoolean rejects 0, 1, strings and null items before
resolution; no truthiness cast or automatic Pydantic validation is added.
Outer Optional, required, fixed-default and fresh-factory behavior remains.
Other unapproved set leaves and nested sets retain the warning/String fallback.
Exact set[float] and typing.Set[float] mutation inputs expose
[Float!]!. Send a finite numeric list such as
"amounts": [1.5, 1.5, 2.0] or a singleton integer such as
"amounts": 2. Native GraphQLFloat converts the integer to 2.0 and
rejects booleans, strings and null items; variable coercion also rejects
nonfinite values. A field-local check rejects overflowing numeric literals
such as 1e999 before resolution. The resolver receives an actual Python set ({1.5, 2.0} or {2.0}), with
duplicates removed and no ordering guarantee. Outer Optional, required,
fixed-default, fresh-factory, alias and pruning behavior remains established;
no additional Pydantic or global validation is added. Other unapproved set
leaves and nested sets retain the warning/String fallback.
Exact set[uuid.UUID] and typing.Set[uuid.UUID] mutation inputs expose
[UUID!]!. Send UUID strings as a list, for example
"keys": ["12345678-1234-5678-1234-567812345678", "12345678123456781234567812345678"],
or a singleton "keys": "12345678123456781234567812345678".
The existing native UUID scalar accepts canonical, uppercase and 32-hex
strings, validates each item before resolution and supplies UUID objects.
The resolver receives an actual deduplicated Python set, never the characters
of a singleton string; iteration order is unspecified. Invalid UUIDs and
null items are rejected before resolution. Outer Optional, defaults, aliases
and permission pruning retain their established behavior. Other unapproved
set leaves and nested sets retain the warning/String fallback.
Exact set[decimal.Decimal] and typing.Set[decimal.Decimal] mutation inputs
expose [Decimal!]!. Send native Decimal values in a
list, for example "amounts": ["10.10", "10.10"], or a whole-value
singleton "amounts": "10.10". The existing Decimal scalar preserves
precision and accepts its established finite representations; even a finite
value such as "1E+999999" is not converted through float. The resolver
receives {Decimal("10.10")} as an actual Python set, with duplicates removed
and no ordering guarantee. A field-local check rejects supplied NaN, sNaN and
positive/negative Infinity before the mutation and omitted sibling factories;
null items still fail native list-item validation. Existing outer Optional,
fixed/factory defaults, aliases and permission pruning remain unchanged,
including malformed legacy omitted defaults. The global Decimal scalar and
other unapproved set forms are unchanged.
Exact direct typing.Union[int, str], int | str, and reversed-order
annotations expose the shared IntOrString input scalar. A required field is IntOrString!; an outer nullable union is
IntOrString. Send value: 123 or a JSON variable {"value": 123} for
a Python integer, and value: "123" or {"value": "123"} for a Python
string. The resolver receives the same branch without an implicit cast.
Only signed 32-bit integers are accepted; booleans, all floats (including
1.0), objects, lists, and out-of-range integers reject before resolution.
Declare variables as $value: IntOrString! (or nullable IntOrString)
and regenerate typed client bindings. Existing omitted fixed and factory defaults remain unchanged,
including malformed legacy defaults. This does not change the global Int,
String, ID, or JSON scalars, and does not support list-wrapped or other
Union leaves.
Numeric or mixed Literal and other Union annotations
remain unsupported and warn when they fall back to GraphQL String. Other
parameterized dictionaries remain unsupported and use the same warning and
String fallback. They are not automatically validated as Pydantic models. For
other string-valued fields, annotate str and explicitly call the input
model's model_validate() in the resolver if narrower validation is needed.
A supported int | None remains Int, and list[int] remains [Int!]!.
Nested lists of already-supported input leaves preserve each list and nullable
position. For example, rows: list[list[int]] exposes rows: [[Int!]!]!,
accepts [[1, 2], [3]], and uses ordinary GraphQL list coercion: [1, 2]
becomes [[1], [2]] and 1 becomes [[1]]. GraphQL rejects invalid leaves
before the resolver; it does not cast strings to integers. This also applies
to supported JSON and strict typed-map leaves, all-string Literal enums,
and nested input models. A nested list ending in an unsupported type still
follows the earlier warning and String fallback, without automatic Pydantic
validation.
Compile the input type before you declare the mutation
Field(Base64FileInput, ...) needs Base64FileInput._meta.graphql_input_type
to be compiled already — it is read when YourMutation.Field() is
evaluated in the surrounding ObjectType body, not lazily at schema-build
time. If it is still None, that line raises:
TypeError: Can only create a wrapper for a GraphQLType, but got:
<class 'django_graphex.uploads.Base64FileInput'>
AppConfig.ready() does call compile_all_inputs(), but it can only compile
the InputType subclasses that have been imported by then — and the
package never imports django_graphex.uploads itself. Listing
django_graphex in INSTALLED_APPS is therefore not sufficient on its own.
Do one of these:
# Option A — compile explicitly, right after the import. Works anywhere.
from django_graphex.core.base import compile_all_inputs
from django_graphex.uploads import Base64FileInput
compile_all_inputs()
# Option B — import it from a module Django loads during app population
# (e.g. your app's models.py), so AppConfig.ready() picks it up.
from django_graphex.uploads import Base64FileInput # noqa: F401
The older lambda: GraphQLArgument(GraphQLNonNull(...)) thunk still works as
the low-level substrate, and defers the lookup to schema-build time.
You can also call the module-level helper directly if you hold the raw dict:
from django_graphex.uploads import decode_base64_file
uploaded = decode_base64_file(
{"filename": "report.pdf", "data": b64_string, "content_type": "application/pdf"},
max_size=10 * 1024 * 1024, # 10 MB override
)
GraphQL client example¶
Variables:
Settings¶
Add both settings to your DJANGO_GRAPHEX dict in settings.py:
DJANGO_GRAPHEX = {
# Required when Base64FileInput is used — raises ImproperlyConfigured if
# absent and no per-field override is given.
"MAX_UPLOAD_SIZE": 5 * 1024 * 1024, # 5 MB per decoded file
# Primary memory cap: the full JSON body is rejected BEFORE parsing when
# this limit is exceeded. None = disabled (not recommended for public APIs).
# Rule of thumb: ≥ base64_overhead × MAX_UPLOAD_SIZE × expected_files_per_request
# (base64 encodes 3 bytes as 4 ASCII chars → overhead ≈ 4/3)
"MAX_REQUEST_BODY_SIZE": 20 * 1024 * 1024, # 20 MB total body
}
Per-field override¶
Pass max_size to .to_uploaded_file() or decode_base64_file() to use a tighter (or looser) cap for a specific field:
avatar.to_uploaded_file(max_size=512 * 1024) # 512 KB — tight avatar cap
document.to_uploaded_file(max_size=50 * 1024 * 1024) # 50 MB — loose document cap
Per-field max_size overrides the global MAX_UPLOAD_SIZE for that call only.
Memory-safety architecture¶
Why two settings?
The base64 payload lives inside the JSON body. By the time any field resolver sees it, the entire body is already in RAM. The guards compose as follows:
| Guard | Where it fires | What it saves |
|---|---|---|
MAX_REQUEST_BODY_SIZE |
dispatch() — BEFORE JSON parse |
The entire body allocation |
| Per-field decoded-size pre-check | Inside decode_base64_file — BEFORE base64.decode |
The decoded-bytes allocation |
Peak memory ≈ body_size + MAX_UPLOAD_SIZE.
For batch requests, MAX_BATCH_SIZE (op count) + MAX_REQUEST_BODY_SIZE (bytes) compose naturally: operations execute sequentially so peak decoded memory is bounded by one file's size, not the whole batch.
Error handling¶
| Condition | Result |
|---|---|
Body exceeds MAX_REQUEST_BODY_SIZE |
HTTP 413 (before parsing) |
| Estimated decoded size > effective cap | GraphQLError (before decode) |
| Invalid / malformed base64 | GraphQLError (never HTTP 500) |
MAX_UPLOAD_SIZE unset + no per-field override |
ImproperlyConfigured at call time |
Content validation¶
Magic-byte / MIME-type sniffing is out of scope. Use Django's FileField validators (e.g. FileExtensionValidator) or a custom model validator for content-type enforcement — that is the correct layer.
Query cost¶
Input payload size is not accounted for in query-cost analysis (the MAX_QUERY_COST setting). The body-size guard (MAX_REQUEST_BODY_SIZE) is the canonical byte-level cap.
Response caching¶
Upload mutations are not cached. The response-cache layer already skips mutation operations — no special configuration needed.
Batch uploads¶
For batch requests:
MAX_REQUEST_BODY_SIZEcaps the total body of all operations combined.- Per-field decoded-size pre-checks fire inside each operation's resolver.
- No special upload-batch rejection: rely on
MAX_BATCH_SIZE(op count) +MAX_REQUEST_BODY_SIZE(bytes).
REST side-channel alternative¶
For gateway-constrained environments (e.g. an API gateway with a 10 MB payload limit), the recommended alternative is a REST side-channel:
- Upload the file directly to a presigned URL (S3, GCS, Azure Blob) or a separate
POST /upload/endpoint. - Receive a file key or public URL in response.
- Pass that reference as a plain
Stringfield in the GraphQL mutation.
This avoids base64 overhead (≈33% size increase) entirely and scales naturally with file size.