Data Validation with Pydantic

Reviewed & published by Brayan K

Pydantic turns ordinary type hints into a powerful runtime validation engine. Define a model once and Pydantic parses, validates, and coerces messy external data into clean, typed Python objects — or tells you exactly what went wrong.

Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

🛡️ 1. Why Pydantic Exists

Real data is messy: missing keys, strings where numbers belong, extra fields. Pydantic validates it at runtime so the rest of your code can trust it.

You describe the shape of your data with normal Python type hints, and Pydantic enforces it.

📦 2. Your First Model

Subclass BaseModel and annotate the fields:

from pydantic import BaseModel

class User(BaseModel):
    id: int
    name: str
    active: bool = True

# Pydantic validates and coerces the input
u = User(id="42", name="Ada")
print(u.id)      # 42  (the string was coerced to int)
print(u.active)  # True (default used)

# ✅ Expected output:
# 42
# True

Notice that "42" became the integer 42 — Pydantic coerces compatible types by default.

🚨 3. Validation Errors

When data does not fit, Pydantic raises ValidationError:

from pydantic import BaseModel, ValidationError

class User(BaseModel):
    id: int
    name: str

try:
    User(id="not-a-number", name="Ada")
except ValidationError as e:
    print(e)
    # Reports: id -> Input should be a valid integer

# ✅ Expected output:
# 1 validation error for User
# id
#   Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='not-a-number', input_type=str]
#     For further information visit https://errors.pydantic.dev/2.13/v/int_parsing

The error object lists every failing field, the bad value, and why it failed.

🎚️ 4. Constraints with Field()

Use Field() to add bounds, defaults, and metadata:

from pydantic import BaseModel, Field

class Product(BaseModel):
    name: str = Field(min_length=1, max_length=50)
    price: float = Field(gt=0)          # must be > 0
    quantity: int = Field(ge=0, le=1000) # 0..1000

p = Product(name="Pen", price=1.5, quantity=10)
print(p.price)  # 1.5

# ✅ Expected output:
# 1.5
ConstraintMeaning
gt / geGreater than / greater-or-equal
lt / leLess than / less-or-equal
min_length / max_lengthString / collection length bounds

✍️ 5. Custom Validators

For rules that constraints cannot express, use @field_validator (Pydantic v2):

from pydantic import BaseModel, field_validator

class Account(BaseModel):
    username: str

    @field_validator("username")
    @classmethod
    def no_spaces(cls, v: str) -> str:
        if " " in v:
            raise ValueError("username cannot contain spaces")
        return v.lower()

a = Account(username="Ada")
print(a.username)  # ada

# ✅ Expected output:
# ada

Validators can also transform values (here we lowercased the username).

🪆 6. Nested Models

Models can contain other models, validated recursively:

from pydantic import BaseModel

class Address(BaseModel):
    city: str
    zip: str

class Person(BaseModel):
    name: str
    address: Address

p = Person(name="Ada", address={"city": "Paris", "zip": "75001"})
print(p.address.city)  # Paris

# ✅ Expected output:
# Paris

The inner dict was validated and turned into a real Address instance.

🔁 7. Serialize and Parse

Convert models to plain data and back:

from pydantic import BaseModel

class User(BaseModel):
    id: int
    name: str

u = User(id=1, name="Ada")

# Model -> dict / JSON
print(u.model_dump())       # {'id': 1, 'name': 'Ada'}
print(u.model_dump_json())  # '{"id":1,"name":"Ada"}'

# dict -> validated model
u2 = User.model_validate({"id": 2, "name": "Linus"})
print(u2.name)              # Linus

# ✅ Expected output:
# {'id': 1, 'name': 'Ada'}
# {"id":1,"name":"Ada"}
# Linus

🧪 Worked Example: One Model That Does Everything

Every idea from the sections above appears here at once: coercion, defaults, a constraint, a custom validator, a nested model, serialisation, and a full error report. Read each comment, then run it and compare with the expected output at the bottom.

from pydantic import BaseModel, Field, ValidationError, field_validator

class Address(BaseModel):
    city: str
    postcode: str = Field(min_length=3, max_length=10)   # length bounds

class Customer(BaseModel):
    id: int
    email: str
    age: int = Field(ge=18)        # ge = "greater than or equal to"
    address: Address               # a nested model - validated recursively
    marketing_ok: bool = False     # has a default, so the input may omit it

    @field_validator("email")      # runs AFTER the type check on that one field
    @classmethod
    def must_look_like_email(cls, value):
        if "@" not in value:
            raise ValueError("email must contain @")   # your own rule
        return value.strip().lower()                   # validators may CLEAN the value

raw = {
    "id": "1042",                       # a string - watch what happens to it
    "email": "  ADA@Example.COM ",      # untidy - the validator will fix it
    "age": 31,
    "address": {"city": "Paris", "postcode": "75001"},   # a plain dict
}

c = Customer(**raw)
print("id      :", c.id, type(c.id).__name__)     # coerced from "1042" to a real int
print("email   :", c.email)                       # trimmed and lowercased by the validator
print("address :", c.address.city, "-", type(c.address).__name__)   # now an Address object
print("default :", c.marketing_ok)                # False, filled in for you
print("dump    :", c.model_dump())                # back to plain Python data

# Now the same model given data that breaks four rules at once.
bad = {"id": "x", "email": "nope", "age": 12, "address": {"city": "Paris", "postcode": "1"}}
try:
    Customer(**bad)
except ValidationError as e:
    # Pydantic reports EVERY problem, not just the first one it meets.
    print("errors  :", e.error_count())
    for err in e.errors():
        # 'loc' is the path to the field - note the nested one at the end.
        print("  ", err["loc"], "->", err["msg"])

# ✅ Expected output:
# id      : 1042 int
# email   : ada@example.com
# address : Paris - Address
# default : False
# dump    : {'id': 1042, 'email': 'ada@example.com', 'age': 31, 'address': {'city': 'Paris', 'postcode': '75001'}, 'marketing_ok': False}
# errors  : 4
#    ('id',) -> Input should be a valid integer, unable to parse string as an integer
#    ('email',) -> Value error, email must contain @
#    ('age',) -> Input should be greater than or equal to 18
#    ('address', 'postcode') -> String should have at least 3 characters

The last error line is the one to remember: ('address', 'postcode') is a path, not a name. However deep your models nest, Pydantic tells you exactly which field failed and why — which is what makes it worth using at the edge of your program.

🎯 Your Turn: Finish the Booking Model

Two blanks: one constraint and one validator target. Fill them in, run it, and match the expected output. Watch the first printed line — it proves the string "3" arrived as a real integer.

from pydantic import BaseModel, Field, ValidationError, field_validator

# 🎯 YOUR TURN — replace each ___ and run it.

class Booking(BaseModel):
    guest: str
    nights: int = Field(___=1)     # 👉 nights must be AT LEAST 1. gt, ge, lt or le?
    email: str

    @field_validator("___")        # 👉 which field should the rule below guard?
    @classmethod
    def must_have_at(cls, value):
        if "@" not in value:
            raise ValueError("email must contain @")
        return value

b = Booking(guest="Ada", nights="3", email="ada@example.com")
print(b.nights, type(b.nights).__name__)   # the string "3" becomes an int
print(b.model_dump())

# This one breaks both rules at once.
try:
    Booking(guest="Bob", nights=0, email="bob-at-example.com")
except ValidationError as e:
    for err in e.errors():
        print(err["loc"], "->", err["msg"])

# ✅ Expected output:
# 3 int
# {'guest': 'Ada', 'nights': 3, 'email': 'ada@example.com'}
# ('nights',) -> Input should be greater than or equal to 1
# ('email',) -> Value error, email must contain @

⚙️ 8. Settings Management

Pydantic's settings classes load configuration from environment variables, with validation:

# pip install pydantic-settings
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    app_name: str = "MyApp"
    debug: bool = False
    port: int = 8000

# Values can be overridden by env vars: PORT=9000
settings = Settings()
print(settings.port)  # 8000 (or 9000 if PORT is set)

⚠️ Requires pip install pydantic-settings

⚖️ 9. Pydantic vs Dataclasses

FeaturedataclassPydantic BaseModel
Runtime validationNoYes
Type coercionNoYes
JSON serializationManualBuilt in
Standard libraryYesThird-party

Use a dataclass for simple in-memory records; use Pydantic when data crosses a boundary and must be validated.

🎉 Conclusion

With Pydantic you can:

✔ Validate external data with plain type hints

✔ Coerce and clean messy inputs automatically

✔ Report errors clearly with ValidationError

✔ Compose nested models and serialize them

✔ Manage validated application settings

Define the shape once, and trust your data everywhere after.

