Python Pydantic BaseModel Fields and Validation
python pydantic basemodel fields and validation: Learn how Pydantic BaseModel fields validate data: type coercion, Field constraints, validators, error handling, and n...
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 behavior behind python 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, a default factory, or is typed as Optional without a default. A common mistake is assuming Optional[str] makes a field optional in the sense of nullable. It does not; 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. A mutable default like list or dict should never be written directly; Pydantic rejects it with a ValueError at class definition time.
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. The exact constraint names depend on the field type: string constraints apply to str, numeric constraints to int and float. Applying a string constraint to an integer field raises a PydanticUserError 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.
from pydantic import BaseModel, model_validator class Range(BaseModel): low: int high: int @model_validator(mode="after") @classmethod def check_range(cls, values): if values.high < values.low: raise ValueError("high must be >= low") return values
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.
Two settings control where validation happens. validate_assignment 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 = -5 # 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.