Model backend (Pydantic)¶
DjangoModelType and DjangoModelMutation validate input and persist objects
through a single, built-in native backend powered by Pydantic v2 and the
Django ORM — there is no serializer and no DRF. The backend is selected
simply by pointing Meta.model at a Django model:
| Backend | Selected by | Needs DRF? |
|---|---|---|
| Native (Pydantic) | Meta.model |
no |
The GraphQL schema is built from the Django model, and every mutation has the
same { ok, errors, <object> } shape.
No DRF backend
In the previous library the default backend was Django REST Framework,
selected with Meta.serializer_class. django-graphex has no DRF backend
and no djangorestframework dependency: declare Meta.model instead.
See the migration guide.
Native (Pydantic) backend¶
Point Meta.model at a model and the library validates with Pydantic v2 and
persists with the ORM — no DRF required:
from django_graphex.types import DjangoModelType
class UserType(DjangoModelType):
class Meta:
model = User # native backend; no DRF
It derives validation rules from the model: field types, max_length, choices
(as an Enum), Decimal precision, required/nullable/defaults, foreign-key pk
types and many-to-many (a list of pks). It also runs the DB-level checks Pydantic
can't see — foreign-key existence, field-level unique, unique_together,
and Meta.constraints UniqueConstraint entries — and supports partial updates
and nested writes (atomic, relation-aware).
Uniqueness validation¶
The backend checks all three Django uniqueness mechanisms before saving, so
violations surface as a structured ErrorType in the mutation response rather than
propagating as an IntegrityError HTTP 500:
| Mechanism | Where errors land |
|---|---|
unique=True on a field |
errors[].field matching the field name |
Meta.unique_together |
errors[].field == "non_field_errors" |
Meta.constraints UniqueConstraint (unconditional, single-field) |
errors[].field matching the field name |
Meta.constraints UniqueConstraint (unconditional, multi-field) |
errors[].field == "non_field_errors" |
Conditional and expression-based constraints (condition=Q(...) or
expressions=[...]) are not pre-checked: replicating their predicate
server-side is not reliable, so they remain DB-enforced. If a conditional
constraint is violated the database will raise an IntegrityError, which the
backend does not currently catch.
Nested writes¶
Meta.nested_fields accepts a Django model for each native child (validated
with Pydantic). Forward FK, reverse FK and M2M children all work, atomically:
class CategoryType(DjangoModelType):
class Meta:
model = Category
nested_fields = {"products": Product} # native reverse-FK children
createCategory(newCategory: {
name: "Books",
products: [{ sku: "A1", name: "Widget", price: "9.99" }] # created + linked
}) { ok errors { field messages } }
Custom validation: inline validate_<field>()¶
The quickest way to add custom rules — declare them as methods right on the class (the same ergonomics DRF serializers offered, without any serializer):
class UserType(DjangoModelType):
class Meta:
model = User
# per-field — runs only when `username` is provided
def validate_username(self, value):
if " " in value:
raise ValueError("username must not contain spaces")
return value # return the (optionally transformed) value
# object-level cross-field — `data` holds the fields the client set
def validate(self, data):
if data.get("password") == data.get("username"):
raise ValueError("password must differ from username")
return data
validate_<field>(self, value)rejects withValueError/AssertionError, and may transform the value by returning a new one. It runs only when that field is in the input (matching DRF / partial-update semantics).validate(self, data)is the cross-field hook; its errors land onnon_field_errors.selfis the type/mutation class (no DRFself.context/self.instance).- A
validate_<x>that matches no model field emits aUserWarningat startup. - Only hooks you declare — on the class or on your own base classes — are
collected. The framework bases a host inherits are not scanned, so
pydantic.BaseModel's deprecatedvalidateclassmethod is never mistaken for your object-level hook.
Under the hood these compile to Pydantic field_validator / model_validator, so
they also work on DjangoModelMutation and compose with Meta.pydantic_model.
Custom validation: Meta.pydantic_model¶
For reusable rule sets (or when you prefer an explicit schema), provide a Pydantic model with validators; the derived fields extend it:
from pydantic import BaseModel, field_validator
class UserRules(BaseModel):
@field_validator("username", check_fields=False) # field comes from the model
@classmethod
def no_spaces(cls, value):
if value and " " in value:
raise ValueError("username must not contain spaces")
return value
class UserType(DjangoModelType):
class Meta:
model = User
pydantic_model = UserRules
check_fields=False
Validators in Meta.pydantic_model reference fields that are added by the
derived schema, so decorate them with check_fields=False (Pydantic
otherwise rejects the validator for a "missing" field).
Current limits of the native backend¶
Exotic field types fall back to a permissive scalar¶
HStoreField, GIS geometry fields, and GenericForeignKey are not natively
modeled by the Pydantic schema the backend derives. They are accepted as-is
(permissive scalar) without type or length validation.
ArrayField and range fields ARE natively modeled in v2.0 output:
ArrayField(CharField()) renders as [String] (nested arrays as [[…]], a
choices base as [<Enum>]), and a *RangeField renders as a { lower, upper }
composite typed by its bound scalar (e.g. IntegerRangeField → { lower: Int,
upper: Int }). See the field-type conversion reference in
Types.
In practice: the fields above pass through without constraint checks. If you
need validation on these, use a validate_<field>() method.
FileField and ImageField are not in that list: the derived schema types
them with a dedicated marker that accepts exactly two shapes — an uploaded file
object (what Django's multipart parser puts in request.FILES) and a storage
path string — and rejects anything else with a structured error. The column's
max_length still constrains the string branch. See
Automatic multipart uploads.
Conditional and expression-based UniqueConstraint entries are DB-enforced only¶
Meta.constraints entries with a condition=Q(...) or expressions=[...]
argument are not pre-checked by the backend. Replicating these predicates
server-side is unreliable (they may reference database functions or
non-deterministic expressions), so the backend skips them deliberately.
What breaks: if a conditional unique constraint is violated, the database
raises an IntegrityError that is not caught by the backend. This propagates
as an HTTP 500 instead of a structured { ok: false, errors: [...] } response.
Workaround: add a validate_<field>() or validate() method that queries
for the conflict and raises ValueError before the backend reaches the DB
write.
Unconditional single-field UniqueConstraint entries (no condition, no
expressions) are fully pre-checked and surfaced as a structured ErrorType.
See Uniqueness validation above.
unique_together reports to non_field_errors¶
Multi-field Meta.unique_together violations (and multi-field unconditional
UniqueConstraint entries) are reported under errors[].field == "non_field_errors",
not on the individual field names. This matches Django's own ValidationError
behavior for multi-field uniqueness.
Example mutation response: