Data Validation with Pydantic
Reviewed & published by Brayan K
Pydantic turns ordinary type hints into a powerful runtime validation engine. Define a model once and Pydantic parses, validates, and coerces messy external data into clean, typed Python objects — or tells you exactly what went wrong.
Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- • How to define models with BaseModel and type-annotated fields
- • How Pydantic validates and coerces incoming data automatically
- • Adding constraints with Field() and custom rules with @field_validator
- • Handling errors cleanly with ValidationError
- • Nested models, serialization with .model_dump(), and parsing with .model_validate()
- • Settings management and how Pydantic differs from dataclasses
🛡️ 1. Why Pydantic Exists
Real data is messy: missing keys, strings where numbers belong, extra fields. Pydantic validates it at runtime so the rest of your code can trust it.
You describe the shape of your data with normal Python type hints, and Pydantic enforces it.
📦 2. Your First Model
Subclass BaseModel and annotate the fields:
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
active: bool = True
# Pydantic validates and coerces the input
u = User(id="42", name="Ada")
print(u.id) # 42 (the string was coerced to int)
print(u.active) # True (default used)
# ✅ Expected output:
# 42
# TrueNotice that "42" became the integer 42 — Pydantic coerces compatible types by default.
🚨 3. Validation Errors
When data does not fit, Pydantic raises ValidationError:
from pydantic import BaseModel, ValidationError
class User(BaseModel):
id: int
name: str
try:
User(id="not-a-number", name="Ada")
except ValidationError as e:
print(e)
# Reports: id -> Input should be a valid integer
# ✅ Expected output:
# 1 validation error for User
# id
# Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='not-a-number', input_type=str]
# For further information visit https://errors.pydantic.dev/2.13/v/int_parsingThe error object lists every failing field, the bad value, and why it failed.
🎚️ 4. Constraints with Field()
Use Field() to add bounds, defaults, and metadata:
from pydantic import BaseModel, Field
class Product(BaseModel):
name: str = Field(min_length=1, max_length=50)
price: float = Field(gt=0) # must be > 0
quantity: int = Field(ge=0, le=1000) # 0..1000
p = Product(name="Pen", price=1.5, quantity=10)
print(p.price) # 1.5
# ✅ Expected output:
# 1.5| Constraint | Meaning |
|---|---|
| gt / ge | Greater than / greater-or-equal |
| lt / le | Less than / less-or-equal |
| min_length / max_length | String / collection length bounds |
✍️ 5. Custom Validators
For rules that constraints cannot express, use @field_validator (Pydantic v2):
from pydantic import BaseModel, field_validator
class Account(BaseModel):
username: str
@field_validator("username")
@classmethod
def no_spaces(cls, v: str) -> str:
if " " in v:
raise ValueError("username cannot contain spaces")
return v.lower()
a = Account(username="Ada")
print(a.username) # ada
# ✅ Expected output:
# adaValidators can also transform values (here we lowercased the username).
🪆 6. Nested Models
Models can contain other models, validated recursively:
from pydantic import BaseModel
class Address(BaseModel):
city: str
zip: str
class Person(BaseModel):
name: str
address: Address
p = Person(name="Ada", address={"city": "Paris", "zip": "75001"})
print(p.address.city) # Paris
# ✅ Expected output:
# ParisThe inner dict was validated and turned into a real Address instance.
🔁 7. Serialize and Parse
Convert models to plain data and back:
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
u = User(id=1, name="Ada")
# Model -> dict / JSON
print(u.model_dump()) # {'id': 1, 'name': 'Ada'}
print(u.model_dump_json()) # '{"id":1,"name":"Ada"}'
# dict -> validated model
u2 = User.model_validate({"id": 2, "name": "Linus"})
print(u2.name) # Linus
# ✅ Expected output:
# {'id': 1, 'name': 'Ada'}
# {"id":1,"name":"Ada"}
# Linus🧪 Worked Example: One Model That Does Everything
Every idea from the sections above appears here at once: coercion, defaults, a constraint, a custom validator, a nested model, serialisation, and a full error report. Read each comment, then run it and compare with the expected output at the bottom.
from pydantic import BaseModel, Field, ValidationError, field_validator
class Address(BaseModel):
city: str
postcode: str = Field(min_length=3, max_length=10) # length bounds
class Customer(BaseModel):
id: int
email: str
age: int = Field(ge=18) # ge = "greater than or equal to"
address: Address # a nested model - validated recursively
marketing_ok: bool = False # has a default, so the input may omit it
@field_validator("email") # runs AFTER the type check on that one field
@classmethod
def must_look_like_email(cls, value):
if "@" not in value:
raise ValueError("email must contain @") # your own rule
return value.strip().lower() # validators may CLEAN the value
raw = {
"id": "1042", # a string - watch what happens to it
"email": " ADA@Example.COM ", # untidy - the validator will fix it
"age": 31,
"address": {"city": "Paris", "postcode": "75001"}, # a plain dict
}
c = Customer(**raw)
print("id :", c.id, type(c.id).__name__) # coerced from "1042" to a real int
print("email :", c.email) # trimmed and lowercased by the validator
print("address :", c.address.city, "-", type(c.address).__name__) # now an Address object
print("default :", c.marketing_ok) # False, filled in for you
print("dump :", c.model_dump()) # back to plain Python data
# Now the same model given data that breaks four rules at once.
bad = {"id": "x", "email": "nope", "age": 12, "address": {"city": "Paris", "postcode": "1"}}
try:
Customer(**bad)
except ValidationError as e:
# Pydantic reports EVERY problem, not just the first one it meets.
print("errors :", e.error_count())
for err in e.errors():
# 'loc' is the path to the field - note the nested one at the end.
print(" ", err["loc"], "->", err["msg"])
# ✅ Expected output:
# id : 1042 int
# email : ada@example.com
# address : Paris - Address
# default : False
# dump : {'id': 1042, 'email': 'ada@example.com', 'age': 31, 'address': {'city': 'Paris', 'postcode': '75001'}, 'marketing_ok': False}
# errors : 4
# ('id',) -> Input should be a valid integer, unable to parse string as an integer
# ('email',) -> Value error, email must contain @
# ('age',) -> Input should be greater than or equal to 18
# ('address', 'postcode') -> String should have at least 3 charactersThe last error line is the one to remember: ('address', 'postcode') is a path, not a name. However deep your models nest, Pydantic tells you exactly which field failed and why — which is what makes it worth using at the edge of your program.
🎯 Your Turn: Finish the Booking Model
Two blanks: one constraint and one validator target. Fill them in, run it, and match the expected output. Watch the first printed line — it proves the string "3" arrived as a real integer.
from pydantic import BaseModel, Field, ValidationError, field_validator
# 🎯 YOUR TURN — replace each ___ and run it.
class Booking(BaseModel):
guest: str
nights: int = Field(___=1) # 👉 nights must be AT LEAST 1. gt, ge, lt or le?
email: str
@field_validator("___") # 👉 which field should the rule below guard?
@classmethod
def must_have_at(cls, value):
if "@" not in value:
raise ValueError("email must contain @")
return value
b = Booking(guest="Ada", nights="3", email="ada@example.com")
print(b.nights, type(b.nights).__name__) # the string "3" becomes an int
print(b.model_dump())
# This one breaks both rules at once.
try:
Booking(guest="Bob", nights=0, email="bob-at-example.com")
except ValidationError as e:
for err in e.errors():
print(err["loc"], "->", err["msg"])
# ✅ Expected output:
# 3 int
# {'guest': 'Ada', 'nights': 3, 'email': 'ada@example.com'}
# ('nights',) -> Input should be greater than or equal to 1
# ('email',) -> Value error, email must contain @⚙️ 8. Settings Management
Pydantic's settings classes load configuration from environment variables, with validation:
# pip install pydantic-settings
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "MyApp"
debug: bool = False
port: int = 8000
# Values can be overridden by env vars: PORT=9000
settings = Settings()
print(settings.port) # 8000 (or 9000 if PORT is set)⚠️ Requires pip install pydantic-settings
⚖️ 9. Pydantic vs Dataclasses
| Feature | dataclass | Pydantic BaseModel |
|---|---|---|
| Runtime validation | No | Yes |
| Type coercion | No | Yes |
| JSON serialization | Manual | Built in |
| Standard library | Yes | Third-party |
Use a dataclass for simple in-memory records; use Pydantic when data crosses a boundary and must be validated.
🎉 Conclusion
With Pydantic you can:
✔ Validate external data with plain type hints
✔ Coerce and clean messy inputs automatically
✔ Report errors clearly with ValidationError
✔ Compose nested models and serialize them
✔ Manage validated application settings
Define the shape once, and trust your data everywhere after.
🎯 Mini-Challenge: Validate a Whole Order
Real payloads are not flat — an order contains a list of items, each with its own rules. Build the two models from the brief and prove they work on one good payload and one bad one. Nothing is written for you.
# 🎯 MINI-CHALLENGE: validate a whole order
#
# 1. Item model: sku: str with min_length=3, qty: int with ge=1
# 2. Order model: reference: str, items: list[Item], note: str = ""
# 3. Validate this good payload with Order.model_validate(...) and print
# the result of .model_dump():
# {"reference": "A-1",
# "items": [{"sku": "KEY-1", "qty": "2"}, {"sku": "MON-9", "qty": 1}]}
# 4. Then validate this bad payload inside a try, catch ValidationError,
# and print each error's ["loc"] and ["msg"] on one line:
# {"reference": "A-2", "items": [{"sku": "AB", "qty": 0}]}
#
# ✅ Expected output:
# {'reference': 'A-1', 'items': [{'sku': 'KEY-1', 'qty': 2}, {'sku': 'MON-9', 'qty': 1}], 'note': ''}
# ('items', 0, 'sku') -> String should have at least 3 characters
# ('items', 0, 'qty') -> Input should be greater than or equal to 1
# your code here📋 Quick Reference — Pydantic
| Syntax | What it does |
|---|---|
| class M(BaseModel) | Define a validated model |
| Field(gt=0, max_length=5) | Add field constraints |
| @field_validator(name) | Custom validation logic |
| m.model_dump() | Serialize model to a dict |
| M.model_validate(data) | Parse and validate input data |
🎉 Great work! You've completed this lesson.
You can now model and validate real-world data with confidence using Pydantic.
Practice quiz
What base class do you inherit from to define a Pydantic model?
- BaseModel
- DataModel
- Schema
- Validator
Answer: BaseModel. Pydantic models subclass pydantic.BaseModel, which drives validation, parsing, and serialization.
How does Pydantic decide what to validate on each field?
- From a separate schema file
- From method names
- From the field's type annotation
- From docstrings
Answer: From the field's type annotation. Pydantic reads the standard type annotations (name: str, age: int) and validates incoming data against them.
If you pass the string '42' to an int field in a Pydantic v2 model, what happens by default?
- It raises ValidationError immediately
- It is coerced to the integer 42
- It stays as the string '42'
- The field is set to None
Answer: It is coerced to the integer 42. By default Pydantic coerces compatible types, so '42' becomes the int 42. Use strict mode to forbid this.
Which exception does Pydantic raise when input data fails validation?
- AssertionError
- TypeError
- ValueError
- ValidationError
Answer: ValidationError. Pydantic raises pydantic.ValidationError, which collects every field error in one structured report.
Which helper adds constraints like a minimum value or max length to a field?
- Field()
- Constraint()
- Validate()
- Rule()
Answer: Field(). Field() lets you set constraints such as gt, ge, lt, le, min_length, max_length, and a default value.
In Pydantic v2, which decorator defines a custom validator for one or more fields?
- @constraint
- @field_validator
- @validator
- @check
Answer: @field_validator. Pydantic v2 uses @field_validator (the v1 @validator is deprecated) to run custom validation logic on fields.
What does the Pydantic v2 method model_dump() return?
- A new model instance
- The model's schema
- A JSON string
- A dict of the model's data
Answer: A dict of the model's data. model_dump() serializes the model into a plain dict; model_dump_json() produces a JSON string.
Which classmethod validates a dict (or object) into a model instance in Pydantic v2?
- model_load()
- model_build()
- model_validate()
- model_parse()
Answer: model_validate(). model_validate() takes external data and returns a validated model instance, replacing v1's parse_obj().
How do you model a field that itself contains another structured object?
- Use a dict type only
- Annotate the field with another BaseModel subclass
- Use a tuple
- It is not supported
Answer: Annotate the field with another BaseModel subclass. Nested models are just fields annotated with another BaseModel subclass; Pydantic validates them recursively.
Compared with a plain dataclass, what does Pydantic add automatically?
- Runtime data validation and type coercion
- Faster attribute access
- Nothing, they are identical
- Only a __repr__
Answer: Runtime data validation and type coercion. Dataclasses just store fields; Pydantic validates and coerces incoming data at runtime against the annotations.
Continue this course
- Previous: Final Project Ideas
- Next: NumPy for Numerical Computing — The ndarray, vectorized math, broadcasting and fast aggregations over arrays
- Quick reference: Python cheat sheet