Chapter 6: extending Pydantic. Custom types, TypeAdapter for non-BaseModel types, RootModel, dataclass integration.

Custom types via Annotated

from typing import Annotated
from pydantic import BeforeValidator, AfterValidator

def coerce_int(v):
    if isinstance(v, str): return int(v.strip())
    return v

def positive(v: int):
    if v <= 0: raise ValueError("must be positive")
    return v

PositiveInt = Annotated[int, BeforeValidator(coerce_int), AfterValidator(positive)]

class M(BaseModel):
    count: PositiveInt

Reusable. Composable. Preferred way to make custom types.

get_pydantic_core_schema

For deeper integration:

from pydantic import GetCoreSchemaHandler
from pydantic_core import CoreSchema, core_schema

class MyType:
    def __init__(self, value: str):
        self.value = value
    
    @classmethod
    def __get_pydantic_core_schema__(cls, source_type, handler: GetCoreSchemaHandler) -> CoreSchema:
        return core_schema.no_info_after_validator_function(
            cls,
            core_schema.str_schema(),
        )
    
    @classmethod
    def __get_pydantic_json_schema__(cls, schema, handler):
        return handler(schema)

For full control over how a type validates and serializes. Most apps won’t need this.

Existing types from third-party libs

For e.g., Decimal-like or money types: write a wrapper using Annotated. Don’t subclass int etc. unless you really need to.

RootModel

For models with a single root value:

from pydantic import RootModel

class Names(RootModel[list[str]]):
    pass

names = Names(["Alice", "Bob"])
names.root  # ["Alice", "Bob"]
names.model_dump()  # ["Alice", "Bob"]  (no wrapping)

For when the JSON is just a list / dict / primitive at the root, no wrapping object.

TypeAdapter

For validating arbitrary types (not BaseModel):

from pydantic import TypeAdapter

UserListAdapter = TypeAdapter(list[User])

users = UserListAdapter.validate_python([{"id": 1, "email": "[email protected]"}, ...])
DictAdapter = TypeAdapter(dict[str, int])
DictAdapter.validate_python({"a": 1, "b": 2})

# JSON
DictAdapter.validate_json('{"a": 1, "b": 2}')
DictAdapter.dump_json({"a": 1})

TypeAdapter brings Pydantic validation/serialization to non-BaseModel types.

dataclass integration

from pydantic.dataclasses import dataclass

@dataclass
class User:
    id: int
    name: str

User(id=1, name="Alice")  # validates

Looks like stdlib dataclass; runs Pydantic validation.

For mixing with stdlib @dataclass: works too, with some limitations.

Validation contexts

class User(BaseModel):
    email: str
    
    @field_validator("email")
    @classmethod
    def check(cls, v, info):
        if info.context and info.context.get("strict"):
            ...
        return v

User.model_validate({"email": "..."}, context={"strict": True})

Pass per-validation context.

Conditional fields

from pydantic import Field, model_validator

class M(BaseModel):
    type: str
    a: str | None = None
    b: int | None = None
    
    @model_validator(mode="after")
    def validate_required(self):
        if self.type == "alpha" and self.a is None:
            raise ValueError("a required when type=alpha")
        if self.type == "beta" and self.b is None:
            raise ValueError("b required when type=beta")
        return self

For “field X is required when Y has value Z” patterns. Discriminated union is often cleaner.

JSON Schema customization

from pydantic import GetJsonSchemaHandler
from pydantic.json_schema import JsonSchemaValue

class MyType:
    @classmethod
    def __get_pydantic_json_schema__(cls, schema, handler: GetJsonSchemaHandler) -> JsonSchemaValue:
        result = handler(schema)
        result["title"] = "MyType"
        result["description"] = "Custom"
        return result

For custom OpenAPI / JSON Schema output.

Schema reuse

class Address(BaseModel):
    street: str
    city: str

class User(BaseModel):
    home: Address
    work: Address | None = None

Address is referenced once in JSON Schema; both fields use the ref.

For deduping in OpenAPI.

Pickle support

import pickle

user = User(...)
pickled = pickle.dumps(user)
restored = pickle.loads(pickled)

Pydantic models are picklable. Useful for caching / IPC.

Common mistakes

1. Subclassing int / str

class Email(str):
    pass

Loses Pydantic features. Use Annotated instead.

2. RootModel for everything

When you have multiple fields, use a regular BaseModel.

3. TypeAdapter recreated per call

def parse(data):
    adapter = TypeAdapter(list[User])  # creates schema each call
    return adapter.validate_python(data)

Module-scope:

USER_LIST_ADAPTER = TypeAdapter(list[User])
def parse(data):
    return USER_LIST_ADAPTER.validate_python(data)

4. Mixing pydantic.dataclass with BaseModel

Subclasses don’t always behave the same. Pick one style per type.

5. Forgetting model_rebuild for forward references

Custom types referenced before defined cause issues.

What’s next

Chapter 7: Strict mode and coercion.

Read this next


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .