pydantic-core and custom types

Pydantic · Expert · 15 min

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 to fn
  • serialization=core_schema.plain_serializer_function_ser_schema(str) controls output
  • core_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"])  # model

Remember

  • 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.

Check yourself

  1. Why does creating a model class or TypeAdapter take noticeable time compared with validating one object?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.pydantic.dev/latest/internals/architecture/