Pydantic Explained: Data Validation in Python

Pydantic v2 shown on one realistic job, validating a payment webhook: how models convert and check types, field constraints, custom field and model validators, the structured errors it reports all at once, strict mode, parsing and producing JSON, and validating lists with TypeAdapter.

GloryTecks Team

Training & Placement Faculty

6 May 2026 7 min read

Last updated: 24 September 2026

Share:

Data that crosses a boundary into your program cannot be trusted as-is: a JSON payload from a webhook, a row from a CSV export, a form submission, the output of a language model asked for JSON. Checking it by hand means dozens of if statements and error messages that drift out of date. Pydantic replaces them with a class definition. You describe what valid data looks like with type hints and constraints; Pydantic converts what it can, rejects what it cannot, and tells you exactly which field failed and why.

This guide uses Pydantic v2 (python -m pip install pydantic) to validate one realistic input: a payment webhook event.

A model describes valid data

python
from datetime import datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, field_validator, model_validator

class Customer(BaseModel):
    id: int
    email: str

    @field_validator("email")
    @classmethod
    def normalise_email(cls, v: str) -> str:
        v = v.strip().lower()
        if "@" not in v:
            raise ValueError("not an email address")
        return v

class PaymentEvent(BaseModel):
    event_id: str = Field(min_length=8)
    type: Literal["payment.succeeded", "payment.failed", "payment.refunded"]
    amount: Decimal = Field(gt=0, decimal_places=2)
    currency: str = Field(pattern=r"^[A-Z]{3}$")
    created_at: datetime
    customer: Customer
    refund_of: str | None = None

    @model_validator(mode="after")
    def refunds_reference_a_payment(self):
        if self.type == "payment.refunded" and not self.refund_of:
            raise ValueError("refund events must say which payment they refund (refund_of)")
        return self

Each line is a rule. Literal restricts type to three values. Field(gt=0, decimal_places=2) says an amount is positive with at most two decimals. The pattern requires a three-letter upper-case currency code. customer is itself a model, so it is validated recursively (Pydantic docs: models).

Valid input is converted, not just checked

Webhook payloads often send numbers as strings. By default Pydantic works in lax mode: it converts values that clearly mean the right thing:

python
payload = {"event_id": "evt_20260925_001", "type": "payment.succeeded", "amount": "1499.00",
           "currency": "INR", "created_at": "2026-09-25T10:15:00+05:30",
           "customer": {"id": "42", "email": " Asha@Example.COM "}}
event = PaymentEvent(**payload)
print(repr(event.amount), type(event.customer.id).__name__, event.customer.email, event.created_at.utcoffset())
text
Decimal('1499.00') int asha@example.com 5:30:00

The amount arrived as a string and is now an exact Decimal; the customer ID "42" is an int; the ISO timestamp is an aware datetime with its offset; and the field validator cleaned up the email address. Code after this line can rely on the types without checking them again.

Invalid input gets every error at once

python
bad = {"event_id": "e1", "type": "payment.done", "amount": "-5", "currency": "inr",
       "created_at": "yesterday", "customer": {"id": "x", "email": "nobody"}}
try:
    PaymentEvent(**bad)
except ValidationError as exc:
    print(exc.error_count())
    for e in exc.errors():
        print(".".join(map(str, e["loc"])), "|", e["type"], "|", e["msg"])
text
7
event_id | string_too_short | String should have at least 8 characters
type | literal_error | Input should be 'payment.succeeded', 'payment.failed' or 'payment.refunded'
amount | greater_than | Input should be greater than 0
currency | string_pattern_mismatch | String should match pattern '^[A-Z]{3}$'
created_at | datetime_from_date_parsing | Input should be a valid datetime or date, input is too short
customer.id | int_parsing | Input should be a valid integer, unable to parse string as an integer
customer.email | value_error | Value error, not an email address

Three properties make these errors useful. Pydantic reports all problems in one pass, not just the first. Each has a loc, a path to the field, including nested ones like customer.id. And each has a stable machine-readable type, such as int_parsing, alongside the human message (Pydantic docs: validation errors). An API can return that list directly as its 422 response body, which is exactly what FastAPI does.

Rules that involve several fields

A field validator sees one value. Rules about combinations, such as "a refund must name the payment it refunds", need the whole object, which is what model_validator(mode="after") receives (Pydantic docs: validators):

python
try:
    PaymentEvent(**{**payload, "type": "payment.refunded"})
except ValidationError as exc:
    print(exc.errors()[0]["msg"])
text
Value error, refund events must say which payment they refund (refund_of)

