Intermediate
13 min read
#Python#Pydantic#Validation#Data Modeling#FastAPI

Pydantic — The Complete Notebook

Comprehensive guide on Pydantic — The Complete Notebook.

Pydantic

1. Overview#

Pydantic is a data validation library that uses Python type hints to define the shape of data — and then actually enforces that shape at runtime, unlike plain type hints (see type-hints.md). It's the validation engine behind FastAPI's request/response handling, but it's equally useful on its own for config loading, parsing external API responses, or validating any structured data.

This note covers Pydantic v2 (the current major version). The API changed meaningfully from v1 — if you see @validator or class Config: in older code/tutorials, that's v1 syntax; v2 uses @field_validator and model_config.


2. Defining and Using Models#

2.1 Basic Model#

🐍 Python
from pydantic import BaseModel class User(BaseModel): id: int name: str email: str is_active: bool = True # default value — optional field user = User(id=1, name="Kamal", email="kamal@example.com") print(user) # id=1 name='Kamal' email='kamal@example.com' is_active=True print(user.model_dump()) # {'id': 1, 'name': 'Kamal', ...} — convert to a plain dict print(user.model_dump_json())# '{"id":1,"name":"Kamal",...}' — convert to a JSON string

2.2 Automatic Type Coercion#

Pydantic tries to convert compatible input into the declared type instead of rejecting it outright.

🐍 Python
class Product(BaseModel): name: str price: float quantity: int p = Product(name="Keyboard", price="1499.50", quantity="3") print(p.price, type(p.price)) # 1499.5 <class 'float'> print(p.quantity, type(p.quantity)) # 3 <class 'int'>

2.3 Validation Errors#

🐍 Python
from pydantic import ValidationError try: User(id="not-a-number", name="Kamal", email="kamal@example.com") except ValidationError as e: print(e) # Shows exactly which field failed and why, e.g.: # id # Input should be a valid integer, unable to parse string as an integer

3. Field Customization with Field#

🐍 Python
from pydantic import BaseModel, Field class Product(BaseModel): name: str = Field(min_length=2, max_length=100) price: float = Field(gt=0, description="Price must be positive") quantity: int = Field(default=0, ge=0, le=10_000) sku: str = Field(alias="SKU") # accept "SKU" from input, but expose as .sku product = Product(name="Mouse", price=799.0, SKU="MSE-001") print(product.sku) # MSE-001
ConstraintApplies ToExample
gt, ge, lt, leNumbersprice: float = Field(gt=0)
min_length, max_lengthStrings, listsname: str = Field(min_length=2)
patternStrings (regex)code: str = Field(pattern=r"^[A-Z]{3}\d{3}$")
defaultAny fieldquantity: int = Field(default=0)
aliasAny fieldAccept a different input key name

4. Nested Models#

🐍 Python
from pydantic import BaseModel class Address(BaseModel): street: str city: str pincode: str class User(BaseModel): name: str address: Address # a model can contain another model user = User( name="Asha", address={"street": "MG Road", "city": "Bengaluru", "pincode": "560001"} ) print(user.address.city) # Bengaluru print(user.model_dump()) # nested dict output, fully validated at every level

4.1 Lists of Models#

🐍 Python
from pydantic import BaseModel class OrderItem(BaseModel): product_name: str quantity: int class Order(BaseModel): order_id: str items: list[OrderItem] order = Order( order_id="ORD-001", items=[ {"product_name": "Laptop", "quantity": 1}, {"product_name": "Mouse", "quantity": 2}, ] ) print(order.items[1].product_name) # Mouse

5. Optional Fields and None#

🐍 Python
from pydantic import BaseModel class Profile(BaseModel): name: str bio: str | None = None # optional — defaults to None if omitted age: int | None = Field(default=None, ge=0) p1 = Profile(name="Kamal") print(p1.bio) # None p2 = Profile(name="Asha", bio="Engineer", age=29)

A field is only truly optional in the request sense if it has a default value. bio: str | None without = None still requires the key to be present (it can just be null).


6. Custom Validators#

6.1 field_validator — Validate a Single Field#

🐍 Python
from pydantic import BaseModel, field_validator class User(BaseModel): username: str email: str @field_validator("username") @classmethod def username_must_be_alphanumeric(cls, value: str) -> str: if not value.isalnum(): raise ValueError("Username must be alphanumeric") return value.lower() # validators can also transform the value @field_validator("email") @classmethod def email_must_contain_at(cls, value: str) -> str: if "@" not in value: raise ValueError("Invalid email address") return value user = User(username="Kamal123", email="kamal@example.com") print(user.username) # kamal123 — lowercased by the validator

6.2 model_validator — Validate Across Multiple Fields#

🐍 Python
from pydantic import BaseModel, model_validator class DateRange(BaseModel): start_date: str end_date: str @model_validator(mode="after") def check_dates_ordered(self) -> "DateRange": if self.start_date > self.end_date: raise ValueError("start_date must be before end_date") return self DateRange(start_date="2026-01-01", end_date="2026-01-10") # OK # DateRange(start_date="2026-02-01", end_date="2026-01-10") # raises ValidationError

7. Computed Fields#

Values derived from other fields, included automatically in serialized output.

🐍 Python
from pydantic import BaseModel, computed_field class Rectangle(BaseModel): width: float height: float @computed_field @property def area(self) -> float: return self.width * self.height r = Rectangle(width=4, height=5) print(r.area) # 20.0 print(r.model_dump()) # {'width': 4.0, 'height': 5.0, 'area': 20.0}

8. Enums for Constrained Choices#

