Pydantic v2: Input Data Validation in Python

1. Overview

Data from HTTP requests, JSON files, or message queues often have wrong types, missing fields, or nonsensical values. If you check with scattered if/else statements, your code will quickly become messy. Pydantic v2 lets you describe the “shape” of data using type hints, then automatically parse, coerce types, and report detailed errors.

This article covers the main tools of Pydantic v2: BaseModel, Field, field_validator, model_validator, ConfigDict, TypeAdapter. The final part covers how to write tests with pytest. After reading, you’ll be able to build safe input validation models for your application.

2. Installation

This article uses Python 3.11+ and Pydantic 2.x:

pip install "pydantic>=2.7" pytest

If you need the EmailStr type, install the optional extras:

pip install "pydantic[email]"

3. Basic Models with BaseModel

3.1. Declaration and Type Coercion

A model is a class that inherits from BaseModel. Each attribute with a type hint is a field:

from pydantic import BaseModel

class Customer(BaseModel):
    id: int
    name: str
    email: str
    is_active: bool = True

customer = Customer(id="42", name="An", email="an@example.com")
print(customer)
print(customer.id, type(customer.id))
id=42 name='An' email='an@example.com' is_active=True
42 <class 'int'>

You pass id="42" as a string, but Pydantic automatically converts it to int. This is lax mode, the default behavior: Pydantic coerces types if the conversion is reasonable. The is_active field has a default value, so it’s not required.

3.2. Handling ValidationError

When data is invalid, Pydantic raises ValidationError and collects all errors at once, not just the first one:

from pydantic import ValidationError

try:
    Customer(id="abc", name="An")
except ValidationError as e:
    print(e.error_count())
    for err in e.errors():
        print(err["loc"], err["type"], err["msg"])
2
('id',) int_parsing Input should be a valid integer, unable to parse string as an integer
('email',) missing Field required

Each error is a dict with loc (field location), type (stable error code), and msg (human-readable message). When returning errors to the client, base your logic on loc and type. The msg content may change between versions.

4. Constraining Values with Field and Annotated

Type hints only check the type. To constrain values, you use Field:

from decimal import Decimal
from typing import Annotated
from pydantic import BaseModel, Field

Quantity = Annotated[int, Field(gt=0, le=100)]

class OrderItem(BaseModel):
    sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$")
    quantity: Quantity
    unit_price: Decimal = Field(gt=0, max_digits=10, decimal_places=2)

item = OrderItem(sku="ABC-1234", quantity=3, unit_price="19.90")
print(item)
sku='ABC-1234' quantity=3 unit_price=Decimal('19.90')

Explanation:

  • pattern enforces sku to match a regex, e.g. ABC-1234.
  • Quantity is a reusable type thanks to Annotated. Every field declared as Quantity must be in the range (0, 100].
  • unit_price uses Decimal to avoid floating-point errors. The value must be positive with at most 2 decimal places.

Try passing invalid data:

try:
    OrderItem(sku="abc", quantity=0, unit_price="10")
except ValidationError as e:
    for err in e.errors():
        print(err["loc"], err["type"])
('sku',) string_pattern_mismatch
('quantity',) greater_than

Other common constraints: min_length/max_length for strings and lists, ge/lt for numbers, multiple_of for multiples.

5. Custom Validators with field_validator

When constraints are more complex than regex or ranges, you write custom validators:

from pydantic import BaseModel, Field, field_validator

class Contact(BaseModel):
    name: str
    phone: str = Field(pattern=r"^0\d{9}$")

    @field_validator("name")
    @classmethod
    def clean_name(cls, v: str) -> str:
        v = v.strip()
        if not v:
            raise ValueError("name cannot be empty")
        return v.title()

    @field_validator("phone", mode="before")
    @classmethod
    def normalize_phone(cls, v):
        if isinstance(v, str):
            v = v.replace(" ", "").replace(".", "")
            if v.startswith("+84"):
                v = "0" + v[3:]
        return v

contact = Contact(name="  nguyễn văn an ", phone="+84 912 345 678")
print(contact)
name='Nguyễn Văn An' phone='0912345678'

There are two modes to distinguish:

  • mode="after" (default), like clean_name: runs after Pydantic has type-checked, so v is guaranteed to be a str.
  • mode="before", like normalize_phone: runs before type checking and pattern validation. v can be anything, so you must check isinstance. Here we normalize +84 912 345 678 to 0912345678, then pattern is applied.

Validators must return a value. The returned value is what gets stored in the field. To report an error, raise ValueError or AssertionError. Pydantic wraps it as a value_error with the message "Value error, name cannot be empty".

Note: The decorator order is crucial. @field_validator must be above @classmethod. If reversed, you’ll get a confusing error when defining the class.

6. Validating Multiple Fields with model_validator

Some rules involve multiple fields, for example checkout date must be after check-in date. This requires model_validator:

from datetime import date
from typing import Self
from pydantic import BaseModel, ValidationError, model_validator

class Booking(BaseModel):
    room_id: int
    check_in: date
    check_out: date

    @model_validator(mode="after")
    def check_dates(self) -> Self:
        if self.check_out <= self.check_in:
            raise ValueError("check_out must be after check_in")
        return self

try:
    Booking(room_id=101, check_in="2026-10-20", check_out="2026-10-18")
except ValidationError as e:
    err = e.errors()[0]
    print(err["loc"], err["msg"])
() Value error, check_out must be after check_in

With mode="after", the validator receives an instance that’s already valid for each field. You access self.check_in like a normal attribute and must return self. The error has an empty loc tuple () because it concerns the whole model, not a specific field. The string "2026-10-20" is also automatically parsed to a date.

7. Configuring Models with ConfigDict

model_config controls the model’s overall behavior. This configuration is useful for sensitive data like payments:

