Pydantic has become very popular, leveraging Python type hints to validate data. It allows for complex data schemas with minimal boilerplate, automatically converting incoming data to the defined types and validating it against the specified constraints. However, if you are unfamiliar with how it works, staring at its code or even reading its document can intimidate you greatly.

In my experience, learning from examples is the best way to learn Pydantic. This article will review a few ways to ask Pydantic to validate an integer and introduce some Pydantic concepts.

What’s inside

1. Basic Integer Validation

Here’s a simple Pydantic model that validates an integer field. To use Pydantic, you must import its BaseModel and inherit your class.

from pydantic import BaseModel, ValidationError

class User(BaseModel):
    age: int

def test_validation():
    try:
        user = User(age='25')
        print(user)
    except ValidationError as e:
        print(e)

It’s that simple. When you construct your class, Pydantic will verify that it’s an integer and try to convert the input parameter to an integer. From the above example, if age is assigned a string that can be converted to an integer, Pydantic will convert it for you and won’t raise any errors. Otherwise, Pydantic will raise a proper error, and your program needs to handle it.

2. Use decorator @field_validator to Validate Integer

Pydantic allows for more complex validations, such as ensuring an integer falls within a specific range or meets custom conditions. One way to do this is to use @field_validator decorator. Do not use the old Pydantic V1 style decorator @validator anymore. As of March 2024, when this blog was written, Pydantic’s version was 2.6. The V1 style decorator will retire when Pydantic 3.0 is released.

from pydantic import BaseModel, field_validator, ValidationError

class User(BaseModel):
    age: int

    @field_validator('age', mode='after') 
    def check_age(cls, v): 
        if v < 18 or v > 130: 
            raise ValueError('Age must be between 18 and 130') 
        return v 

def test_validation():
    try: 
        user = User(age=17) 
    except ValidationError as e: 
        print(e)

This model ensures that ‘age‘ is not only an integer but also falls within a realistic range. There are a few things to pay attention to as a developer.

  1. @field_validator can work on different modes, including before, after, wrap, plain, or a combination of the four mentioned modes. ‘After’ means before the internal validation of pydantic, such as verify the passed argument is indeed an integer. In the above example, validating after the internal validation makes sense so the check_age() can safely compare v with a number.
  2. At the end of the @field_validator function, the value itself needs to be returned unless you want to cast some black magic, like returning 2*v. Please refrain from doing that since it’s a source of confusion.

3. Combine with typing.Annotated to Validate Integer

Typing is a standard Python library introduced since Python 3.5, designed to support type hints and improve code readability and maintainability. One of the powerful features added in later versions of Python (3.9 and onwards) is typing.Annotated. This feature allows you to add metadata to your type hints, which can be particularly useful for validation purposes. Please note the metadata can be anything, such as a string, a function, a list or a dictionary, etc. It’s up to the third-party library, such as Pydantic, who can understand and interpret the metadata.

Let’s explore how you can use typing.Annotated to validate integers in your Python programs.

3.1 Understanding typing.Annotated

Typing.Annotated allows you to attach additional information to your type hints. For example, you can specify that an integer should not only be an int but also adhere to certain conditions like being positive or within a specific range.

3.2 Defining a Validation Function

To validate integers using typing.Annotated, you first need a function that performs the validation based on the criteria you want to enforce. Here’s a simple example that checks if an integer is positive:

def is_positive(n: int) -> bool: 
    return n > 0

3.3 Using typing.Annotated for Validation

Now, you can use typing.Annotated to specify that an integer should be positive. While typing.Annotated itself doesn’t enforce the validation, it can be used with pydantic that understand and act upon the annotations.

Here’s an example of how to annotate a variable to indicate it should be a positive integer:

from typing import Annotated  # for python version>= 3.9
from typing_extensions import Annotated # for python < 3.9, need 'pip install typing_extensions'
 
PositiveInt = Annotated[int, is_positive]

In this code, PositiveInt is an integer that should (but not one enforce it) satisfy the is_positive condition. It’s still a type hint at this point.

3.4 Applying It with Pydantic

While the standard library’s typing module allows us to specify these annotations, we need a way to enforce them. This is where Pydantic comes in handy. Pydantic can use the information from typing.Annotated to perform actual data validation.

Here’s how you can define a Pydantic model that uses our PositiveInt:

from pydantic import BaseModel, ValidationError 
class MyModel(BaseModel): 
    positive_number: PositiveInt 

