skip to content

A Django Ninja booking endpoint returns 500 in production, and a plain-text traceback locally, when its return value breaks the response schema - why not 422, and how do you fix it?

level: seniorimportance: should knowfreq 30%

answer

  1. output check, not input check
  2. Pydantic's exception, not Ninja's
  3. generic Exception handler, DEBUG split
  4. fix the data or the schema

basics

~10 s

Response validation raises pydantic.ValidationError, not ninja.errors.ValidationError, so only the generic Exception handler catches it: a traceback under DEBUG, Django's 500 otherwise. It is a server bug; fix the schema or the returned data.

solid answer

~50 s

The 422 path covers only request parameters, which raise `ninja.errors.ValidationError`. Output is validated after the view returns: Ninja wraps the result, calls `model_validate()` on the operation's response schema, and a mismatch raises a plain `pydantic.ValidationError`. No default handler is registered for that class, so the `Exception` handler takes it - a text/plain traceback with 500 when `DEBUG` is on, a re-raise into Django's 500 handling when it is off. That is the correct status: the client sent valid data and the server broke its own contract. Typical causes are a nullable relation or column behind a required field, a resolver returning `None`, or a `Status` code missing from the dict (`ConfigError`). The fix is to make the schema tell the truth, for example `Optional[...] = None`, or fix the data - not to remap the error to 422.

code

python · 17 lines
python
from datetime import date
from typing import Optional

from django.shortcuts import get_object_or_404
from ninja import Schema


class BookingOut(Schema):
    id: int
    check_in: date
    # Booking.cancelled_by is ForeignKey(User, null=True, ...)
    cancelled_by_id: Optional[int] = None  # was: cancelled_by_id: int -> 500 for active bookings


@api.get("/bookings/{booking_id}", response=BookingOut)
def get_booking(request, booking_id: int):
    return get_object_or_404(Booking, id=booking_id)

go deeper

for a junior

Recall that the response schema is checked after the view returns and that a mismatch is a server error, not a 422.

for a middle

Explain which exception each validation step raises and why only the generic Exception handler catches output failures, differently under DEBUG.

for a senior

Diagnose from the traceback to the row shape that breaks the schema, fix the schema or the data, and add a regression test instead of remapping statuses.

for a principal

Make response contracts part of change review, so migrations that relax nullability or relations update schemas and tests in the same change.

## Two validation steps, two exceptions **Django Ninja** validates data twice per request, and the two steps fail in different ways: | step | when | exception | default outcome | |---|---|---|---| | request validation | before the view, per parameter source | `ninja.errors.ValidationError` | 422 `{"detail": [...]}` | | response validation | after the view returns | `pydantic.ValidationError` | 500 | | response code lookup | after the view returns | `ninja.errors.ConfigError` | 500 | For output, Ninja wraps the return value, looks up the schema for the chosen status code and calls `model_validate()` on it. If a required field is missing or has the wrong type, Pydantic raises **its own** `ValidationError`. NinjaAPI registers default handlers only for Ninja's `ValidationError`, `HttpError`, Django's `Http404` and `Exception`, so the Pydantic exception falls through to the generic `Exception` handler. ## Why the symptoms differ by environment The default `Exception` handler branches on `settings.DEBUG`: - **`DEBUG = True`** - it logs the exception and returns the formatted traceback as `text/plain` with status 500, which is why a developer sees a traceback in the browser or the interactive docs; - **`DEBUG = False`** - it re-raises, so Django's own error handling runs: the exception is logged by Django and the project's 500 page is rendered, which may be HTML even though the endpoint is JSON. Neither is a 422, and that is right. A 422 tells the client to fix its request; here the request was valid and the server failed to produce what its contract promised. ## How to diagnose it 1. Reproduce locally with `DEBUG` on, or read the production log entry: the traceback ends in Pydantic's error listing the failing field under `response`, such as `response.cancelled_by_id`. 2. Identify which record triggers it - usually a row with a `NULL` foreign key, an empty optional column, or a value a resolver cannot handle. 3. Check the operation's `response=` mapping if the error is a `ConfigError`: the view returned a `Status` code with no schema. 4. Write a test with `ninja.testing.TestClient` that hits the endpoint with that data and asserts the status and body. ## Reading the error The traceback's last lines name the failing path: ```text pydantic_core._pydantic_core.ValidationError: 1 validation error for NinjaResponseSchema response.cancelled_by_id Input should be a valid integer [type=int_type, input_value=None, input_type=NoneType] ``` `NinjaResponseSchema` is the wrapper Ninja builds around each declared schema, with a single field called `response`; that is why every path starts with `response`. For a list endpoint the path includes the item index, such as `response.17.cancelled_by_id`, which points at the offending row in the page. The exception class printed here is Pydantic's, which is the quickest confirmation that this is an output failure rather than a request one. ## How to fix it - **Make the schema honest**: a field backed by `null=True` or an optional relation becomes `Optional[...] = None` (a `ModelSchema` does this automatically only for the fields it generates, not for ones you annotated by hand). - **Fix the resolver**: return a value of the declared type, or widen the annotation. - **Fix the data path**: if the field must never be empty, the bug is in how rows are written, not in the schema. - **Declare every code** the view can return in `response=`, or add a `...` catch-all key. ## Fixes that make it worse - Registering a handler that maps `pydantic.ValidationError` to 422 - it blames the client for a server bug, and it would also catch Pydantic errors raised by your own code inside views. - Setting `exclude_none=True` on the decorator - exclusion happens when dumping, after validation has already failed. - Returning `HttpResponse` or `JsonResponse` to skip the schema - the endpoint then drifts silently from its documented contract. A JSON 500 is a legitimate improvement: register a handler for `Exception` (or `pydantic.ValidationError`) that logs the error and returns `api.create_response(request, {"detail": "Internal error"}, status=500)`, keeping the status honest. ## Keeping it from recurring - Tests for list endpoints with edge-case rows: cancelled bookings, deleted rooms, empty notes. - Response schemas reviewed alongside migrations that add `null=True` or change a relation. - Alerts on 500 rates per endpoint, since these failures only occur for some rows and hide in averages.

  • Why is mapping pydantic.ValidationError to a 422 in a custom handler a bad idea?
    It tells the client its request was wrong when the server broke its own output contract, so clients retry or change valid requests. It also catches Pydantic errors your own code raises inside views, turning genuine server faults into client errors that no one alerts on.
  • Does returning an instance of the response schema itself avoid output validation?
    Ninja skips re-validation when the returned object is already an instance of the declared schema class and dumps it directly. But constructing that instance in the view validates it, so a bad value fails there instead - still a `pydantic.ValidationError`, still a 500.

saying these in an interview costs you the question

  • Response schema failures are client errors and should return 422.
  • Ninja skips response validation when DEBUG is False.
  • exclude_none=True prevents validation errors for missing values.
  • A plain-text traceback in production means DEBUG handling is correct.
  • Returning a JsonResponse is the proper fix for schema mismatches.