Skip to content

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_together constraints
  • 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 .models import Customer
from django_graphex.mutation import DjangoModelMutation

class CustomerMutation(DjangoModelMutation):
    class Meta:
        model = Customer
        description = "Customer mutations: create, update, delete"
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: ...):

class Mutation(ObjectType):
    create_customer = CustomerMutation.CreateField(
        deprecation_reason="Use `createCustomerV2` instead; will be removed in 3.0."
    )

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.

from .models import Customer

class CustomerMutation(DjangoModelMutation):
    class Meta:
        model = Customer
        only_fields = ('name', 'email', 'phone')
from .models import Customer

class CustomerMutation(DjangoModelMutation):
    class Meta:
        model = Customer
        exclude_fields = ('internal_notes', 'credit_limit')

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.

class TenantArgs:
    tenant = CharField(required=True)

class CustomerMutation(DjangoModelMutation):
    class Meta:
        model = Customer

    class Arguments(TenantArgs):
        send_email = BooleanField(default=False)
# compiles to: customerMutation(tenant: String!, sendEmail: Boolean, ...)

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)
mutation CreateCustomer($customerData: NewCustomer!, $sendEmail: Boolean) {
  createCustomer(newCustomer: $customerData, sendWelcomeEmail: $sendEmail) {
    ok
    customer {
      id
      username
      email
      profile {
        bio
        avatar
      }
      addresses {
        totalCount
        results { street city country }
      }
    }
    errors { field messages }
  }
}
{
  "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 existing save_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 / OneToOneField child saved first its pk is set on the parent
    Reverse FK / reverse OneToOne parent 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 id updates that row; without an id it creates a new one. (The nested input only exposes id on 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 / OneToOneField or a ManyToManyField, a payload carrying an id the 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 / DjangoModelMutation for the child model declares inline validate_<field> / validate methods or a Meta.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_fields on a DjangoModelType / DjangoModelMutation for the child model apply to the parent's nested payload too. The two axes are merged differently, because they say different things.

    An exclude_fields is 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_fields is 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 declaring model_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_fields case, 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, through Meta.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 leaves id on 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 ImproperlyConfigured naming 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 with model_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 own permission_classes / authorize, exactly as the child's own mutation does. A denial is the same PERMISSION_DENIED / HTTP 403 the direct mutation returns, and it rolls the whole write back — parent included, no orphan rows. Every DjangoModelType / DjangoModelMutation declared for the child model is consulted, so two hosts for one model fail closed: all of them must allow the write. A DjangoModelMutation has no permission_classes at all, so a DjangoModelMutation-only child gets the scoping below and nothing else.

    The hosts are read from the parent's registry unioned with the global one. Meta.registry is an option on DjangoModelMutation only, so a child's permission_classes can only ever live in the global registry; reading the parent's registry alone left a parent declared with Meta.registry finding 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 the get_queryset / filter_queryset (and Meta.queryset) of the child hosts that serve the write, so it mirrors what those hosts' own update / delete do. 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 to model_operations = ("create",) has no update to mirror, so its scope is never applied to a nested update, and a DjangoModelType declaring model_operations = ("list", "retrieve") is a read host whose Meta.queryset leaves the nested write path entirely. That option is the remedy to reach for before narrowing Meta.queryset on 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 own update and delete apply 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 as field.subfield (e.g. addresses.zip_code). - M2M pk validation — when M2M pks are passed directly to the top-level mutation (not via nested_fields), non-existent pks are caught before the DB write and returned as a structured ErrorType (field name + message), never as an IntegrityError 500. - To-one list guard — supplying a list to a forward ForeignKey / OneToOneField nested 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 reverse OneToOneField nested 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 — reverse ForeignKey and reverse OneToOneField — identically, so the two are indistinguishable to a client. It does not apply to forward ForeignKey / OneToOneField or to ManyToManyField children: 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.

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:

  1. 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.
  2. Make authorized rows available through the destination type's get_queryset for those requests, or expose a separate, explicitly authorized workflow. Do not broaden a tenant scope merely to keep an old ID-link request succeeding.
  3. Register the destination public type in the same schema Registry that 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:

class PostMutation(DjangoModelMutation):
    class Meta:
        model = Post
        only_fields = ["id", "title", "body"]   # no "author": injected from parent

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

  1. Use DjangoModelMutation: Drive mutations from Meta.model for consistency
  2. Validate Input: Always validate input data before processing
  3. Handle Errors Gracefully: Provide clear, actionable error messages
  4. Test Thoroughly: Write tests for all mutation scenarios
  5. Document Fields: Use descriptions for all mutation fields and arguments
  6. 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

mutation UploadAvatar($file: Base64FileInput!) {
  uploadAvatar(avatar: $file) { ok }
}

Variables:

{
  "file": {
    "filename": "photo.png",
    "contentType": "image/png",
    "data": "<base64 string>"
  }
}

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_SIZE caps 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:

  1. Upload the file directly to a presigned URL (S3, GCS, Azure Blob) or a separate POST /upload/ endpoint.
  2. Receive a file key or public URL in response.
  3. Pass that reference as a plain String field in the GraphQL mutation.

This avoids base64 overhead (≈33% size increase) entirely and scales naturally with file size.