Your FastAPI API can now accept parameters, validate requests, and return meaningful HTTP responses.
But there is another important question:
How do we make sure the data flowing through our application is actually valid?
Imagine receiving patient information where the age is a string, an email has an invalid format, or a value violates a business rule.
Manually checking every field can quickly become repetitive and difficult to maintain.
This is where Pydantic comes in.
In this part, we’ll explore how Pydantic helps define clear, type-safe data models and automatically validates incoming data. We’ll start with the basics and gradually move toward more advanced features.
Overview
Why Pydantic?
Python is a dynamically typed language, which means variables can hold different types of values at runtime. This flexibility is useful, but it can also lead to unexpected data entering an application.
For example, a function may expect an integer for age, but receive a string instead. Without proper validation, developers may need to write repetitive checks for every field and every function.
This is where Pydantic helps.
Pydantic lets us define what our data should look like and automatically validates the input against that structure.

Instead of writing manual type checks and validation logic repeatedly, we can define a model using Python type hints. Pydantic then validates the data when we create an instance of that model.
For example, without a validation model, we might need to manually check whether name is a string and age is an integer before processing the data.
With Pydantic, the model itself defines the expected structure:
from pydantic import BaseModel
class Patient(BaseModel):
name: str
age: intNow we can create a Patient object from input data:
patient_info = {
"name": "shubham",
"age": 20
}
patient1 = Patient(**patient_info)
print(patient1)Pydantic automatically validates the data based on the model definition. Your Part 5 example uses exactly this BaseModel approach to define and instantiate a Patient model.
Key Benefits of Pydantic
- Type Safety — Define the expected type of each field using Python type hints.
- Automatic Validation — Validate input data when creating model instances.
- Type Coercion — Convert compatible input values into the expected Python types when possible.
- Less Boilerplate — Avoid writing repetitive manual validation code.
- Clear Data Models — Define the expected structure of application data in one place.
- Production-Ready Validation — Invalid data results in a
ValidationErrorinstead of silently passing through the application.
Instead of checking data manually everywhere, we define the rules once in a Pydantic model and let Pydantic handle the validation.
This becomes especially useful in FastAPI, where Pydantic models can define and validate the data flowing through API requests and responses.
Complete Code Example
# Without Type Safety
# def insert_patient_data(name, age):
# print(name)
# print(age)
# print("inserted into database")
# insert_patient_data('shubham', 'twenty')
# With Type Safety
# Inserting Data
def insert_patient_data(name: str, age: int):
# type error
if type(name) == str and type(age) == int:
# value error (data validation)
if age < 0:
raise ValueError('Age cannot be negative')
else:
print(name)
print(age)
print("inserted into database")
else:
raise TypeError('Incorrect data type')
insert_patient_data('shubham', 20)
# Updating Data
def update_patient_data(name: str, age: int):
# type error
if type(name) == str and type(age) == int:
# value error (data validation)
if age < 0:
raise ValueError('Age cannot be negative')
else:
print(name)
print(age)
print("updated into database")
else:
raise TypeError('Incorrect data type')
update_patient_data('shubham', 20)Output
shubham
20
inserted into database
shubham
20
updated into databaseCode Execution Explanation
The code manually checks both the data type and data value before using the data.
For insertion and updating, the same validation logic has to be written again. This is one of the problems Pydantic helps solve by centralizing validation inside reusable models.
How Pydantic Works
Pydantic follows a simple three-step process: define a model, validate the input by creating an instance, and then use the validated model throughout your application.
1. Define a Pydantic Model
First, we define a Pydantic model that represents the ideal structure of our data.
The model specifies the expected fields, their data types, and any validation constraints.
from pydantic import BaseModel
class Patient(BaseModel):
name: str
age: intHere:
nameis expected to be astr.ageis expected to be anint.
We can also add constraints to fields when needed, such as requiring a number to be greater than zero.
2. Instantiate the Model
Next, we provide the input data, usually as a dictionary, and create an instance of the Pydantic model.
patient_info = {
"name": "shubham",
"age": 20
}
patient1 = Patient(**patient_info)When the model is instantiated, Pydantic automatically validates the data and, when possible, converts compatible values into the expected Python types.
For example, if age is provided as the string "20", Pydantic can convert it to the integer 20 when the field allows that conversion.
If the input does not satisfy the model’s requirements, Pydantic raises a ValidationError.
3. Use the Validated Model
Once the model has been successfully created, we can pass the validated object to functions or use it throughout our application.
def insert_patient_data(patient: Patient):
print(patient.name)
print(patient.age)
print("inserted into database")
insert_patient_data(patient1)This means the function works with a validated Pydantic model rather than repeatedly checking the data manually.
The idea is simple: define the rules once, validate the data when the model is created, and then work with clean, type-safe data throughout the application.
The Complete Flow
The overall process looks like this:
Raw Input → Pydantic Model → Validation & Type Conversion → Validated Model → Application Logic
This approach helps ensure that different parts of the application work with clean, type-safe, and logically valid data.
Complete Code Example
from pydantic import BaseModel
class Patient(BaseModel):
name: str
age: int
def insert_patient_data(patient: Patient):
print(patient.name)
print(patient.age)
print("inserted into database")
patient_info = {"name": "shubham", "age": 20}
patient1 = Patient(**patient_info)
print(patient1)
insert_patient_data(patient1)Output
name='shubham' age=20
shubham
20
inserted into databaseCode Execution Explanation
First, we define the Patient model with name and age.
Then, the dictionary is converted into a Pydantic model using:
patient1 = Patient(**patient_info)Pydantic validates the data according to the model, and the resulting Patient object can then be passed directly to the function.
Pydantic Models
A Pydantic model defines the expected structure of your data using Python type hints.
At the center of Pydantic is BaseModel. By inheriting from BaseModel, a class becomes a Pydantic model that can validate and process input data automatically.
from pydantic import BaseModel
class Patient(BaseModel):
name: str
age: intHere, the Patient model defines two fields:
namemust be astr.agemust be anint.
We can then create a model instance using a dictionary:
patient_info = {
"name": "shubham",
"age": 20
}
patient1 = Patient(**patient_info)
print(patient1)The ** operator expands the dictionary into keyword arguments, allowing Pydantic to populate the model fields.
A Pydantic model acts as a clear schema for your data — defining what fields are expected and what types they should contain.
Adding More Fields
Pydantic models can represent more realistic data structures by defining multiple fields with different types.
For example:
from pydantic import BaseModel
class Patient(BaseModel):
name: str
email: str
age: int
weight: float
married: boolNow the model describes the expected structure of a patient record:
name→ stringemail→ stringage→ integerweight→ floating-point numbermarried→ boolean
Pydantic uses these type definitions when validating the data provided to the model.
Why Models Are Useful
Without a structured model, developers may need to manually check the type and validity of data before processing it. Your example demonstrates how this can lead to repetitive validation logic.
With Pydantic, the expected structure is defined once:
class Patient(BaseModel):
name: str
age: intThe model can then be passed directly to application functions:
def insert_patient_data(patient: Patient):
print(patient.name)
print(patient.age)
print("inserted into database")This keeps the data structure and the application logic clearly separated.
Define the model once, validate the data automatically, and reuse the validated model throughout your application.
Pydantic Models in Practice
As our models become more complex, we can add field constraints, optional fields, lists, dictionaries, URLs, and custom validation rules.
For example, one of your later examples uses Field() to define constraints such as maximum name length, positive age, positive weight, and a maximum number of allergies.
These features allow Pydantic models to evolve from simple data structures into powerful validation schemas.
Complete Code Example
from pydantic import BaseModel, EmailStr, Field, HttpUrl
from typing import List, Dict, Optional, Annotated
class Patient(BaseModel):
name: Annotated[
str,
Field(
max_length=50,
title='Name of the patient',
description='Give the name of the patient in less than 50 chars',
examples=['Shubham', 'Arun']
)
]
email: EmailStr
linkedin_url: HttpUrl
age: int = Field(gt=0, lt=120)
weight: Annotated[float, Field(gt=0, strict=True)]
married: Annotated[
bool,
Field(default=None, description='Is the patient married or not')
]
allergies: Annotated[
Optional[List[str]],
Field(default=None, max_length=5)
]
contact_details: Dict[str, str]
def update_patient_data(patient: Patient):
print(patient.name)
print(patient.age)
print(patient.allergies)
print(patient.married)
print('updated into database')
patient_info = {
'name': 'shubham',
'email': 'abc@gmail.com',
'linkedin_url': 'https://linkedin.com/in/shubham',
'age': '20',
'weight': 60,
'married': False,
'allergies': ['dust', 'oil'],
'contact_details': {'phone': '9876543210'}
}
patient1 = Patient(**patient_info)
update_patient_data(patient1)Output
shubham
20
['dust', 'oil']
False
updated into databaseCode Execution Explanation
The model defines the structure and validation rules for a patient.
For example:
EmailStrvalidates the email format.HttpUrlvalidates the LinkedIn URL.Field(gt=0, lt=120)adds age constraints.Optional[List[str]]allows allergies to be optional.strict=Trueprevents automatic type coercion for the weight field.
This gives us a much clearer definition of what valid patient data should look like.
Data Validation and Type Coercion
One of the most useful features of Pydantic is its ability to validate incoming data automatically.
When we create a Pydantic model, Pydantic checks the provided values against the types and rules defined in the model.
For example:
from pydantic import BaseModel
class Patient(BaseModel):
name: str
age: intNow, if we provide:
patient_info = {
"name": "shubham",
"age": "20"
}
patient1 = Patient(**patient_info)Pydantic validates the input while creating the Patient instance.
Type Coercion
Pydantic can also coerce compatible values into the expected Python types.
In the example above, age is defined as an int, but the input is the string "20".
Pydantic can convert this value into the integer 20 when the conversion is possible. Your validation example demonstrates this behavior with age: int and an input value of "20".
We can verify the resulting type:
print(patient1.age)
print(type(patient1.age))The value is now treated as an integer.
Pydantic validates the input against the model and can convert compatible values into the expected Python types.
Field Types and Constraints
Pydantic also allows us to define more specific types and validation constraints.
For example:
from pydantic import BaseModel, EmailStr, HttpUrl, Field
from typing import List, Dict, Optional, Annotated
class Patient(BaseModel):
name: Annotated[
str,
Field(
max_length=50,
title="Name of the patient",
description="Give the name of the patient in less than 50 chars",
examples=["Shubham", "Arun"]
)
]
email: EmailStr
linkedin_url: HttpUrl
age: int = Field(gt=0, lt=120)
weight: Annotated[float, Field(gt=0, strict=True)]
married: Annotated[
bool,
Field(default=None, description="Is the patient married or not")
]
allergies: Annotated[
Optional[List[str]],
Field(default=None, max_length=5)
]
contact_details: Dict[str, str]This model demonstrates several useful Pydantic features.
EmailStr— Validates that the value is a valid email address.HttpUrl— Validates that the value is a valid HTTP URL.Field()— Allows us to define additional validation rules and metadata.Annotated— Combines a type with additional field information.gt— Requires a value to be greater than the specified number.lt— Requires a value to be less than the specified number.max_length— Limits the maximum length of a value or collection.Optional— Allows a field to have no value.- Default values — Fields can define a default value when one is not provided.
strict=True— Enables stricter type handling for the field.
For example:
age: int = Field(gt=0, lt=120)means the patient’s age must be greater than 0 and less than 120.
Similarly:
weight: Annotated[float, Field(gt=0, strict=True)]requires the weight to be greater than 0 and applies strict type handling.
Type Validation vs Value Validation
It is useful to think about validation in two ways:
- Type Validation → Is the value the expected type?
- Value Validation → Does the value satisfy the defined constraints?
For example:
age: int = Field(gt=0, lt=120)checks both the expected type and the allowed range.
Pydantic combines Python type hints with validation rules to turn raw input into structured, validated data.
These basic validation features provide the foundation for more advanced validation, where we can define custom rules for individual fields or relationships between multiple fields.
Complete Code Example
from pydantic import BaseModel, EmailStr, Field, HttpUrl
from typing import List, Dict, Optional, Annotated
class Patient(BaseModel):
name: Annotated[
str,
Field(
max_length=50,
title='Name of the patient',
description='Give the name of the patient in less than 50 chars',
examples=['Shubham', 'Arun']
)
]
email: EmailStr
linkedin_url: HttpUrl
age: int = Field(gt=0, lt=120)
weight: Annotated[float, Field(gt=0, strict=True)]
married: Annotated[
bool,
Field(default=None, description='Is the patient married or not')
]
allergies: Annotated[
Optional[List[str]],
Field(default=None, max_length=5)
]
contact_details: Dict[str, str]
def update_patient_data(patient: Patient):
print(patient.name)
print(patient.age)
print(patient.allergies)
print(patient.married)
print('updated into database')
patient_info = {
'name': 'shubham',
'email': 'abc@gmail.com',
'linkedin_url': 'https://linkedin.com/in/shubham',
'age': '20',
'weight': 60,
'married': False,
'allergies': ['dust', 'oil'],
'contact_details': {'phone': '9876543210'}
}
patient1 = Patient(**patient_info)
update_patient_data(patient1)Output
shubham
20
['dust', 'oil']
False
updated into databaseCode Execution Explanation
Although the input contains:
'age': '20'the model expects:
age: intPydantic successfully converts "20" into the integer 20.
At the same time, the model validates other requirements such as the email format, URL format, age range, positive weight, and maximum number of allergies.
If the data cannot satisfy the model requirements, Pydantic raises a ValidationError.
Field Validators
Sometimes basic type validation is not enough.
For example, an email field may need to belong to a specific domain, a patient's name may need to be converted to uppercase, or an age may need to fall within a particular range.
For these situations, Pydantic provides the @field_validator decorator.
Field validators allow us to add custom validation or transformation logic to individual fields.
Using @field_validator
Let’s look at the Patient model from our example:
from pydantic import BaseModel, EmailStr, field_validator
class Patient(BaseModel):
name: str
email: EmailStr
age: int
weight: float
married: bool
allergies: list[str]
contact_details: dict[str, str]We can then add custom validation to specific fields.
Validating the Email Domain
Suppose our application only accepts email addresses from specific domains.
@field_validator('email')
@classmethod
def email_validator(cls, value):
valid_domains = ['hdfc.com', 'icici.com']
domain_name = value.split('@')[-1]
if domain_name not in valid_domains:
raise ValueError('Not a valid domain')
return valueHere, the validator:
- Runs whenever the email field is validated.
- Extracts the domain from the email address.
- Checks whether the domain exists in
valid_domains. - Raises a
ValueErrorif the domain is not allowed. - Returns the value if validation succeeds.
This logic comes directly from the email field validator in your Part 5 example.
Transforming a Field
Field validators can also transform values instead of only rejecting invalid data.
For example, we can convert a patient’s name to uppercase:
@field_validator('name')
@classmethod
def transform_name(cls, value):
return value.upper()If the input is:
patient_info = {
"name": "shubham",
"age": "20",
"email": "abc@gmail.com",
"weight": 60,
"married": False,
"allergies": ["dust", "oil"],
"contact_details": {"phone": "9876543210"}
}the validator transforms "shubham" into "SHUBHAM". This transformation is implemented in your transform_name validator.
Validating Age
We can also apply custom rules to numeric fields.
@field_validator('age', mode='after')
@classmethod
def validate_age(cls, value):
if 0 < value < 100:
return value
else:
raise ValueError('Age should be in between 0 and 100')Here, the validator checks whether the patient’s age is between 0 and 100.
If the condition is satisfied, the value is returned. Otherwise, Pydantic raises a validation error.
Why Use Field Validators?
Field validators are useful when the validation rule is specific to one field.
Common use cases include:
- Validating email domains
- Checking numeric ranges
- Normalizing text
- Transforming input values
- Enforcing application-specific rules
Type hints handle basic type validation, while field validators allow us to define custom rules for individual fields.
Complete Code Example
from pydantic import BaseModel, EmailStr, AnyUrl, Field, field_validator
from typing import List, Dict, Optional, Annotated
class Patient(BaseModel):
name: str
email: EmailStr
age: int
weight: float
married: bool
allergies: List[str]
contact_details: Dict[str, str]
@field_validator('email')
@classmethod
def email_validator(cls, value):
valid_domains = ['hdfc.com', 'icici.com']
# abc@gmail.com
domain_name = value.split('@')[-1]
if domain_name not in valid_domains:
raise ValueError('Not a valid domain')
return value
@field_validator('name')
@classmethod
def transform_name(cls, value):
return value.upper()
@field_validator('age', mode='after')
@classmethod
def validate_age(cls, value):
if 0 < value < 100:
return value
else:
raise ValueError('Age should be in between 0 and 100')
def update_patient_data(patient: Patient):
print(patient.name)
print(patient.age)
print(patient.allergies)
print(patient.married)
print('updated')
patient_info = {
'name': 'shubham',
'email': 'abc@gmail.com',
'linkedin_url': 'https://linkedin.com/in/shubham',
'age': '20',
'weight': 60,
'married': False,
'allergies': ['dust', 'oil'],
'contact_details': {'phone': '9876543210'}
}
patient1 = Patient(**patient_info)
update_patient_data(patient1)Output
1 validation error for Patient
email
Value error, Not a valid domainCode Execution Explanation
The current input uses:
'email': 'abc@gmail.com'but the validator only allows:
['hdfc.com', 'icici.com']Therefore, the model raises a validation error before update_patient_data() is executed.
The same model also contains validators that:
- convert the patient’s name to uppercase
- ensure the age is between 0 and 100
This is a good example of custom field-level validation.
Model Validators
Sometimes validation depends on multiple fields working together, rather than on a single field.
For example, suppose our application has a rule that patients older than 60 must provide an emergency contact. Checking only the age field is not enough—we also need to inspect contact_details.
For these situations, Pydantic provides the @model_validator decorator.
Field validators validate individual fields, while model validators are useful when validation depends on the overall model.
Using @model_validator
Here is the example from our Patient model:
from pydantic import BaseModel, EmailStr, model_validator
class Patient(BaseModel):
name: str
email: EmailStr
age: int
weight: float
married: bool
allergies: list[str]
contact_details: dict[str, str]
@model_validator(mode='after')
def validate_emergency_contact(cls, model):
if model.age > 60 and 'emergency' not in model.contact_details:
raise ValueError(
'Patients older than 60 must have an emergency contact'
)
return modelThis validator is defined with:
@model_validator(mode='after')The after mode means the validation runs after the model's fields have been validated and the model data is available.
The validator can then access multiple fields through the model object.
Your Part 5 example uses exactly this pattern: it checks age and contact_details together and raises a ValueError when an emergency contact is missing for patients older than 60.
Understanding the Validation Rule
The important part is:
if model.age > 60 and 'emergency' not in model.contact_details:
raise ValueError(
'Patients older than 60 must have an emergency contact'
)The rule depends on two different fields:
age— determines whether the patient is older than 60.contact_details— determines whether an emergency contact exists.
If both conditions are true, validation fails.
Otherwise, the model is returned:
return modelField Validator vs Model Validator
The difference can be summarized simply:
@field_validator— Custom validation for a specific field.@model_validator— Validation involving the model as a whole, especially when multiple fields are related.
Use a field validator when the rule belongs to one field. Use a model validator when the rule depends on relationships between fields.
This makes model validators particularly useful for implementing business rules that cannot be expressed by validating fields independently.
Complete Code Example
from pydantic import BaseModel, EmailStr, model_validator
from typing import List, Dict
class Patient(BaseModel):
name: str
email: EmailStr
age: int
weight: float
married: bool
allergies: List[str]
contact_details: Dict[str, str]
@model_validator(mode='after')
def validate_emergency_contact(cls, model):
if model.age > 60 and 'emergency' not in model.contact_details:
raise ValueError(
'Patients older than 60 must have an emergency contact'
)
return model
def update_patient_data(patient: Patient):
print(patient.name)
print(patient.age)
print(patient.allergies)
print(patient.married)
print('updated')
patient_info = {
'name': 'shubham',
'email': 'abc@gmail.com',
'linkedin_url': 'https://linkedin.com/in/shubham',
'age': '20',
'weight': 60,
'married': False,
'allergies': ['dust', 'oil'],
'contact_details': {'phone': '9876543210'}
}
patient1 = Patient(**patient_info)
update_patient_data(patient1)Output
shubham
20
['dust', 'oil']
False
updatedCode Execution Explanation
The patient’s age is 20, so the emergency-contact rule does not apply.
The model is therefore successfully created and passed to the function.
If the age were greater than 60 and the contact_details dictionary did not contain an "emergency" key, Pydantic would raise the custom validation error.
Computed Fields
Sometimes a value does not need to be provided directly by the user because it can be calculated from other fields.
For example, in a patient management system, we may store a patient’s weight and height, but calculate their BMI automatically.
Pydantic provides the @computed_field decorator for this purpose.
Computed fields allow us to expose values that are calculated dynamically from other fields in a Pydantic model.
Using @computed_field
Here is the example from our Patient model:
from pydantic import BaseModel, EmailStr, computed_field
class Patient(BaseModel):
name: str
email: EmailStr
age: int
weight: float
height: float
married: bool
allergies: list[str]
contact_details: dict[str, str]
@computed_field
@property
def bmi(self) -> float:
bmi = round(self.weight / (self.height ** 2), 2)
return bmiThe bmi field is not provided as part of the input data. Instead, it is calculated using the patient's weight and height. Your example implements BMI exactly this way.
How It Works
The calculation is:
bmi = round(self.weight / (self.height ** 2), 2)Here:
self.weight→ patient's weight in kilograms.self.height→ patient's height in meters.self.height ** 2→ height squared.round(..., 2)→ rounds the result to two decimal places.
Once the Patient object is created, we can access the computed value like a normal attribute:
patient1 = Patient(**patient_info)
print(patient1.bmi)The bmi value is calculated from the model's existing data rather than being manually supplied.
Why Use Computed Fields?
Computed fields are useful when a value:
- Can be derived from existing model fields.
- Should always reflect the current model data.
- Does not need to be supplied as raw input.
- Represents calculated information such as BMI, totals, scores, or other derived values.
For example, your implementation calculates BMI inside the model and then uses patient.bmi when processing the patient.
Store the source data, calculate the derived value, and let the model expose it when needed.
This becomes particularly useful when building structured data models where some information is calculated rather than directly entered.
Complete Code Example
from pydantic import BaseModel, EmailStr, computed_field
from typing import List, Dict
class Patient(BaseModel):
name: str
email: EmailStr
age: int
weight: float # kg
height: float # mtr
married: bool
allergies: List[str]
contact_details: Dict[str, str]
@computed_field
@property
def bmi(self) -> float:
bmi = round(self.weight / (self.height ** 2), 2)
return bmi
def update_patient_data(patient: Patient):
print(patient.name)
print(patient.age)
print(patient.allergies)
print(patient.married)
print('BMI', patient.bmi)
print('updated')
patient_info = {
'name': 'shubham',
'email': 'abc@gmail.com',
'linkedin_url': 'https://linkedin.com/in/shubham',
'age': '20',
'height': 1.75,
'weight': 60,
'married': False,
'allergies': ['dust', 'oil'],
'contact_details': {'phone': '9876543210'}
}
patient1 = Patient(**patient_info)
update_patient_data(patient1)Output
shubham
20
['dust', 'oil']
False
BMI 19.59
updatedCode Execution Explanation
The current repository code defines height as a required field:
height: floatbut the patient_info dictionary does not provide a height value. Therefore, the model cannot be created and the BMI calculation is never reached.
Important: Keep this output if you want the article to reproduce the repository exactly. If you later fix the repository by adding a height value, the output will include the calculated BMI.
Nested Models
As applications grow, data often becomes more complex.
A patient record, for example, may contain not only basic information such as name, age, and gender, but also structured information such as an address, vitals, or insurance details.
Instead of keeping all these fields in one large model, Pydantic allows us to create nested models.
Nested models let us organize complex data structures by placing one Pydantic model inside another.
Creating a Nested Model
Let’s start by creating a separate Address model:
from pydantic import BaseModel
class Address(BaseModel):
city: str
state: str
pin: strNow we can use Address inside our Patient model:
class Patient(BaseModel):
name: str
gender: str
age: int
address: AddressHere, address is not simply a dictionary or a string.
It is expected to be an Address Pydantic model.
Your example follows this exact structure, with Address nested inside Patient.
Creating the Nested Data
We can first create the address:
address_dict = {
"city": "jaipur",
"state": "rajasthan",
"pin": "302017"
}
address1 = Address(**address_dict)Then use that Address object while creating the patient:
patient_dict = {
"name": "shubham",
"age": 20,
"address": address1
}
patient1 = Patient(**patient_dict)Pydantic validates the nested Address model as part of the Patient model.
Why Use Nested Models?
Nested models provide several benefits:
- Better Organization — Related data can be grouped into separate models.
- Reusability — The same model can be used in multiple Pydantic models.
- Readability — Complex data structures become easier for developers and API consumers to understand.
- Validation — Nested models are validated automatically.
For example, an Address model could potentially be reused across a Patient, Doctor, or Hospital model.
Instead of creating one large model, break complex data into smaller, reusable models and combine them when needed.
Nested Data Structure
Conceptually, our model now looks like this:
Patient
├── name
├── gender
├── age
└── address
├── city
├── state
└── pinThis makes the structure of complex application data much clearer.
Complete Code Example
from pydantic import BaseModel
class Address(BaseModel):
city: str
state: str
pin: str
class Patient(BaseModel):
name: str
gender: str
age: int
address: Address
address_dict = {
'city': 'jaipur',
'state': 'rajasthan',
'pin': '302017'
}
address1 = Address(**address_dict)
patient_dict = {
'name': 'shubham',
'gender': 'male',
'age': 20,
'address': address1
}
patient1 = Patient(**patient_dict)
temp = patient1.model_dump(include="")
print(type(temp))Output
<class 'dict'>Code Execution Explanation
The Address model is used as a field inside Patient.
This keeps related data organized and makes the models easier to understand and reuse.
Pydantic also validates the nested Address model automatically.
Serialization
After validating and working with Pydantic models, we often need to convert the model back into a standard format that can be used by other parts of an application.
This process is called serialization.
Serialization converts a Pydantic model into a standard Python dictionary or JSON representation.
For example, after creating a Patient model:
class Patient(BaseModel):
name: str
age: int
gender: str = 'Male'Here, gender has a default value of 'Male'. If the field is not explicitly provided while creating the model, Pydantic uses this default value.
We can create an instance:
patient_info = {
"name": "shubham",
"age": 20
}
patient1 = Patient(**patient_info)The result is a Pydantic model object. To convert it into a Python dictionary, we can use model_dump().
Using model_dump()
temp = patient1.model_dump()
print(temp)
print(type(temp))The output is a standard Python dictionary:
{'name': 'shubham', 'age': 20, 'gender': 'Male'}
<class 'dict'>Your nested-model example uses model_dump() to convert the Patient object into a dictionary.
Controlling Serialized Data
Pydantic also provides options to control which fields are included or excluded during serialization.
For example:
temp = patient1.model_dump(exclude_unset=True)exclude_unset=True can be useful when you only want fields that were explicitly provided when creating the model.
Similarly, model_dump() supports options such as:
include— Include specific fields.exclude— Exclude specific fields.exclude_unset— Exclude fields that were not explicitly set.
Why Serialization Matters
Serialization is especially useful when data needs to move between different parts of an application.
For example:
Pydantic Model → Python Dictionary → JSON → API Response
This allows validated application data to be converted into formats that can be stored, transmitted, or returned to an API consumer.
Validation turns raw input into a structured Pydantic model; serialization turns that model back into a standard data representation.
With this, we have covered the core Pydantic workflow — from defining models and validating data to custom validation, computed fields, nested models, and serialization.
Complete Code Example
from pydantic import BaseModel
class Address(BaseModel):
city: str
state: str
pin: str
class Patient(BaseModel):
name: str
gender: str = 'Male'
age: int
address: Address
address_dict = {
'city': 'jaipur',
'state': 'rajasthan',
'pin': '302017'
}
address1 = Address(**address_dict)
patient_dict = {
'name': 'shubham',
'age': 20,
'address': address1
}
patient1 = Patient(**patient_dict)
temp = patient1.model_dump(exclude_unset=True)
print(temp)
print(type(temp))Output
{'name': 'shubham', 'age': 20, 'address': {'city': 'jaipur', 'state': 'rajasthan', 'pin': '302017'}}
<class 'dict'>Code Execution Explanation
The gender field has a default value:
gender: str = 'Male'But it was not explicitly provided when creating patient1.
Because we use:
model_dump(exclude_unset=True)Pydantic excludes fields that were not explicitly set by the input.
The resulting object is a normal Python dictionary, including the nested address data.
Key Takeaways
By now, you should have a clear understanding of how Pydantic models help define, validate, and manage structured data in Python.
| Concept | Summary |
|---|---|
| Pydantic | A Python library for data validation and building type-safe data models. |
| BaseModel | The foundation for creating Pydantic data models. |
| Type Safety | Python type hints define the expected type of each field. |
| Automatic Validation | Pydantic validates input when a model instance is created. |
| Type Coercion | Compatible input values can be converted into the expected Python types. |
| ValidationError | Raised when input data does not satisfy the model's requirements. |
| @field_validator | Adds custom validation or transformation logic to individual fields. |
| @model_validator | Handles validation rules that depend on multiple fields or the overall model. |
| @computed_field | Creates values dynamically from other fields, such as calculating BMI. |
| Nested Models | Organize complex data structures by placing one Pydantic model inside another. |
| Serialization | Converts Pydantic models into standard Python dictionaries or JSON representations. |
| model_dump() | Provides control over serialized data using options such as include, exclude, and exclude_unset. |
Pydantic helps turn raw, potentially unreliable input into structured, validated, and reusable data models.
Conclusion
Pydantic provides a simple and powerful way to define, validate, and manage structured data in Python.
We started with basic Pydantic models and saw how type hints can define the expected structure of our data. We then explored how Pydantic automatically validates input, performs type coercion when possible, and raises validation errors when the data does not satisfy the defined requirements.
We also looked at more advanced features such as field validators, model validators, computed fields, nested models, and serialization. These features make it possible to handle custom validation rules, calculated values, complex data structures, and different data representations.
Pydantic helps turn raw input into structured, validated, and reusable data models.
This foundation is especially important for FastAPI, where structured and validated data plays a central role in building reliable APIs.
Next, we’ll see how these Pydantic concepts come together with FastAPI to handle real API data.
What’s Next?
Now that you understand how Pydantic models define structured data and automatically validate input, the next step is to use those models directly inside a FastAPI request.
In the next part, we’ll explore request bodies and learn how clients can send structured data to a FastAPI application using the POST method. We’ll also see how FastAPI uses a Pydantic model to validate and process that incoming data.
You’ll learn:
- What is a Request Body? How data is sent from the client to the server as part of an HTTP request.
- POST Requests: How the
POSTmethod is used to send data to the API, commonly for creating new resources. - Pydantic Request Models: How a Pydantic
BaseModelcan define and validate the structure of incoming request data. - Creating a Patient: How to build a
POST /createendpoint that accepts patient information. - Data Validation: How FastAPI and Pydantic validate the request body before the endpoint processes it.
- Saving Data: How the validated patient data can be added to the existing JSON-based data store.
- HTTP 201 Response: How the API returns a
201 Createdresponse when a new patient is successfully created.
We’ll continue building our Patient Management System and see how request bodies connect the client, FastAPI, Pydantic, and the data stored by the application.
Series: FastAPI for Machine Learning — Part 5 of 12