June 2023. Pydantic v2 promises 5-17x performance improvement. I look at my pet project with 28 models and think: "What if?".
Six weeks of spare time, three microservices (API Gateway, Order Processing, User Management), artificial load ~2000 req/sec. Solo project — nobody will critique architectural decisions at 2 AM, so I can experiment freely.
Important: This is not a "migrate in 5 easy steps" tutorial. This is an honest breakdown of what went wrong, what surprised me, and why I still don't regret spending those evenings and weekends.
Why migrate at all?
Let's start with the pain. I had three problems, all bottlenecked by Pydantic v1 performance.
Problem #1: Validation eats CPU
API Gateway validates JWT token on every request. Simple model with five fields:
# Pydantic v1
class JWTPayload(BaseModel):
user_id: int
email: str
roles: List[str]
exp: int
iat: int
# Metrics before migration
Latency (P95): 12ms for token validation
CPU: 18% utilization for validation
Throughput: ~2000 req/sec before CPU throttlingBottom line: At 2500+ req/sec, validation becomes the bottleneck. 18% CPU goes solely to validating five fields in a token.
Problem #2: Memory leaks during bulk operations
Order Processing Service loads CSV with 50,000 orders:
orders = [Order(**row) for row in csv_reader] # Peak consumption: 1.2GBWhen processing multiple files in parallel, we run out of memory.
Problem #3: Slow serialization
User Management returns user lists with nested data:
# 1000 users with profiles and permissions
users = [UserResponse.from_orm(u) for u in db_users]
serialized = [u.dict() for u in users] # ~450msWhy not just optimize v1?
Naturally, I tried squeezing maximum from v1 before diving into migration:
model_construct()for trusted data — helped, but not universally applicable (lose validation)- Schema caching — mere ~2-3%, bottleneck was elsewhere
- Request batching — integration complexity outweighed benefits
Result: squeezed out 20-30%, but CPU still hits the ceiling at peaks.
Then Pydantic v2 waves a flag with "5-17x faster" thanks to Rust-core. Decided: screw it, let's try. Worst case — spend a couple weekends and write a post about "why migration was a bad idea".
What is model_construct() and why it helped
model_construct() is a Pydantic method that creates a model instance without validation. It's a bypass of the data checking mechanism.
How regular model creation works:
class Order(BaseModel):
product_id: int
quantity: int
price: Decimal
# Regular creation (with validation)
order = Order(product_id="123", quantity="5", price="99.99")
# 1. Type validation: "123" -> int, "5" -> int
# 2. Constraint validation (if any)
# 3. Object creation
# Time: ~10-50 μs per object (depends on model complexity)How model_construct() works:
# Creation without validation
order = Order.model_construct(product_id=123, quantity=5, price=Decimal("99.99"))
# 1. Just creates object with passed values
# 2. NO type checking
# 3. NO validators
# Time: ~1-2 μs per object (5-25x faster)When to use:
✅ Safe (trusted data):
- Data from DB (already validated on insert)
- Data from internal services
- CSV files from trusted sources (with pre-validated structure)
❌ Dangerous (untrusted data):
- User data (API requests)
- External APIs
- Files from unknown sources
Real example from our migration:
# CSV with 50,000 orders from internal system
# Format guaranteed (generated by our own code)
# ❌ Slow: validate every row
orders = [Order(**row) for row in csv_rows] # ~2.5 seconds + 1.2GB RAM
# ✅ Fast: validate only file structure
def validate_csv_structure(headers):
expected = set(Order.model_fields.keys())
if not expected.issubset(headers):
raise ValueError(f"Invalid CSV: missing {expected - set(headers)}")
validate_csv_structure(csv_headers) # Once per file
orders = [Order.model_construct(**row) for row in csv_rows] # ~0.3 seconds + 380MB RAMTrade-off:
- Get 7-8x speedup and 3x memory savings
- Lose protection from invalid data
- Solution: validate data at system boundary (during file load), use model_construct() internally
Migration process: what I did week by week
Week 1-2: Preparation and inventory
Step 1: Model inventory
# Script to find all BaseModel (rg = ripgrep, fast grep alternative)
rg "class \w+\(BaseModel\)" --type py -c
# Result:
API Gateway: 8 models
Order Service: 12 models
User Service: 6 models
Shared libs: 2 models
Total: 28 modelsStep 2: Dependency analysis — understand what really needs rewriting
Before migration, I needed to assess the scope of work: which parts of Pydantic v1 API are used in the project. Wrote a script that parses Python code via AST (Abstract Syntax Tree) and looks for v1 patterns that changed in v2.
How it works:
# check_dependencies.py
import ast
import sys
class ValidatorVisitor(ast.NodeVisitor):
"""Traverses Python file AST and collects Pydantic v1 usage statistics."""
def __init__(self):
self.validators = [] # List of models with @validator
self.config_classes = [] # List of models with nested class Config
def visit_FunctionDef(self, node):
"""Called for every function/method in code."""
# Check method decorators
for decorator in node.decorator_list:
# If found @validator — it's v1 API, v2 needs @field_validator
if isinstance(decorator, ast.Name) and decorator.id == 'validator':
self.validators.append(node.name)
def visit_ClassDef(self, node):
"""Called for every class in code."""
# Check class body for nested class Config
for item in node.body:
if isinstance(item, ast.ClassDef) and item.name == 'Config':
# class Config -> model_config = ConfigDict() in v2
self.config_classes.append(node.name)
self.generic_visit(node) # Continue traversing nested nodes
# Run on all project Python files, collect statistics
# Analysis results:
# 18 models with @validator ← need to change to @field_validator
# 14 models with Config class ← need to change to model_config = ConfigDict()
# 8 models with orm_mode = True ← need to change to from_attributes = True
# 3 models with JSON encoders ← need to rewrite to @field_serializerWhy needed: AST analysis showed that ~64% of models (18 out of 28) use @validator — the main breaking change in v2. Without this inventory, I wouldn't have understood the real scope of work.
Step 3: Created feat/pydantic-v2 branch and started migration in isolation.
Week 3-4: Migration automation
Wrote a codemod (libCST) for automatic migration:
# migrate_pydantic.py
import libcst as cst
from libcst import matchers as m
class PydanticV2Transformer(cst.CSTTransformer):
"""Automatic Pydantic v1 -> v2 migration."""
def leave_FunctionDef(self, original_node, updated_node):
# @validator -> @field_validator
if self._has_validator_decorator(updated_node):
return self._convert_validator(updated_node)
return updated_node
def leave_ClassDef(self, original_node, updated_node):
# Config -> model_config = ConfigDict(...)
if self._has_config_class(updated_node):
return self._convert_config(updated_node)
return updated_node
def _convert_validator(self, node):
"""@validator('field') -> @field_validator('field')."""
new_decorators = []
for decorator in node.decorators:
if m.matches(decorator, m.Decorator(decorator=m.Name("validator"))):
# Replace decorator name
new_dec = decorator.with_changes(
decorator=cst.Name("field_validator")
)
new_decorators.append(new_dec)
else:
new_decorators.append(decorator)
# Add @classmethod if missing
has_classmethod = any(
m.matches(d, m.Decorator(decorator=m.Name("classmethod")))
for d in new_decorators
)
if not has_classmethod:
new_decorators.insert(0, cst.Decorator(decorator=cst.Name("classmethod")))
return node.with_changes(decorators=new_decorators)
def _convert_config(self, node):
"""class Config -> model_config = ConfigDict(...)."""
# Find Config inside model
config_class = None
new_body = []
for item in node.body:
if isinstance(item, cst.ClassDef) and item.name.value == "Config":
config_class = item
else:
new_body.append(item)
if not config_class:
return node
# Extract parameters from Config
config_params = self._extract_config_params(config_class)
# Create model_config = ConfigDict(...)
config_dict = cst.Assign(
targets=[cst.AssignTarget(target=cst.Name("model_config"))],
value=cst.Call(
func=cst.Name("ConfigDict"),
args=[
cst.Arg(
keyword=cst.Name(k),
value=cst.Name(str(v)) if isinstance(v, bool) else cst.SimpleString(f'"{v}"')
)
for k, v in config_params.items()
]
)
)
# Insert at beginning of class body
new_body.insert(0, config_dict)
return node.with_changes(body=new_body)
# Apply to files
for file_path in all_model_files:
with open(file_path) as f:
source = f.read()
tree = cst.parse_module(source)
transformer = PydanticV2Transformer()
new_tree = tree.visit(transformer)
with open(file_path, 'w') as f:
f.write(new_tree.code)Automation results:
- 22 models (78%) migrated automatically
- 6 models (22%) required manual work (complex validators, custom JSON encoders)
Week 5: Manual refinement and tests
Problems automation didn't solve:
1. Complex validators with side effects
⚠️ Anti-pattern from real practice: Examples below show v1 code that used I/O operations (DB queries) inside validators. This is bad practice for several reasons:
- N+1 problem: DB query for each validated object
- Blocking I/O: validation slowdown by tens of times
- Testing complexity: need to mock DB for tests
- Single Responsibility violation: validator should check data, not access external systems
During v2 migration, I fixed this anti-pattern by moving existence checks from validators to business logic.
# v1 - worked (but it's an ANTI-PATTERN!)
class Order(BaseModel):
product_id: int
quantity: int
@validator('product_id')
def check_product_exists(cls, v):
# DB query inside validator
product = db.query(Product).get(v)
if not product:
raise ValueError(f'Product {v} not found')
return v
# v2 - needs rewriting
class Order(BaseModel):
product_id: int
quantity: int
# Validator must be @classmethod in v2
@field_validator('product_id')
@classmethod
def check_product_exists(cls, v):
# ⚠️ Problem: db unavailable in classmethod
# Solution: remove I/O from validator
if v <= 0:
raise ValueError('Product ID must be positive')
return v
# Moved existence check to endpoint
@classmethod
def create_with_validation(cls, data: dict, db: Session):
order = cls(**data)
# Check product after schema validation
product = db.query(Product).get(order.product_id)
if not product:
raise HTTPException(404, f'Product {order.product_id} not found')
return order2. Custom JSON encoders
# v1
class Event(BaseModel):
timestamp: datetime
data: dict
class Config:
json_encoders = {
datetime: lambda v: v.isoformat()
}
# v2 - use field_serializer
class Event(BaseModel):
timestamp: datetime
data: dict
@field_serializer('timestamp')
def serialize_datetime(self, value: datetime) -> str:
return value.isoformat()3. Code introspection broke
# v1 - worked
for field_name, field in MyModel.__fields__.items():
print(f"{field_name}: {field.type_}")
# v2 - needs rewriting
for field_name, field_info in MyModel.model_fields.items():
print(f"{field_name}: {field_info.annotation}")Rewrote 6 models manually in 1 day.
Testing:
# Ran entire test suite
pytest tests/ -v --cov
# Results:
Coverage: 87% (was 87% - unchanged)
Tests passed: 142/145
Tests failed: 3 ← problems!3 failed tests:
- 1 test: validation error messages changed
- 1 test: mocks for
__fields__don't work - 1 test: issues with
orm_mode(nowfrom_attributes)
Fixed in 1 day.
Week 6: Deployment to test stand and first issues
Deployed to test stand with simulated load.
Problem #1: Feature flags broke
# Config model
class FeatureFlags(BaseModel):
new_checkout: bool
beta_features: bool
debug_mode: bool
# Redis stored strings "true"/"false"
# v1: converted "false" -> False ✅
# v2 strict mode: "false" -> True (any non-empty string) ❌
# Result: all feature flags turned on in test standRoot cause: In v1, string-to-bool conversion was smarter. v2 in non-strict mode uses Python's bool(str) → any non-empty string = True.
Solution:
class FeatureFlags(BaseModel):
model_config = ConfigDict(strict=True) # Only exact types
new_checkout: bool
beta_features: bool
debug_mode: bool
# Added explicit conversion in Redis loading code
def load_feature_flags(redis_client):
data = redis_client.hgetall("feature_flags")
# Explicitly convert strings to bool
parsed = {
k: v.lower() == "true" if isinstance(v, str) else v
for k, v in data.items()
}
return FeatureFlags(**parsed)Time to fix: 2 hours (thanks to test stand).
Problem #2: Memory leak in Order Service
After 6 hours running on test stand, Order Service started crashing with OOMKilled.
# Memory profiling
import tracemalloc
tracemalloc.start()
# Before file processing
snapshot1 = tracemalloc.take_snapshot()
# Processing 50k orders
orders = [Order(**row) for row in csv_rows]
process_orders(orders)
# After processing
snapshot2 = tracemalloc.take_snapshot()
top_stats = snapshot2.compare_to(snapshot1, 'lineno')
for stat in top_stats[:3]:
print(stat)
# Output:
# /app/models/order.py:45: size=842 MiB (+842 MiB), count=50234 (+50234)
# /venv/pydantic/main.py:156: size=234 MiB (+234 MiB), count=150702 (+150702)Root cause: In v2, more metadata is cached when creating models (for performance). With mass object creation, memory wasn't freed immediately.
Solution: Used model_construct() for trusted CSV data:
# Before (with validation)
orders = [Order(**row) for row in csv_rows] # 1.2GB memory
# After (without validation for trusted data)
orders = [Order.model_construct(**row) for row in csv_rows] # 380MB memory
# Moved validation to CSV loading stage (fail fast)
def validate_csv_header(headers):
expected = set(Order.model_fields.keys())
if not expected.issubset(headers):
raise ValueError(f"Missing columns: {expected - set(headers)}")Time to fix: 1 day (including profiling).
Timeline and migration costs
Summary table of all migration stages on the test project:
| Stage | Duration | Key tasks | Result |
|---|---|---|---|
| Weeks 1-2: Preparation | 2 weeks | Model inventory, dependency analysis, branch creation | 28 models inventoried, dependency graph built |
| Weeks 3-4: Automation | 2 weeks | libCST codemod development, automatic migration | 22 models (78%) migrated automatically |
| Week 5: Manual refinement | 1 week | Complex validator migration, JSON encoders, tests | 6 models refined manually, 142/145 tests passing |
| Week 6: Deployment | 1 week | Deploy to test stand, monitoring, fixing issues | 3 problems found and fixed (feature flags, memory leak, validation errors) |
| Total | 6 weeks | Full migration of 28 models + 3 services | Migration complete, production ready |
Key process metrics:
- Automation: 78% of models migrated automatically (saved ~80 hours manual work)
- Quality: 98% test coverage maintained (was 87%, stayed 87%)
- Incidents: 3 problems on test stand (0 in production thanks to testing)
- Rollbacks: 0 (all problems found before production)
Testing: gradual load increase
Testing strategy
- Week 1: User Service (least critical) — 5% → 25% → 50% → 100% load
- Week 2: Order Service — 10% → 50% → 100%
- Week 3: API Gateway (most loaded) — 5% → 25% → 50% → 75% → 100%
About load testing: Currently preparing detailed material on how to conduct load testing with k6, write scenarios, analyze results, and integrate into CI/CD. Follow updates in the k6 course.
Gradual load increase configuration:
# kubernetes/api-gateway-canary.yaml
apiVersion: v1
kind: Service
metadata:
name: api-gateway-canary
spec:
selector:
app: api-gateway
version: v2-pydantic
ports:
- port: 8000
---
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: api-gateway-traffic-split
spec:
hosts:
- api-gateway
http:
- match:
- headers:
x-canary:
exact: "true"
route:
- destination:
host: api-gateway-canary
weight: 100
- route:
- destination:
host: api-gateway-stable
weight: 95
- destination:
host: api-gateway-canary
weight: 5 # Started with 5%Test monitoring
# prometheus_metrics.py
from prometheus_client import Histogram, Counter
# Metrics for v1 vs v2 comparison
validation_duration = Histogram(
'pydantic_validation_duration_seconds',
'Time spent in Pydantic validation',
['version', 'model_name']
)
validation_errors = Counter(
'pydantic_validation_errors_total',
'Total validation errors',
['version', 'model_name', 'error_type']
)
# In code
def validate_request(data: dict) -> RequestModel:
version = "v2" if PYDANTIC_V2_ENABLED else "v1"
with validation_duration.labels(version=version, model_name="RequestModel").time():
try:
return RequestModel(**data)
except ValidationError as e:
for error in e.errors():
validation_errors.labels(
version=version,
model_name="RequestModel",
error_type=error['type']
).inc()
raiseGrafana dashboard for monitoring:
# Latency comparison v1 vs v2
histogram_quantile(0.95,
rate(pydantic_validation_duration_seconds_bucket[5m])
) by (version)
# Error rate v1 vs v2
rate(pydantic_validation_errors_total[5m]) by (version, error_type)Problem #3: Spike in validation errors at 25% load
On test API Gateway when increasing load to 25%, validation errors started growing:
[ERROR] ValidationError in POST /api/orders
Input: {"items": [{"id": "123", "qty": 2}]}
Error: Input should be a valid integer [type=int_type, input_value='123', input_type=str]
Root cause: Mobile app sent id as string. v1 silently converted to int, v2 with strict=False should have too — but I enabled strict=True after the feature flags incident.
Solution: Per-field strict mode:
# Was: global strict
class OrderItem(BaseModel):
model_config = ConfigDict(strict=True)
id: int
qty: int
# Became: hybrid approach
class OrderItem(BaseModel):
model_config = ConfigDict(strict=False) # Flexible by default
id: int # Allow "123" -> 123
qty: int
price: Decimal = Field(strict=True) # But money only exact types!Actions: Temporarily returned load to 5% while fixing. 3 hours later deployed fix, raised to 50%.
Migration results: before/after metrics
API Gateway (JWT validation)
| Metric | v1 (before) | v2 (after) | Change |
|---|---|---|---|
| Latency (P50) | 8ms | 2ms | -75% |
| Latency (P95) | 12ms | 3ms | -75% |
| Latency (P99) | 18ms | 5ms | -72% |
| CPU usage | 18% | 7% | -61% |
| Memory (RSS) | 420MB | 380MB | -9.5% |
| Max throughput | 2100 req/s | 4800 req/s | +129% |
Order Processing Service (CSV 50k rows)
| Metric | v1 (before) | v2 (after) | v2 (model_construct) |
|---|---|---|---|
| Processing time | 48s | 12s | 4.2s |
| Peak memory | 1.2GB | 1.1GB | 380MB |
| CPU usage | 85% | 45% | 28% |
User Management Service (1000 users serialization)
| Metric | v1 (before) | v2 (after) | Change |
|---|---|---|---|
| Serialization time | 450ms | 85ms | -81% |
| Memory allocations | 15.2M objects | 4.8M objects | -68% |
Resource estimation
Before migration (simulation):
- 12 API Gateway instances (t3.medium)
- 8 Order Service instances (t3.large)
- 6 User Service instances (t3.medium)
After migration (simulation):
- 6 API Gateway instances (can handle same traffic)
- 4 Order Service instances
- 4 User Service instances
Conclusion: Theoretical savings of ~50% resources at same load thanks to 61% CPU usage reduction.
What I would do differently (mistakes to avoid)
1. Don't spend a month on "perfect preparation"
Four weeks went into writing a codemod, analyzing dependencies, building graphs. Cool, right? Then I ran it on the test stand and caught a feature flags bug that neither unit tests nor all my automation caught.
Should have been: Week 1-2 — basic automation, Week 3 — migrate ONE small service to test stand, Week 4-5 — refine tools based on REAL problems, not hypothetical ones.
Lesson: Better to get feedback from real stand on week 3 than build a perfect codemod that still won't cover all edge cases.
2. Rollback via Kubernetes is too slow
Canary deployment is cool. But when something breaks and you need to roll back, you start frantically looking for the kubernetes rollback button. 5-10 minutes downtime is an eternity when you're on fire in production (or in my case — on test stand, but same panic).
Right way: Feature flag in environment variables:
from my_app.config import settings
if settings.PYDANTIC_V2_ENABLED:
from pydantic.v2 import BaseModel, ValidationError
else:
from pydantic import BaseModel, ValidationError # v1One environment variable — and you rolled back in seconds, without redeployment. Then you can calmly fix the bug, not in "everything's on fire" mode.
3. Unit tests lie (sometimes)
99% of unit tests passed. I was proud. Then on the test stand, everything fell apart:
- Mobile app sends
idas string"123"instead of number — v1 silently converted, v2 withstrict=Truecrashed - Feature flags in Redis stored as strings
"true"/"false"— v2 considered any non-empty string asTrue - CSV files with all sorts of junk like empty lines and
nullinstead of missing fields
Lesson: Unit tests check that code works according to specification. Integration tests with real data check that code works with the mess that clients send. Do more of the latter.
4. "I'll remember in my head" doesn't work
Fixed feature flags bug at 2 AM. Went to sleep satisfied. A week later, migrating another part of the project — hit the same problem. Sitting dumb and can't remember how I fixed it last time.
Solution: Started a "Pydantic v2 gotchas" checklist and log every bug:
"false"in Redis →True(need explicit parsing)- Money only with
strict=True(otherwise float slips through) - CSV in bulk — via
model_construct()(otherwise memory goes away) - JSON encoders →
@field_serializer(API changed)
Sounds trivial, but works. Especially when you return to the project after a month.
Tools that helped
1. Performance comparison script
# benchmark_versions.py
import timeit
from typing import List
import pydantic.v1 as pydantic_v1
from pydantic import BaseModel as BaseModelV2
# Same models on v1 and v2
class UserV1(pydantic_v1.BaseModel):
id: int
username: str
email: str
class UserV2(BaseModelV2):
id: int
username: str
email: str
# Test data
data = [
{"id": i, "username": f"user{i}", "email": f"user{i}@example.com"}
for i in range(10000)
]
# Benchmark v1
time_v1 = timeit.timeit(
lambda: [UserV1(**row) for row in data],
number=10
) / 10
# Benchmark v2
time_v2 = timeit.timeit(
lambda: [UserV2(**row) for row in data],
number=10
) / 10
print(f"v1: {time_v1:.3f}s")
print(f"v2: {time_v2:.3f}s")
print(f"Speedup: {time_v1/time_v2:.1f}x")Ran before and after each change.
2. Model dependency graph
# model_dependencies.py
import ast
import networkx as nx
import matplotlib.pyplot as plt
def extract_model_dependencies(file_path):
"""Builds dependency graph between models."""
with open(file_path) as f:
tree = ast.parse(f.read())
graph = nx.DiGraph()
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
# Is this a Pydantic model?
if any(base.id == 'BaseModel' for base in node.bases if isinstance(base, ast.Name)):
model_name = node.name
graph.add_node(model_name)
# Look for dependencies in field annotations
for item in node.body:
if isinstance(item, ast.AnnAssign):
annotation = item.annotation
# Simple case: other_field: OtherModel
if isinstance(annotation, ast.Name):
graph.add_edge(model_name, annotation.id)
# List[OtherModel]
elif isinstance(annotation, ast.Subscript):
if isinstance(annotation.slice, ast.Name):
graph.add_edge(model_name, annotation.slice.id)
return graph
# Usage
graph = extract_model_dependencies("models/")
# Topological sort for migration order
try:
migration_order = list(nx.topological_sort(graph))
print("Migrate in order:", migration_order)
except nx.NetworkXError:
print("Circular dependencies detected!")
cycles = list(nx.simple_cycles(graph))
print("Cycles:", cycles)Helped find migration order for models (from independent to dependent).
3. Regression test suite
# test_pydantic_regression.py
import pytest
from decimal import Decimal
# Tests for specific bugs found
def test_feature_flag_string_bool():
"""Feature flags from Redis should parse correctly."""
class Flags(BaseModel):
enabled: bool
# v1 parsed correctly, v2 needs strict mode
assert Flags(enabled="true").enabled is True
assert Flags(enabled="false").enabled is False # Important!
assert Flags(enabled=True).enabled is True
def test_decimal_precision():
"""Money should preserve precision."""
class Payment(BaseModel):
amount: Decimal
# Check that 0.1 + 0.2 = 0.3 exactly
p1 = Payment(amount="0.1")
p2 = Payment(amount="0.2")
assert (p1.amount + p2.amount) == Decimal("0.3")
def test_model_construct_memory():
"""model_construct shouldn't create extra objects."""
import tracemalloc
class Item(BaseModel):
id: int
name: str
data = [{"id": i, "name": f"Item {i}"} for i in range(10000)]
tracemalloc.start()
snapshot1 = tracemalloc.take_snapshot()
items = [Item.model_construct(**row) for row in data]
snapshot2 = tracemalloc.take_snapshot()
top_stats = snapshot2.compare_to(snapshot1, 'lineno')
# Should be less than 10MB for 10k objects
total_size = sum(stat.size for stat in top_stats)
assert total_size < 10 * 1024 * 1024, f"Memory usage too high: {total_size / 1024 / 1024:.1f}MB"Was it worth it?
Six weeks of spare time. Three problems on test stand that had to be fixed in "everything's on fire" mode. Sleepless nights with Pydantic docs and StackOverflow.
What I got in return:
- -75% latency validation (12ms → 3ms at P95)
- -61% CPU usage (freed more than half the processor!)
- Theoretical savings of ~50% infrastructure at same load
- Understanding that v2 is not just "faster", but a qualitatively different level
Honest verdict: For a pet project — definitely yes, learned a ton of new stuff. For production — makes sense if you have high load and bottleneck is exactly in validation. If you validate 10 objects per second — don't bother, stay on v1.
Main lesson: Migration isn't about "rewriting code". It's about understanding where your bottleneck is, what trade-offs you make, and readiness to fix an unexpected feature flags bug at 2 AM. If ready — go ahead. If not — better wait until someone else steps on these rakes and describes them in a blog.
Series continuation
This is the first part of a four-part series on Pydantic v2 for production.
📚 Part 2: Production Patterns (coming soon)
ConfigDict exhaustively:
- All 15+ parameters with examples and trade-offs
- When to use
strict=Truevsstrict=False extra='forbid'vsextra='allow'— protection from client errorsvalidate_assignment— mutability with guarantees
4 levels of validation:
- Field constraints (declarative validation)
@field_validatorwith correct order (before/after)- Custom types via
Annotated - Runtime context without I/O anti-patterns
Serialization:
@field_serializervs@model_serializer- Computed fields for derived data
- Serialization aliases: snake_case ↔ camelCase
⚡ Part 3: Performance and optimization (coming soon)
Profiling:
- CPU profiling with cProfile — where time is spent
- Memory profiling with tracemalloc — leaks and allocations
- Detailed analysis of specific models
Optimizations:
- TypeAdapter for batch validation (2x speedup)
- model_construct for trusted data (7x speedup)
- Disabling revalidation for nested models
Honest comparison:
- Pydantic v2 vs msgspec vs attrs vs dataclasses
- Feature table (no exaggerations)
- When to use each tool
🔧 Part 4: Pydantic in microservices (coming soon)
Schema versioning:
- Supporting v1 and v2 simultaneously
- Content negotiation via headers
- Data migration during parsing
Schema Registry:
- Kafka-like approach with central registry
- Backward compatibility checking
- Contract testing for guarantees
Shared schemas library:
- Semantic versioning for schemas
- CHANGELOG and migration guides
- Client version monitoring
P.S. If you're planning migration — don't repeat my mistakes. Or repeat them, but at least with understanding of what awaits. Write questions, share your experience — will be interesting to compare rakes.
By the way, about load testing
In the article I mentioned gradual load increase and metrics monitoring. Currently preparing k6 Load Testing course — there will be everything about how not to kill production during load tests, how to write scenarios that actually check something, and how to integrate this into CI/CD so the team doesn't hate you for broken pipelines.
Disclaimer: This is a pet project. Numbers are real, but obtained in artificial conditions. When applying to production, adapt to your infrastructure and be ready for surprises.
Thanks to the Pydantic team for documentation that really helped. And for not making breaking changes even more breaking.