Skip to content

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, .env files) 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 number 42, "2024-04-01" becomes a real date.
  • 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.

#FileTopicNeeds SQL Server?
0101_basemodel_basics.pyBaseModel, types, defaults, Optional, ValidationError (this lesson)No
0202_field_constraints.pyField() rules: length, regex, rangesNo
0303_validators.pyYour own validation logicNo
0404_serialization.pyModel to dict / JSON, aliasesNo
0505_orm_from_attributes.pySQLAlchemy objects to PydanticYes
0606_nested_and_dto_schemas.pyNested models, Create/Update/Read schemas, CRUDYes
0707_settings.pySettings from .envNo

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
Output
id=1 name='Asha' email='asha@example.com' age=None is_active=True tags=[]
Asha
True
  • 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))
Output
id=42 name='Ravi' email='ravi@example.com' age=30 is_active=True tags=[]
<class 'int'> <class 'int'> <class 'bool'>
  • "42" became the integer 42, and "30" became 30.
  • "yes" became True. Pydantic also accepts "true", "1", "on", 1 for True.

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']}")
Output
Number of errors: 3
3 validation errors for User
id
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='abc', input_type=str]
email
  Field required [type=missing, input_value={'id': 'abc', 'name': 'Kiran', 'age': 25.7}, input_type=dict]
age
  Input should be a valid integer, got a number with a fractional part [type=int_from_float, input_value=25.7, input_type=float]

The same errors as a list (useful for APIs):
   field=('id',)  type=int_parsing  message=Input should be a valid integer, unable to parse string as an integer
   field=('email',)  type=missing  message=Field required
   field=('age',)  type=int_from_float  message=Input should be a valid integer, got a number with a fractional part

(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 has loc (which field), type (a short error code) and msg (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"])
Output
name='Meena' phone=None city=None country='India'
name='Meena' phone='9876543210' city='Pune' country='UK'

phone is missing -> Field required

Two separate questions decide how a field behaves:

FieldMay I leave it out?May the value be None?
name: strNoNo
phone: str | NoneNoYes
city: str | None = NoneYesYes
country: str = "India"YesNo

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)
Output
id=101 name='Sneha' salary=Decimal('85000.50') rating=4.5 joined_on=datetime.date(2024, 4, 1) last_login=datetime.datetime(2026, 10, 20, 9, 15) role=<Role.MANAGER: 'Manager'> shift='Day' skills=['Python', 'SQL'] scores={'python': 8, 'sql': 7} location=(18.52, 73.85)
joined_on is a date - year: 2024
role is Role.MANAGER -> Manager

Look at what Pydantic did with the input:

  • "85000.50" became an exact Decimal. Always use Decimal for money; float can give results like 0.1 + 0.2 = 0.30000000000000004.
  • "2024-04-01" became a real date object, so you can use .year, compare dates and so on.
  • "Manager" became the Enum member Role.MANAGER.
  • Inside the dictionary, "8" became 8 because the type is dict[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']}")
Output
   ('joined_on',): Input should be a valid date or datetime, invalid character in year
   ('shift',): Input should be 'Day' or 'Night'

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)
Output
id=7 name='Arjun' email='arjun@example.com' age=None is_active=True tags=['new']
Same data, so the models are equal: True

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))
Output
Without validate_assignment: 'not a number'

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"))
Output
Whitespace removed: 'Asha'
validate_assignment -> Input should be a valid integer, unable to parse string as an integer
extra='forbid'      -> Extra inputs are not permitted
Default extra='ignore' just drops unknown fields: id=1 name='A' email='a@x.com' age=None is_active=True tags=[]
SettingWhat it does
validate_assignment=TrueAlso 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=TrueRemoves 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
Output
Field names : ['id', 'name', 'email', 'age', 'is_active', 'tags']
Is age required? False
Copy        : id=7 name='Arjun Kumar' email='arjun@example.com' age=None is_active=True tags=['new']
Original    : id=7 name='Arjun' email='arjun@example.com' age=None is_active=True tags=['new']
As dict     : {'id': 7, 'name': 'Arjun', 'email': 'arjun@example.com', 'age': None, 'is_active': True, 'tags': ['new']}
  • model_fields lists 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:

  1. Create a model Product with: id (int), name (str), price (Decimal), in_stock (bool, default True) and category (str | None, default None).
  2. Create a Product from the dictionary {"id": "5", "name": "Pen", "price": "10.50"} and print the type of id and price.
  3. Try to create a Product with price="ten" and print every error.
  4. Add a field size: Literal["S", "M", "L"] = "M" and try size="XL".
  5. 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" to 42).
  • Wrong data raises ValidationError, which lists every problem with its field name.
  • A default value makes a field optional; | None only allows the value None.
  • Use Decimal for money, date/datetime for dates, Enum/Literal for fixed choices.
  • model_validate() builds a model from a dict; model_config changes how the model behaves.

Next: Field constraints: length, regex and number ranges.