← Back to list

Pydantic v2 Advanced Features and Custom Types

Settings management, custom types, generic models, and discriminated unions in Pydantic v2

ez7 in Production Engineering Playbook · 2026-05-28 06:31 · 0 claps · 17.1 min read paywalled
#pydantic #pydanticv2 #pydantic-settings #pydantic-ai #python
Open on Medium ↗
Wiki topics: GEN · Genomics & Sequencing BIZ · Business Strategy 📊 · Economic Policy

Pydantic v2 Advanced Features and Custom Types

Settings management, custom types, generic models, and discriminated unions in Pydantic v2

TL;DR

  • pydantic-settings reads config from env vars, .env files, 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_call applies 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:

  1. Init kwargs — values passed directly to the constructor
  2. Environment variables — from the running process environment
  3. **.env file** — loaded from the file specified in env_file
  4. Secrets directory — files in a secrets dir (useful for Docker/Kubernetes secrets)
  5. 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 a handler callable. 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_types constraints (Ge, Gt, Le, MinLen, MaxLen, etc.) — useful when sharing constraint metadata with libraries outside Pydantic that read Annotated metadata 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 = None creates a tension — the declared type is T, but fail() always produces data=None. Strict type checkers (mypy, pyright) will flag the mismatch. If that bothers you, split into two separate response types or annotate data as T | None explicitly in the fail return 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 of Generic[T]. The Generic[T] form remains supported and works on Python 3.10+. Most readers should use the Generic[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 discriminator keyword.

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_field together 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 area to 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 set validate_assignment=True on the model config and use ordinary attribute assignment after model_copy(). The model_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_factory calls 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