🐍 Python
from enum import Enum from pydantic import BaseModel class OrderStatus(str, Enum): PENDING = "pending" SHIPPED = "shipped" DELIVERED = "delivered" class Order(BaseModel): order_id: str status: OrderStatus order = Order(order_id="ORD-001", status="shipped") print(order.status) # OrderStatus.SHIPPED print(order.status.value) # shipped # Order(order_id="ORD-002", status="lost") # raises ValidationError — "lost" isn't a valid choice

9. Model Configuration with model_config#

🐍 Python
from pydantic import BaseModel, ConfigDict class User(BaseModel): model_config = ConfigDict( str_strip_whitespace=True, # auto-trims leading/trailing whitespace from strings frozen=True, # makes the model immutable after creation extra="forbid", # reject any fields not defined on the model ) name: str email: str user = User(name=" Kamal ", email="kamal@example.com") print(repr(user.name)) # 'Kamal' — whitespace stripped automatically # user.name = "New Name" # raises an error — model is frozen # User(name="A", email="a@b.com", extra_field="x") # raises — extra="forbid"
extra SettingBehavior on Unknown Fields
"ignore" (default)Silently drops unknown fields
"forbid"Raises a ValidationError
"allow"Keeps unknown fields on the model

10. Serialization Control#

🐍 Python
from pydantic import BaseModel, Field class User(BaseModel): name: str password: str = Field(exclude=True) # never included in serialized output email: str user = User(name="Kamal", password="secret123", email="kamal@example.com") print(user.model_dump()) # {'name': 'Kamal', 'email': 'kamal@example.com'} — password excluded automatically
🐍 Python
# Selective serialization user.model_dump(include={"name", "email"}) user.model_dump(exclude={"email"}) user.model_dump(by_alias=True) # use field aliases as keys instead of Python names

11. Parsing From Different Sources#

🐍 Python
from pydantic import BaseModel class User(BaseModel): name: str age: int # From a dict user = User.model_validate({"name": "Kamal", "age": 30}) # From a JSON string user = User.model_validate_json('{"name": "Kamal", "age": 30}') # Validating a list of items at once from pydantic import TypeAdapter adapter = TypeAdapter(list[User]) users = adapter.validate_python([{"name": "Asha", "age": 29}, {"name": "Ravi", "age": 34}])

12. Settings Management with pydantic-settings#

A common real-world use: loading and validating environment variables/config at startup.

bash
pip install pydantic-settings
🐍 Python
from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env") database_url: str api_key: str debug: bool = False max_connections: int = 10 settings = Settings() # reads from environment variables / .env, validated automatically print(settings.debug)
Mathematical Formulation
# .env
DATABASE_URL=postgresql://user:pass@localhost/mydb
API_KEY=your-secret-key
DEBUG=true

13. Pydantic + FastAPI (Quick Recap)#

Pydantic models are what FastAPI uses to validate request bodies and shape response payloads — see fastapi.md for the full picture.

🐍 Python
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class CreateUserRequest(BaseModel): name: str email: str @app.post("/users") def create_user(payload: CreateUserRequest): # payload is already validated by the time this line runs return {"created": payload.name}

14. Common Pitfalls#

INCORRECT: Using a Mutable Default Directly#

🐍 Python
class Cart(BaseModel): items: list = [] # Pydantic actually handles this safely, unlike a plain Python function default

Unlike the mutable-default-argument trap in plain functions (see basic.md), Pydantic models are safe with = [] or = {} as defaults — Pydantic creates a fresh instance per object. Still, Field(default_factory=list) is the more explicit, conventional style.

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

INCORRECT: Forgetting @classmethod on a field_validator#

🐍 Python
class User(BaseModel): name: str @field_validator("name") def check_name(cls, value): # missing @classmethod — works but triggers linter warnings/type errors return value

CORRECT: Fix#

🐍 Python
@field_validator("name") @classmethod def check_name(cls, value: str) -> str: return value

INCORRECT: Assuming Type Hints Alone Validate Data#

🐍 Python
def process(user: User): # a plain function — NOT a Pydantic model ... process("not a user") # no error at all; type hints alone don't enforce anything

CORRECT: Fix — Validation Only Happens When You Actually Construct/Validate a Model#

🐍 Python
data = User.model_validate(raw_input) # THIS is what performs validation

15. Summary & Best Practices Checklist#

  • Use Field(...) constraints (gt, min_length, pattern, etc.) instead of writing manual if checks for simple rules.
  • Use field_validator for single-field logic and model_validator for cross-field logic.
  • Use Field(default_factory=list/dict) for mutable defaults, for clarity even though Pydantic handles = [] safely.
  • Use Field(exclude=True) to keep sensitive fields (passwords, tokens) out of serialized output.
  • Set extra="forbid" on models where unexpected fields should be rejected outright (e.g., strict API contracts).
  • Use pydantic-settings for typed, validated environment configuration instead of raw os.environ access.
  • Remember: Pydantic validates at construction time (Model(...), .model_validate(...)) — bare type hints elsewhere in your code still aren't enforced.
Knowledge Checkpoint

Pydantic V2 Data Validation Checkpoint

Q1.In Pydantic V2, which decorator replaces the legacy V1 `@validator` for validating individual model fields?
A@field_validator
B@model_validator
C@validate_field
D@check_property
Q2.What decorator is used in Pydantic V2 to perform cross-field validation on the entire model state?
A@field_validator
B@model_validator(mode='after')
C@validate_all
D@root_validator_v2
Q3.How do you serialize a Pydantic V2 model instance into a standard Python dictionary and a JSON string, respectively?
A`model.dict()` and `model.json()` (legacy V1 syntax)
B`model.model_dump()` and `model.model_dump_json()`
C`model.to_dict()` and `model.to_json()`
D`dict(model)` and `str(model)`
Track Your Learning

Finished studying this notebook?

Mark this guide as completed to update your course progress roadmap.