Skip to content
Guide

Errors

This page shows the status and JSON body of each error your API returns, and how to raise and handle your own errors.

Read the error responses

Status When Body
400 The body is not valid JSON {"detail": "Cannot parse request body"}
401 Authentication fails {"detail": "Unauthorized"}
403 A permission check returns False {"error": "forbidden", "details": "..."}
404 The object or a related object does not exist {"article": "not found"}
409 A database constraint fails, like a duplicate unique value {"error": "conflict", "details": "..."}
422 The body, path or query does not match the schema {"detail": [...]}
500 Your code raises an exception nobody handles Django's error response

Validation errors

A body that does not match the create or update schema returns 422 with one entry per problem:

JSON
{
  "detail": [
    {"type": "string_type", "loc": ["body", "data", "title"], "msg": "Input should be a valid string"},
    {"type": "missing", "loc": ["body", "data", "body"], "msg": "Field required"}
  ]
}

A wrong path value, like GET /api/articles/abc, returns 422 with "loc": ["path", "id"].

Validation errors raised inside your own code, for example Article.create() with invalid data in a custom action, return 400 with the details:

JSON
{
  "error": "Validation Error",
  "details": [{"type": "missing", "loc": ["body"], "msg": "Field required"}]
}

Not found

GET, PATCH and DELETE on a missing id return 404. The key is the model name:

JSON
{"article": "not found"}

Creating an article with a category_id that does not exist returns the same shape for the related model: {"category": "not found"}.

Empty updates

By default a PATCH with an empty body is accepted. Set require_update_fields = True on the viewset to reject it:

Python
@api.viewset(model=Article)
class ArticleViewSet(APIViewSet):
    require_update_fields = True

An empty body then returns 400:

JSON
{"error": "No fields provided for update."}

Database errors

A database constraint error, like a duplicate value in a unique field, returns 409:

JSON
{"error": "conflict", "details": "The request violates a database constraint."}

To change this response, see Handle your own exceptions.

Raise an error from your code

Raise these exceptions from custom actions, viewset hooks, permission methods and serializer hooks. The API turns them into a JSON response.

blog/api.py
from ninja_aio import APIViewSet, on
from ninja_aio.exceptions import ForbiddenError, SerializeError

from .auth import JWTAuth
from .models import Article


@api.viewset(model=Article)
class ArticleViewSet(APIViewSet):
    auth = [JWTAuth()]

    @on("publish")
    async def publish(self, request, obj):
        if obj.is_published:
            raise SerializeError({"is_published": "already published"}, 409)
        if not request.auth.is_staff:
            raise ForbiddenError(details="Only staff can publish")
        obj.is_published = True
        await obj.asave(update_fields=["is_published"])
        return {"published": True}

The first error returns 409 with {"is_published": "already published"}. The second returns 403 with {"error": "forbidden", "details": "Only staff can publish"}.

Exception Arguments Default status
SerializeError error, status_code=None, details=None 400
NotFoundError model, details=None 404
ForbiddenError error=None, details=None 403
AuthError error, status_code=None, details=None 401
BaseException error, status_code=None, details=None 400

All of them live in ninja_aio.exceptions. The body depends on error:

  • A string becomes {"error": "..."}.
  • A dict is returned as it is, like {"title": "is taken"}.
  • details, when given, is added under the "details" key.

raise NotFoundError(Article) returns {"article": "not found"} with 404.

A lookup for one object, like Article.get(title="Hello"), that matches more than one article raises MultipleObjectsError: 400 with {"article": "multiple objects match the lookup"}. When a queryset_request join only repeats the same row, you get the object.

You can also raise Django Ninja's HttpError:

Python
from ninja.errors import HttpError

raise HttpError(409, "Title already used")

This returns 409 with {"detail": "Title already used"}.

Create your own error class

Subclass BaseException and set status_code:

blog/errors.py
from ninja_aio.exceptions import BaseException


class PaymentRequired(BaseException):
    status_code = 402
    code = "payment_required"
Python
raise PaymentRequired("Upgrade your plan")

The response is 402 with {"error": "Upgrade your plan"}. Every instance has error (the body), status_code and code.

Set error on the class to give it a default message:

Python
class PaymentRequired(BaseException):
    status_code = 402
    error = "payment required"

raise PaymentRequired() then returns 402 with {"error": "payment required"}.

Change the not found key

The 404 key comes from the model's verbose_name, with spaces replaced by underscores. Set this in settings.py to use the class name in snake case instead:

settings.py
NINJA_AIO_NOT_FOUND_ERROR_USE_VERBOSE_NAMES = False
Model True (default) False
class BlogPost, no verbose_name blog_post blog_post
class Article, verbose_name = "blog post" blog_post article

Document errors in OpenAPI

Every generated CRUD endpoint documents 400, 401, 403 and 404 with the viewset's error_schema. The default is GenericMessageSchema, an object with string values. Set your own schema on the viewset:

blog/api.py
from ninja import Schema


class ErrorSchema(Schema):
    error: str
    details: str | None = None


@api.viewset(model=Article)
class ArticleViewSet(APIViewSet):
    error_schema = ErrorSchema

This changes only the documentation, not the responses. Custom actions document only the responses you pass with response=.

Handle your own exceptions

Register a handler on the API with @api.exception_handler. It works for any exception class, including Django ones:

blog/api.py
from django.db import IntegrityError

from ninja_aio import NinjaAIO

api = NinjaAIO(title="Blog API")


@api.exception_handler(IntegrityError)
def integrity_error(request, exc):
    return api.create_response(request, {"error": "already exists"}, status=409)

A duplicate category name now returns 409 with {"error": "already exists"} instead of the default conflict body.

Unhandled errors

Any other exception returns 500. With DEBUG = False, Django handles it and returns its standard error page. With DEBUG = True, the response is the traceback as plain text.

See also