Pydantic v2 Advanced Features and Custom Types
Settings management, custom types, generic models, and discriminated unions in Pydantic v2
Pydantic v2 Advanced Features and Custom Types
Settings management, custom types, generic models, and discriminated unions in Pydantic v2

TL;DR
pydantic-settingsreads config from env vars,.envfiles, and secrets directories — all validated by Pydantic.Annotated[T, Field(...)]is the idiomatic v2 way to attach constraints and metadata to a type.__get_pydantic_core_schema__is the escape hatch for integrating third-party types Pydantic doesn't recognize.- Generic models (
BaseModel, Generic[T]) eliminate structural repetition across paginated responses and API envelopes. - Discriminated unions jump straight to the right type without trial-and-error — faster validation and cleaner errors.
@validate_callapplies the same validation engine to function arguments without defining a full model.
Contents
- Settings Management with pydantic-settings
- Custom Types
- Generic Models
- Discriminated Unions
- Computed Fields
- Other Power Features
- Validating Function Arguments with @validate_call
- ValidationError Structure
- JSON Schema Customization
- FAQ
You know how to define models, validate data, and serialize output. That covers a solid majority of everyday Pydantic usage. But Pydantic v2 has a deeper toolkit — features that solve the kinds of problems you hit once your application grows past a single file. Settings pulled from environment variables. Types you define once and reuse across your entire codebase. Generic models that eliminate copy-paste. Discriminated unions that validate polymorphic data cleanly and fast.
This article walks through the advanced features that separate “I use Pydantic” from “I use Pydantic well.” Every code example is complete and runnable on Python 3.10+ with Pydantic v2. If you haven’t read Part 1: What is Pydantic v2? and Part 2: Core Tutorial yet, those cover the fundamentals this article builds on.
Settings Management with pydantic-settings
Hardcoding configuration is a rite of passage. So is the painful refactor that follows. Pydantic’s pydantic-settings package gives you a BaseSettings class that reads configuration from environment variables, .env files, secrets directories, and init kwargs — all validated through the same Pydantic machinery you already know.
Installation
Settings management lives in a separate package:
pip install pydantic-settings
Basic Usage
BaseSettings works like BaseModel, but it automatically pulls values from environment variables whose names match the field names (case-insensitive by default):
from pydantic_settings import BaseSettings
class AppSettings(BaseSettings):
app_name: str = "my-app"
debug: bool = False
port: int = 8000
secret_key: str
# If SECRET_KEY is set in the environment, it gets picked up.
# Running this without SECRET_KEY set will raise a ValidationError.
# $ export SECRET_KEY="super-secret-value"
settings = AppSettings()
print(settings.secret_key) # "super-secret-value"
print(settings.port) # 8000
No manual os.getenv(). No forgotten type conversions. No silent failures when someone sets DEBUG=yes and your code checks if debug == "true".
Configuring Settings with SettingsConfigDict
The real power comes from SettingsConfigDict, which controls where and how settings are loaded:
from pydantic_settings import BaseSettings, SettingsConfigDict
class AppSettings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="MYAPP_", # All env vars prefixed: MYAPP_DEBUG, MYAPP_PORT
env_file=".env", # Load from .env file
env_file_encoding="utf-8", # Encoding for the .env file
env_nested_delimiter="__", # Support nested models via double underscore
)
app_name: str = "my-app"
debug: bool = False
port: int = 8000
With env_prefix="MYAPP_", the setting debug maps to the environment variable MYAPP_DEBUG. This prevents collisions when you have multiple services on the same machine.
Priority Order
Settings sources have a defined priority, highest to lowest:
- Init kwargs — values passed directly to the constructor
- Environment variables — from the running process environment
**.envfile** — loaded from the file specified inenv_file- Secrets directory — files in a secrets dir (useful for Docker/Kubernetes secrets)
- Default values — defined in the class body
This means you can set defaults in your .env file, override them with environment variables in production, and still pass explicit values in tests:
# In tests, init kwargs win over everything:
settings = AppSettings(debug=True, secret_key="test-key")
To change source priority or add custom sources (CLI, cloud secrets managers, etc.), override settings_customise_sources on the settings class. See the pydantic-settings docs for details.
Nested Settings
Real applications have structured configuration. You can nest settings models using env_nested_delimiter:
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseSettings(BaseModel):
host: str = "localhost"
port: int = 5432
name: str = "mydb"
user: str = "postgres"
password: str = ""
class CacheSettings(BaseModel):
host: str = "localhost"
port: int = 6379
ttl: int = 300
class AppSettings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="MYAPP_",
env_file=".env",
env_nested_delimiter="__",
)
app_name: str = "my-app"
debug: bool = False
database: DatabaseSettings = DatabaseSettings()
cache: CacheSettings = CacheSettings()
With env_nested_delimiter="__", you set nested values using double underscores:
export MYAPP_DATABASE__HOST=db.production.internal
export MYAPP_DATABASE__PASSWORD=hunter2
export MYAPP_CACHE__TTL=600
settings = AppSettings()
print(settings.database.host) # "db.production.internal"
print(settings.cache.ttl) # 600
Note that the nested models inherit from BaseModel, not BaseSettings. Only the top-level class needs to be a BaseSettings subclass.
This pattern eliminates the mess of flat environment variables and gives you typed, validated, structured configuration with almost no boilerplate.
Custom Types
Pydantic v2’s type system is extensible. You can build reusable validated types that carry their validation logic with them — define once, use everywhere, and get correct JSON schemas for free.
Annotated Validators
The simplest way to create a custom type is with Annotated and Pydantic's validator functions. These wrap an existing type with additional validation:
from typing import Annotated
from pydantic import AfterValidator, BaseModel
def normalize_phone(value: str) -> str:
"""Strip everything except digits and leading +."""
cleaned = "".join(c for c in value if c.isdigit() or c == "+")
if not cleaned.startswith("+"):
cleaned = "+1" + cleaned # Default to US country code
if len(cleaned) < 10 or len(cleaned) > 15:
raise ValueError("Phone number must be between 10 and 15 characters")
return cleaned
PhoneNumber = Annotated[str, AfterValidator(normalize_phone)]
class Contact(BaseModel):
name: str
phone: PhoneNumber
contact = Contact(name="Alice", phone="(555) 867-5309")
print(contact.phone) # "+15558675309"
AfterValidator runs after Pydantic's built-in validation. Here is when each validator type runs:
**BeforeValidator** — runs before Pydantic's internal validation. Receives raw input. Useful for coercion.**AfterValidator** — runs after internal validation. Receives the already-validated value. Useful for business rules on clean data.**PlainValidator** — replaces Pydantic's internal validation entirely. You handle everything.**WrapValidator** — wraps Pydantic's internal validation. Receives the raw input and ahandlercallable. You decide when (or if) to call the handler.
WrapValidator is useful when you need to inspect or transform the value both before and after Pydantic's coercion:
from typing import Annotated, Any
from pydantic import BaseModel, WrapValidator
from pydantic_core import core_schema
def clamp_to_range(value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> int:
"""Validate with Pydantic first, then clamp the result to [0, 100]."""
result = handler(value) # Let Pydantic coerce to int; raises on non-numeric
return max(0, min(100, result))
ClampedScore = Annotated[int, WrapValidator(clamp_to_range)]
class Quiz(BaseModel):
score: ClampedScore
print(Quiz(score=150).score) # 100
print(Quiz(score=-5).score) # 0
print(Quiz(score=75).score) # 75
Annotated ordering rule: when stacking metadata in Annotated[T, X, Y, Z], order matters. BeforeValidator runs before built-in coercion; AfterValidator runs after. Constraints (like Field(ge=0)) are evaluated after the final coerced value is produced.
Reusable Constrained Types
For simple constraints, combine Annotated with Field. This is the idiomatic Pydantic v2 approach:
from typing import Annotated
from pydantic import BaseModel, Field
PositiveInt = Annotated[int, Field(gt=0)]
Percentage = Annotated[float, Field(ge=0, le=100)]
NonEmptyStr = Annotated[str, Field(min_length=1)]
Username = Annotated[str, Field(min_length=3, max_length=32)]
class UserScore(BaseModel):
username: Username
score: PositiveInt
percentile: Percentage
These types are reusable across your entire codebase. Define them in a types.py module and import them wherever you need them.
Pydantic v2 note: You no longer need
Field(...)to mark a field as required — a type annotation without a default is required by default. The...ellipsis was a v1-ism.
Pydantic v2 also accepts
annotated_typesconstraints (Ge,Gt,Le,MinLen,MaxLen, etc.) — useful when sharing constraint metadata with libraries outside Pydantic that readAnnotatedmetadata directly. For code that is Pydantic-only,Field(...)is the canonical path.
Full Custom Types with __get_pydantic_core_schema__
Most readers won’t write __get_pydantic_core_schema__ directly — Pydantic's built-ins cover the vast majority of cases. Reach for it when you're integrating an existing third-party type (like a Color library, a custom Money class, or a UUID-like value object) that Pydantic doesn't recognize, and you want users to be able to declare it as a field annotation cleanly without wrapping it in Annotated each time.
For types that need complete control over parsing, validation, and JSON schema generation, implement __get_pydantic_core_schema__:
from __future__ import annotations
import re
from typing import Any
from pydantic import BaseModel, GetCoreSchemaHandler, GetJsonSchemaHandler
from pydantic.json_schema import JsonSchemaValue
from pydantic_core import CoreSchema, core_schema
class Color:
"""A custom color type that accepts hex, RGB tuples, and named colors."""
NAMED_COLORS = {
"red": (255, 0, 0),
"green": (0, 128, 0),
"blue": (0, 0, 255),
"white": (255, 255, 255),
"black": (0, 0, 0),
}
__slots__ = ("r", "g", "b")
def __init__(self, r: int, g: int, b: int):
self.r = r
self.g = g
self.b = b
def __repr__(self) -> str:
return f"Color(r={self.r}, g={self.g}, b={self.b})"
def as_hex(self) -> str:
return f"#{self.r:02x}{self.g:02x}{self.b:02x}"
@classmethod
def _parse(cls, value: Any) -> Color:
if isinstance(value, cls):
return value
if isinstance(value, str):
value = value.strip().lower()
if value in cls.NAMED_COLORS:
return cls(*cls.NAMED_COLORS[value])
match = re.fullmatch(r"#([0-9a-f]{6})", value)
if match:
hex_str = match.group(1)
return cls(
int(hex_str[0:2], 16),
int(hex_str[2:4], 16),
int(hex_str[4:6], 16),
)
raise ValueError(f"Invalid color string: {value!r}")
if isinstance(value, (list, tuple)) and len(value) == 3:
return cls(int(value[0]), int(value[1]), int(value[2]))
raise ValueError(f"Cannot parse {type(value)} as Color")
@classmethod
def __get_pydantic_core_schema__(
cls, source_type: Any, handler: GetCoreSchemaHandler
) -> CoreSchema:
# `handler` is unused here because we're defining a fully custom schema.
# If you wanted to *wrap* an existing schema (e.g., add pre-processing to
# a known type), call `handler(source_type)` and modify the result.
# See: https://docs.pydantic.dev/latest/concepts/types/#customizing-validation-with-__get_pydantic_core_schema__
return core_schema.with_info_plain_validator_function(
# `with_info_plain_validator_function` — "plain" means this replaces
# validation entirely (no Pydantic coercion before or after).
# Pydantic v2 has variants: no_info_plain_validator_function,
# with_info_after_validator_function, etc.
lambda value, _info: cls._parse(value),
serialization=core_schema.to_string_ser_schema(),
)
@classmethod
def __get_pydantic_json_schema__(
cls, _schema: CoreSchema, handler: GetJsonSchemaHandler
) -> JsonSchemaValue:
return {
"type": "string",
"description": "A color as hex (#rrggbb), named color, or [r, g, b] array",
"examples": ["#ff0000", "red", [255, 0, 0]],
}
def __str__(self) -> str:
return self.as_hex()
class Theme(BaseModel):
name: str
primary: Color
background: Color
theme = Theme(name="ocean", primary="#0077be", background="blue")
print(theme.primary) # #0077be (calls __str__ -> as_hex())
print(theme.background) # #0000ff
# model_dump() returns Python objects — Color instances, not strings.
# to_string_ser_schema() only kicks in during JSON serialization.
print(theme.model_dump())
# {'name': 'ocean', 'primary': Color(r=0, g=119, b=190), 'background': Color(r=0, g=0, b=255)}
# model_dump(mode='json') or model_dump_json() applies the string serializer:
print(theme.model_dump(mode="json"))
# {'name': 'ocean', 'primary': '#0077be', 'background': '#0000ff'}
print(theme.model_dump_json())
# {"name":"ocean","primary":"#0077be","background":"#0000ff"}
The __get_pydantic_core_schema__ method gives you total control over how Pydantic parses and serializes your type. The __get_pydantic_json_schema__ method controls what appears in generated JSON schemas. This is the escape hatch for when Annotated validators are not expressive enough.
Generic Models
When you build APIs, you quickly notice patterns. Every paginated endpoint returns the same wrapper structure — just with different item types. Every response envelope has the same success/error shape. Generic models let you define these patterns once.
Basic Generic Models
Combine BaseModel with Generic[T]:
from typing import Generic, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class PaginatedResponse(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
per_page: int
has_next: bool
class User(BaseModel):
id: int
name: str
email: str
class Product(BaseModel):
id: int
name: str
price: float
# Parametrize with concrete types:
user_page = PaginatedResponse[User](
items=[User(id=1, name="Alice", email="alice@example.com")],
total=50,
page=1,
per_page=20,
has_next=True,
)
product_page = PaginatedResponse[Product](
items=[Product(id=1, name="Widget", price=9.99)],
total=3,
page=1,
per_page=20,
has_next=False,
)
print(user_page.model_dump())
# {'items': [{'id': 1, 'name': 'Alice', 'email': 'alice@example.com'}],
# 'total': 50, 'page': 1, 'per_page': 20, 'has_next': True}
When you write PaginatedResponse[User], Pydantic creates a concrete model class that validates items as list[User]. The JSON schema is also fully resolved — no ambiguity.
Envelope Pattern
A common pattern for API responses is wrapping data in a success/error envelope:
from typing import Generic, TypeVar
from typing_extensions import Self # use typing.Self on Python 3.11+
from pydantic import BaseModel
T = TypeVar("T")
class Envelope(BaseModel, Generic[T]):
success: bool
data: T | None = None # Optional so fail() can omit it; strict checkers will flag this
error: str | None = None
@classmethod
def ok(cls, data: T) -> Self:
return cls(success=True, data=data)
@classmethod
def fail(cls, error: str) -> Self:
return cls(success=False, error=error)
class UserProfile(BaseModel):
id: int
display_name: str
response = Envelope[UserProfile].ok(
UserProfile(id=42, display_name="Alice")
)
print(response.model_dump())
# {'success': True, 'data': {'id': 42, 'display_name': 'Alice'}, 'error': None}
error_response = Envelope[UserProfile].fail("User not found")
print(error_response.model_dump())
# {'success': False, 'data': None, 'error': 'User not found'}
Typing note:
data: T | None = Nonecreates a tension — the declared type isT, butfail()always producesdata=None. Strict type checkers (mypy, pyright) will flag the mismatch. If that bothers you, split into two separate response types or annotatedataasT | Noneexplicitly in thefailreturn type.
Python 3.12+ footnote: If you’re on Python 3.12+ with Pydantic v2.4+, you can use the PEP 695 generic syntax (
class Page[T](BaseModel): ...) instead ofGeneric[T]. TheGeneric[T]form remains supported and works on Python 3.10+. Most readers should use theGeneric[T]style above.
Discriminated Unions
When your data can be one of several types, Pydantic needs to figure out which type it is looking at. By default, it tries each member of the union in order. Discriminated unions give Pydantic a field to check first, so it can jump directly to the right type without trial and error.
Tagged Unions with Literal
The standard approach uses a Literal field as a tag:
from typing import Literal
from pydantic import BaseModel, Field
class EmailNotification(BaseModel):
channel: Literal["email"]
recipient: str
subject: str
body: str
class SMSNotification(BaseModel):
channel: Literal["sms"]
recipient: str
message: str
class PushNotification(BaseModel):
channel: Literal["push"]
device_token: str
title: str
body: str
class NotificationRequest(BaseModel):
notification: (
EmailNotification | SMSNotification | PushNotification
) = Field(discriminator="channel")
# Pydantic checks the "channel" field first, then validates against the matching model:
req = NotificationRequest(
notification={
"channel": "email",
"recipient": "alice@example.com",
"subject": "Hello",
"body": "Welcome aboard!",
}
)
print(type(req.notification)) # <class 'EmailNotification'>
req2 = NotificationRequest(
notification={
"channel": "sms",
"recipient": "+15558675309",
"message": "Your code is 123456",
}
)
print(type(req2.notification)) # <class 'SMSNotification'>
Every model in the union must have the discriminator field (channel), and its type must be a Literal with a unique value. Pydantic reads the discriminator first, picks the matching model, and validates against only that model.
Why This Matters
Without a discriminator, Pydantic tries to validate against each union member in order. If the first type almost matches but fails on one field, you get a confusing error message combining failures from all union members. With a discriminator:
- Validation is faster — Pydantic makes one type check instead of trying every option.
- Error messages are clearer — failures reference only the matched type, not the entire union.
- JSON schemas are correct — the generated schema uses the OpenAPI
discriminatorkeyword.
Callable Discriminators
Sometimes the discriminator logic is not a simple field lookup. Pydantic supports callable discriminators for these cases:
from typing import Annotated, Literal, Union
from pydantic import BaseModel, Discriminator, Tag
class InternalEvent(BaseModel):
source: Literal["internal"]
event_type: str
payload: dict
class ExternalEvent(BaseModel):
origin: Literal["external"]
event_type: str
data: dict
def get_event_source(value: dict | InternalEvent | ExternalEvent) -> str:
"""Determine event type from heterogeneous data."""
if isinstance(value, dict):
if "source" in value:
return "internal"
if "origin" in value:
return "external"
return "unknown" # falls through to no Tag match → union_tag_invalid
if isinstance(value, InternalEvent):
return "internal"
return "external"
Event = Annotated[
Union[
Annotated[InternalEvent, Tag("internal")],
Annotated[ExternalEvent, Tag("external")],
],
Discriminator(get_event_source),
]
class EventLog(BaseModel):
events: list[Event]
log = EventLog(
events=[
{"source": "internal", "event_type": "user.created", "payload": {"id": 1}},
{"origin": "external", "event_type": "payment.received", "data": {"amount": 99}},
]
)
print(type(log.events[0])) # <class 'InternalEvent'>
print(type(log.events[1])) # <class 'ExternalEvent'>
The callable discriminator receives the raw input and returns a string that matches one of the Tag values. This gives you complete flexibility over how Pydantic picks the right type.
What happens when the discriminator can’t match? When get_event_source returns a tag that doesn't correspond to any Tag(...) in the union, Pydantic raises a ValidationError with a union_tag_invalid error type:
from pydantic import ValidationError
try:
EventLog(events=[{"event_type": "mystery", "payload": {}}])
except ValidationError as e:
print(e)
# 1 validation error for EventLog
# events.0
# Input tag 'unknown' found using get_event_source() does not match
# any of the expected tags: 'internal', 'external'
# [type=union_tag_invalid, input_type=dict, ...]
A note on error types: Pydantic uses union_tag_invalid when the discriminator returns a value that doesn't match any tag, and union_tag_not_found when the discriminator can't extract a tag at all (e.g., a missing key on a string-discriminated union). If your discriminator callable raises an exception, that exception propagates unchanged — wrap it in your own try/except if you want a ValidationError-shaped failure instead.
Computed Fields
Sometimes a field’s value is derived from other fields. @computed_field solves this by turning a property into a first-class field that appears in serialization output.
Basic Usage
from pydantic import BaseModel, computed_field
class Rectangle(BaseModel):
width: float
height: float
@computed_field
@property
def area(self) -> float:
return self.width * self.height
@computed_field
@property
def perimeter(self) -> float:
return 2 * (self.width + self.height)
rect = Rectangle(width=5.0, height=3.0)
print(rect.area) # 15.0
print(rect.perimeter) # 16.0
print(rect.model_dump())
# {'width': 5.0, 'height': 3.0, 'area': 15.0, 'perimeter': 16.0}
Key rules:
- You must use
@computed_fieldtogether with@property(or@cached_property). - The return type annotation is required. Pydantic uses it for JSON schema generation and serialization.
- Computed fields are not part of the input schema — you cannot pass
areato the constructor.
Practical Example: Order Totals
from decimal import Decimal
from pydantic import BaseModel, computed_field
class LineItem(BaseModel):
product_name: str
quantity: int
unit_price: Decimal
@computed_field
@property
def subtotal(self) -> Decimal:
return self.quantity * self.unit_price
class Order(BaseModel):
order_id: str
items: list[LineItem]
tax_rate: Decimal = Decimal("0.08")
@computed_field
@property
def subtotal(self) -> Decimal:
return sum((item.subtotal for item in self.items), Decimal("0"))
@computed_field
@property
def tax(self) -> Decimal:
return (self.subtotal * self.tax_rate).quantize(Decimal("0.01"))
@computed_field
@property
def total(self) -> Decimal:
return self.subtotal + self.tax
order = Order(
order_id="ORD-001",
items=[
LineItem(product_name="Widget", quantity=3, unit_price=Decimal("9.99")),
LineItem(product_name="Gadget", quantity=1, unit_price=Decimal("24.99")),
],
)
print(order.model_dump())
# {'order_id': 'ORD-001',
# 'items': [...],
# 'tax_rate': Decimal('0.08'),
# 'subtotal': Decimal('54.96'),
# 'tax': Decimal('4.40'),
# 'total': Decimal('59.36')}
Use @cached_property instead of @property when the computation is expensive and the model is immutable.
Other Power Features
Pydantic v2 includes several utility features that are invaluable in production code.
model_copy() — Safe Cloning with Overrides
When you need a modified copy of a model instance, model_copy() creates a shallow copy with optional field overrides:
from pydantic import BaseModel
class Config(BaseModel):
host: str
port: int
debug: bool = False
base = Config(host="localhost", port=8000)
# Create a production variant:
production = base.model_copy(update={"host": "api.prod.internal", "port": 443})
print(production)
# host='api.prod.internal' port=443 debug=False
# Deep copy (for mutable nested objects):
deep = base.model_copy(deep=True)
This replaces the v1 .copy() method. Use deep=True when your model contains mutable objects like lists or dicts that you do not want shared between the original and the copy.
Note:
model_copy(update=...)does not validate the update values by default. If you need validation, the cleanest pattern is to setvalidate_assignment=Trueon the model config and use ordinary attribute assignment aftermodel_copy(). Themodel_dump()-then-model_validate()round-trip works but is wasteful — it re-validates fields you already trust. See the Pydantic serialization docs.
model_construct() — Skipping Validation
When you have data that you know is already valid — because it came from your own database, a trusted internal service, or was just validated moments ago — you can skip validation entirely:
from pydantic import BaseModel
class SensorReading(BaseModel):
sensor_id: str
temperature: float
humidity: float
# Bulk loading trusted data - skip validation for speed:
readings = [
SensorReading.model_construct(
sensor_id=f"sensor-{i}",
temperature=22.5 + i * 0.1,
humidity=40.0 + i * 0.5,
)
for i in range(10_000)
]
model_construct() is faster because it skips the Pydantic validation pipeline. Know what it skips and what it keeps:
- Skips: all validators (field and model), alias resolution, required-field enforcement.
- Runs:
default_factorycalls for fields that have them.
Use model_construct only for trusted data you've already validated — for example, reconstituting models from a database row of values you validated on write. Never use it at an ingestion boundary.
model_rebuild() — Deferred Schema Building
Call model_rebuild() when forward references can't be resolved at class-definition time — typically when models reference each other across modules, or when one references the other before the second is defined. Don't call it defensively; if your model works without it, you don't need it.
from __future__ import annotations
from pydantic import BaseModel
class TreeNode(BaseModel):
value: str
children: list[TreeNode] = []
# TreeNode references itself; with `from __future__ import annotations`,
# Pydantic defers resolution. model_rebuild() forces the schema to be
# built now with all names in scope.
TreeNode.model_rebuild()
tree = TreeNode(
value="root",
children=[
TreeNode(value="child-1", children=[TreeNode(value="grandchild")]),
TreeNode(value="child-2"),
],
)
print(tree.model_dump())
RootModel — Models Without Field Names
Sometimes your model is not a set of named fields but a single value — a list, a dict, or a constrained scalar. RootModel handles this. It replaces the v1 __root__ pattern. For typed lists or sequences where you don't need extra methods, prefer TypeAdapter(list[Item]).validate_python(data) — it's lighter weight.
from pydantic import BaseModel, RootModel
class Item(BaseModel):
name: str
price: float
class ItemList(RootModel[list[Item]]):
pass
class TagMap(RootModel[dict[str, list[str]]]):
pass
# Validation works on the root value:
items = ItemList.model_validate([
{"name": "Widget", "price": 9.99},
{"name": "Gadget", "price": 24.99},
])
# Access the underlying value with .root:
print(items.root[0].name) # "Widget"
print(len(items.root)) # 2
# Serialization works as expected:
print(items.model_dump())
# [{'name': 'Widget', 'price': 9.99}, {'name': 'Gadget', 'price': 24.99}]
tags = TagMap.model_validate({"python": ["pydantic", "fastapi"], "rust": ["serde"]})
print(tags.root["python"]) # ['pydantic', 'fastapi']
# model_dump() returns the raw dict - no wrapper key:
print(tags.model_dump())
# {'python': ['pydantic', 'fastapi'], 'rust': ['serde']}
Validating Function Arguments with @validate_call
Pydantic isn’t only for models. @validate_call validates function arguments at call time using the same coercion and constraint engine. Use it at trust boundaries when defining a full BaseModel would be overkill — for example, a utility function that accepts user-supplied data, or a CLI entry point.
from typing import Annotated
from pydantic import Field, validate_call
@validate_call
def create_user(
name: Annotated[str, Field(min_length=1, max_length=64)],
age: Annotated[int, Field(ge=0, le=120)],
email: str,
) -> dict:
return {"name": name, "age": age, "email": email}
print(create_user("Alice", "30", "alice@example.com"))
# {"name": "Alice", "age": 30, "email": "alice@example.com"}
# Note: "30" (string) was coerced to int 30.
try:
create_user("", 25, "alice@example.com")
except Exception as e:
print(type(e).__name__, "-", str(e).splitlines()[0])
# ValidationError - 1 validation error for create_user
@validate_call adds per-call validation overhead, so it's best at trust boundaries (CLI entry points, public utility functions) rather than on every internal helper. Set validate_return=True to also validate the function's return value. See the validate_call docs for the rest of the options.
ValidationError Structure
Every ValidationError carries a structured list you can inspect programmatically. Understanding the fields helps you write better error handlers and tests.
from pydantic import BaseModel, Field, ValidationError
class Item(BaseModel):
name: str
price: float = Field(gt=0)
quantity: int = Field(ge=1)
try:
Item(name="", price=-5.0, quantity=0)
except ValidationError as e:
for error in e.errors():
print(error)
Each dict in e.errors() has these keys:
KeyDescriptiontypeStable error identifier, e.g. string_too_short, greater_than, int_parsinglocTuple of field names / indices showing where the error occurredmsgHuman-readable messageinputThe value that failed validationctxExtra context (e.g. {'gt': 0} for a greater_than error)urlLink to the Pydantic error docs for this error type
The type field is stable across Pydantic versions and safe to match against in code. See the full error catalog for all error types.
JSON Schema Customization
Pydantic generates JSON schemas from your models automatically. You can customize what gets emitted using a few tools — useful when your models back a FastAPI or OpenAPI endpoint.
import json
from typing import Annotated
from pydantic import BaseModel, Field, WithJsonSchema
from pydantic.json_schema import SkipJsonSchema
class Product(BaseModel):
# json_schema_extra adds arbitrary properties to this field's schema:
name: str = Field(json_schema_extra={"examples": ["Widget", "Gadget"]})
# WithJsonSchema overrides the generated schema for this type entirely:
sku: Annotated[str, WithJsonSchema({"type": "string", "pattern": "^[A-Z]{3}-\\d{4}$"})]
# SkipJsonSchema is used as the type itself (not wrapped in Annotated)
# - the field is still validated at runtime, just hidden from the schema:
internal_id: SkipJsonSchema[int] = 0
print(json.dumps(Product.model_json_schema(), indent=2))
# "name" includes "examples"; "sku" shows the regex pattern;
# "internal_id" does not appear in the schema at all.
By default, model_json_schema() returns the validation schema. Pass mode='serialization' to get the schema that describes serialized output (FastAPI uses this for response models).
Reference: Pydantic JSON Schema docs.
Wrapping Up
You have now seen the features that make Pydantic v2 a full-fledged data modeling toolkit, not just a validation library. Settings management replaces brittle environment variable parsing. Custom types let you encode domain rules once and enforce them everywhere. Generic models eliminate structural repetition. Discriminated unions handle polymorphic data cleanly. Computed fields keep derived values in sync with their sources. And utility features like model_construct() and RootModel give you the escape hatches you need in production.
Each of these features becomes more powerful when combined with the others. A BaseSettings class with custom types for validated connection strings. A generic PaginatedResponse with discriminated union items. An Order model with computed fields, custom Money types, and model_copy() for draft-to-final transitions.
FAQ
When should I write __get_pydantic_core_schema__? When integrating a third-party type that Pydantic doesn't recognize — a Color class, a custom Money type, a UUID-like value object — and you want users to declare it as a field annotation without any extra wrapping. For types you own, Annotated validators are simpler.
What’s the difference between TypeAdapter and RootModel? TypeAdapter is a standalone validator/serializer for any type expression — list[User], dict[str, int], UUID — without defining a class. RootModel creates a model class (with .model_dump(), .model_validate(), etc.) whose entire value is a single typed root. Use TypeAdapter when you don't need the model class interface; it is lighter weight.
Do I need model_rebuild() for self-referencing models? Only when forward references can't be resolved at class-definition time — usually when models reference each other across modules. For self-references in the same module with from __future__ import annotations, Pydantic often resolves them automatically. Call model_rebuild() only when you get a PydanticUserError about unresolved references.
How do I customize JSON Schema output? Use Field(json_schema_extra={...}) for per-field additions, Annotated[T, WithJsonSchema({...})] to override a type's schema entirely, or SkipJsonSchema[T] (as the field's type, not wrapped in Annotated) to hide a field. For model-level control, override model_json_schema() or use __get_pydantic_json_schema__ on custom types. Pass mode='serialization' to model_json_schema() for the response-shaped schema FastAPI uses. See the JSON Schema docs.
Next in the Series
In Part 4: Pydantic v2 Real-World Use Cases, we put Pydantic to work where it shines brightest: FastAPI applications, configuration management, data pipelines, API client wrappers, and database ORM integration. You will see how models, validators, and all the features from this article come together to build real production systems.
Back to **Part 2: Pydantic v2 Core Tutorial** if you want to revisit validators, serialization, or TypeAdapter.
메타데이터
- post_id
- c80ff3870acd
- slug
- pydantic-v2-advanced-features-and-custom-types-c80ff3870acd
- url
- https://medium.com/engineering-playbook/pydantic-v2-advanced-features-and-custom-types-c80ff3870acd
- canonical_url
- https://medium.com/engineering-playbook/pydantic-v2-advanced-features-and-custom-types-c80ff3870acd
- author_url
- https://medium.com/@ez7
- status
- ok
- fetched_at
- 2026-06-14 13:58:26