skip to content

In Django Ninja, when a posted JSON body fails the operation's Schema, where does the 422 come from and what does it contain?

level: juniorimportance: must knowfreq 55%

answer

  1. checked before the view runs
  2. Ninja's own exception, not Pydantic's
  3. default handler registered by NinjaAPI
  4. detail list with type, loc, msg

basics

~10 s

Django Ninja validates every declared parameter before calling the view, collects the failures into ninja.errors.ValidationError, and NinjaAPI's default handler for that exception returns 422 with a detail list of type, loc and msg entries.

solid answer

~40 s

Before the view function runs, Django Ninja builds a Pydantic model per parameter source (`path`, `query`, `header`, `cookie`, `body`, `form`, `file`) from the type hints and validates the request against each. It collects every `pydantic.ValidationError` - a bad query parameter and two bad body fields end up in one list - and `NinjaAPI.validation_error_from_error_contexts()` turns them into `ninja.errors.ValidationError`, which is Ninja's own class, not Pydantic's. The view is never called. Every `NinjaAPI` registers a default handler for that exception that answers `422` with `{"detail": [...]}`, each entry carrying `type`, `msg` and a `loc` that starts with the source and parameter name, like `["body", "payload", "check_out"]`. A body that is not parseable JSON never reaches validation: it gets a `400` "Cannot parse request body".

code

python · 18 lines
python
from datetime import date

from ninja import Field, NinjaAPI, Schema

api = NinjaAPI()


class BookingIn(Schema):
    room_id: int
    check_in: date
    check_out: date
    guests: int = Field(..., ge=1)


@api.post("/bookings")
def create_booking(request, payload: BookingIn):
    # Reached only when every declared parameter validated.
    return {"room_id": payload.room_id, "nights": (payload.check_out - payload.check_in).days}

go deeper

for a junior

Recall that invalid input never reaches your view: Ninja answers 422 with a detail list, and each entry's loc names the source and field.

for a middle

Explain the sequence: per-source parameter models, collected Pydantic errors, conversion to ninja.errors.ValidationError, and the default handler NinjaAPI registers for it.

for a senior

Separate the statuses precisely: 400 for unparseable bodies, 422 for schema failures, 500 for output mismatches, and know where to hook in to reshape each.

for a principal

Treat the 422 body as a public contract: decide whether Ninja's Pydantic-derived entries are stable enough for clients or should be mapped to a documented error format.