from pydantic import BaseModel, ConfigDict, ValidationError

class PaymentRequest(BaseModel):
    model_config = ConfigDict(
        extra="forbid",
        strict=True,
        str_strip_whitespace=True,
        frozen=True,
    )

    order_id: int
    amount: int
    currency: str

try:
    PaymentRequest(order_id="1", amount=50000, currency="VND", coupon="FREE")
except ValidationError as e:
    for err in e.errors():
        print(err["loc"], err["type"])
('order_id',) int_type
('coupon',) extra_forbidden

Meaning of each option:

  • extra="forbid": reject unknown fields like coupon. Default is "ignore", meaning Pydantic silently ignores extra fields. This can hide typos on the client side.
  • strict=True: disable type coercion. "1" is no longer accepted for int.
  • str_strip_whitespace=True: automatically strip whitespace from strings.
  • frozen=True: make instances immutable. Assigning payment.amount = 0 will raise an error.

If you want strict mode only for certain fields, use Field(strict=True) or types like StrictInt, StrictStr.

Another useful option is validate_assignment=True. By default, Pydantic doesn’t validate when you reassign attributes after object creation. Enable this option to keep the model always valid.

8. Validating from Dicts, JSON, and Custom Types

In practice, data often comes as a dict or JSON string. Pydantic v2 provides two class methods:

raw_json = '{"id": 7, "name": "Bình", "email": "binh@example.com"}'
customer = Customer.model_validate_json(raw_json)

raw_dict = {"id": 8, "name": "Chi", "email": "chi@example.com", "is_active": False}
customer2 = Customer.model_validate(raw_dict)

print(customer.model_dump())
print(customer2.model_dump_json())
{'id': 7, 'name': 'Bình', 'email': 'binh@example.com', 'is_active': True}
{"id":8,"name":"Chi","email":"chi@example.com","is_active":false}

If the input is a JSON string, use model_validate_json instead of json.loads followed by model_validate. This is faster because Pydantic’s Rust core parses and validates in a single step. In the opposite direction, model_dump() returns a dict, while model_dump_json() returns a JSON string.

When you need to validate a non-model type, like list[OrderItem], use TypeAdapter:

from pydantic import TypeAdapter

items_adapter = TypeAdapter(list[OrderItem])

items = items_adapter.validate_python([
    {"sku": "ABC-0001", "quantity": 2, "unit_price": "5.00"},
    {"sku": "XYZ-0002", "quantity": 1, "unit_price": "12.50"},
])
print(len(items), items[1].unit_price)
2 12.50

You should create a TypeAdapter once at the module level and reuse it. Initialization has the cost of building a schema, so don’t repeat it for every request.

9. Writing Tests with pytest

Validation is business logic and should be tested. Suppose the models above are in a file called models.py:

from decimal import Decimal

import pytest
from pydantic import ValidationError

from models import Booking, Contact, OrderItem


def test_order_item_coerces_valid_input():
    item = OrderItem(sku="ABC-1234", quantity="3", unit_price="19.90")

    assert item.quantity == 3
    assert item.unit_price == Decimal("19.90")


@pytest.mark.parametrize("quantity", [0, -1, 101])
def test_order_item_rejects_out_of_range_quantity(quantity):
    with pytest.raises(ValidationError) as exc_info:
        OrderItem(sku="ABC-1234", quantity=quantity, unit_price="10")

    error = exc_info.value.errors()[0]
    assert error["loc"] == ("quantity",)
    assert error["type"] in {"greater_than", "less_than_equal"}


def test_contact_normalizes_phone():
    contact = Contact(name="an", phone="+84 912.345.678")

    assert contact.phone == "0912345678"
    assert contact.name == "An"


def test_booking_rejects_checkout_before_checkin():
    with pytest.raises(ValidationError, match="check_out must be after check_in"):
        Booking(room_id=1, check_in="2026-10-20", check_out="2026-10-20")

Run tests:

pytest -q
.....                                                    [100%]
5 passed

Note that tests assert on loc and type, not the literal msg. This way, tests won’t break when Pydantic changes the message wording in a new version. parametrize lets you cover both boundaries 0 and 101 with a single function.

10. Common Pitfalls and When Not to Use Pydantic

Using v1 API by mistake. Many old docs still use v1 syntax. Quick reference table:

v1                         v2
@validator                 @field_validator
@root_validator            @model_validator
class Config: ...          model_config = ConfigDict(...)
.dict() / .json()          .model_dump() / .model_dump_json()
.parse_obj() / .parse_raw()  .model_validate() / .model_validate_json()

Old API still works but emits DeprecationWarning. You should migrate to the new API.

Surprises with lax mode. Lax mode accepts "yes" as True and 1.0 as 1. With financial data or IDs, this behavior may be undesirable. In that case, enable strict.

Forgetting return in validators. A field_validator without return will set the field to None. A model_validator(mode="after") without return self will also fail.

Bypassing validation unknowingly. model_construct() creates an instance without validation. This method is only for data you absolutely trust, like reading back from your own database.

Note: Don’t use Pydantic for every internal object. In hot loops processing millions of clean objects, validation overhead is wasteful. In that case, dataclass or NamedTuple are better fits. Place Pydantic at the boundary of your system: APIs, config files, messages from other systems.

11. Conclusion

Pydantic v2 turns input validation from imperative code into declarations. You describe types with type hints, add constraints with Field and Annotated, and write custom rules with field_validator and model_validator. Common behaviors like forbidding extra fields or disabling coercion are controlled via ConfigDict.

Three things to remember: use model_validate_json for JSON input, enable extra="forbid" for important data, and test validation based on loc/type rather than message content. Applied correctly at system boundaries, Pydantic ensures your application’s core only works with clean, correctly-typed data.

pythonpydanticvalidationpytest