Part 2 makes your data trustworthy. Pydantic v2 turns plain type hints into runtime validation, so bad input is rejected at the boundary instead of corrupting logic three layers deep. This is the same engine FastAPI uses to validate requests, and the same pattern you will use later to force language models to return clean JSON. Learn it well here and the rest of the series gets easier.
What you will learn
- Modeling data with BaseModel and validating at the edge
- Field constraints, defaults, and computed values
- Custom validators for rules types cannot express
- Serializing models back to clean dictionaries and JSON
1. A model is a contract
A Pydantic model declares the shape and rules of your data once. Construct it with raw input and you either get a valid object or a precise error that names the bad field. Run this to see validation reject a bad value.
2. Constraints beat manual checks
Field constraints replace scattered if statements. Instead of checking lengths and ranges by hand in every function, you declare them once and Pydantic enforces them on construction.
from pydantic import BaseModel, Field, EmailStr
class User(BaseModel):
email: EmailStr
age: int = Field(ge=0, le=130)
tags: list[str] = Field(default_factory=list, max_length=10)
Warning
EmailStr needs an extra package
EmailStr requires the email-validator package. Add it with uv add "pydantic[email]" so the import does not fail at runtime.
3. Custom validators for real rules
Some rules cannot be expressed as a constraint. A field validator runs your own logic and either returns a cleaned value or raises an error that Pydantic reports like any other.
from pydantic import BaseModel, field_validator
class Article(BaseModel):
title: str
slug: str
@field_validator("slug")
@classmethod
def slug_is_kebab(cls, v: str) -> str:
if not v.replace("-", "").isalnum() or v != v.lower():
raise ValueError("slug must be lowercase alphanumeric with dashes")
return v
4. Serializing back out
Validation is only half the job. You also need to send data out cleanly. model_dump gives you a dictionary and model_dump_json gives you a JSON string, both honoring your field types.
article = Article(title="Hello", slug="hello-world")
print(article.model_dump()) # {'title': 'Hello', 'slug': 'hello-world'}
print(article.model_dump_json()) # '{"title":"Hello","slug":"hello-world"}'
Checkpoint
Where should validation happen in a well-structured app?
5. Nested models compose
Real data is rarely flat. A project has an owner, an order has line items, a chat request has a list of messages. Pydantic models nest naturally: use one model as the type of a field on another, and validation recurses all the way down. You describe the shape once and get a validated tree of typed objects, not a soup of dictionaries you index by string keys and hope are present.
from pydantic import BaseModel
class Message(BaseModel):
role: str
content: str
class ChatRequest(BaseModel):
model: str
messages: list[Message] # each item is validated as a Message
req = ChatRequest.model_validate({
"model": "claude-opus-4-8",
"messages": [{"role": "user", "content": "Hello"}],
})
print(req.messages[0].role) # 'user', a real typed object
Notice model_validate, which builds a model from a dictionary you already have, such as a parsed JSON body or a database row. It is the method you reach for most once data is flowing through your app rather than being typed by hand.
6. Model configuration: be strict on purpose
By default a model ignores unexpected keys. For data crossing a trust boundary that is often too forgiving, because a typo in a field name passes silently. Configure the model to forbid extra fields, and freeze models that should never change after construction. These two settings catch a surprising number of bugs before they ship.
from pydantic import BaseModel, ConfigDict
class Settings(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
timeout: float = 30.0
retries: int = 3
# A misspelled key is now an error, not a silently dropped value
try:
Settings(timeoutt=10) # note the typo
except Exception as e:
print("rejected unexpected field")
7. Aliases bridge external names
External APIs love naming conventions you would not choose: camelCase keys, a field literally called from, or a leading underscore. You do not have to live with those names inside your code. An alias maps the external key to a clean Python attribute, so the messy name stays at the boundary and your logic reads naturally.
from pydantic import BaseModel, Field
class WebhookEvent(BaseModel):
event_type: str = Field(alias="eventType")
created_at: str = Field(alias="createdAt")
evt = WebhookEvent.model_validate({"eventType": "ping", "createdAt": "2026-06-10"})
print(evt.event_type) # clean snake_case in your code
print(evt.model_dump(by_alias=True)) # emits camelCase back out
8. Computed fields and clear errors
Some values are derived, not stored. A computed field exposes a property as part of the model output without you maintaining it by hand. And when validation fails, Pydantic does not stop at the first problem: it collects every error with the exact location, which is what lets FastAPI return a precise 422 listing each bad field in Part 7.
from pydantic import BaseModel, computed_field, ValidationError
class Order(BaseModel):
unit_price: float
quantity: int
@computed_field
@property
def total(self) -> float:
return round(self.unit_price * self.quantity, 2)
print(Order(unit_price=2.5, quantity=4).model_dump()) # includes 'total': 10.0
try:
Order.model_validate({"unit_price": "free", "quantity": -1})
except ValidationError as e:
for err in e.errors():
print(err["loc"], err["msg"]) # one line per problem, with location
Pro tip
Reuse the same models everywhere: as FastAPI request and response schemas, as the shape for structured model output, and as your internal types. One definition shared across the app means one place to change when the contract changes.
9. Validation in practice: parse, do not trust
The habit to build is to parse external data into a model the instant it arrives, then work only with the typed object afterward. Consider a payload from a third party webhook. Instead of reaching into a dictionary with string keys and defensive get calls, validate it once. From that point on, the value is either a correct object or you have a precise error explaining why it is not, and every line downstream can assume the shape is right.
from pydantic import BaseModel, ValidationError
class IncomingOrder(BaseModel):
order_id: str
amount: float
currency: str = "USD"
raw = '{"order_id": "A-100", "amount": 49.9}' # JSON string from the wire
try:
order = IncomingOrder.model_validate_json(raw) # parse + validate in one step
print(order.order_id, order.amount, order.currency) # A-100 49.9 USD
except ValidationError as e:
print("bad payload:", e.error_count(), "error(s)")
model_validate_json parses the JSON and validates it together, which is both faster and safer than calling json.loads yourself and then constructing the model. Note how the currency default filled in, so the rest of your code never has to handle a missing currency.
10. Updating models immutably
Once you have a validated object you often need a slightly changed copy, for example to apply a partial update from a PATCH request without mutating the original. model_copy with an update produces a new instance and revalidates nothing it does not need to, which keeps the original safe and the change explicit. Pair this with optional fields to model a partial update cleanly.
from pydantic import BaseModel
class Project(BaseModel):
name: str
status: str = "draft"
current = Project(name="Search API", status="active")
patched = current.model_copy(update={"status": "archived"})
print(current.status) # active (unchanged)
print(patched.status) # archived (new object)
Tip
Models are documentation that runs
A new teammate can read your models and know the exact shape of every request, response, and config value in the app, and unlike a comment, the model cannot drift out of date because the code enforces it.
The bottom line
Pydantic v2 turns type hints into a runtime contract. Model your data once, validate at the edge, and serialize cleanly on the way out. The mental shift worth keeping is to stop passing dictionaries around and to parse into typed objects at every boundary, so that the moment data is inside your app it is already known to be valid. Constraints replace scattered checks, validators express the rules that types cannot, and clear errors point at the exact field that failed. Because FastAPI is built on the same engine, everything you practiced here transfers directly to request and response models in the next part, and later to forcing reliable JSON out of a language model, where the same models double as the schema the model must fill.
? Frequently asked questions
Is Pydantic slow? +
Pydantic v2 has a Rust core and is fast enough for request validation in hot paths. Validation cost is tiny next to network and model latency.
When should I use a dataclass instead? +
Use a dataclass for internal structures that do not need validation. Reach for Pydantic at trust boundaries: requests, config, and model output.
Up next: Part 3, async Python and concurrency.
Comments
0No comments yet. Be the first to share your thoughts.