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:
patternenforcesskuto match a regex, e.g.ABC-1234.Quantityis a reusable type thanks toAnnotated. Every field declared asQuantitymust be in the range(0, 100].unit_priceusesDecimalto 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), likeclean_name: runs after Pydantic has type-checked, sovis guaranteed to be astr.mode="before", likenormalize_phone: runs before type checking andpatternvalidation.vcan be anything, so you must checkisinstance. Here we normalize+84 912 345 678to0912345678, thenpatternis 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_validatormust 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 likecoupon. 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 forint.str_strip_whitespace=True: automatically strip whitespace from strings.frozen=True: make instances immutable. Assigningpayment.amount = 0will 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,
dataclassorNamedTupleare 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.