DEV Community

Cover image for Pydantic Validation 101
Gagan Saravanakannan
Gagan Saravanakannan

Posted on

Pydantic Validation 101

Pydantic is a Python library that checks and cleans your data before your code uses it. This guide covers its 5 main ideas in plain words. Instead of memorizing syntax, the goal is to understand what happens to your data, why Pydantic works this way, and where each feature fits.

Quick picture: think of Pydantic as a security guard at the door of your program. Data comes in messy, the guard checks it, fixes small problems, and either lets it in clean or turns it away with a clear note explaining why.


1. Models - The Door Where Data Gets Checked

The Simple Idea

Python doesn't check types when your code runs. If you pass a function a number, a string, or None, it just accepts it. That's a problem because real data (web requests, database rows, environment variables) usually arrives messy and unchecked.

A Pydantic Model is a checkpoint for that data. You write a class that inherits from BaseModel and list the fields with their types. Pydantic reads this once, when the class is defined, and builds a fast checker for it (written in Rust, so it's quick).

After that, creating a model doesn't just store values. It runs the checker. You either get a valid object, or you get a clear error that says exactly what went wrong.

Diagram: How Data Moves Through a Model

Raw Input Data (dict / JSON)
        |
        v
+--------------------------------------------------------+
| Rust Core Engine (pydantic-core)                       |
|                                                        |
|  1. Check that required keys are present               |
|  2. Run 'before' field validators                      |
|  3. Check types (and convert them where possible)      |
|  4. Run 'after' field validators                       |
|  5. Run model validators                               |
+----------------------------+---------------------------+
                             |
              +--------------+--------------+
              v                             v
        [ Success ]                   [ Failure ]
   Valid model object            ValidationError raised
   (use it with dot access)      (says what failed, and where)
Enter fullscreen mode Exit fullscreen mode

Key Ideas

Inheriting from BaseModel

To define your data shape, create a class that inherits from BaseModel and add type annotations for each field. Attributes without an annotation are ignored by Pydantic.

Creating a Model & Reading Values

Create a model with keyword arguments, or unpack a dict with the double-asterisk operator (like User(**data)). Once it's valid, read values with dot notation (like user.id).

What Happens When Validation Fails

If the data is bad, Pydantic raises a ValidationError. Calling .errors() on it gives you a list of problems. Each one tells you:

  • loc: where the problem is (which field)
  • type: what kind of error it is
  • msg: a readable message
  • input: the value that caused it

How It Compares to Alternatives

Feature Pydantic BaseModel Python @dataclass TypedDict
Main purpose Check data and convert types while the program runs A shortcut for writing simple data-holding classes Type hints for dictionaries (for editors and type checkers)
Checks data at runtime? Yes (strict or flexible) No (only if you write your own __post_init__) No (hints disappear at runtime)
Converts types? Yes, automatically (e.g., "123" ➔ 123) No No
Turning into dict/JSON Built in (model_dump, model_dump_json) You do it by hand with asdict() Just use json.dumps()

Code Example: Building a Model and Reading Errors

from datetime import datetime
from pydantic import BaseModel, PositiveInt, ValidationError

# 1. Define the model (the "shape" of your data)
class User(BaseModel):
    id: int
    name: str = "John Doe"
    signup_ts: datetime | None
    tastes: dict[str, PositiveInt]

# 2. Create a model from messy data; Pydantic converts types for you
external_data = {
    'id': '123',                     # str -> int
    'signup_ts': '2026-10-06 12:00', # str -> datetime
    'tastes': {'wine': 9, b'cheese': 7, 'cabbage': '1'}  # keys and values get converted
}

user = User(**external_data)
print(f"Validated User ID: {user.id} ({type(user.id)})")
# Output: Validated User ID: 123 (<class 'int'>)

# 3. What happens with bad data
invalid_data = {'id': 'not-an-int', 'tastes': {}}

try:
    User(**invalid_data)
except ValidationError as e:
    print("Caught ValidationError payload:")
    for err in e.errors():
        print(f" - Field {err['loc']}: {err['msg']} (Input: {err['input']})")
Enter fullscreen mode Exit fullscreen mode

2. Fields & Types - Setting the Rules for Each Value

The Simple Idea

If the Model is the whole contract, then:

  • Types say what kind of value a field can hold (like int or str).
  • Fields add extra rules and notes (like "must be greater than 0").

Pydantic has two ways of reading input:

  • Lax mode (the default): Pydantic is forgiving. It tries to safely convert values, like turning the string "42" into the number 42.
  • Strict mode: Pydantic is picky. If the value isn't exactly the right Python type, it's rejected.

Diagram: How a Single Field Is Checked

Input Value --> [ Look at the field's type ]
                          |
         +----------------+----------------+
         v                                 v
    Lax Mode                          Strict Mode
 (Try to convert,                  (Must already be
  e.g. "123" -> 123)                the exact type)
         |                                 |
         +----------------+----------------+
                          v
         [ Check the field's rules ]
         (gt, lt, pattern, length)
                          |
                          v
            Pass -> Value is saved on the object
Enter fullscreen mode Exit fullscreen mode

Key Ideas

The Field() Function

Field() lets you add extra information to a field: default values, notes, and limits. Common limits are gt (greater than), ge (greater or equal), lt (less than), le (less or equal), min_length, max_length, and pattern.

Annotated Types

Modern Pydantic uses Python's typing.Annotated to bundle a type and its rules together, so you can reuse them. For example, Annotated[int, Field(gt=0)] means "an integer greater than 0".

Unions & Discriminators

A Union (Union[A, B] or A or B written with the pipe symbol) means "this field can be A or B".

A Discriminated Union adds a "label" field (like type: Literal['user', 'admin']). Pydantic reads the label and goes straight to the right model, instead of trying each option one by one until something fits.

Aliases

An alias lets the name in your Python code differ from the name in the incoming data. For example, your code can use snake_case while an API sends camelCase, or the API uses a name that Python doesn't allow. Pydantic offers alias, validation_alias (for input), and serialization_alias (for output).

What Lax Mode Converts

Expected Type Input Example Converted Result Notes
int "100", 100.0, True 100, 100, 1 Strings are parsed; floats work only if they have no decimal part
datetime "2026-10-06T12:00:00Z", 1700000000 a datetime object Accepts standard date strings (ISO 8601) and Unix timestamps
bool "true", "1", 1, "yes" True Text matching ignores upper/lower case
bytes "hello" b"hello" Strings are turned into bytes (UTF-8)

Code Example: Field Rules, Aliases, and Discriminated Unions

from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field

# 1. The two payment options. 'method' is the label.
class CreditCardPayment(BaseModel):
    method: Literal["credit_card"]
    card_number: str = Field(..., min_length=16, max_length=16)

class PayPalPayment(BaseModel):
    method: Literal["paypal"]
    email: str

PaymentMethod = Annotated[
    Union[CreditCardPayment, PayPalPayment],
    Field(discriminator="method")   # use 'method' to pick the right model
]

# 2. A model using field rules and an alias
class Transaction(BaseModel):
    transaction_id: str = Field(..., validation_alias="txId")  # input uses "txId"
    amount: Annotated[float, Field(gt=0, description="Amount in USD")]
    payment: PaymentMethod

# Input uses the alias name, and the 'method' label picks PayPalPayment
data = {
    "txId": "TX-9901",
    "amount": 49.99,
    "payment": {
        "method": "paypal",
        "email": "user@example.com"
    }
}

tx = Transaction(**data)
print(f"Transaction ID: {tx.transaction_id}, Payment Email: {tx.payment.email}")
Enter fullscreen mode Exit fullscreen mode

3. Validators - Your Own Custom Rules

The Simple Idea

Type checks only confirm the shape of data. Real apps also have business rules, like "a password must contain a symbol" or "the end date must come after the start date".

Validators are small functions you attach to the checking process to enforce these rules. You choose two things:

  • Target: is the rule about one field, or about the whole model?
  • Mode: when does it run: before type checking, after it, or wrapped around it?

Diagram: The Order Validators Run In

Raw Input Data
      |
      v
+---------------------------------------------------------+
| 1. @field_validator(mode='before')                      |
|    (Sees the raw, unchecked input, e.g. str/dict)       |
+----------------------------+----------------------------+
                             |
                             v
+---------------------------------------------------------+
| 2. Pydantic's own type checking                         |
+----------------------------+----------------------------+
                             |
                             v
+---------------------------------------------------------+
| 3. @field_validator(mode='after')  [DEFAULT]            |
|    (Sees the value after it has the correct type)       |
+----------------------------+----------------------------+
                             |
                             v
+---------------------------------------------------------+
| 4. @model_validator(mode='after')                       |
|    (Sees the whole finished model object)               |
+---------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

Key Ideas

@field_validator

Works on one field.

  • mode='before': Runs before Pydantic checks the type. Good for cleaning up raw input (like trimming text).
  • mode='after' (default): Runs after the type check passes, so the value already has the right type.

@model_validator

Works on the whole model, so it can look at several fields together.

  • mode='before': Gets the raw dictionary before any field is processed.
  • mode='after': Gets the finished model object once every field has passed its own checks. This is the right place for rules that compare fields (like start date vs. end date).

Validator Modes at a Glance

Mode What It Receives Best Used For Watch Out For
before The raw input value (could be anything) Cleaning up input (e.g., trimming text, splitting a list) Types haven't been checked yet, so the input might be something unexpected
after The value, already the correct type Applying business rules (e.g., number limits, database lookups) Only runs if the normal type check passed
wrap The raw value plus a handler function Taking control: custom error handling, or deciding whether to run the normal check More advanced; you must call the validation step yourself

Code Example: Field and Model Validators

from datetime import date
from pydantic import BaseModel, Field, field_validator, model_validator

class EventBooking(BaseModel):
    event_code: str
    start_date: date
    end_date: date

    # 1. Field validator (before): clean the raw text first
    @field_validator("event_code", mode="before")
    @classmethod
    def uppercase_and_strip(cls, v: str) -> str:
        if isinstance(v, str):
            return v.strip().upper()
        return v

    # 2. Model validator (after): compare two fields
    @model_validator(mode="after")
    def validate_date_range(self) -> "EventBooking":
        if self.end_date < self.start_date:
            raise ValueError("end_date cannot be earlier than start_date")
        return self

# Try it out
booking = EventBooking(
    event_code="  conf-2026  ",
    start_date=date(2026, 11, 1),
    end_date=date(2026, 11, 5)
)

print(f"Sanitized Event Code: '{booking.event_code}'")
# Output: Sanitized Event Code: 'CONF-2026'
Enter fullscreen mode Exit fullscreen mode

4. Serialization & JSON - Turning Models Back into Plain Data

The Simple Idea

Validation takes messy outside data and turns it into clean Python objects. Serialization does the reverse: it turns your clean objects back into plain data you can send out, such as dictionaries, JSON strings, or API documentation.

Pydantic makes sure special types like datetime, UUID, or Decimal come out as ordinary JSON values (strings and numbers), so you can send them anywhere and read them back in later.

Diagram: How Data Goes Out

      Validated Model Object
               |
      +--------+---------------------+
      v                              v
 model_dump()                 model_dump_json()
 (gives a Python dict)        (gives a JSON string)
      |                              |
      +--------------+---------------+
                     v
         [ Optional export settings ]
         - exclude_unset=True
         - exclude_none=True
         - by_alias=True
Enter fullscreen mode Exit fullscreen mode

Key Ideas

model_dump() vs model_dump_json()

  • model_dump() gives you a normal Python dict. Types like datetime or UUID are kept or converted depending on your options.
  • model_dump_json() gives you a JSON str directly. It's done by the Rust core, so it's the fastest option.

Export Settings

  • exclude_unset: Leaves out fields you never passed in when creating the object.
  • exclude_defaults: Leaves out fields whose value equals the default.
  • exclude_none: Leaves out fields whose value is None.
  • by_alias: Uses the alias names (like camelCase) instead of your Python field names.

JSON Schema Generation

Model.model_json_schema() gives you a standard JSON Schema (OpenAPI-compatible) description of your model. Tools can use it to generate documentation and client code automatically.

Export Settings Cheat Sheet

Option Model State Result
exclude_unset=True User(id=1) (where name defaults to "John") {"id": 1}
exclude_defaults=True User(id=1, name="John") {"id": 1}
exclude_none=True User(id=1, signup_ts=None) {"id": 1, "name": "John"}
by_alias=True Tx(transaction_id="TX1") (alias "txId") {"txId": "TX1"}

Code Example: Custom Output and Schema

import json
from datetime import datetime
from pydantic import BaseModel, Field, field_serializer

class Article(BaseModel):
    title: str = Field(..., serialization_alias="articleTitle")
    published_at: datetime
    internal_draft: bool = False

    # Custom output format for one field
    @field_serializer("published_at")
    def serialize_dt(self, dt: datetime, _info) -> str:
        return dt.strftime("%Y-%m-%d")

article = Article(
    title="Pydantic First Principles",
    published_at=datetime(2026, 10, 6, 22, 0)
)

# 1. Export as a Python dict, using aliases
print("model_dump(by_alias=True):")
print(article.model_dump(by_alias=True, exclude_defaults=True))
# Output: {'articleTitle': 'Pydantic First Principles', 'published_at': '2026-10-06'}

# 2. Export straight to a JSON string
print("\nmodel_dump_json():")
print(article.model_dump_json(by_alias=True))

# 3. Generate the JSON Schema
schema = Article.model_json_schema()
print("\nGenerated JSON Schema Title:", schema['title'])
Enter fullscreen mode Exit fullscreen mode

5. Configuration - Model-Wide Settings

The Simple Idea

Field() sets rules for one field. Configuration (ConfigDict) sets rules for the whole model at once, such as "be strict everywhere" or "don't allow unknown keys".

Diagram: Where Config Fits

Input Data
    |
    v
+-------------------------------------------------------------+
| ConfigDict Settings                                         |
|                                                             |
|  * strict = True / False                                    |
|  * extra = 'forbid' | 'ignore' | 'allow'                    |
|  * str_strip_whitespace = True / False                      |
|  * frozen = True / False                                    |
+-----------------------------+-------------------------------+
                              |
                              v
            Validation runs using these settings
Enter fullscreen mode Exit fullscreen mode

Key Ideas

Setting model_config

Inside your class, add model_config = ConfigDict(...) and put your settings in it.

Main Options

  • strict: A global switch. When on, no field gets type conversion; values must already be the exact right type.
  • extra: What to do with keys in the input that your model doesn't know about:
    • 'ignore' (default): Quietly drops them.
    • 'forbid': Raises a ValidationError.
    • 'allow': Keeps them as extra attributes on the object.
  • frozen: Makes the object read-only. Trying to change a value (like model.x = value) raises an error.
  • str_strip_whitespace: Automatically removes spaces at the start and end of text values.

Config Options at a Glance

Config Option Setting What It Does
extra 'ignore' Drops unknown fields without complaining
extra 'forbid' Fails validation if unknown fields are given
extra 'allow' Keeps unknown fields (stored in __pydantic_extra__)
strict True Turns off type conversion everywhere ("1" for an int fails)
frozen True Object can't be changed after it's created
populate_by_name True Lets you create the object using either the Python field name OR the alias

Code Example: Strict Mode and Extra Fields

from pydantic import BaseModel, ConfigDict, ValidationError

class StrictUserProfile(BaseModel):
    # Settings for the whole model
    model_config = ConfigDict(
        strict=True,
        extra="forbid",
        str_strip_whitespace=True,
        frozen=True
    )

    username: str
    age: int

# 1. Fails: strict=True blocks converting "30" to 30
try:
    StrictUserProfile(username="alice", age="30")  # age given as a string
except ValidationError as e:
    print("Failed strict check (string not coerced to int):")
    print(f" - Error: {e.errors()[0]['msg']}")

# 2. Fails: extra='forbid' rejects the unknown 'location' key
try:
    StrictUserProfile(username="alice", age=30, location="NYC")
except ValidationError as e:
    print("\nFailed extra check (extra key 'location'):")
    print(f" - Error: {e.errors()[0]['msg']}")

# 3. Works: spaces are stripped, and the object is read-only
profile = StrictUserProfile(username="  alice_dev  ", age=30)
print(f"\nStripped Username: '{profile.username}'")  # 'alice_dev'

try:
    profile.age = 31  # Fails because frozen=True
except ValidationError as e:
    print("Instance is immutable (frozen=True)")
Enter fullscreen mode Exit fullscreen mode

Recap in One Minute

# Concept One-Line Summary
1 Models A class that checks incoming data and gives you a clean object or a clear error
2 Fields & Types The rules for each value: its type, limits, aliases, and how forgiving to be
3 Validators Your own functions for custom rules, on one field or across the whole model
4 Serialization Turning clean objects back into dicts, JSON, or schemas
5 Configuration Settings that change how the whole model behaves (strict, extra, frozen)

This guide was prepared in Oct 2026. For a fresher/deeper guide into Pydantic, check the official Pydantic docs.

Top comments (0)