Karya Semi
HomeBlogSearchCategoriesAboutContact
Karya Semi

Less noise. More notes.

HomeBlogAboutContactPrivacy PolicyDisclaimer

© 2026 Karya Semi. All rights reserved.

XGitHubLinkedIn
  1. Home
  2. /Categories
  3. /Programming

Designing Resilient Pydantic v2 Schemas: Advanced Validation, Custom Serialization, and Error Handling

Learn how to use Pydantic v2's modern validation engine, model configuration, and custom serializers to build bulletproof data schemas in Python applications. Do NOT write any math symbols or variables wrapped in '$' dollar signs. Do NOT use LaTeX math '$' tags at all. Use backticks for variables (e.g., `validate_assignment`). Absolutely ZERO single dollar signs for math formulas. Do NOT generate any em-dashes ('—' or '--').

Dian Rijal Asyrof/July 26, 2026/7 min read
Illustration for Designing Resilient Pydantic v2 Schemas: Advanced Validation, Custom Serialization, and Error Handling

Pydantic v2 represents a major shift in how Python developers handle data validation. The library uses a validation engine written in Rust rather than relying on Python loops. This core engine, called pydantic-core, separates validation logic from Python class representation. The result is a massive speed increase, but the real benefit is the architectural cleanup. The validation process is now predictable, explicit, and highly customizable.

When you build production applications, your schemas are the first line of defense. A weak schema design allows malformed data to seep into your database, causing bugs that are difficult to track down. Designing resilient schemas requires a deep understanding of how Pydantic processes input, how to configure model behavior, and how to handle errors cleanly.

Let's start with model configuration. By default, Pydantic is permissive. It ignores extra fields in payloads and tries to coerce types where possible. While this is convenient for quick scripts, it is dangerous in production APIs. You can tighten this behavior using the model_config attribute.

from pydantic import BaseModel, ConfigDict
 
class UserSchema(BaseModel):
    model_config = ConfigDict(
        extra="forbid",
        validate_assignment=True,
        strict=True
    )
    username: str
    email: str

Setting extra="forbid" prevents clients from sending unexpected data. If a client tries to inject a field like is_admin=True into a registration payload, the validation engine rejects the entire request. This protects your application from parameter injection vulnerabilities.

The validate_assignment setting is equally important. Without it, Pydantic only checks data when the model is first created. If you change a field value later in your code, like user.email = "invalid-email", Pydantic will not stop you. Enabling validate_assignment ensures that every change to an instantiated model is validated against the schema rules.

Then we have strict=True. In lax mode, Pydantic converts the string "123" to the integer 123 if the field expects an int. In strict mode, it rejects the string outright. If your API contract expects an integer, you should enforce it. Strict mode prevents unexpected type conversions that might cause logic errors later in your application flow.

Sometimes you need to validate data that does not fit neatly into a BaseModel. For example, you might receive a raw list of strings or a dictionary mapping strings to integers. In Pydantic v1, you had to wrap these primitives in a dummy model. Pydantic v2 solves this with TypeAdapter.

from pydantic import TypeAdapter, ValidationError
 
integer_list_adapter = TypeAdapter(list[int])
 
try:
    validated_data = integer_list_adapter.validate_python(["1", 2, 3])
    print(validated_data)  # [1, 2, 3] in lax mode
except ValidationError as e:
    print(e.json())

The TypeAdapter class gives you access to the same validation and serialization engine used by BaseModel, but for arbitrary Python types. You can use it to validate query parameters, configuration dictionaries, or third-party API payloads without creating unnecessary wrapper classes.

Basic type checking is only the beginning. Production schemas often require custom business logic, range checks, or conditional validation based on multiple fields. Pydantic v2 provides a pipeline of validators: before, after, and wrap.

An after validator is the standard validator. It runs after Pydantic has validated the field type. You receive the parsed Python object and return the modified or verified value.

from pydantic import BaseModel, field_validator
 
class Product(BaseModel):
    price: float
 
    @field_validator("price")
    @classmethod
    def check_minimum_price(cls, value: float) -> float:
        if value <= 0:
            raise ValueError("Price must be greater than zero")
        return value

A before validator runs before any type validation or coercion. You receive the raw input data. This is useful for normalizing data, parsing custom string formats, or converting legacy formats before Pydantic attempts to match them to the target type.

from typing import Any
from pydantic import BaseModel, field_validator
 
class TaggedItem(BaseModel):
    tags: list[str]
 
    @field_validator("tags", mode="before")
    @classmethod
    def split_comma_string(cls, value: Any) -> list[str]:
        if isinstance(value, str):
            return [tag.strip() for tag in value.split(",")]
        return value

When you need to validate fields that depend on each other, use @model_validator(mode="after"). This validator runs after the entire model is parsed. You can access all fields through the self instance.

from pydantic import BaseModel, model_validator
 
class Subscription(BaseModel):
    plan: str
    trial_period_days: int
 
    @model_validator(mode="after")
    def validate_trial_period(self) -> "Subscription":
        if self.plan == "free" and self.trial_period_days > 0:
            raise ValueError("Free plans cannot have a trial period")
        return self

Validation handles incoming data; serialization handles outgoing data. When you call model.model_dump() or model.model_dump_json(), you might need to format dates, hide sensitive data, or transform keys. Pydantic v2 makes custom serialization straightforward with @field_serializer and @model_serializer.

Suppose you store datetime objects in UTC but need to output them as Unix timestamps to a legacy client.

from datetime import datetime
from pydantic import BaseModel, field_serializer
 
class Event(BaseModel):
    name: str
    timestamp: datetime
 
    @field_serializer("timestamp")
    def serialize_timestamp(self, dt: datetime) -> float:
        return dt.timestamp()

This serializer only runs when you export the model. The internal timestamp field remains a standard Python datetime object.

One of the most powerful features of Pydantic v2 is the ability to pass context to serializers. This allows you to dynamically alter the output based on who is requesting the data.

from pydantic import BaseModel, field_serializer, FieldSerializationInfo
 
class UserProfile(BaseModel):
    username: str
    email: str
    phone_number: str
 
    @field_serializer("phone_number")
    def mask_phone_number(self, value: str, info: FieldSerializationInfo) -> str:
        context = info.context or {}
        user_role = context.get("role", "guest")
        
        if user_role == "admin":
            return value
        return value[:3] + "*" * (len(value) - 3)

When you serialize the model, you can pass the context dictionary:

profile = UserProfile(username="johndoe", email="john@example.com", phone_number="1234567890")
guest_data = profile.model_dump(context={"role": "guest"})
admin_data = profile.model_dump(context={"role": "admin"})

This approach avoids the need to maintain separate schemas for different access levels.

When validation fails, Pydantic raises a ValidationError. Dumping the raw exception stack trace to your API clients is bad practice. You need to parse the error and return a clean, structured response.

A ValidationError contains a list of errors, which you can access via the .errors() method. Each error dictionary contains the location of the error, the input value, the type of error, and a human-readable message.

Let's write a helper function to format these errors for an API response.

from pydantic import ValidationError
 
def format_validation_error(exc: ValidationError) -> dict[str, list[dict[str, str]]]:
    formatted_errors = []
    for error in exc.errors():
        loc = ".".join(str(item) for item in error["loc"])
        formatted_errors.append({
            "field": loc,
            "message": error["msg"],
            "type": error["type"]
        })
    return {"errors": formatted_errors}

If a client sends an invalid integer for an age field, this helper converts Pydantic's internal error structure into a clean JSON payload:

{
  "errors": [
    {
      "field": "age",
      "message": "Input should be a valid integer, unable to parse string as an integer",
      "type": "int_parsing"
    }
  ]
}

Real-world schemas are rarely flat. They contain nested models, lists, and dicts. When validation fails deep inside a nested structure, Pydantic tracks the exact path. For instance, if you have a Company model containing a list of Employee models, and one employee has an invalid email, the error location will look like ("employees", 2, "email"). Your error formatter can split this tuple to highlight the exact index and field that failed.

You can also customize the error messages raised by your custom validators. Instead of raising a generic ValueError, you can raise a custom exception or use Pydantic's built-in error types to provide clearer feedback.

from pydantic import BaseModel, field_validator
from pydantic_core import PydanticCustomError
 
class Product(BaseModel):
    sku: str
 
    @field_validator("sku")
    @classmethod
    def validate_sku(cls, value: str) -> str:
        if not value.startswith("SKU-"):
            raise PydanticCustomError(
                "invalid_sku",
                "The SKU must start with the prefix 'SKU-'"
            )
        return value

By using PydanticCustomError, you have full control over the error type code and message, making it easier for client applications to handle specific failures programmatically.

When designing schemas, you must plan for change. Fields will be added, deprecated, or renamed. Using default values is the easiest way to ensure backwards compatibility. If you add a new field to a schema, give it a default value or make it optional. If you do not, older clients sending requests without that field will suddenly break.

If you must rename a field, use Field(validation_alias="old_name"). This allows the model to accept the old field name during validation while exposing the new name internally.

from pydantic import BaseModel, Field
 
class Configuration(BaseModel):
    timeout_seconds: int = Field(validation_alias="timeout")

Here, the model accepts input data containing the key timeout but assigns the value to timeout_seconds. This keeps your internal code clean while maintaining compatibility with legacy clients.

You can also use an alias generator to handle cases where your API uses camelCase but your Python code uses snake_case.

from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
 
class ApiModel(BaseModel):
    model_config = ConfigDict(
        alias_generator=to_camel,
        populate_by_name=True,
        serialize_by_alias=True
    )
    user_id: int
    first_name: str

With populate_by_name=True, the model can be instantiated using either user_id or userId. With serialize_by_alias=True, the serialized JSON will use userId and firstName by default, keeping your API responses consistent with JavaScript conventions.

Pydantic v2 relies heavily on Python's typing.Annotated to define metadata and validation rules directly on types. This is a massive improvement over v1 because it allows you to create reusable types that carry their validation logic with them.

from typing import Annotated
from pydantic import BaseModel, Field, AfterValidator
 
def verify_non_empty(value: str) -> str:
    if not value.strip():
        raise ValueError("String cannot be empty or whitespace only")
    return value.strip()
 
NonEmptyString = Annotated[str, Field(min_length=1), AfterValidator(verify_non_empty)]
 
class UserRegistration(BaseModel):
    username: NonEmptyString
    bio: NonEmptyString

You can define the validation logic once and bind it to a type alias, rather than writing the same @field_validator on five different fields across multiple models. This keeps your schemas dry and makes code reviews much easier.

Because Pydantic v2 compiles validation rules in Rust, instantiation is incredibly fast. However, you can still bottleneck your application if you call validators inefficiently.

Avoid database queries inside validators. If you need to check if a username exists in the database, do it in your service layer, not in the Pydantic validator. Validators should remain pure functions that depend only on the input data. Putting side effects inside validators makes testing difficult and slows down validation loops.

Another performance tip: reuse TypeAdapter instances. Creating a TypeAdapter is relatively cheap, but doing it inside a hot loop can add unnecessary overhead. Instantiate your adapters globally or cache them if they are used frequently.

When you write tests, make sure to test both strict and lax modes if your application supports both. Pydantic's behavior can change significantly depending on these settings, and having explicit tests for type coercion will prevent bugs when upgrading dependencies.

DR

Dian Rijal Asyrof

Writes about useful AI tools, programming practice, and the craft of building reliable software.

Previous articleOptimizing Core Web Vitals in Next.js App Router: LCP, FID, and CLS Practical FixesNext articleAdvanced RAG Architectures: Implementing Parent-Document Retrieval and Query Rewriting
ProgrammingBest PracticesSoftware

See also

Illustration for PyPI Closes the Windows of Exposure: Inside the 14-Day Release File Rejection Rule
Software Engineering/Jul 26, 2026

PyPI Closes the Windows of Exposure: Inside the 14-Day Release File Rejection Rule

An analysis of the Python Package Index's new security policy rejecting new file uploads to existing releases after 14 days, its impact on supply chain security, and developer deployment pipelines. Strictly do NOT use em-dashes ('—' or '--') anywhere. Use commas, hyphens, or colons instead. Do NOT use LaTeX math '$' tags.

6 min read
SecuritySoftware Engineering
Illustration for Claude Sonnet 5 Just Dropped. Here's What Developers Should Know.
Programming/Jul 15, 2026

Claude Sonnet 5 Just Dropped. Here's What Developers Should Know.

Anthropic released Claude Sonnet 5, a model that nearly matches Opus 4.8 at a fraction of the cost. Here's what changed and whether it matters for your workflow.

3 min read
AI CodingProgramming
Illustration for The Developer's Trap: Why Git Signed Commits Don't Guarantee Codebase Security
Software Engineering/Jul 15, 2026

The Developer's Trap: Why Git Signed Commits Don't Guarantee Codebase Security

Mandating Git commit signing is a trending compliance requirement. But relying on the green badge creates a false sense of security that leaves repositories vulnerable.

5 min read
GitSecurity