BaseModel is the foundation of all Pydantic models. You inherit from it to create a schema. Pydantic reads your Python type annotations and uses them to validate and coerce incoming data automatically.
from pydantic import BaseModel class User(BaseModel): id: int name: str email: str is_active: bool = True # default value # Valid usage user = User(id=1, name="Alice", email="alice@example.com") print(user.name) # Alice print(user.is_active) # True (default) # Pydantic auto-coerces: "1" → 1 user2 = User(id="1", name="Bob", email="bob@example.com") print(user2.id) # 1 (int, not "1")
"1" for an int field, it will convert it to 1 silently. But if you pass "hello" for an int, it raises a ValidationError.Fields are declared as class-level annotations. You can attach metadata, constraints, and descriptions using the Field() function from Pydantic.
from pydantic import BaseModel, Field class Product(BaseModel): name: str = Field( ..., # ... means required min_length=2, max_length=100, description="Product name", examples=["Laptop", "Phone"] ) price: float = Field( ..., gt=0, # greater than 0 le=99999, # less than or equal description="Price in USD" ) stock: int = Field(default=0, ge=0) # >= 0 sku: str = Field(default="", pattern=r"^[A-Z]{3}-\d{4}$")
Fields with a default value are optional at creation time. Use Optional[T] (or T | None in Python 3.10+) when the field can explicitly be None.
from typing import Optional from pydantic import BaseModel class Article(BaseModel): title: str # required, cannot be None content: str # required subtitle: Optional[str] = None # optional, can be None views: int = 0 # optional with default tags: list[str] = [] # default empty list # Creating with only required fields a = Article(title="FastAPI Guide", content="...") print(a.subtitle) # None print(a.views) # 0 print(a.tags) # []
tags: list = [] as a plain Python default — Pydantic handles this correctly by creating a new list per instance, but always prefer Field(default_factory=list) for clarity in complex cases.When validation fails, Pydantic raises a ValidationError with clear, structured error messages. Each error tells you the field, location, and reason.
from pydantic import BaseModel, ValidationError class Order(BaseModel): item: str quantity: int price: float try: o = Order(item="widget", quantity="lots", price="cheap") except ValidationError as e: print(e.error_count()) # 2 errors for err in e.errors(): print(err['loc'], err['msg']) # ('quantity',) Input should be a valid integer # ('price',) Input should be a valid number
Pydantic v2 performs lax coercion by default — it tries to convert compatible types before raising an error. This is why JSON strings coming from HTTP work seamlessly.
| Input | Field Type | Result | Notes |
|---|---|---|---|
"42" | int | 42 | String → int coercion |
"3.14" | float | 3.14 | String → float coercion |
1 | bool | True | int → bool |
"true" | bool | True | String → bool |
"hello" | int | ValidationError | Cannot convert |
[1,2,3] | tuple | (1,2,3) | list → tuple |
model_config = ConfigDict(strict=True)), no coercion happens — types must match exactly. Useful for internal validation.age: int in a Pydantic model and pass age="25". What happens?"25" to 25 (int) — succeeds"25" as a stringA before validator runs before Pydantic converts the value to the declared type. Use it to clean or transform raw input first — like stripping whitespace or uppercasing a code.
from pydantic import BaseModel, field_validator class UserSignup(BaseModel): username: str email: str country_code: str @field_validator("username", mode="before") @classmethod def strip_username(cls, v): # Runs BEFORE type coercion if isinstance(v, str): return v.strip().lower() return v @field_validator("country_code", mode="before") @classmethod def normalize_country(cls, v): return v.upper() if isinstance(v, str) else v u = UserSignup( username=" Alice ", email="alice@x.com", country_code="us" ) print(u.username) # "alice" (stripped + lowercased) print(u.country_code) # "US"
An after validator runs after Pydantic has already converted the value to the declared type. The v argument is guaranteed to be the right type. Use it to apply business rules.
from pydantic import BaseModel, field_validator, ValidationInfo class Password(BaseModel): value: str @field_validator("value", mode="after") @classmethod def check_strength(cls, v: str) -> str: # v is already a str at this point if len(v) < 8: raise ValueError("Password must be at least 8 characters") if not any(c.isdigit() for c in v): raise ValueError("Password must contain a digit") return v # ✅ Valid p = Password(value="secure42") # ❌ Too short Password(value="abc") # ValidationError: Password must be at least 8 characters
@field_validator, raise ValueError (not a custom exception) to produce a Pydantic-friendly error message.Sometimes you need to validate one field relative to another — for example, checking that end_date is after start_date, or that password and confirm_password match. Use @model_validator for this.
from pydantic import BaseModel, model_validator from datetime import date class BookingRequest(BaseModel): check_in: date check_out: date guests: int room_type: str @model_validator(mode="after") def validate_dates_and_capacity(self) -> "BookingRequest": # self is the model instance — all fields are available if self.check_out <= self.check_in: raise ValueError("check_out must be after check_in") max_guests = 2 if self.room_type == "single" else 4 if self.guests > max_guests: raise ValueError( f"Room type '{self.room_type}' allows max {max_guests} guests" ) return self # ✅ Valid b = BookingRequest( check_in=date(2025,6,1), check_out=date(2025,6,5), guests=2, room_type="single" ) # ❌ Too many guests for single room BookingRequest( check_in=date(2025,6,1), check_out=date(2025,6,5), guests=5, room_type="single" )
end_date > start_date. Which decorator is most appropriate?@field_validator("end_date", mode="after")@model_validator(mode="after")@field_validator("start_date", mode="before")__init__ manuallymodel_validate), and it can turn that object back into a plain dict or JSON (model_dump) for responses or storage.
model_dump() converts a model instance to a plain Python dictionary. You can control which fields to include, exclude, or how to handle nested models.
from pydantic import BaseModel class User(BaseModel): id: int name: str password: str email: str u = User(id=1, name="Alice", password="secret", email="a@x.com") # Full dump print(u.model_dump()) # {'id': 1, 'name': 'Alice', 'password': 'secret', 'email': 'a@x.com'} # Exclude sensitive fields print(u.model_dump(exclude={"password"})) # {'id': 1, 'name': 'Alice', 'email': 'a@x.com'} # Include only specific fields print(u.model_dump(include={"id", "name"})) # {'id': 1, 'name': 'Alice'} # Exclude fields with None values print(u.model_dump(exclude_none=True)) # Exclude fields with default values print(u.model_dump(exclude_defaults=True))
model_validate() is the class-level constructor that creates a model instance from a dictionary, ORM object, or any mapping. It's the replacement for Pydantic v1's parse_obj().
from pydantic import BaseModel class Product(BaseModel): name: str price: float in_stock: bool # From a plain dict data = {"name": "Laptop", "price": "999.99", "in_stock": "true"} p = Product.model_validate(data) print(p.price) # 999.99 (float) print(p.in_stock) # True (bool) # From JSON string json_str = '{"name": "Phone", "price": 499, "in_stock": false}' p2 = Product.model_validate_json(json_str) print(p2.name) # Phone # From ORM object (SQLAlchemy row, etc.) # Use model_config = ConfigDict(from_attributes=True) from pydantic import ConfigDict class ProductFromORM(BaseModel): model_config = ConfigDict(from_attributes=True) name: str price: float # orm_object is a SQLAlchemy model with .name and .price attributes # product = ProductFromORM.model_validate(orm_object)
from_attributes=True is essential when integrating Pydantic with SQLAlchemy. It tells Pydantic to read from object attributes (like row.name) rather than dictionary keys.Pydantic models are immutable by default. To "update" a model, you create a copy with some fields changed using model_copy(update=...). This is the Pydantic v2 equivalent of .copy(update=...).
from pydantic import BaseModel class Config(BaseModel): host: str = "localhost" port: int = 5432 db_name: str = "dev" base_config = Config() print(base_config) # host='localhost' port=5432 db_name='dev' # Create a copy with some fields updated prod_config = base_config.model_copy(update={ "host": "prod-db.internal", "db_name": "production" }) print(prod_config) # host='prod-db.internal' port=5432 db_name='production' print(base_config) # unchanged — original is intact # Deep copy (copies nested models too) test_config = base_config.model_copy(deep=True, update={"port": 5433})
User model with a password field. You want to return the user to the client without exposing the password. What's the cleanest approach?user.model_dump(exclude={"password"})user.password = None before returningjson.dumps() after removing the fieldExternal APIs often use camelCase or legacy names that don't match Python's snake_case convention. Pydantic aliases let you map between them cleanly.
from pydantic import BaseModel, Field, ConfigDict from pydantic.alias_generators import to_camel # Option 1: Per-field alias class Payment(BaseModel): user_id: int = Field(alias="userId") # input key is "userId" amount_cents: int = Field(alias="amountCents") # Must use alias for input p = Payment.model_validate({"userId": 42, "amountCents": 1000}) print(p.user_id) # 42 (Python name) print(p.model_dump(by_alias=True)) # {'userId': 42, 'amountCents': 1000} # Option 2: Global alias generator (entire model) class APIResponse(BaseModel): model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) first_name: str last_name: str created_at: str # Both work with populate_by_name=True: r1 = APIResponse(first_name="John", last_name="Doe", created_at="2025") r2 = APIResponse.model_validate({"firstName":"Jane","lastName":"Doe","createdAt":"2025"})
alias_generator=to_camel globally on your response models to auto-convert Python's snake_case to JS-friendly camelCase.A @computed_field is a read-only property that is included in model_dump() and the JSON schema. It derives its value from other fields — like a full name from first + last.
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 @computed_field @property def perimeter(self) -> float: return 2 * (self.width + self.height) r = Rectangle(width=5.0, height=3.0) print(r.area) # 15.0 print(r.perimeter) # 16.0 print(r.model_dump()) # {'width': 5.0, 'height': 3.0, 'area': 15.0, 'perimeter': 16.0} # FastAPI use case: full_name derived from first + last class UserProfile(BaseModel): first_name: str last_name: str @computed_field @property def full_name(self) -> str: return f"{self.first_name} {self.last_name}"
A Pydantic model field can itself be another Pydantic model. This lets you model complex, hierarchical data — like an Order containing a list of LineItem objects and an Address.
from pydantic import BaseModel from typing import Optional class Address(BaseModel): street: str city: str zip_code: str country: str = "IN" class LineItem(BaseModel): product_id: int name: str quantity: int unit_price: float @computed_field @property def subtotal(self) -> float: return self.quantity * self.unit_price class Order(BaseModel): order_id: str customer_name: str shipping_address: Address # nested model items: list[LineItem] # list of nested models notes: Optional[str] = None # Pydantic recursively validates the nested dicts order = Order.model_validate({ "order_id": "ORD-001", "customer_name": "Alice", "shipping_address": { "street": "123 MG Road", "city": "Bengaluru", "zip_code": "560001" }, "items": [ {"product_id": 1, "name": "Laptop", "quantity": 1, "unit_price": 75000}, {"product_id": 2, "name": "Mouse", "quantity": 2, "unit_price": 500} ] }) print(order.shipping_address.city) # Bengaluru print(order.items[0].name) # Laptop
Generic models let you create reusable wrapper schemas — like a standard API response envelope that can wrap any data type. Extremely common in production FastAPI apps.
from pydantic import BaseModel from typing import Generic, TypeVar, Optional T = TypeVar("T") class APIResponse(BaseModel, Generic[T]): """Standard API envelope used across all endpoints.""" success: bool data: Optional[T] = None message: str = "" code: int = 200 # Use it with any type class UserOut(BaseModel): id: int name: str class ProductOut(BaseModel): id: int title: str # FastAPI endpoint from fastapi import FastAPI app = FastAPI() @app.get("/users/{user_id"}, response_model=APIResponse[UserOut]) async def get_user(user_id: int): user = UserOut(id=user_id, name="Alice") return APIResponse(success=True, data=user) # Response JSON: # {"success": true, "data": {"id": 1, "name": "Alice"}, "message": "", "code": 200}
APIResponse[UserOut] in the docs, showing exactly what shape the data field has.A discriminated union lets Pydantic decide which model to use based on a specific discriminator field in the data. Think of it as a type switch — you send "type": "email" and Pydantic knows to use the EmailNotification model.
type field on the box tells it which belt to send the package down.
from pydantic import BaseModel from typing import Literal, Union, Annotated from pydantic import Field class EmailNotification(BaseModel): type: Literal["email"] # discriminator value to: str subject: str body: str class SMSNotification(BaseModel): type: Literal["sms"] # discriminator value phone: str message: str class PushNotification(BaseModel): type: Literal["push"] # discriminator value device_token: str title: str body: str # Annotated Union with discriminator Notification = Annotated[ Union[EmailNotification, SMSNotification, PushNotification], Field(discriminator="type") ] class NotificationJob(BaseModel): notification: Notification priority: int = 1 # Pydantic picks the right model based on "type" job1 = NotificationJob.model_validate({ "notification": {"type": "email", "to": "a@x.com", "subject": "Hi", "body": "Hello"} }) print(type(job1.notification).__name__) # EmailNotification job2 = NotificationJob.model_validate({ "notification": {"type": "sms", "phone": "+91999", "message": "Your OTP is 1234"} }) print(type(job2.notification).__name__) # SMSNotification
Union types. With a discriminator, Pydantic doesn't try each model in turn — it jumps directly to the right one in O(1) time.{"success": true, "data": <any model>}. Which Pydantic feature fits best?APIResponse[T] wraps any modelResponse[T] wrappers with TypeVarLiteral discriminator field