You wrote a clean API endpoint and it worked on your machine. Then someone sent "age": "twenty-eight" in a JSON body, your code did math on a string, and the request blew up three functions deep. The Python Pydantic library stops that at the door: v2 checks every field as data arrives, coerces "42" into a real 42, and raises one clear error naming the exact bad field.
“There are two ways of constructing a software design: one way is to make it so simple that there are obviously no deficiencies, and the other way is to make it so complicated that there are no obvious deficiencies.”
C. A. R. Hoare, 1980 Turing Award Lecture
Last Updated: July 2026 | Tested on: Python 3.14.6, Pydantic 2.13.4 | Difficulty: Intermediate | Reading Time: 17 minutes
Think of Pydantic like the bouncer at a club. The dataclass from the dataclasses tutorial is a door with no bouncer: anybody walks in, dressed however they like. If someone passes age="not a number", the dataclass shrugs and stores the string. Pydantic is the bouncer who checks every guest at the door. Wrong type? Either it quietly fixes what it safely can (the string "42" becomes the integer 42) or it stops you cold with a ValidationError that says exactly which field failed and why.
Version 2 is a full rewrite with a core written in Rust, so it runs the same checks roughly 5 to 50 times faster than v1. That speed is why Pydantic sits underneath FastAPI, the most popular Python API framework. If you build APIs, parse JSON from outside sources, or load config from environment variables, Python Pydantic is the tool you reach for.
Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.
The diagram follows one piece of data through Pydantic v2’s pipeline. Raw input (a dict, a JSON string, or keyword arguments) enters the model at the top. First comes type coercion, where a string like "42" turns into the integer 42. Then your field validators run their per-field rules, and a model validator checks rules that span several fields at once. If everything passes, you get a validated model instance you can trust. If anything fails, Pydantic collects all the problems and raises one ValidationError that names each bad field.
The whole pipeline runs in the Rust core, which is why it is fast enough to sit in front of every API request. The examples below walk through each box in order.
Table of Contents
Install and Verify
Pydantic itself ships in one package. The settings feature (reading config from .env files and environment variables) moved into a separate package in v2, so install both up front.
📄 Terminal: install Pydantic v2 and pydantic-settings
pip install pydantic pydantic-settings
Now confirm the version. You want v2, not the old v1, because the API changed a lot between them. Every example in this post was tested on the version shown below.
📄 Terminal: confirm you are on Pydantic v2
python -c "import pydantic; print(pydantic.VERSION)"
▶ Output
2.13.4
What happened here: If you see a number starting with 2., you are good. If it starts with 1., the v2 syntax in this post (model_dump, field_validator, ConfigDict) will not work, so run pip install -U pydantic to upgrade. Your patch number may differ from 2.13.4 if you install later, and that is fine.
The Quick Win: Your First Python Pydantic Model
Here is the whole idea in one screen. You subclass BaseModel, list your fields with type hints, and Pydantic builds a validating constructor for you. No boilerplate, no manual if checks. In the example, two users named Rahul and Anvay sign up, and Pydantic checks both at the door.
📄 first_model.py: define and validate a user model
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
email: str
is_active: bool = True
# Valid data
user = User(name="Rahul", age=28, email="rahul@example.com")
print(user)
print(user.model_dump())
# Type coercion: the string "25" becomes the int 25
user2 = User(name="Anvay", age="25", email="anvay@example.com")
print(f"Age type: {type(user2.age)}") # <class 'int'>
▶ Output
name='Rahul' age=28 email='rahul@example.com' is_active=True
{'name': 'Rahul', 'age': 28, 'email': 'rahul@example.com', 'is_active': True}
Age type: <class 'int'>
What happened here: When you called User(...), Pydantic checked each value against its type hint before building the object. The is_active field had a default of True, so we did not have to pass it. The interesting line is the last one. We passed age="25", a string, but the field is typed int, so Pydantic coerced it into the real integer 25. That is the bouncer fixing a small problem at the door instead of turning the guest away. Coercion only happens when it is safe and obvious. A string of digits becomes an int. A word like "twenty" would not, as you are about to see.
Validation Errors That Actually Help
What makes Python Pydantic worth the install is what happens when data is wrong. Think of a good teacher grading an exam: instead of handing the paper back the moment one answer is wrong, they mark every mistake in a single pass so you can fix them all together. Pydantic grades your data the same way. Instead of a vague crash somewhere deep in your code, you get one error object that lists every bad field, what was wrong, and the value that caused it.
📄 validation_errors.py: what happens with bad data
from pydantic import BaseModel, ValidationError
class Product(BaseModel):
name: str
price: float
quantity: int
try:
product = Product(name="Widget", price="not-a-number", quantity=-5)
except ValidationError as e:
print(e)
▶ Output
1 validation error for Product
price
Input should be a valid number, unable to parse string as a number [type=float_parsing, input_value='not-a-number', input_type=str]
For further information visit https://errors.pydantic.dev/2.13/v/float_parsing
What happened here: The message reads like a bug report your own code wrote for you. It names the model (Product), the field (price), the reason ("not-a-number" cannot become a float), and even links to the docs page for that error type. Notice that quantity=-5 did not raise anything. We never told Pydantic that quantity must be positive, so -5 is a perfectly valid integer as far as the type hint goes. To add a rule like “quantity cannot be negative”, you need a field validator, which is the next section. And if several fields are wrong at once, this same error lists them all in one go instead of stopping at the first one.
Field Validators: Custom Rules Per Field
Type hints handle “is this an int?” Field validators handle “is this a sensible int?” It is the difference between an airport officer checking that you have a passport at all and checking the rules printed inside it, like whether it has expired. You attach a method to one field with the @field_validator decorator, and it runs every time that field is set. The method can reject the value by raising ValueError, or clean it up and return a fixed version. In the example below, an HR (Human Resources) system takes in a new employee named Niranjan Raut, typed with stray spaces and no capital letters.
📄 validators.py: @field_validator for custom rules
from pydantic import BaseModel, field_validator
class Employee(BaseModel):
name: str
age: int
salary: float
@field_validator("age")
@classmethod
def age_must_be_in_range(cls, v):
if v < 18 or v > 65:
raise ValueError("Age must be between 18 and 65")
return v
@field_validator("name")
@classmethod
def name_must_not_be_empty(cls, v):
if not v.strip():
raise ValueError("Name cannot be empty or whitespace")
return v.strip().title()
emp = Employee(name=" niranjan raut ", age=26, salary=70000)
print(emp.name) # cleaned and title-cased
▶ Output
Niranjan Raut
What happened here: Two things happened on the way in. The age validator checked that 26 sits between 18 and 65 and let it through untouched. The name validator did real work: it took the messy input " niranjan raut ", stripped the spaces, and title-cased it into "Niranjan Raut". Because the validator returns the cleaned value, that cleaned version is what gets stored on the model. A validator that returns a value transforms the data; a validator that raises ValueError rejects it. The @classmethod line under @field_validator is the standard pattern (validators run on the class, not on an instance), and the order matters: @field_validator goes on top, @classmethod right below it.
One small but important detail: validators receive the value as v by convention, but you can name it anything. For rules that need to compare several fields against each other (say, “end date must be after start date”), reach for @model_validator instead, which runs once after all the individual fields are in.
Serialization: model_dump and model_dump_json
Validation gets data in. Serialization gets it back out, as a plain dict or a JSON string, ready to send over the wire or save to a file. Think of packing a suitcase: at home your things sit however you like, but to travel they get folded into a standard shape, and at the destination they are unpacked back to normal. This is the round trip every API does: parse the incoming JSON, work with a typed object, then hand back JSON in the response. The example below tracks a workshop with three attendees, Pravin, Aditi, and Prathamesh, then parses a second event straight from JSON where a user named Anvi signed up.
📄 serialization.py: convert models to dicts and JSON, and back
from pydantic import BaseModel
from datetime import datetime
class Event(BaseModel):
name: str
date: datetime
attendees: list[str]
event = Event(
name="Python Workshop",
date="2026-04-15T10:00:00",
attendees=["Pravin", "Aditi", "Prathamesh"]
)
# To a Python dict (datetime stays a datetime object)
print(event.model_dump())
# To a JSON string (datetime becomes an ISO 8601 string)
print(event.model_dump_json(indent=2))
# From a JSON string back into a validated model
json_str = '{"name": "Meetup", "date": "2026-05-01T18:00:00", "attendees": ["Anvi"]}'
event2 = Event.model_validate_json(json_str)
print(event2.name)
▶ Output
{'name': 'Python Workshop', 'date': datetime.datetime(2026, 4, 15, 10, 0), 'attendees': ['Pravin', 'Aditi', 'Prathamesh']}
{
"name": "Python Workshop",
"date": "2026-04-15T10:00:00",
"attendees": [
"Pravin",
"Aditi",
"Prathamesh"
]
}
Meetup
What happened here: Watch the date field across the three outputs. We passed it in as a string, "2026-04-15T10:00:00", and Pydantic coerced it into a real datetime object. model_dump() gives you a dict with the value still a datetime, handy when you stay inside Python. model_dump_json() serializes the same model to a JSON string and turns that datetime into a standard ISO 8601 string, because JSON has no datetime type.
Going the other way, model_validate_json() takes raw JSON text and runs it through the full validation pipeline, so event2 is just as trustworthy as if you had built it by hand. These four methods (model_dump, model_dump_json, model_validate, model_validate_json) are the v2 names. If you find old tutorials using .dict() or .parse_obj(), those are the v1 spellings and are deprecated.
Settings from Environment Variables
Hard-coding a database URL or an API key in your source is how secrets end up on GitHub. The usual fix is to read config from environment variables, but then you are back to os.environ.get() calls and manual type conversion everywhere. pydantic-settings turns that whole chore into a model. It works like laying out every ingredient before you start cooking: you find out a spice is missing before the pan is hot, not halfway through the recipe. You declare the config you expect, and Pydantic pulls each value from the environment, coerces it to the right type, and validates it, all at startup.
📄 settings.py: load config from environment variables
import os
from pydantic_settings import BaseSettings, SettingsConfigDict
class AppSettings(BaseSettings):
app_name: str = "MyApp"
debug: bool = False
database_url: str
api_key: str
# Read from a .env file too, if one exists next to your code
model_config = SettingsConfigDict(env_file=".env")
# In real life these live in your shell or a .env file. We set them
# here so the example runs end to end and prints real output.
os.environ["DEBUG"] = "true"
os.environ["DATABASE_URL"] = "sqlite:///app.db"
os.environ["API_KEY"] = "secret-key-123"
settings = AppSettings()
print(settings.app_name)
print(settings.debug, type(settings.debug).__name__)
print(settings.database_url)
print(settings.api_key)
▶ Output
MyApp True bool sqlite:///app.db secret-key-123
What happened here: AppSettings() took no arguments, yet every field got filled. Pydantic matched each field name to an environment variable, case-insensitively, so database_url picked up DATABASE_URL. The line worth staring at is the second one. The environment variable DEBUG was the string "true", but the field is typed bool, so it came out as a real Python True, not the string "true" (which would be truthy for the wrong reason).
The app_name field used its default of "MyApp" because we never set that variable. And if a required field like api_key were missing, AppSettings() would raise a ValidationError the moment your app started, instead of failing mysteriously an hour into production. That is the whole point: catch a bad config at boot, not at 2am.
Ecosystem and Where to Go Next
Python Pydantic rarely travels alone. Here is where it fits with the tools around it:
- FastAPI uses Pydantic models as request and response schemas. Type a route parameter as a
BaseModeland FastAPI validates the incoming JSON, generates OpenAPI docs, and serializes the response for free. The skills in this post are exactly what you use there. - pydantic-settings is the config sibling we used above. Pair it with a
.envfile in development and real environment variables in production. - model_json_schema() gives you a JSON Schema document straight from any model, which is what powers FastAPI’s automatic docs and tool-calling schemas for LLMs (Large Language Models).
- Dataclasses and attrs are the lighter alternatives. When you do not need validation (internal data that you already trust), a plain dataclass is faster to construct. The next post compares all three head to head.
Common Mistakes
Mistake 1: Expecting a dataclass to validate
This is the trap that sends people to Pydantic in the first place. A dataclass stores whatever you hand it, no questions asked. Pydantic checks first. If you are weighing the two for a project, the dataclasses vs Pydantic comparison settles the tradeoff feature by feature.
❌ The difference, side by side
# Dataclass: NO validation, NO coercion
from dataclasses import dataclass
@dataclass
class DC:
age: int
dc = DC(age="not a number") # stored silently, no complaint
print(f"Dataclass stored: {dc.age!r} (type: {type(dc.age).__name__})")
# Pydantic: VALIDATES and COERCES
from pydantic import BaseModel
class PM(BaseModel):
age: int
# PM(age="not a number") # this would raise ValidationError
pm = PM(age="42") # coerced to the int 42
print(f"Pydantic stored: {pm.age!r} (type: {type(pm.age).__name__})")
▶ Output
Dataclass stored: 'not a number' (type: str) Pydantic stored: 42 (type: int)
Why: A dataclass type hint is documentation, not a rule. It tells humans what age should be, but Python never enforces it, so the string "not a number" sailed right in. Pydantic treats the same hint as a contract: it coerced "42" into 42 and would have rejected "not a number" outright. Use a dataclass for data you already trust. Use Pydantic for data from the outside world.
Mistake 2: Using v1 method names on a v2 model
🚫 Old v1 API (deprecated)
user.dict() # v1 spelling User.parse_obj(data) # v1 spelling User.parse_raw(json_text) # v1 spelling
✅ Current v2 API
user.model_dump() # v2 spelling User.model_validate(data) # v2 spelling User.model_validate_json(json_text) # v2 spelling
Why: Pydantic v2 renamed the core methods to a consistent model_* prefix. The old names still work for now but print deprecation warnings and will eventually be removed. Most “my Pydantic example does not work” questions online are really v1 code running on a v2 install. Stick to the model_* names and you sidestep the whole mess.
Mistake 3: Putting @classmethod above @field_validator
🚫 Wrong order
@classmethod
@field_validator("age") # decorators applied bottom-up, so this breaks
def check_age(cls, v):
return v
✅ Right order
@field_validator("age") # this goes on top
@classmethod
def check_age(cls, v):
return v
Why: Python applies decorators from the bottom up, so @field_validator must sit on top to wrap the already-classmethod-ified function. Flip them and Pydantic does not register the validator correctly. The fix is to remember the rule: @field_validator first, @classmethod second, every time.
Conclusion
You now have the full Python Pydantic v2 toolkit: define a BaseModel with type hints and get a validating constructor for free, let safe coercion turn "42" into 42, add per-field rules with @field_validator, round-trip data with model_dump and model_validate_json, and load typed config from environment variables with pydantic-settings. The habit to take away: validate at the edge, trust the inside. Up next, we put Pydantic side by side with dataclasses and attrs so you know exactly which one to reach for in each situation. For the full learning path from basics to AI/ML, visit the Python + AI/ML tutorial series home.
Frequently Asked Questions
What is Pydantic in Python?
Python Pydantic is a data validation library that uses type hints to validate data at runtime. You define a model with type-annotated fields, and Pydantic validates, coerces, and serializes data for you. It is the validation layer underneath FastAPI, so anyone building Python APIs runs into it quickly.
What is the difference between Pydantic and dataclasses?
A dataclass generates boilerplate methods but never checks the data: a string passed where an int is expected gets stored as-is. Pydantic validates every field on creation, coerces compatible types (the string ’42’ becomes the int 42), and adds JSON serialization. Use dataclasses for internal data you already trust, and Pydantic for data from APIs, files, or users.
What is type coercion in Pydantic?
Coercion is Pydantic safely converting a value to the declared type when the conversion is unambiguous. The string ’42’ becomes the integer 42, and an ISO date string becomes a datetime object. A value that cannot be converted cleanly, like ‘not a number’ for a float field, raises a ValidationError instead.
How do I use Pydantic for settings management?
Install pydantic-settings, then create a class that inherits from BaseSettings, declare your config fields, and set model_config = SettingsConfigDict(env_file=’.env’). Pydantic reads each value from the matching environment variable or .env entry, coerces it to the right type, and validates it when your app starts.
Is Pydantic v2 faster than v1?
Yes, roughly 5 to 50 times faster depending on the model. Pydantic v2 has a core written in Rust (pydantic-core) that runs validation and serialization at near-C speed. The Python API was renamed (model_dump, model_validate, field_validator), so v1 code needs small changes to run on v2.
How do I convert a Pydantic model to JSON?
Call model.model_dump_json() on the instance to get a JSON string, with indent=2 for pretty output. Use model.model_dump() instead if you want a plain Python dict. To go the other way, Model.model_validate_json(json_string) parses JSON text back into a validated model.
Interview Questions on Pydantic
These come from real screens and onsites. Practice answering before you read each answer.
Q: A client sends extra fields your Pydantic model does not declare, and they silently vanish. How do you make the model reject them instead?
By default a BaseModel ignores unknown fields (extra="ignore"). Set model_config = ConfigDict(extra="forbid") on the model and any undeclared field raises a ValidationError naming that field. This is a common hardening step for public APIs, because silently dropped fields usually mean the client has a typo you will never hear about. There is also extra="allow", which stores unknown fields on the instance.
Q: What is the difference between @field_validator and @model_validator, and when does each run?
A @field_validator is attached to one or more named fields and runs when that field is validated, so it can only see that field’s value. A @model_validator runs once for the whole model (in mode="after", after every field has passed) and can compare fields against each other, like checking that end_date is later than start_date. Rule of thumb: one field, field validator; relationships between fields, model validator.
Q: Your service validates a batch of a million records and Central Processing Unit (CPU) time spikes. What do you look at first?
First check that the model class is defined once at module level, not rebuilt inside the loop, because building a model class compiles its Rust validation schema and that is the expensive part. Then validate the whole batch with TypeAdapter(list[MyModel]).validate_python(records) instead of looping in Python. If the records come from a source you already validated (say, your own database), MyModel.model_construct() skips validation entirely, but only use it on data you genuinely trust.
Q: How do you turn off type coercion so that age=”25″ (a string) is rejected instead of converted?
Enable strict mode. Set model_config = ConfigDict(strict=True) to make the whole model reject any value that is not already the declared type, or use Field(strict=True) to do it for a single field. In strict mode the string "25" for an int field raises a ValidationError instead of becoming 25. Lax (coercing) mode is the default because most real-world input arrives as JSON strings.
Q: Your BaseSettings class works on your laptop but crashes at startup on the server with a ValidationError saying database_url is missing. What do you check first?
Check whether the value came from a .env file locally, because .env files are usually gitignored and never reach the server, so the variable simply does not exist there. Confirm the environment variable is actually set in the server’s environment (DATABASE_URL matches the field name case-insensitively), and check for an env_prefix in SettingsConfigDict that would change the expected name. The crash itself is Pydantic working as designed: it failed at boot instead of mid-request.
Q: What does model_construct() do, and why is it dangerous on user input?
model_construct() builds a model instance without running any validation or coercion, which makes it fast for data you have already verified. On user input it is dangerous because every guarantee the model normally gives you disappears: a string can sit in an int field, required fields can be missing, and the bad data only surfaces later, far from the code that let it in. Reserve it for trusted internal sources and benchmarked hot paths.
Further reading: for the full reference, see Pydantic documentation.
Related Posts
Previous: Python: Dataclasses, Modern Data Containers
Next: Python: Dataclasses vs Pydantic vs attrs (Which Data Class Library?)
Series Home: Python + AI/ML Tutorial Series

No comment