How to go through all Pydantic validators even if one fails, and then raise multiple ValueErrors in a FastAPI response?

Question:

Is it possible to call all validators to get back a full list of errors?

@validator('password', always=True)
def validate_password1(cls, value):
    password = value.get_secret_value()

    min_length = 8
    if len(password) < min_length:
        raise ValueError('Password must be at least 8 characters long.')

    return value

@validator('password', always=True)
def validate_password2(cls, value):
    password = value.get_secret_value()

    if not any(character.islower() for character in password):
        raise ValueError('Password should contain at least one lowercase character.')

    return value

The current behavior seems to call one validator at a time.

My Pydantic class:

class User(BaseModel):
    email: EmailStr
    password: SecretStr

If I did not include the email, or password, field on a request then I would get both validation failures in an array, which is what I want to do for the password field, but the current behavior seems to call one, and if it fails then throws the error immediately.

Asked By: dataviews

||

Answers:

You cannot use Pydantic’s validators like that; it always looks one of them.

To achieve your answer, you can use following 2 methods

1 – You can use one main validator which checks all conditions

@validator('password', always=True)
def validate_password(cls, value):
    password = value.get_secret_value()

    validate_password1(password)
    validate_password2(password)

    return value
    
def validate_password1(password):

    min_length = 8
    if len(password) < min_length:
        raise ValueError('Password must be at least 8 characters long.')


def validate_password2(password):

    if not any(character.islower() for character in password):
        raise ValueError('Password should contain at least one lowercase character.')


2 – You can use duplicate variable in model to check condition

class User(BaseModel):
    email: EmailStr
    password: SecretStr
    password2: SecretStr

and obviously, your decorators should be:

@validator('password', always=True)
def validate_password1(cls, value):

and

@validator('password2', always=True)
def validate_password2(cls, value):

UPDATE: The OP wants to raise all errors, so the updated answer as follows.

In addition to 1st bullet, you may try something like that:

@validator('password', always=True)
def validate_password(cls, value):
    password = value.get_secret_value()

    try:
        validate_password1(password)
    except Exception as e:
        print('First error: ' + str(e))
        
    try:
        validate_password2(password)
    except Exception as e:
        print('Second error: ' + str(e))

    return value
    
def validate_password1(password):

    min_length = 8
    if len(password) < min_length:
        raise ValueError('Password must be at least 8 characters long.')


def validate_password2(password):

    if not any(character.islower() for character in password):
        raise ValueError('Password should contain at least one lowercase character.')


However, be careful when returning the value. You may try to add one more custom exception in your main code.

Answered By: stuck

You can’t raise multiple Validation errors/exceptions for a specific field, in the way you demonstrate in your question. Suggested solutions are given below.

Option 1

Concatenate error messages using a single variable, and raise the ValueError once at the end (if errors occured):

@validator('password', always=True)
def validate_password1(cls, value):
    password = value.get_secret_value()
    min_length = 8
    errors = ''
    if len(password) < min_length:
        errors += 'Password must be at least 8 characters long. '
    if not any(character.islower() for character in password):
        errors += 'Password should contain at least one lowercase character.'
    if errors:
        raise ValueError(errors)
        
    return value

In the case that all the above conditional statements are met, the output will be:

{
  "detail": [
    {
      "loc": [
        "body",
        "password"
      ],
      "msg": "Password must be at least 8 characters long. Password should contain at least one lowercase character.",
      "type": "value_error"
    }
  ]
}

Option 2

Raise ValidationError directly, using a list of ErrorWrapper class.

from pydantic import ValidationError
from pydantic.error_wrappers import ErrorWrapper

@validator('password', always=True)
def validate_password1(cls, value):
    password = value.get_secret_value()
    min_length = 8
    errors = []
    if len(password) < min_length:
        errors.append(ErrorWrapper(ValueError('Password must be at least 8 characters long.'), loc=None))
    if not any(character.islower() for character in password):
        errors.append(ErrorWrapper(ValueError('Password should contain at least one lowercase character.'), loc=None))
    if errors:
        raise ValidationError(errors, model=User)
        
    return value

Since FastAPI seems to be adding the loc attribute itself, loc would end up having the field name (i.e., password) twice, if it was added in the ErrorWrapper, using the loc attribute (which is a required parameter). Hence, you could leave it empty (using None), which you can later remove through a validation exception handler, as shown below:

from fastapi import Request, status
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    for error in exc.errors(): 
        error['loc'] = [x for x in error['loc'] if x]  # remove null attributes
        
    return JSONResponse(content=jsonable_encoder({"detail": exc.errors()}), status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)

In the case that all the above conditional statements are met, the output will be:

{
  "detail": [
    {
      "loc": [
        "body",
        "password"
      ],
      "msg": "Password must be at least 8 characters long.",
      "type": "value_error"
    },
    {
      "loc": [
        "body",
        "password"
      ],
      "msg": "Password should contain at least one lowercase character.",
      "type": "value_error"
    }
  ]
}
Answered By: Chris