Back to Blog
Python

Python Pydantic BaseModel Fields and Validation

How Pydantic BaseModel fields validate data: type coercion, Field constraints, validators, nested models, error handling, and validation overhead.

pydanticpython validationdata modelingpython typingpydantic v2
Illustration of a Pydantic BaseModel schema validating incoming data fields with type checks and constraints.

How Field Validation Works on a BaseModel

When you define a Pydantic BaseModel, every annotated attribute becomes a field. Validation runs when you instantiate the model, not when you declare the class. This is the central idea behind Pydantic BaseModel fields and validation: the type annotation is both the schema and the runtime check.

from pydantic import BaseModel class User(BaseModel): name: str age: int user = User(name='Ada', age=36)

Passing age='36' works because Pydantic coerces compatible input. Passing age='old' raises a ValidationError before the object exists. The important detail is that coercion is not the same as validation. Pydantic converts values where the conversion is well-defined, then applies the declared type and any constraints.

Declaring Fields with Types and Defaults

A field is required unless it has a default or a default factory. Declaring the type as Optional[str] does not make the field optional; it only allows None as a value. The field is still required unless you also provide a default.

from typing import Optional from pydantic import BaseModel class Profile(BaseModel): email: Optional[str] = None # optional and nullable handle: Optional[str] # required, but may be None

Use Field(default_factory=...) when the default must be a fresh object per instance. For mutable collections, prefer this over writing a direct default, because it creates a new collection for every instance.

from pydantic import BaseModel, Field class Cart(BaseModel): items: list[str] = Field(default_factory=list)

Constraining Fields with Field()

The Field() function attaches metadata and validation constraints to a field. Constraints such as min_length, max_length, ge, le, and pattern are enforced during validation, after type coercion.

from pydantic import BaseModel, Field class Order(BaseModel): sku: str = Field(min_length=4, max_length=32) quantity: int = Field(ge=1, le=100) code: str = Field(pattern=r'^[A-Z]{3}$')

pattern compiles to a regular expression check. Constraint names are type-specific: min_length and max_length are length constraints, while ge and le are numeric constraints. Using a constraint that does not match the field's type raises an error at class definition time, which keeps invalid schemas from silently passing.

Adding Validators for Custom Rules

Type annotations and Field() constraints cover declarative rules. When a rule depends on multiple values or external state, use a validator. Pydantic v2 provides field_validator and model_validator. The mode argument controls whether the validator runs before or after Pydantic's own coercion.

from pydantic import BaseModel, field_validator class Reservation(BaseModel): start: int end: int @field_validator('end') @classmethod def end_after_start(cls, value, info): if value <= info.data.get('start', value): raise ValueError('end must be after start') return value

field_validator receives the value of a single field and, through info.data, the already-validated values of earlier fields. Use mode='before' when the raw input needs transformation before type coercion, for example stripping whitespace before a length check.

A model_validator runs across the whole model and is the right place for rules that span fields in both directions. With mode='after' it receives the fully validated model instance.

from pydantic import BaseModel, model_validator class Range(BaseModel): low: int high: int @model_validator(mode='after') def check_range(self): if self.high < self.low: raise ValueError('high must be >= low') return self

Note the API difference between Pydantic v1 and v2. v1 used @validator and @root_validator; v2 renamed them to field_validator and model_validator. Code written against v1 validators needs updating when moving to v2.

Understanding Validation Errors

When validation fails, Pydantic raises ValidationError. The exception carries an errors() list where each entry describes the location, the failing value, and the error type. This structure is stable enough to map directly to API error responses.

from pydantic import BaseModel, ValidationError class Item(BaseModel): name: str price: float try: Item(name='', price='not-a-number') except ValidationError as exc: for error in exc.errors(): print(error['loc'], error['type'], error['msg'])

The loc tuple gives the field path, which becomes important in nested models where a failure deep inside a child model must be reported accurately. error['type'] is a stable machine-readable identifier such as string_too_short or float_parsing, while msg is a human-readable message.

Validation Overhead and When It Runs

Validation is not free. By default, Pydantic validates every field on instantiation, and in v2 the core validation logic runs in Rust through pydantic-core. The practical cost appears when you validate large lists or high-throughput request payloads repeatedly.

The validate_assignment setting in model_config makes attribute assignment validate too, which is useful for invariants but adds work on every set. Without it, assignment is unchecked:

from pydantic import BaseModel, ConfigDict class Account(BaseModel): model_config = ConfigDict(validate_assignment=True) balance: float
account = Account(balance=10.0) account.balance = 'not-a-number' # raises ValidationError with validate_assignment=True

For hot paths, prefer validating at the boundary — parse and validate incoming data once with model_validate, then work with the trusted model internally. Avoid re-validating the same data in a loop.

Nested Models and Reusable Validation

Nested BaseModel fields validate recursively. A parent model's validation fails if any child model fails, and the error location reflects the full path.

from pydantic import BaseModel, Field class Address(BaseModel): city: str zip_code: str = Field(pattern=r'^\d{5}$') class Customer(BaseModel): name: str address: Address Customer(name='Ada', address={'city': 'London', 'zip_code': 'abc'})

When the same constraints appear across many models, define them once with Annotated and reuse the type alias. This keeps validation rules in one place instead of duplicating Field(...) arguments.

from typing import Annotated from pydantic import BaseModel, Field Sku = Annotated[str, Field(min_length=4, max_length=32)] class Product(BaseModel): sku: Sku class Variant(BaseModel): sku: Sku

A shared Annotated type is also the cleanest way to compose constraints with validators, because the type alias can be referenced in multiple models without repeating the rule.

How Pydantic BaseModel Fields Validate Data in Python | RYUSLOG DEV