Pydantic is the quiet dependency under a remarkable amount of the modern Python stack. If you have ever declared a FastAPI endpoint, asked an LLM for structured output with instructor, defined tools with the MCP Python SDK, or let an agent framework check its own inputs, a pydantic model did the actual checking. The project describes itself plainly in the README: “Data validation using Python type hints,” fast and extensible, where you “define how data should be in pure, canonical Python 3.10+; validate it with Pydantic.” That one-sentence promise hides an intricate machine, and this post opens the hood.

What makes the repository interesting is the split of responsibilities. The Python package you import (pydantic) is an orchestration layer: it watches your class definitions, collects your annotations and decorators, and compiles everything into an internal description called a core schema. The actual validation and serialization loops run in the Rust-powered pydantic-core crate that the package depends on. So reading the Python side is really about understanding one thing deeply: how a class body like id: int plus a decorator or two becomes a compiled validator that rejects bad data and a serializer that emits clean output. This tour walks that path through the current main branch, where pydantic/version.py reports version 2.15.0a0+dev at the time of writing.

We prepared two architecture diagrams from the tree. The overview shows the four neighborhoods of the codebase and how your code reaches them; the detail diagram goes one level deeper into the specific functions that do the work. Every node links back to the real source on GitHub, so you can click through while reading. Line numbers below were verified against main at the time of the tour.

Overview architecture of the pydantic repository: public surface, class construction, declarative rules, and the schema engine

Overview of pydantic's architecture: your application imports the public surface, the metaclass builds each model from config, fields and decorators, and the schema engine turns it all into compiled validators plus JSON Schema.

Reading the overview from left to right:

  • BaseModel, L136 in pydantic/main.py is the class you actually subclass. It also hosts the runtime API you call every day: model_validate, L749, model_dump, L472, and model_dump_json, L538.
  • ModelMetaclass, L88 in _model_construction.py is the hook that runs the moment Python executes your class User(BaseModel): statement. Nothing about a model is ready before this metaclass finishes.
  • ConfigWrapper, L62 in _internal/_config.py takes your model_config = ConfigDict(...) and resolves it against inherited settings into one checked object.
  • FieldInfo, L99 in fields.py is the metadata object behind every field; the Field, L1327 function is how you tune it inline in a class body.
  • GenerateSchema, L356 in _internal/_generate_schema.py is the heart of the machine: it converts type hints, fields and decorator functions into a core schema.
  • DecoratorInfos, L414 in _internal/_decorators.py is the registry where your field_validator, model_validator and serializer declarations are collected before schema building.
  • GenerateJsonSchema, L223 in json_schema.py projects all of that structure outward as standard JSON Schema documents.
  • MockValSer, L76 in _internal/_mock_val_ser.py covers the awkward case where a model cannot be finished yet — say, a forward reference that is still undefined — by installing placeholders that attempt a rebuild on first use.
Detailed architecture of pydantic: public API surface, model construction, core schema generation, validation layer, serialization layer, and JSON Schema groups

The detailed view groups pydantic into six layers: the public API surface, model construction internals, core schema generation, the validation layer built from decorators, the serialization layer, and the JSON Schema bridge.

From Install to Your First Model

Start with the README’s instruction: pip install -U pydantic (or conda install pydantic -c conda-forge). No compiled Python-side dependencies beyond the pydantic-core binary wheel are needed, which is why installation is usually uneventful even on exotic platforms.

Then define a model exactly the way the README does:

from datetime import datetime
from typing import Optional
from pydantic import BaseModel

class User(BaseModel):
    id: int
    name: str = 'John Doe'
    signup_ts: Optional[datetime] = None
    friends: list[int] = []

external_data = {'id': '123', 'signup_ts': '2017-06-01 12:22', 'friends': [1, '2', b'3']}
user = User(**external_data)
print(user.id)
#> 123

Three things happened in that snippet. First, the four annotated attributes were collected into FieldInfo objects, with defaults preserved and id marked required because it has no default. Second, the string '123' was coerced to the integer 123, and messy list items like '2' and b'3' became clean ints — that is the lenient “smart” mode, and User.model_validate(external_data) exercises the identical generated validator; the classmethod is defined at model_validate, L749 and simply delegates to cls.__pydantic_validator__.validate_python(...), the compiled validator attached to your class. Third, model_dump() returns a plain dictionary and model_dump_json() returns a JSON string, both with knobs for include/exclude, aliases, exclude_unset, and a mode switch between Python-native and JSON-ready output.

The outbound side mirrors the inbound side. For schema documentation, User.model_json_schema() — the convenience function at model_json_schema, L2734 — hands you a standards-shaped JSON Schema for the model, which is precisely what tools like FastAPI and the MCP Python SDK embed in their generated interfaces.

Class Creation: What Happens Before __init__ Runs

