Skip to main content

Pydantic v1→v2 Migration: Experience Upgrading 28 Models in a Test Project

Константин Потапов
20 min

Honest breakdown of migrating to Pydantic v2: 6 weeks of spare time, 3 production issues, -75% latency, and a bunch of gotchas discovered. No marketing, just practice.

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 throttling

Bottom 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.2GB

When 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]  # ~450ms

Why 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 RAM

Trade-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 models

Step 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_serializer

Why 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 order

2. 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 (now from_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 stand

Root 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:

StageDurationKey tasksResult
Weeks 1-2: Preparation2 weeksModel inventory, dependency analysis, branch creation28 models inventoried, dependency graph built
Weeks 3-4: Automation2 weekslibCST codemod development, automatic migration22 models (78%) migrated automatically
Week 5: Manual refinement1 weekComplex validator migration, JSON encoders, tests6 models refined manually, 142/145 tests passing
Week 6: Deployment1 weekDeploy to test stand, monitoring, fixing issues3 problems found and fixed (feature flags, memory leak, validation errors)
Total6 weeksFull migration of 28 models + 3 servicesMigration 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

  1. Week 1: User Service (least critical) — 5% → 25% → 50% → 100% load
  2. Week 2: Order Service — 10% → 50% → 100%
  3. 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()
            raise

Grafana 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)

Metricv1 (before)v2 (after)Change
Latency (P50)8ms2ms-75%
Latency (P95)12ms3ms-75%
Latency (P99)18ms5ms-72%
CPU usage18%7%-61%
Memory (RSS)420MB380MB-9.5%
Max throughput2100 req/s4800 req/s+129%

Order Processing Service (CSV 50k rows)

Metricv1 (before)v2 (after)v2 (model_construct)
Processing time48s12s4.2s
Peak memory1.2GB1.1GB380MB
CPU usage85%45%28%

User Management Service (1000 users serialization)

Metricv1 (before)v2 (after)Change
Serialization time450ms85ms-81%
Memory allocations15.2M objects4.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  # v1

One 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 id as string "123" instead of number — v1 silently converted, v2 with strict=True crashed
  • Feature flags in Redis stored as strings "true"/"false" — v2 considered any non-empty string as True
  • CSV files with all sorts of junk like empty lines and null instead 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=True vs strict=False
  • extra='forbid' vs extra='allow' — protection from client errors
  • validate_assignment — mutability with guarantees

4 levels of validation:

  1. Field constraints (declarative validation)
  2. @field_validator with correct order (before/after)
  3. Custom types via Annotated
  4. Runtime context without I/O anti-patterns

Serialization:

  • @field_serializer vs @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.