Raising ValueError inside any validator turns into a regular validation error, with the same structure as the built-in ones.

Strict mode when coercion is wrong

Lax conversion is convenient at the edges of a system, but inside it you may want types to match exactly. Strict mode refuses conversions such as a numeric string for an int (Pydantic docs: strict mode):

python
class StrictCustomer(Customer):
    model_config = ConfigDict(strict=True)

try:
    StrictCustomer(id="42", email="a@b.com")
except ValidationError as exc:
    print(exc.errors()[0]["type"], "|", exc.errors()[0]["msg"])
text
int_type | Input should be a valid integer

Strictness can be set per model, as here, or per field with Field(strict=True).

JSON in and out

Webhooks arrive as JSON text. model_validate_json parses and validates in one step, and model_dump(mode="json") produces a JSON-ready dictionary on the way out (Pydantic docs: serialization):

python
raw = '{"event_id": "evt_20260925_002", "type": "payment.failed", "amount": "250.50", "currency": "INR", "created_at": "2026-09-25T11:00:00Z", "customer": {"id": 7, "email": "ravi@example.com"}}'
print(PaymentEvent.model_validate_json(raw).created_at)
print(event.model_dump(mode="json"))
text
2026-09-25 11:00:00+00:00
{'event_id': 'evt_20260925_001', 'type': 'payment.succeeded', 'amount': '1499.00', 'currency': 'INR', 'created_at': '2026-09-25T10:15:00+05:30', 'customer': {'id': 42, 'email': 'asha@example.com'}, 'refund_of': None}

In JSON mode the Decimal becomes a string, so no precision is lost, and the datetime becomes an ISO string with its offset. The plain model_dump() keeps Python objects instead. For JSON handling without models, see working with JSON in Python.

Validating things that are not models

A webhook batch might be a list of events. TypeAdapter validates any type annotation, not only model classes (Pydantic docs: TypeAdapter):

python
events = TypeAdapter(list[PaymentEvent]).validate_python([payload, {**payload, "event_id": "evt_20260925_003"}])
print(len(events), events[1].event_id)
text
2 evt_20260925_003

Settings you will reach for in real models

A handful of options come up in almost every real project:

  • Unknown fields. By default, fields the model does not declare are silently ignored. That is forgiving, but a typo such as ammount then disappears without a trace. model_config = ConfigDict(extra="forbid") turns unknown fields into validation errors, which suits inputs you control the format of; keep the default for third-party payloads that add fields over time.
  • Aliases. External APIs often use camelCase keys while Python code uses snake_case. Field(alias="eventId") maps one field; an alias generator in model_config maps them all, and populate_by_name=True lets your own code still use the Python names.
  • Mutable defaults. Give list and dictionary fields Field(default_factory=list) so each instance gets its own empty container.
  • Optional is not the same as "may be omitted". A field annotated str | None without a default is still required; it just accepts None. Add = None when the key may be missing altogether.

Each of these is a line of configuration, and together they decide how forgiving your boundary is. Decide deliberately, and write the decision down in the model rather than in scattered checks.

Where Pydantic fits

  • API boundaries. Request and response bodies; FastAPI builds its validation on these models, as the FastAPI guide shows.
  • Configuration. The separate pydantic-settings package reads settings from environment variables into a validated model, which suits the approach in environment variables and secrets.
  • Files and messages. Rows from CSV files, messages from a queue, structured output from a language model.

For data that your own code creates and already trusts, the standard library's dataclasses are lighter and have no dependency. Use Pydantic where data comes from outside and conversion and validation are the point.

Is Pydantic slow?

Pydantic v2 moved its validation core to Rust (the pydantic-core package), which made validation much faster than version 1. For most applications, network and database time dominate long before validation does.

What changed between Pydantic v1 and v2?

Method names changed (parse_obj became model_validate, dict became model_dump, validator became field_validator), configuration moved to model_config, and the core was rewritten. Code written for v1 usually needs updating.

Should I validate language-model output with Pydantic?

Yes, whenever you ask a model for structured data. Parse its reply with model_validate_json; if validation fails, you get a precise list of what was wrong, which you can log, retry with, or send back to the model with a request to fix those fields.

Does Pydantic replace type checkers such as mypy?

No. A type checker analyses code before it runs; Pydantic checks data while the program runs. They complement each other; see Python type hints.

Pydantic models build on Python classes and JSON handling, both covered in the Python Programming course.

Sources

GloryTecks Team

Training & Placement Faculty

The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.

Want to master Python?

Join GloryTecks’ expert-led Python Programming training in Hyderabad with real-time projects and 100% placement support.

Explore course