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
Last updated: 24 September 2026
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
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 selfEach 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:
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())Decimal('1499.00') int asha@example.com 5:30:00The 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
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"])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 addressThree 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):
try:
PaymentEvent(**{**payload, "type": "payment.refunded"})
except ValidationError as exc:
print(exc.errors()[0]["msg"])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):
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"])int_type | Input should be a valid integerStrictness 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):
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"))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):
events = TypeAdapter(list[PaymentEvent]).validate_python([payload, {**payload, "event_id": "evt_20260925_003"}])
print(len(events), events[1].event_id)2 evt_20260925_003Settings 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
ammountthen 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
camelCasekeys while Python code usessnake_case.Field(alias="eventId")maps one field; an alias generator inmodel_configmaps them all, andpopulate_by_name=Truelets 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 | Nonewithout a default is still required; it just acceptsNone. Add= Nonewhen 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?
What changed between Pydantic v1 and v2?
Should I validate language-model output with Pydantic?
Does Pydantic replace type checkers such as mypy?
Pydantic models build on Python classes and JSON handling, both covered in the Python Programming course.
Sources
- Models — Pydantic documentation
- Validators — Pydantic documentation
- Strict Mode — Pydantic documentation
- Validation Errors — Pydantic documentation
- Serialization — Pydantic documentation
- Type Adapter — Pydantic documentation
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.
Related articles
Python for Data Science: The Essential Libraries
The Python data science stack explained by the job each library does: NumPy for arrays, pandas for tables, Matplotlib and seaborn for charts, SciPy and statsmodels for statistics, scikit-learn for models and Jupyter for exploration, with one runnable example that passes a real dataset through them.
Learn Python from Scratch: A Complete 2026 Roadmap
How to learn Python from zero: install a current version (including the new Windows install manager), then work through seven stages, from values and loops to files, virtual environments, classes and tests, each ending with a small program you should be able to write on your own.
Python Interview Questions: Top 50 with Answers
Fifty conceptual Python interview questions with short, precise answers, grouped by topic: basics, types and mutability, data structures, functions and scope, OOP, iterators and decorators, errors and files, and internals, with runnable examples for the ones people most often get wrong.