🎯 Mini-Challenge: Validate a Whole Order

Real payloads are not flat — an order contains a list of items, each with its own rules. Build the two models from the brief and prove they work on one good payload and one bad one. Nothing is written for you.

# 🎯 MINI-CHALLENGE: validate a whole order
#
# 1. Item model:  sku: str with min_length=3,  qty: int with ge=1
# 2. Order model: reference: str,  items: list[Item],  note: str = ""
# 3. Validate this good payload with Order.model_validate(...) and print
#    the result of .model_dump():
#        {"reference": "A-1",
#         "items": [{"sku": "KEY-1", "qty": "2"}, {"sku": "MON-9", "qty": 1}]}
# 4. Then validate this bad payload inside a try, catch ValidationError,
#    and print each error's ["loc"] and ["msg"] on one line:
#        {"reference": "A-2", "items": [{"sku": "AB", "qty": 0}]}
#
# ✅ Expected output:
# {'reference': 'A-1', 'items': [{'sku': 'KEY-1', 'qty': 2}, {'sku': 'MON-9', 'qty': 1}], 'note': ''}
# ('items', 0, 'sku') -> String should have at least 3 characters
# ('items', 0, 'qty') -> Input should be greater than or equal to 1

# your code here

📋 Quick Reference — Pydantic

SyntaxWhat it does
class M(BaseModel)Define a validated model
Field(gt=0, max_length=5)Add field constraints
@field_validator(name)Custom validation logic
m.model_dump()Serialize model to a dict
M.model_validate(data)Parse and validate input data

🎉 Great work! You've completed this lesson.

You can now model and validate real-world data with confidence using Pydantic.

Practice quiz

What base class do you inherit from to define a Pydantic model?

  • BaseModel
  • DataModel
  • Schema
  • Validator

Answer: BaseModel. Pydantic models subclass pydantic.BaseModel, which drives validation, parsing, and serialization.

How does Pydantic decide what to validate on each field?

  • From a separate schema file
  • From method names
  • From the field's type annotation
  • From docstrings

Answer: From the field's type annotation. Pydantic reads the standard type annotations (name: str, age: int) and validates incoming data against them.

If you pass the string '42' to an int field in a Pydantic v2 model, what happens by default?

  • It raises ValidationError immediately
  • It is coerced to the integer 42
  • It stays as the string '42'
  • The field is set to None

Answer: It is coerced to the integer 42. By default Pydantic coerces compatible types, so '42' becomes the int 42. Use strict mode to forbid this.

Which exception does Pydantic raise when input data fails validation?

  • AssertionError
  • TypeError
  • ValueError
  • ValidationError

Answer: ValidationError. Pydantic raises pydantic.ValidationError, which collects every field error in one structured report.

Which helper adds constraints like a minimum value or max length to a field?

  • Field()
  • Constraint()
  • Validate()
  • Rule()

Answer: Field(). Field() lets you set constraints such as gt, ge, lt, le, min_length, max_length, and a default value.

In Pydantic v2, which decorator defines a custom validator for one or more fields?

  • @constraint
  • @field_validator
  • @validator
  • @check

Answer: @field_validator. Pydantic v2 uses @field_validator (the v1 @validator is deprecated) to run custom validation logic on fields.

What does the Pydantic v2 method model_dump() return?

  • A new model instance
  • The model's schema
  • A JSON string
  • A dict of the model's data

Answer: A dict of the model's data. model_dump() serializes the model into a plain dict; model_dump_json() produces a JSON string.

Which classmethod validates a dict (or object) into a model instance in Pydantic v2?

  • model_load()
  • model_build()
  • model_validate()
  • model_parse()

Answer: model_validate(). model_validate() takes external data and returns a validated model instance, replacing v1's parse_obj().

How do you model a field that itself contains another structured object?

  • Use a dict type only
  • Annotate the field with another BaseModel subclass
  • Use a tuple
  • It is not supported

Answer: Annotate the field with another BaseModel subclass. Nested models are just fields annotated with another BaseModel subclass; Pydantic validates them recursively.

Compared with a plain dataclass, what does Pydantic add automatically?

  • Runtime data validation and type coercion
  • Faster attribute access
  • Nothing, they are identical
  • Only a __repr__

Answer: Runtime data validation and type coercion. Dataclasses just store fields; Pydantic validates and coerces incoming data at runtime against the annotations.

Continue this course