Knowing the architecture explains performance, error behavior and how to support third-party types cleanly.
Pydantic v2 is split in two. The Python package pydantic inspects your annotations and builds a core schema: a nested dict describing validation (for example {'type': 'model', 'schema': {'type': 'model-fields', ...}}). The Rust package pydantic-core compiles that schema into a SchemaValidator and a SchemaSerializer, which do all the work at runtime. That is why validation is fast and why building a model or adapter has an upfront cost.
You can look at the result: Model.__pydantic_core_schema__ is the schema, and Model.__pydantic_validator__ is the compiled validator.
To make your own class usable as a field type, implement the classmethod __get_pydantic_core_schema__(cls, source_type, handler) and return a schema built with helpers from pydantic_core.core_schema. A common recipe is "validate as some known type, then convert":
core_schema.no_info_after_validator_function(fn, core_schema.str_schema())validates a string and passes it tofnserialization=core_schema.plain_serializer_function_ser_schema(str)controls outputcore_schema.json_or_python_schema(...)can accept an instance of your class in Python while expecting a string in JSON
Implement __get_pydantic_json_schema__ too if the class should appear correctly in JSON Schema.
Reach for this only for genuinely new types; for constrained versions of existing types, Annotated validators are simpler and do the same thing under the hood.
Example
from pydantic import BaseModel
from pydantic_core import core_schema
class Color:
def __init__(self, hex_: str):
self.hex = hex_.lower()
@classmethod
def __get_pydantic_core_schema__(cls, source, handler):
from_str = core_schema.no_info_after_validator_function(
cls, core_schema.str_schema(pattern=r"^#[0-9a-fA-F]{6}$"))
return core_schema.json_or_python_schema(
json_schema=from_str,
python_schema=core_schema.union_schema([core_schema.is_instance_schema(cls), from_str]),
serialization=core_schema.plain_serializer_function_ser_schema(lambda c: c.hex),
)
class Theme(BaseModel):
primary: Color
t = Theme.model_validate_json('{"primary": "#FF8800"}')
print(t.primary.hex, t.model_dump_json()) # #ff8800 {"primary":"#ff8800"}
print(Theme(primary=Color("#000000")).primary.hex) # #000000
print(Theme.__pydantic_core_schema__["type"]) # modelRemember
- Python builds a core schema; Rust pydantic-core compiles and runs it
- __get_pydantic_core_schema__ plugs custom classes into validation and serialization
- Prefer Annotated validators unless you need a genuinely new type
Watch out for
- Forgetting a serialization schema, so dumping the custom type fails or emits warnings.