Pydantic Basics: BaseModel, Field Types and Validation
Pydantic is a Python library that checks your data using normal Python type hints. You describe what your data should look like in a small class, and Pydantic makes sure every value really matches that description. If something is wrong, it tells you exactly which field is wrong and why.
Think of Pydantic as the security guard at the entrance of your program. Data coming from a form, a JSON API, a file or a database must show its "ID card" (the type) before it is allowed inside. If the ID is fine, it goes in. If not, the guard stops it and writes a clear report.
Why learn Pydantic?
- Data from outside your program (users, APIs, files,
.envfiles) is always untrusted. Pydantic checks it in one line. - FastAPI is built on Pydantic. Every request body and response in FastAPI is a Pydantic model, so learn Pydantic first and FastAPI becomes easy.
- It converts data for you: the text
"42"becomes the number42,"2024-04-01"becomes a realdate. - It turns your objects into dictionaries and JSON with one call.
About this Pydantic series
This series follows the PydanticTutorial project: seven Python files, one lesson each. Every file is written to be read from top to bottom and run. In this website series we go through each file section by section, explain the code in simple words, and show the real output.
| # | File | Topic | Needs SQL Server? |
|---|---|---|---|
| 01 | 01_basemodel_basics.py | BaseModel, types, defaults, Optional, ValidationError (this lesson) | No |
| 02 | 02_field_constraints.py | Field() rules: length, regex, ranges | No |
| 03 | 03_validators.py | Your own validation logic | No |
| 04 | 04_serialization.py | Model to dict / JSON, aliases | No |
| 05 | 05_orm_from_attributes.py | SQLAlchemy objects to Pydantic | Yes |
| 06 | 06_nested_and_dto_schemas.py | Nested models, Create/Update/Read schemas, CRUD | Yes |
| 07 | 07_settings.py | Settings from .env | No |
Setup
The project uses Pydantic v2 (version 2.11 or newer). Create a virtual environment and install the packages from requirements.txt:
cd PydanticTutorial
python -m venv venv
.\venv\Scripts\Activate.ps1 # Windows PowerShell
pip install -r requirements.txt
python 01_basemodel_basics.py
The requirements.txt file contains:
pydantic[email]>=2.11 # [email] adds email-validator for EmailStr
pydantic-settings>=2.6
SQLAlchemy>=2.0
pyodbc
Lessons 01 to 04 and 07 need only Python. Lessons 05 and 06 also use SQL Server through SQLAlchemy.
Every lesson file has a small helper at the top called section(). It only prints a heading like === 1. Your first model === so the output is easy to read. You will see these headings in the outputs below.
1. Your first model
A Pydantic model is a class that inherits from BaseModel. Each line inside the class is a field: a name, a type and (optionally) a default value.
from pydantic import BaseModel
class User(BaseModel):
id: int # required
name: str # required
email: str # required
age: int | None = None # optional: can be missing or None
is_active: bool = True # optional: default True
tags: list[str] = [] # optional: default empty list (safe in Pydantic)
user = User(id=1, name="Asha", email="asha@example.com")
print(user)
print(user.name) # read a field like a normal attribute
print(user.is_active) # default value was used
- A field without a default (
id,name,email) is required. - A field with a default (
age,is_active,tags) is optional; if you don't pass it, the default is used. - You read fields with a dot, exactly like a normal object:
user.name. - In normal Python classes,
tags: list = []as a default is dangerous because all objects share one list. Pydantic copies the default for each object, so it is safe here.
2. Pydantic converts values when it is safe
By default Pydantic works in "lax" mode: if a value has the wrong type but can be converted safely, Pydantic converts it for you.
user = User(id="42", name="Ravi", email="ravi@example.com", age="30", is_active="yes")
print(user)
print(type(user.id), type(user.age), type(user.is_active))
"42"became the integer42, and"30"became30."yes"becameTrue. Pydantic also accepts"true","1","on",1forTrue.
This is very useful because data from web forms, URLs and environment variables always arrives as text. Pydantic turns it into the right Python types.
3. ValidationError: when data is wrong
If a value cannot be converted safely, Pydantic raises a ValidationError. The best part: it reports all problems at once, not just the first one.
from pydantic import ValidationError
try:
User(id="abc", name="Kiran", age=25.7) # id is not a number, email is missing, age has decimals
except ValidationError as error:
print("Number of errors:", error.error_count())
print(error) # readable message
print("\nThe same errors as a list (useful for APIs):")
for item in error.errors():
print(f" field={item['loc']} type={item['type']} message={item['msg']}")
(The real output also prints a "For further information visit https://errors.pydantic.dev/..." link under each error; it is left out here to keep it short.)
error.error_count()gives the number of problems.print(error)gives a readable report.error.errors()gives a list of dictionaries. Each one hasloc(which field),type(a short error code) andmsg(the message). FastAPI sends exactly this list back to the client when a request is invalid.- Notice
age=25.7: Pydantic refuses to cut off the decimals, because that would change your data. "Safe" conversion only.
4. Required vs Optional: the important difference
This is the point where most beginners get confused, so read it slowly.
class Contact(BaseModel):
name: str # required, cannot be None
phone: str | None # REQUIRED, but the value may be None
city: str | None = None # optional, default None
country: str = "India" # optional, default "India"
print(Contact(name="Meena", phone=None))
print(Contact(name="Meena", phone="9876543210", city="Pune", country="UK"))
try:
Contact(name="Meena") # phone is missing
except ValidationError as error:
print("\nphone is missing ->", error.errors()[0]["msg"])
Two separate questions decide how a field behaves:
| Field | May I leave it out? | May the value be None? |
|---|---|---|
name: str | No | No |
phone: str | None | No | Yes |
city: str | None = None | Yes | Yes |
country: str = "India" | Yes | No |
Rule to remember: | None only says the value may be None. A default value (= None, = "India") is what makes a field optional.
5. Common field types
Pydantic understands all the usual Python types, and many more. This Employee model shows the ones you will use most often:
from datetime import date, datetime
from decimal import Decimal
from enum import Enum
from typing import Literal
class Role(str, Enum):
ADMIN = "Admin"
MANAGER = "Manager"
STAFF = "Staff"
class Employee(BaseModel):
id: int
name: str
salary: Decimal # exact money values (use Decimal, not float)
rating: float
joined_on: date # "2024-04-01" becomes a date
last_login: datetime | None = None # "2024-04-01T10:30:00" becomes a datetime
role: Role = Role.STAFF # only values of the Enum
shift: Literal["Day", "Night"] = "Day" # only these exact values
skills: list[str] = [] # list of strings
scores: dict[str, int] = {} # {"python": 8}
location: tuple[float, float] | None = None # (latitude, longitude)
employee = Employee(
id=101, name="Sneha", salary="85000.50", rating=4.5, joined_on="2024-04-01",
last_login="2026-10-20T09:15:00", role="Manager", skills=["Python", "SQL"],
scores={"python": "8", "sql": 7}, location=["18.52", "73.85"],
)
print(employee)
print("joined_on is a", type(employee.joined_on).__name__, "- year:", employee.joined_on.year)
print("role is", employee.role, "->", employee.role.value)
Look at what Pydantic did with the input:
"85000.50"became an exactDecimal. Always useDecimalfor money;floatcan give results like0.1 + 0.2 = 0.30000000000000004."2024-04-01"became a realdateobject, so you can use.year, compare dates and so on."Manager"became the Enum memberRole.MANAGER.- Inside the dictionary,
"8"became8because the type isdict[str, int]. Pydantic checks inside lists, dicts and tuples too. - The list
["18.52", "73.85"]became a tuple of two floats.
And when values don't fit:
try:
Employee(id=1, name="X", salary=1, rating=1, joined_on="01/04/2024", shift="Evening")
except ValidationError as error:
for item in error.errors():
print(f" {item['loc']}: {item['msg']}")
Dates must be in ISO format (YYYY-MM-DD), and a Literal field only accepts the listed values.
6. Creating a model from a dictionary
In real programs, data usually arrives as a dictionary (for example, from json.loads()). There are two ways to turn it into a model:
data = {"id": 7, "name": "Arjun", "email": "arjun@example.com", "tags": ["new"]}
user1 = User(**data) # unpack the dictionary
user2 = User.model_validate(data) # the Pydantic way (also used for ORM objects in lesson 05)
print(user1)
print("Same data, so the models are equal:", user1 == user2)
Prefer model_validate(). It is clearer, and in lesson 05 the same method reads data from database objects.
7. Changing values and model_config
By default, Pydantic checks data only when the model is created. If you change a field later, it is not checked:
user = User(id=1, name="Asha", email="asha@example.com")
user.age = "not a number" # NOT checked by default!
print("Without validate_assignment:", repr(user.age))
To change how a whole model behaves, add model_config:
from pydantic import ConfigDict
class StrictUser(BaseModel):
# model_config changes how the whole model behaves
model_config = ConfigDict(
validate_assignment=True, # check values when you change a field
extra="forbid", # unknown fields are an error ("ignore" is the default)
str_strip_whitespace=True, # " Asha " -> "Asha"
)
id: int
name: str
strict_user = StrictUser(id=1, name=" Asha ")
print("Whitespace removed:", repr(strict_user.name))
try:
strict_user.id = "abc"
except ValidationError as error:
print("validate_assignment ->", error.errors()[0]["msg"])
try:
StrictUser(id=1, name="Asha", password="secret")
except ValidationError as error:
print("extra='forbid' ->", error.errors()[0]["msg"])
print("Default extra='ignore' just drops unknown fields:",
User(id=1, name="A", email="a@x.com", password="secret"))
| Setting | What it does |
|---|---|
validate_assignment=True | Also checks values when you change a field later |
extra="forbid" | Unknown fields cause an error |
extra="ignore" (default) | Unknown fields are silently dropped (see password above) |
str_strip_whitespace=True | Removes spaces at the start and end of every string |
extra="forbid" is a good choice for API input: if a client sends "emial" instead of "email", they get a clear error instead of their value being silently ignored.
8. Useful things every model has
print("Field names :", list(User.model_fields))
print("Is age required?", User.model_fields["age"].is_required())
copy = user1.model_copy(update={"name": "Arjun Kumar"}) # copy with some changes
print("Copy :", copy)
print("Original :", user1)
print("As dict :", user1.model_dump()) # more about this in lesson 04
model_fieldslists every field and its settings.model_copy(update=...)makes a copy with some values changed; the original stays the same.model_dump()turns the model into a dictionary. Lesson 04 covers it in detail.
Exercises
These are the exercises at the end of 01_basemodel_basics.py. Try them before moving on:
- Create a model
Productwith:id(int),name(str),price(Decimal),in_stock(bool, default True) andcategory(str | None, default None). - Create a Product from the dictionary
{"id": "5", "name": "Pen", "price": "10.50"}and print the type ofidandprice. - Try to create a Product with
price="ten"and print every error. - Add a field
size: Literal["S", "M", "L"] = "M"and trysize="XL". - Give Product
model_config = ConfigDict(extra="forbid")and pass an unknown field.
Summary
- A Pydantic model is a class that inherits from
BaseModel; fields are written with type hints. - Pydantic checks every value and converts it when that is safe (
"42"to42). - Wrong data raises
ValidationError, which lists every problem with its field name. - A default value makes a field optional;
| Noneonly allows the value None. - Use
Decimalfor money,date/datetimefor dates,Enum/Literalfor fixed choices. model_validate()builds a model from a dict;model_configchanges how the model behaves.