The metaclass is where pydantic departs from ordinary Python. When ModelMetaclass.__new__ executes, it walks the new class in a fixed order. set_model_fields, L528 converts the annotations into the field map; ConfigWrapper, L62 merges the model’s model_config with everything inherited from parent classes; and the decorator registry DecoratorInfos, L414 gathers every validation and serialization decorator you declared. Only then does complete_model_class, L561 call the schema engine, attach the resulting compiled validator and serializer to __pydantic_validator__ and __pydantic_serializer__, and your class is open for business.

If any annotation cannot be resolved yet — a forward reference to a class defined later, a type imported lazily — pydantic does not fail at import time. It installs the MockValSer, L76 placeholders, which raise a helpful error if touched and attempt to rebuild the real validator once the missing pieces exist. That is why pydantic models can be spread across modules that reference each other in loops, a situation ordinary dataclass-based designs handle poorly.

The Schema Engine: One Visitor for Every Type

GenerateSchema, L356 is the largest and most important internal class. Its entry method generate_schema, L794 caches results per type, then _generate_schema_inner, L1099 dispatches on the annotation’s shape: primitives, generics, literals, unions, dataclasses, or models. For model classes it builds a nested schema via _model_schema, L839, reading each FieldInfo and the resolved config, and wiring in the standard validators it reuses from _internal/_validators.py, such as sequence_validator, L28 for lists, sets and tuples.

The elegant part is that everything in the ecosystem funnels through this one engine. TypeAdapter, L69 lets you validate a bare type like list[Task] without inventing a wrapper model, and it calls the same generate_schema machinery. Standard-library dataclasses get the treatment through complete_dataclass, L85, and even plain function signatures can be validated at runtime with validate_call, L83. One visitor, many surfaces — which is why behavior stays consistent whether you entered through a model, an adapter, a dataclass, or a decorated function.

Validators, Serializers, and the JSON Schema Bridge

Custom logic enters through a small set of decorators, and each one is pure bookkeeping until the schema engine consumes it. The field_validator, L410 decorator registers a per-field check by producing a FieldValidatorDecoratorInfo, L55 record; the model_validator, L677 decorator does the same for whole-object checks via ModelValidatorDecoratorInfo, L134. On the way out, field_serializer, L224 and model_serializer, L355 attach custom dumping behavior, recorded as FieldSerializerDecoratorInfo, L92. For inline use inside Annotated, PlainSerializer, L21 and WrapSerializer, L91 wrap a function so it travels with the type annotation itself rather than with a specific class.

Once the core schema exists, the JSON Schema side is a read-only projection. GenerateJsonSchema, L223 walks the core schema and emits the standard document, and because it is a class you can subclass, teams customize titles, descriptions, or OpenAPI-flavored output without touching validation. Edge shapes are supported too: RootModel, L34 wraps a bare root value when the whole payload is, say, a list rather than an object.

This composability is exactly why the library has become the lingua franca for structured data in the LLM world. instructor leans on pydantic models to turn free-form chat completions into typed objects, and the validation errors loop straight back into the prompt as repair feedback. The pydantic team’s own pydantic-ai framework uses the same models to type agent tools and results, and the MCP Python SDK describes tool inputs and outputs with them as well. Learn one model syntax here and you have learned the interface all three expect.

Honest Limits

A few things worth knowing before you build on this tour:

  • The Python layer is not where the speed lives. The validation and serialization hot loops run inside the Rust pydantic-core crate, which this tour deliberately did not open. The Python package’s job — metaclass orchestration, schema building, decorator bookkeeping — happens once per class definition, not per instance.
  • No benchmark numbers here on purpose. The README advertises pydantic as “Fast” without publishing figures in that document, and we did not run our own measurements, so this post makes no performance claims.
  • V2 is a different library from V1. The README states plainly that Pydantic V2 is “a ground-up rewrite” with breaking changes. The repository ships an in-tree V1 compatibility copy under pydantic/v1/ (usable as from pydantic import v1 as pydantic_v1), and deprecated helpers such as parse_obj_as, L31 still live in pydantic/deprecated/. Settings management, formerly BaseSettings, moved to the separate pydantic-settings package; the in-repo pydantic/env_settings.py is explicitly a “backport module from V1” per its own docstring.
  • Line numbers drift. We toured main at version 2.15.0a0+dev. Every file and line cited here was verified with search at writing time, but main moves; the diagrams and their click-through links reflect the branch as of this post’s date.
  • Schema building has a real cost. The metaclass work is per-class, but huge models, deeply nested unions, and heavy decorator use all widen the schema-building step; deferred builds via the mock placeholders exist precisely because some classes cannot be completed eagerly.

If you want to go deeper into the ecosystem after this tour, the structured-output workflow in instructor is the natural next stop, and the agent-side patterns in pydantic-ai show the same models driving tool-calling loops end to end.

Watch PyShine on YouTube

Contents