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
- Official Docs: Pydantic V2.10 Release Notes
- GitHub: Pydantic Repository
- Community: Join the Pydantic Discord for questions
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.