Custom Validation Errors in FastAPI with Pydantic

Abenezer Belachew

Abenezer Belachew Β· January 01, 2024

5 min read

When working with FastAPI and Pydantic, you may occasionally find that the standard validation error messages don't quite align with your requirements. To better illustrate this point, let's consider a straightforward example involving a route designed for updating a post:

main.py
@app.put("/{post_id}", response_model=schemas.PostResponse)
def update_post(
    post_id: int,
    post: schemas.PostUpdate,
    db: Session = Depends(get_db),
    current_user: schemas.User = Depends(oauth2.get_current_user),
):
    db_post = get_post_or_404(db, post_id)
    # Check if the user is the owner of the post
    if is_post_owner(db_post, current_user):
				# code for updating
  • In this setup, the route receives a post_id as a URL parameter and uses it to retrieve the corresponding post from the database.
  • The update process is initiated only if the post exists and the logged-in user is its author. However, the focus here extends beyond just the post_id.
  • The route also anticipates receiving a post object. This object conforms to a PostUpdate schema, which we've defined separately in another file. You can disregard the details concerning db and current_user for this discussion. They're not crucial to the aspect we're focusing on.
  • Say the PostUpdate schema looks something like this:
schemas.py
class PostUpdate(BaseModel):
    title: str
    content: str
    published: bool = False
    rating: Optional[int] = None
  • The core expectation here is to receive at least a title and content. For the fields published and rating, the system is designed to use default values if they are not explicitly provided, ensuring flexibility and robustness in data handling.
  • So, what if we hit the endpoint and send a title that isn’t a string? What would happen?
curl -i --request PUT \
  --url http://localhost:8000/posts/38 \
  --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiaGkgdGhlcmUsIGN1cmlvdXMgZnJpZW5kIiwiZXhwIjoxNzA1ODUyMDM3fQ.qpNy5poAYReVIcacP8GxRKW8DoMlVqs6ljoIq9QNYOw' \
  --header 'Content-Type: application/json' \
  --data '{
        "title": 25,
        "content": "this is the updated content"
}'

HTTP/1.1 422 Unprocessable Entity
date: Mon, 01 Jan 2024 08:42:27 GMT
server: uvicorn
content-length: 162
content-type: application/json

{
  "detail": [
    {
      "type": "string_type",
      "loc": [
        "body",
        "title"
      ],
      "msg": "Input should be a valid string",
      "input": 25,
      "url": "https://errors.pydantic.dev/2.5/v/string_type"
    }
  ]
}
  • As you can see, we got a response detail that looks something like this:
{
  "detail": [
    {
      "type": "string_type",
      "loc": [
        "body",
        "title"
      ],
      "msg": "Input should be a valid string",
      "input": 25,
      "url": "https://errors.pydantic.dev/2.5/v/string_type"
    }
  ]
}
  • It's good and informative, but that's not quite what we want. Let's add a little razzle-dazzle to it. Let's transform the validation error to include more details and format it the way we want.
{
	"status": "validation_error",
	"detail": "Input validation failed πŸ˜•",
	"errors": [
		{
			"field": "body -> title",
			"message": "Input should be a valid string 🚨",
			"error_type": "string_type",
			"suggestion": "Check this field for errors. πŸ› οΈ"
		}
	],
	"tip": "Please review the errors and try again! πŸ‘€",
	"custom_docs": "http://localhost:8000/docs/posts/38",
	"custom_support": "555-I-WONT-PICK-UP"
}
  • To accomplish this, we can customize the RequestValidationError from FastAPI. This modification can be implemented in the section of your code where your app is defined. Here's how you can do it:
main.py
from fastapi.exceptions import RequestValidationError
from fastapi.requests import Request
from fastapi.responses import JSONResponse

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    errors = exc.errors()
    formatted_errors = []

    def generate_helpful_message(error):
        # Custom logic to generate a helpful message based on error type/location
        if error["type"] == "type_error.integer":
            return "Ensure this value is an integer. πŸ”’"
        elif error["type"] == "value_error.missing":
            return "This field is required and cannot be missing. ❗"
        # Add more cases as needed
        return "Check this field for errors. πŸ› οΈ"

    for error in errors:
        field = " -> ".join(map(str, error["loc"]))
        message = error["msg"]
        helpful_message = generate_helpful_message(error)

        pretty_message = {
            "field": field,
            "message": f"{message} 🚨",
            "error_type": error["type"],
            "suggestion": helpful_message,
        }
        formatted_errors.append(pretty_message)

    # Generate a link to the docs for this endpoint
    docs_url = (
        f"{request.base_url.scheme}://{request.base_url.netloc}/docs{request.url.path}"
    )

    return JSONResponse(
        status_code=422,
        content={
            "status": "validation_error",
            "detail": "Input validation failed πŸ˜•",
            "errors": formatted_errors,
            "tip": "Please review the errors and try again! πŸ‘€",
            "custom_docs": f"{docs_url}",  # Link to your API documentation
            "custom_support": "555-I-WONT-PICK-UP",  # Link to your support page
        },
    )

Let’s try it now.

curl -i --request PUT \
  --url http://localhost:8000/posts/38  \         
  --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjo0LCJleHAiOjE3MDU4NTIwMzd9.TI6j0t8wBOrAz_00NDI-FdtSjtMqtlv3w-Gl2ZUwz9c' \
  --header 'Content-Type: application/json' \
  --header 'User-Agent: insomnia/8.4.5' \
  --data '{
        "title": 25,
        "content": "this is the content"
}'         

HTTP/1.1 422 Unprocessable Entity
date: Mon, 01 Jan 2024 09:20:40 GMT
server: uvicorn
content-length: 376
content-type: application/json

{
  "status": "validation_error",
  "detail": "Input validation failed πŸ˜•",
  "errors": [
    {
      "field": "body -> title",
      "message": "Input should be a valid string 🚨",
      "error_type": "string_type",
      "suggestion": "Check this field for errors. πŸ› οΈ"
    }
  ],
  "tip": "Please review the errors and try again! πŸ‘€",
  "custom_docs": "http://localhost:8000/docs/posts/38",
  "custom_support": "555-I-WONT-PICK-UP"
}
  • Notice how the new validation error is much more informative? While using emojis in responses might not be conventional or recommended due to compatibility and parsing issues, they certainly add a visually appealing element. Plus, if you have custom documentation, you can link to it directly, offering users a way to troubleshoot issues. And as a last resort, you can even add a custom support line in the response for additional help.
πŸŽ‰