The upgrade from Pydantic V1 to V2 is not merely a version bump; it is a paradigm shift. With the core logic rewritten in Rust (pydantic-core), V2 offers significant performance gains but introduces strict validation rules that break legacy V1 implementations.
Backend engineers migrating FastAPI applications or data pipelines often encounter two specific blockers: deprecated validator signatures referencing FieldValidationInfo (or values dictionaries) and runtime validation errors claiming "Input should be a valid dictionary."
This guide provides the root cause analysis and production-ready code required to migrate complex validation logic to the Pydantic V2 standard.
The Root Cause: Rust Core and Validator Topology
In Pydantic V1, validation was pure Python. It was permissive regarding input types and allowed accessing sibling fields via a loosely typed values dictionary in validators.
Pydantic V2 moves validation to Rust. This results in:
- Stricter Types: The engine no longer attempts to guess how to coerce arbitrary objects into dictionaries unless explicitly told to do so.
- Validator Topology: Validation order is strictly defined. When you access sibling data (formerly
values), you are accessing theValidationInfocontext. If a previous field failed validation, that data will not be available in the context of subsequent fields.
The error "Input should be a valid dictionary" typically signifies that you are passing an ORM object or class instance to a model that expects raw JSON, and the configuration to handle attributes is missing.
Fixing Field Validators and Sibling Access
The most common breaking change involves the @validator decorator. In V1, you accessed other fields via the values argument. In V2, @validator is deprecated in favor of @field_validator, and context is passed via ValidationInfo.
The Legacy V1 Pattern (Broken)
# ❌ THIS BREAKS IN V2
from pydantic import BaseModel, validator
class UserChangePassword(BaseModel):
password: str
confirm_password: str
@validator('confirm_password')
def passwords_match(cls, v, values):
if 'password' in values and v != values['password']:
raise ValueError('Passwords do not match')
return v
The V2 Solution: ValidationInfo
To fix this, switch to @field_validator. Note that you must explicitly import ValidationInfo. The values dictionary is now accessed via info.data.
Critical Implementation Detail: You must handle the case where info.data does not contain the sibling field (e.g., if the sibling field failed its own validation).
# ✅ MODERN V2 IMPLEMENTATION
from typing import Any
from pydantic import BaseModel, field_validator, ValidationInfo
class UserChangePassword(BaseModel):
password: str
confirm_password: str
@field_validator('confirm_password')
@classmethod
def passwords_match(cls, v: str, info: ValidationInfo) -> str:
# info.data contains fields that have ALREADY passed validation
if 'password' in info.data:
if v != info.data['password']:
raise ValueError('Passwords do not match')
return v
# Usage
try:
UserChangePassword(password="secure123", confirm_password="secure123")
print("Validation Successful")
except ValueError as e:
print(e)
Resolving "Input should be a valid dictionary"
This error is prevalent when integrating Pydantic V2 with ORMs like SQLAlchemy, Django, or Tortoise ORM. In V1, orm_mode = True handled this. In V2, the syntax has changed, and the strictness of input parsing has increased.
The Configuration Migration
You must replace the inner Config class with the model_config attribute using ConfigDict.
from pydantic import BaseModel, ConfigDict
class UserORM(BaseModel):
id: int
username: str
# ❌ V1 Legacy (Deprecated)
# class Config:
# orm_mode = True
# ✅ V2 Standard
model_config = ConfigDict(from_attributes=True)
# Simulating an ORM object
class UserTable:
def __init__(self, id, username):
self.id = id
self.username = username
db_user = UserTable(id=101, username="admin_user")
# This works because from_attributes=True tells Pydantic
# to read attributes (db_user.id) instead of keys (db_user['id'])
user_model = UserORM.model_validate(db_user)
print(user_model.model_dump())
Advanced Fix: Handling Complex Inputs with BeforeValidator
Sometimes from_attributes=True is insufficient. If you are receiving messy data (e.g., a JSON string that needs parsing before validation, or a complex object), use a functional validator with mode='before'.
This approach intercepts the raw input before Pydantic's Rust core attempts to validate types, preventing the "Input should be a valid dictionary" error at the schema entry level.
from typing import Any, Dict
import json
from pydantic import BaseModel, model_validator
class LogEntry(BaseModel):
timestamp: int
level: str
message: str
@model_validator(mode='before')
@classmethod
def parse_json_input(cls, data: Any) -> Any:
# If input is a JSON string, parse it into a dict
# before Pydantic validation kicks in.
if isinstance(data, str):
try:
return json.loads(data)
except json.JSONDecodeError:
raise ValueError("Invalid JSON string provided")
# If it's a legacy object with a .to_dict() method
if hasattr(data, "to_dict"):
return data.to_dict()
return data
# Usage Example
raw_json = '{"timestamp": 1698771200, "level": "INFO", "message": "System check"}'
log = LogEntry.model_validate(raw_json)
print(f"Parsed Log: {log.level} - {log.message}")
Pitfall: Serialization Changes (.dict() vs .model_dump())
A major source of post-migration bugs is the serialization API. V1 methods .dict() and .json() exist in V2 but are deprecated. They may behave inconsistently regarding custom type serialization.
Migrate all serialization calls to the new API immediately to ensure consistency with the new pydantic-core logic.
| V1 Method (Deprecated) | V2 Method (Recommended) | Notes |
|---|---|---|
obj.dict() | obj.model_dump() | Returns a Python dictionary. |
obj.json() | obj.model_dump_json() | Returns a serialized JSON string. Speed optimized by Rust. |
Handling Custom Types during Dump
In V2, serializers are strictly separated. If you need to convert specific types (like datetime to ISO strings) during the dump process, use mode='json'.
from datetime import datetime
from pydantic import BaseModel
class Event(BaseModel):
when: datetime
event = Event(when=datetime.now())
# Standard Python objects (datetime object remains)
print(event.model_dump())
# Output: {'when': datetime.datetime(...)}
# JSON compatible (datetime becomes str)
print(event.model_dump(mode='json'))
# Output: {'when': '2023-10-31T12:00:00.000000'}
Deep Dive: Using Annotated for Reusable Validation
The most robust way to handle validation in Pydantic V2 is decoupling validation logic from specific models using Python's typing.Annotated. This allows you to create reusable types across your entire application.
This pattern is highly recommended for large FastAPI codebases to reduce code duplication.
from typing import Annotated
from pydantic import BaseModel, AfterValidator
# 1. Define the validation logic function
def validate_uppercase(v: str) -> str:
if not v.isupper():
raise ValueError('Must be uppercase')
return v
# 2. Create a reusable type
# Pydantic applies this validator to any field using this type
UppercaseStr = Annotated[str, AfterValidator(validate_uppercase)]
# 3. Use in models without writing new validator methods
class ProductCodes(BaseModel):
sku: UppercaseStr
region_code: UppercaseStr
try:
ProductCodes(sku="ABC-123", region_code="us-east")
except ValueError as e:
# Catches the error in region_code
print("Validation Error:", e)
Conclusion
Migrating to Pydantic V2 resolves around understanding that values is no longer a plain dictionary and that the Rust core is strict about input types. By adopting @field_validator with ValidationInfo, utilizing ConfigDict(from_attributes=True), and leveraging Annotated types, you not only fix the migration errors but significantly improve the type safety and performance of your application.
For FastAPI users, ensure you bump your FastAPI version alongside Pydantic, as recent FastAPI releases (0.100.0+) handle the Pydantic V2 internals automatically.