def test_validation():
    try: 
        model = MyModel(positive_number=5) # This should pass 
        print(model) 
    except ValidationError as e: 
        print(e) 

    try: model = MyModel(positive_number=-5) # This should raise an error 
        print(model) 
    except ValidationError as e: 
    print(e)

In this example, Pydantic uses the PositiveInt annotation to validate that positive_number is indeed a positive integer. If the validation fails, Pydantic raises a ValidationError.

3.5 Validator can be Marked Specific Types

Similar to the field_validator() we discussed in Section 2, the validation function specified in the type annotation’s metadata, such as is_positive(), can be marked with different modes as well, including before‘, ‘after‘, ‘wrap‘, or ‘plain. However, since the type validation function is not just a field of the Pydantic model, we’ll use another set of Pydantic metadata classes, BeforeValidator, AfterValidator, WrapValidator, and PlainValidator. Those classes can be used directly as decorators or class wrappers, as two code snippets are shown below.

@AfterValidator
def is_positive(n: int) -> bool: 
    return n > 0
import typing import annotated

PositiveInt = Annotated[int, AfterValidator(is_positive)]

3.6 Always name Annotated type a meaningful name

If you decide to use type annotation instead of simple field validation, it indicates there are enough reasons that you would like to abstract a separate type. So, I recommend that you always name the annotation properly and avoid embedding the type annotation within the Pydantic model definition. Below is an example of bad practice.

@BeforeValidator
def to_int_or_zero(s):
    try:
        return int(s)
    except (ValueError, TypeError):
        return 0

class User(BaseModel):
    age: Annotated[int, to_int_or_zero]
    income: Annotated[int, to_int_or_zero] = 0

A better Pythonic way is as follows.

@BeforeValidator
def to_int_or_zero(s):
    try:
        return int(s)
    except (ValueError, TypeError):
        return 0

ValidIntOrZero = Annotated[str, to_int_or_zero]

class User(BaseModel):
    age: ValidIntOrZero
    income: ValidIntOrZero = 0

In the above code, the type annotation is appropriately named, and readers of your code can quickly understand your reasoning logic. In the example, the income is mandatory, and debt is optional. We’ll continue to discuss some of the complexity introduced by default values.

4. Complexity Introduced by Default Values

You might think that default values are so simple, and you won’t get any confusion. But when it’s combined with Pydantic model, it’s not that straightforward.

4.1 Default Values are not Validated

Not validating default values is the default Pydantic behavior since it’s so unusual that a programmer gives a default value that does not follow the type hint. The following code runs just fine.

from pydantic import BaseModel

class User(BaseModel):
    age: int = 'I_am_not_an_int'

def test_validation()
    my_user = User()
    print(my_user.age)

If you do not trust the default value you assigned by yourself for some reason, you have an option to force Pydantic to validate a default value.

from pydantic import BaseModel

class User(BaseModel):
    age: Annotated[int, Field(validate_default=True)] = 'I_am_not_an_int'

The above code snippet will validate the default value and throw a validation error.

4.2 Understand None

A lot of times, you want to set the default value to None. If you set the default value within Pydantic models, it’s just fine, and you do not need to mark the attribute as Optional.

from pydantic import BaseModel

class User(BaseModel):
    age: int = None

def test_validation():
    my_user = User()

The above code snippet will successfully initialize my_user with age as 'None', and raise an error if you try to pass 'age' something that cannot be converted to an integer.

4.3 When to use Optional

What if you want to validate the input and set it to a default None when it fails and does not throw an error? Then Optional comes into the picture.

from typing import Optional, Annotated
from pydantic import BaseModel, BeforeValidator

@BeforeValidator
def to_int_or_none(s):
    try:
        return int(s)
    except (ValueError, TypeError):
        return None

ValidIntOrNone = Annotated[int, to_int_or_none]

class User(BaseModel):
    age: Optional[ValidIntOrNone]

def test_validation():
    my_user = User(age='not_an_int')
    print(my_user.age)

In the above code snippet, 'not_an_int' is converted to None by to_int_or_none(). Since the value is passed through, it always gets validated, not like the default value you set within Pydantic model definition. Then Pydantic internal validation will check whether it is an integer. But since the input is already converted a None, it’ll fail if age is not marked as Optional.

Optional is not black magic; it simply means an attribute could be the type you intend or None. Optional[X] is equivalent to Union[X, None].

5. Conclusion

We used integer validation as an example to show some fundamental Pydantic concepts. These can easily be extended to other types and use cases. We have prepared some other practical Python tutorials, and you are welcome to check them out. Please comment below if you have any questions about this article or the topics we have discussed.

Leave a Reply

Your email address will not be published. Required fields are marked *