languages
April 08, 2026 · 8 min read · 0 views

Pydantic V2.10: Serialization Modes and Computed Fields Deep Dive

Explore Pydantic V2.10's powerful serialization modes and computed fields—essential for building robust APIs and data validation pipelines.

Understanding Pydantic V2.10’s Evolution

Pydantic has become the de facto standard for data validation and serialization in Python, trusted by millions of developers building APIs, microservices, and data pipelines. The V2.10 release brings significant improvements to serialization modes and computed fields, addressing real pain points in production applications.

This update is particularly valuable for teams working with:

  • REST and GraphQL APIs requiring multiple output formats
  • Database-backed applications needing different serialization contexts
  • Machine learning pipelines with complex data transformations
  • Systems where a single model needs to expose different fields based on the serialization context

What Changed in Pydantic V2.10

Serialization Modes Enhancement

Pydantic V2.10 introduces more flexible serialization mode control, allowing you to define how models serialize based on context. This is crucial when building APIs that need to expose different data representations to different consumers.

Consider a user model in a real-world scenario:

from pydantic import BaseModel, Field, computed_field
from typing import Optional
from datetime import datetime

class User(BaseModel):
    id: int
    username: str
    email: str
    password_hash: str
    created_at: datetime
    last_login: Optional[datetime] = None
    is_admin: bool = False
    
    # Computed field with custom serialization
    @computed_field
    @property
    def account_age_days(self) -> int:
        """Calculate days since account creation."""
        return (datetime.utcnow() - self.created_at).days

In V2.10, you can now control serialization modes more granularly:

from pydantic import SerializationInfo, field_serializer

class User(BaseModel):
    id: int
    username: str
    email: str
    password_hash: str
    created_at: datetime
    last_login: Optional[datetime] = None
    is_admin: bool = False
    
    @field_serializer('password_hash')
    def serialize_password(self, value: str, _info: SerializationInfo) -> Optional[str]:
        """Only include password_hash in 'internal' mode."""
        if _info.mode == 'json' and _info.context and _info.context.get('expose_secrets'):
            return value
        return None
    
    @field_serializer('is_admin')
    def serialize_admin_flag(self, value: bool, _info: SerializationInfo) -> Optional[bool]:
        """Only expose admin flag to internal systems."""
        if _info.context and _info.context.get('role') == 'admin':
            return value
        return None
    
    @computed_field
    @property
    def account_age_days(self) -> int:
        return (datetime.utcnow() - self.created_at).days

Now you can serialize the same model differently based on context:

user = User(
    id=1,
    username="alice",
    email="[email protected]",
    password_hash="bcrypt$2b$12$...",
    created_at=datetime(2023, 1, 1),
    is_admin=True
)

# Public API response
public_response = user.model_dump(
    mode='json',
    context={'role': 'public'}
)
print(public_response)
# Output: {'id': 1, 'username': 'alice', 'email': '[email protected]', 
#          'created_at': '2023-01-01T00:00:00', 'account_age_days': 632}

# Internal API response for admin
admin_response = user.model_dump(
    mode='json',
    context={'role': 'admin', 'expose_secrets': True}
)
print(admin_response)
# Output: {'id': 1, 'username': 'alice', 'email': '[email protected]',
#          'password_hash': 'bcrypt$2b$12$...', 'is_admin': True,
#          'created_at': '2023-01-01T00:00:00', 'account_age_days': 632}

Enhanced Computed Fields

Computed fields are now more powerful in V2.10. You can define dynamic properties that are calculated at serialization time and customize their behavior per context.

Real-World Example: E-commerce Product Model

from pydantic import BaseModel, computed_field, field_validator
from typing import List
from decimal import Decimal

class Review(BaseModel):
    rating: int
    text: str

class Product(BaseModel):
    id: int
    name: str
    price: Decimal
    cost: Decimal  # Internal cost
    reviews: List[Review] = []
    inventory_count: int
    
    @field_validator('price', 'cost')
    @classmethod
    def validate_price(cls, v):
        if v < 0:
            raise ValueError('Price cannot be negative')
        return v
    
    @computed_field
    @property
    def average_rating(self) -> float:
        """Calculate average rating from reviews."""
        if not self.reviews:
            return 0.0
        return sum(r.rating for r in self.reviews) / len(self.reviews)
    
    @computed_field
    @property
    def margin_percentage(self) -> float:
        """Calculate profit margin—only for internal use."""
        if self.cost == 0:
            return 0.0
        return ((self.price - self.cost) / self.price * 100)
    
    @computed_field
    @property
    def in_stock(self) -> bool:
        """Simple inventory check."""
        return self.inventory_count > 0
    
    @field_serializer('cost', 'margin_percentage')
    def hide_cost_data(self, value, _info: SerializationInfo):
        """Hide cost and margin from public API."""
        if _info.context and _info.context.get('internal'):
            return value
        return None