## What happens before the view runs In **Django Ninja**, an operation such as `@api.post("/bookings")` declares its inputs as type hints on the view function. When the operation is registered, Ninja groups those parameters by **source** - `path`, `query`, `header`, `cookie`, `body`, `form` and `file` - and builds one internal Pydantic model per source. On each request, `Operation.run()` works in this order: 1. Runs the operation's auth and throttle checks. 2. Resolves every parameter model against the request: parses the body, reads the query string, converts path segments. 3. Collects each `pydantic.ValidationError` raised along the way instead of stopping at the first one. 4. If anything failed, calls `NinjaAPI.validation_error_from_error_contexts()` to turn the collected errors into one `ninja.errors.ValidationError`, and raises it. 5. Only when nothing failed, calls your view function with the validated values. So the view body never sees a payload that broke its declared types; there is no need to re-check `payload.guests` for being an integer inside the view. ## Where the 422 is produced `ninja.errors.ValidationError` is **Ninja's own exception class**, not `pydantic.ValidationError` and not Django's `django.core.exceptions.ValidationError`. Its `errors` attribute is a list of dictionaries. The `except` block around the operation hands it to `NinjaAPI.on_exception()`, which looks up a registered handler. Every `NinjaAPI` instance registers four **default handlers** when it is created: for `Exception`, `django.http.Http404`, `ninja.errors.HttpError` and `ninja.errors.ValidationError`. The last one renders the list under a `detail` key with status **422**: ```json {"detail": [ {"type": "missing", "loc": ["body", "payload", "check_out"], "msg": "Field required"} ]} ``` Each entry is a Pydantic v2 error dictionary that Ninja post-processes: | key | what it holds | |---|---| | `type` | Pydantic's machine-readable error type, such as `missing` | | `loc` | the path to the bad value: **source first**, then the parameter name, then the field path | | `msg` | the human-readable message | | `ctx` | extra context for some errors, such as a range bound; exception objects are converted to strings | Ninja **deletes the `input` key** (the offending value, which could be a password) and asks Pydantic for errors without its documentation URL. The source prefix is what lets a client tell `["query", "hold_minutes"]` from `["body", "payload", "guests"]`. ## Not every bad request is a 422 | situation | status | raised as | |---|---|---| | body is not parseable JSON | 400, `"Cannot parse request body"` | `HttpError(400, ...)` while reading the body | | JSON parses but misses or breaks schema fields | 422 | `ninja.errors.ValidationError` | | query value `abc` for an `int` parameter | 422, `loc` starts with `query` | `ninja.errors.ValidationError` | | `get_object_or_404()` inside the view | 404, `"Not Found"` | `django.http.Http404` | | the view's return value breaks the `response=` schema | 500 | `pydantic.ValidationError` from the output step | With `DEBUG` on, the 400 message also carries the parser's own error text. The last row matters: output validation is a server fault, so it does not borrow the 422 path. ## Changing the response The 422 is ordinary exception handling, so it can be replaced: - register your own handler with `@api.exception_handler(ValidationError)` (importing `ValidationError` from `ninja.errors`) and return any `HttpResponse`; - or subclass `NinjaAPI` and override `validation_error_from_error_contexts()` to build a different list of error entries while keeping the default handler. Which shape an API *should* return - all errors or the first, arrays or JSON Pointers - is an API-design decision; Ninja only decides where the hook is. ## Seeing it in a test Ninja ships `ninja.testing.TestClient`, which calls an API's or router's operations directly from a test. A test for the booking operation can post `{"room_id": 7, "check_in": "2026-10-01", "guests": 0}` and assert three things: - the status is 422; - `response.json()["detail"]` contains an entry whose `loc` is `["body", "payload", "check_out"]` and whose `type` is `missing`; - no `Booking` row was created, which proves the view never ran. Asserting on `loc` and `type` rather than on `msg` keeps the test stable: the messages are Pydantic's human-readable text and can change between Pydantic releases, while the type codes are meant for machines. The same reasoning applies to API clients - a front end that highlights the bad input should key on `loc`, not parse `msg`. ## Common misreadings - Believing the error escapes from the view: validation happens **before** the call, so a `try`/`except` inside the view never sees it. - Expecting one error per request: Pydantic reports all failing fields of a model, and Ninja concatenates the lists from every parameter source. - Expecting malformed JSON to produce a 422 detail list: it is a 400 with a plain string in `detail`. - Catching `pydantic.ValidationError` in a custom handler to change the 422: the request path raises `ninja.errors.ValidationError`, so that handler never fires for bad input.

  • If the query string and the body are both invalid, how many responses and error lists does the client get?
    One 422 with one list. Ninja validates each parameter source separately, appends every `pydantic.ValidationError` to a list of error contexts, and only after all sources are checked builds a single `ninja.errors.ValidationError`. Entries are told apart by the first element of `loc`, such as `query` versus `body`.
  • What does Django Ninja return for a truncated body like {"guests": 2 on a JSON operation?
    A 400, not a 422. Reading the body fails in the parser, and Ninja raises `HttpError(400, "Cannot parse request body")`; the default `HttpError` handler renders `{"detail": "Cannot parse request body"}`. With `DEBUG` on, the parser's own message is appended in parentheses.
  • How would you change the 422 body without touching the default handler?
    Subclass `NinjaAPI` and override `validation_error_from_error_contexts()`. It receives each context's Pydantic error and the parameter model, including `__ninja_param_source__`, and returns the `ninja.errors.ValidationError` whose `errors` list the default handler renders under `detail`.

saying these in an interview costs you the question

  • The 422 is a pydantic.ValidationError escaping from the view function.
  • Ninja validates the body lazily, the first time the view reads payload.
  • A body that is not valid JSON also gets a 422 with a detail list.
  • Only the first failing field is reported per request.
  • Wrapping the view body in try/except lets you customise the 422.