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 .