Usage:

product = Product(
    id=101,
    name="Wireless Headphones",
    price=Decimal('99.99'),
    cost=Decimal('35.00'),
    reviews=[
        Review(rating=5, text="Excellent sound quality"),
        Review(rating=4, text="Good but a bit pricey"),
    ],
    inventory_count=150
)

# Public storefront response
public = product.model_dump(mode='json', context={'internal': False})
print(public)
# {
#   'id': 101,
#   'name': 'Wireless Headphones',
#   'price': 99.99,
#   'reviews': [...],
#   'inventory_count': 150,
#   'average_rating': 4.5,
#   'in_stock': True
# }

# Internal dashboard response
internal = product.model_dump(mode='json', context={'internal': True})
print(internal)
# {
#   'id': 101,
#   'name': 'Wireless Headphones',
#   'price': 99.99,
#   'cost': 35.00,
#   'reviews': [...],
#   'inventory_count': 150,
#   'average_rating': 4.5,
#   'margin_percentage': 65.01,
#   'in_stock': True
# }

Getting Started with V2.10

Installation

pip install --upgrade pydantic>=2.10.0

Verify your installation:

import pydantic
print(pydantic.VERSION)  # Should be 2.10.0 or higher

Step-by-Step Migration Guide

If you’re upgrading from Pydantic V2.9 or earlier, follow these steps:

Step 1: Update your dependencies

pip install --upgrade pydantic
pip install --upgrade pydantic-settings  # If you use settings

Step 2: Test serialization in your existing models

# Before (V2.9)
user_dict = user.model_dump()

# After (V2.10) — same API, enhanced under the hood
user_dict = user.model_dump(context={'role': 'user'})

Step 3: Refactor field serializers if needed

Pydantic V2.10 improves the SerializationInfo object, giving you better access to context:

from pydantic import field_serializer, SerializationInfo

class MyModel(BaseModel):
    secret_field: str
    
    @field_serializer('secret_field')
    def serialize_secret(self, value: str, _info: SerializationInfo) -> str:
        # V2.10 provides clearer context handling
        if _info.context and _info.context.get('show_secrets'):
            return value
        return "***REDACTED***"

Practical Use Case: Building a Multi-Tenant API

Here’s a complete example showing how V2.10 makes building multi-tenant systems easier:

from pydantic import BaseModel, field_serializer, computed_field, SerializationInfo
from typing import Optional, List
from datetime import datetime

class Tenant(BaseModel):
    id: int
    name: str
    subscription_tier: str  # 'free', 'pro', 'enterprise'
    api_quota: int
    api_usage: int
    billing_email: str
    created_at: datetime
    
    @computed_field
    @property
    def quota_remaining(self) -> int:
        return max(0, self.api_quota - self.api_usage)
    
    @computed_field
    @property
    def quota_percentage(self) -> float:
        if self.api_quota == 0:
            return 0.0
        return (self.api_usage / self.api_quota) * 100
    
    @field_serializer('billing_email')
    def serialize_billing_email(self, value: str, _info: SerializationInfo) -> Optional[str]:
        # Only owner and admin can see billing email
        if _info.context:
            user_id = _info.context.get('user_id')
            user_role = _info.context.get('role')
            tenant_id = _info.context.get('tenant_id')
            
            if user_role == 'admin' or (user_id and user_role == 'owner'):
                return value
        return None
    
    @field_serializer('subscription_tier')
    def serialize_subscription(self, value: str, _info: SerializationInfo) -> str:
        # Always visible but potentially masked
        if _info.context and _info.context.get('masked'):
            return value[:3] + "***"
        return value

class TenantResponse(BaseModel):
    """Wrapper for consistent API responses."""
    tenant: Tenant
    status: str = 'active'

# Usage in a FastAPI endpoint
from fastapi import FastAPI, HTTPException, Header

app = FastAPI()

@app.get('/api/tenant/{tenant_id}')
async def get_tenant(tenant_id: int, authorization: str = Header(None)):
    # Authenticate and get user info
    user_id = extract_user_id(authorization)
    user_role = get_user_role(user_id, tenant_id)
    
    tenant = fetch_tenant(tenant_id)  # Your DB query
    
    # Serialize with appropriate context
    return tenant.model_dump(
        mode='json',
        context={
            'user_id': user_id,
            'role': user_role,
            'tenant_id': tenant_id,
            'masked': user_role not in ['owner', 'admin']
        }
    )

Validating Your JSON with Kloubot

When working with Pydantic serialized output, you’ll want to validate your JSON structure. Use the JSON Formatter to quickly check if your model_dump() output is valid and well-formed, especially when testing different serialization modes.

Common Pitfalls and How to Avoid Them

Pitfall 1: Forgetting Context Defaults

Problem:

@field_serializer('email')
def serialize_email(self, value: str, _info: SerializationInfo) -> Optional[str]:
    # This will crash if context is None
    if _info.context['role'] == 'admin':
        return value
    return None

Solution:

@field_serializer('email')
def serialize_email(self, value: str, _info: SerializationInfo) -> Optional[str]:
    # Always check if context exists first
    if _info.context and _info.context.get('role') == 'admin':
        return value
    return None

Pitfall 2: Computed Fields in Large Loops

Problem: Computed fields recalculate on every serialization, which can be expensive in tight loops.

Solution: Cache expensive computations:

from functools import cached_property

class ExpensiveModel(BaseModel):
    data: List[int]
    
    @cached_property
    def _computed_result(self) -> float:
        # Expensive calculation
        return sum(x ** 2 for x in self.data) / len(self.data)
    
    @computed_field
    @property
    def stats(self) -> float:
        return self._computed_result

Pitfall 3: Circular References in Context

Problem: Passing circular references in context can cause serialization to fail.

Solution: Keep context simple—use IDs instead of object references:

# Bad
context = {'user': user_object, 'tenant': tenant_object}

# Good
context = {'user_id': 123, 'tenant_id': 456, 'role': 'admin'}

Why It Matters

In modern API development, a single data model often needs multiple representations:

  • Public API: Stripped-down view without sensitive fields
  • Admin Dashboard: Full details including costs, margins, and internal flags
  • Mobile App: Lightweight version optimized for bandwidth
  • Analytics Pipeline: Full-featured with all computed metrics

Before Pydantic V2.10, you’d handle this with:

  • Multiple model classes (brittle, repetitive)
  • Manual dictionary manipulation (error-prone)
  • Conditional logic scattered across endpoints (hard to maintain)

With V2.10’s serialization modes and computed fields, you define the logic once in your model and reuse it everywhere. This approach is:

  • DRY: Single source of truth for serialization logic
  • Secure: Easier to audit what data goes where
  • Testable: Serialize with different contexts in unit tests
  • Maintainable: Changes to serialization happen in one place

Testing Serialization Logic

Here’s a testing pattern using pytest:

import pytest
from datetime import datetime

def test_user_serialization_public():
    """Public role should not see sensitive fields."""
    user = User(
        id=1,
        username="alice",
        email="[email protected]",
        password_hash="secret",
        created_at=datetime(2023, 1, 1),
        is_admin=True
    )
    
    public = user.model_dump(context={'role': 'public'})
    
    assert 'password_hash' not in public or public['password_hash'] is None
    assert 'is_admin' not in public or public['is_admin'] is None
    assert public['username'] == 'alice'

def test_user_serialization_admin():
    """Admin role should see all fields."""
    user = User(
        id=1,
        username="alice",
        email="[email protected]",
        password_hash="secret",
        created_at=datetime(2023, 1, 1),
        is_admin=True
    )
    
    admin = user.model_dump(context={'role': 'admin', 'expose_secrets': True})
    
    assert admin['password_hash'] == 'secret'
    assert admin['is_admin'] is True

Advanced: Custom Serializers for Computed Fields

You can also customize how computed fields serialize:

from pydantic import computed_field
from datetime import datetime, timedelta

class TimeTrackingModel(BaseModel):
    created_at: datetime
    
    @computed_field
    @property
    def time_ago(self) -> str:
        """Human-readable time difference."""
        delta = datetime.utcnow() - self.created_at
        
        if delta.seconds < 60:
            return f"{delta.seconds} seconds ago"
        elif delta.seconds < 3600:
            return f"{delta.seconds // 60} minutes ago"
        elif delta.days < 1:
            return f"{delta.seconds // 3600} hours ago"
        else:
            return f"{delta.days} days ago"
    
    @field_serializer('time_ago')
    def serialize_time_ago(self, value: str, _info: SerializationInfo) -> str:
        # In machine-readable mode, return ISO format instead
        if _info.mode == 'python':
            return value
        return self.created_at.isoformat()

Resources and Next Steps

When building APIs with Pydantic, you’ll often need to inspect and debug serialized output. The JSON Formatter at Kloubot is perfect for validating your model_dump() results, especially when testing different serialization contexts.

For APIs that return JWT tokens alongside Pydantic models, check out the JWT Decoder to inspect token payloads and ensure proper claims are being serialized.

If you’re building systems that accept diverse input formats, the CSV/JSON Converter can help you validate that Pydantic models correctly deserialize from various sources.

Conclusion

Pydantic V2.10 represents a maturation of Python’s data validation ecosystem. The enhancements to serialization modes and computed fields give you the tools to build flexible, secure, and maintainable APIs. Whether you’re starting a new project or upgrading an existing one, these features will help you write cleaner, more testable code.

Start by identifying your models that need multiple serialization contexts, and refactor them to use the new V2.10 patterns. Your future self (and your teammates) will thank you.

This post was generated with AI assistance and reviewed for accuracy.