LEARN · DEBUGGING GUIDE

Pydantic V2 Migration Breaking Changes Debugging Guide

Pydantic V2 introduced breaking changes that silently break V1 code. This guide covers the most common failures and how to fix them.

IntermediatePython8 min read

What this usually means

Pydantic V2 overhauled validation internals. Validators now receive Python native types instead of raw input, field definitions use FieldInfo objects with different attributes, and the entire error system moved to pydantic-core (Rust). Config class settings changed names and behaviors. Code that relied on V1's field access patterns or custom validators that expect raw strings will break silently at runtime.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Run 'pip freeze | grep pydantic' to confirm V2 is installed (version >= 2.0.0).
  • 2Check for 'pydantic' imports; if you see 'from pydantic import BaseModel' it's V2 (V1 used 'pydantic.BaseModel' but also works).
  • 3Look for @validator decorators with 'pre=True' – in V2, the value is already converted to the field type.
  • 4Search for 'class Config' – V2 uses 'model_config = ConfigDict(...)' instead.
  • 5Search for '__get_validators__' – this is V1-only; V2 uses '__get_pydantic_core_schema__'.
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • searchModel definition files: check all @validator and @root_validator decorators.
  • searchCustom types that implement __get_validators__.
  • searchAny file referencing pydantic.Field() – check arguments like 'const' or 'regex' which are removed.
  • searchJSON encoder customizations: __json_encoder__ is gone; use 'serialization' with custom serializer.
  • searchLog files for 'warnings.warn("Pydantic V1 style validator")' – add PYTHONWARNINGS=default.
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningValidator expecting raw input string but V2 passes validated type (e.g., str.lower() on an int field).
  • warningFieldInfo object accessed with .default instead of .default_factory or .metadata.
  • warningCustom validators using classmethod(cls, value) signature without the 'info' argument.
  • warningConfig class settings like 'orm_mode' renamed to 'from_attributes'.
  • warningUse of removed features: 'const' field, 'regex' replaced by 'pattern', 'allow_population_by_field_name' replaced by 'populate_by_name'.
  • warningThird-party library not updated for V2 (e.g., FastAPI < 0.100.0).
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildReplace @validator with @field_validator (V2) and adjust signature: use (cls, value: Any, info: ValidationInfo).
  • buildReplace @root_validator with @model_validator (V2) and use 'mode' parameter.
  • buildChange 'class Config' to 'model_config = ConfigDict(...)'. Use V2 equivalents for settings (e.g., 'from_attributes' instead of 'orm_mode').
  • buildFor custom types, implement __get_pydantic_core_schema__ instead of __get_validators__.
  • buildUpdate Field() calls: remove 'const', change 'regex' to 'pattern', 'allow_mutation' to 'frozen'.
  • buildUse pydantic.v1 compatibility layer as short-term workaround: 'from pydantic import v1 as pydantic_v1'.
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedRun test suite with Pydantic V2 and assert no deprecation warnings (use -W error::DeprecationWarning).
  • verifiedCheck that JSON serialization output matches V1 output for all models (use model_dump_json() instead of .json()).
  • verifiedVerify custom validators are called with the expected types by adding logging or breakpoints.
  • verifiedConfirm ConfigDict settings are applied by checking model fields behavior (e.g., strip whitespace).
  • verifiedRun mypy with pydantic plugin to catch type errors early.
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningSilently ignoring deprecation warnings during migration – they point to breaking changes.
  • warningUsing pydantic.v1 compatibility layer as permanent solution – it will be removed in V3.
  • warningAssuming all Field() arguments are the same – check the V2 docs for removed/changed parameters.
  • warningForgetting to update third-party libraries that depend on Pydantic (like FastAPI, SQLModel).
  • warningCopy-pasting V1 validator code without adjusting the signature and pre/post logic.
( 07 )War story

Production API returning 500 after Pydantic V2 upgrade

Backend EngineerFastAPI 0.95.0, Pydantic 1.10 -> 2.5, Python 3.11, Docker

Timeline

  1. 09:15Deploy FastAPI app with Pydantic V2 to staging.
  2. 09:20Staging health check passes, but /users endpoint returns 500.
  3. 09:25Check logs: 'AttributeError: 'FieldInfo' object has no attribute 'default'' in user.py line 42.
  4. 09:30Search code: line 42 does 'field.default' to check if field has default.
  5. 09:35Realize field is from pydantic.Field() and in V2, FieldInfo uses .default_factory and .metadata.
  6. 09:40Fix: use 'field.default is not None or field.default_factory is not None'.
  7. 09:45Deploy fix, /users endpoint works again.
  8. 09:50Run full test suite: 3 more failures due to validators expecting raw strings.
  9. 10:30Update all validators to V2 style with @field_validator and proper signatures.

I upgraded Pydantic from V1 to V2 thinking it was a drop-in replacement. The app deployed fine, but the /users endpoint immediately returned 500. The logs showed an AttributeError on a line that was accessing 'field.default' from a FieldInfo object. In V1, FieldInfo had a 'default' attribute, but in V2 it's been refactored. The field was from a custom validator that checked if the field had a default value. I had to change the logic to use 'field.default is not None or field.default_factory is not None'. That fixed the immediate crash.

After that, the test suite exposed more issues. Several validators using @validator with pre=True were failing because in V2, the value passed to the validator is already converted to the field type. For example, a validator that called '.lower()' on a string field was now getting an integer because the field type was int. I had to replace all @validator with @field_validator and adjust the logic to work with the validated type, not the raw input.

I also had to update the Config class to model_config = ConfigDict(...) and rename settings like 'orm_mode' to 'from_attributes'. The whole process took about 2 hours, but the key was to not ignore deprecation warnings and to run the full test suite after every fix. The pydantic.v1 compatibility layer helped temporarily, but I made sure to migrate everything properly.

Root cause

Pydantic V2 changed FieldInfo object attributes and validator behavior. Code accessing 'field.default' directly broke because V2 uses .default_factory and .metadata. Validators with pre=True received already-validated types instead of raw input.

The fix

Replaced 'field.default' checks with combined check for .default and .default_factory. Updated all validators to use @field_validator with proper signature and logic that works on validated types.

The lesson

Never assume a major version upgrade is backward-compatible. Always run full test suites and check deprecation warnings. The pydantic.v1 shim can help but should not be permanent.

( 08 )Validator Signature Changes

In Pydantic V1, validators were defined as @validator('field') and the function signature was (cls, value) or (cls, value, values, config, field). The value was the raw input before validation. In V2, use @field_validator('field') and the function signature is (cls, value, info: ValidationInfo). The value is already the validated type (e.g., if field is int, value is int, not string). To replicate V1 pre-validator behavior, you need to set 'mode' to 'before' in the decorator: @field_validator('field', mode='before'). This runs on the raw input.

Another change: the 'pre' parameter is removed; use 'mode'. Also, the 'always' parameter is removed; validators always run unless 'skip_on_failure' is set. The 'check_fields' parameter is removed; validators are always checked against fields. For root validators, replace @root_validator(pre=True) with @model_validator(mode='before') and @root_validator(pre=False) with @model_validator(mode='after'). The function signature for model validators is (cls, data: Any) -> Any.

( 09 )FieldInfo and Field() Changes

pydantic.Field() in V2 has many removed or renamed arguments. The 'const' argument is removed; use 'Literal' types or 'Field(frozen=True)'. 'regex' is replaced by 'pattern' (which accepts a string or compiled regex). 'allow_mutation' is replaced by 'frozen'. 'min_length' and 'max_length' still work but now also apply to bytes. 'ge', 'le', 'gt', 'lt' work for numeric fields. The 'default' argument still works, but accessing FieldInfo.default directly may not give the correct value if a default_factory is used. Instead, use 'field.default is not None or field.default_factory is not None' to check for a default.

The FieldInfo object itself changed: in V1, you could access field.default, field.required, etc. In V2, these are still available but some are computed differently. For example, field.required is True if no default and no default_factory. field.default returns the default value, but if none, it raises AttributeError. So always check with hasattr or use field.default_factory. The metadata attribute is now a list of constraints, not a dict.

( 10 )Config Class to ConfigDict Migration

Pydantic V1 used a nested class Config to set model-level settings. In V2, this is replaced by model_config = ConfigDict(...). The ConfigDict is a dict-like object that accepts many settings, but some are renamed. For example, 'orm_mode' is now 'from_attributes'. 'allow_population_by_field_name' is 'populate_by_name'. 'anystr_strip_whitespace' is 'str_strip_whitespace'. 'anystr_lower' is 'str_to_lower'. 'validate_assignment' still works. 'extra' still works but now uses 'allow', 'forbid', 'ignore' as strings.

If you use 'class Config' in V2, Pydantic will emit a deprecation warning but still work (for now). To migrate, change 'class Config' to 'model_config = ConfigDict(...)' and update the setting names. For example: class Config: orm_mode = True becomes model_config = ConfigDict(from_attributes=True). Also, note that some settings like 'json_encoders' are now handled differently: use 'model_config = ConfigDict(ser_json_timedelta='iso8601')' and custom serializers with @field_serializer.

( 11 )Custom Types and __get_validators__

Pydantic V1 allowed custom types by implementing __get_validators__ as a classmethod that yields validator functions. In V2, this method is ignored (with a warning). Instead, implement __get_pydantic_core_schema__ which returns a core schema object. The function signature is: @classmethod def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> CoreSchema. This gives you access to the full core schema system. If you need a quick migration, you can use the pydantic.v1 compatibility layer by inheriting from pydantic.v1.BaseModel for legacy types.

Alternatively, you can use Annotated validators with AfterValidator or BeforeValidator to attach validation logic to existing types. For example: from pydantic import BaseModel, AfterValidator; from typing import Annotated; def my_validator(v): ...; MyType = Annotated[str, AfterValidator(my_validator)]. This is the recommended way for simple custom validations.

( 12 )Serialization and JSON Encoding

Pydantic V1 allowed custom JSON encoders via Config.json_encoders or __json_encoder__ method on the model. In V2, these are removed. Instead, use serialization decorators: @field_serializer('field') to customize how a field is serialized. For model-level serialization, use @model_serializer. The .json() method is replaced by .model_dump_json(). The .dict() method is replaced by .model_dump(). The .schema() method is replaced by .model_json_schema(). The .schema_json() is removed.

If you need to pass a custom encoder to json.dumps, use the 'serialization' argument in model_dump_json: model.model_dump_json(serialization={'encoder': my_encoder}). Or you can override the default encoder by setting model_config = ConfigDict(ser_json_custom=True) and implementing __get_pydantic_core_schema__ with a custom serializer. This is more complex but gives full control.

Frequently asked questions

How do I quickly check if my code uses V1 patterns that will break in V2?

Run 'pip install pydantic-v1-check' and execute 'pydantic-v1-check your_project/'. This tool scans for common V1 patterns like __get_validators__, class Config, @validator with pre=True, and Field(const=...). Alternatively, grep for these patterns manually. Also run your test suite with Pydantic V2 and deprecation warnings turned into errors: 'PYTHONWARNINGS=error::DeprecationWarning pytest'.

Can I keep using V1 style validators with some workaround?

Yes, you can use the pydantic.v1 compatibility layer: 'from pydantic import v1 as pydantic_v1'. Then use pydantic_v1.BaseModel for models that need V1 validators. However, this is only a temporary solution because pydantic.v1 will be removed in V3. It's better to migrate to V2 style validators. If you have many V1 validators, you can also use the 'compat' module: 'from pydantic._internal._validators import import_v1_validator' but that's internal and may change.

Why did my custom JSON encoder stop working after upgrade?

Pydantic V2 removed __json_encoder__ and Config.json_encoders. Instead, use @field_serializer for per-field serialization, or @model_serializer for model-level. For example: 'from pydantic import BaseModel, field_serializer; class MyModel(BaseModel): x: int; @field_serializer('x') def serialize_x(self, value, _info): return str(value)'. If you need to pass a custom encoder to json.dumps, use 'model_dump_json(serialization={'encoder': custom_encoder})'.

What is the correct way to define optional fields with a default in V2?

It's the same as V1: 'field: Optional[int] = None' or 'field: int = 0'. However, if you use Field(default=None), note that in V2, Field() accepts 'default' but if you want to distinguish between 'not set' and 'None', use 'default=None' and also set 'validate_default=False' if needed. The FieldInfo object now has a 'default_factory' attribute that is set if you pass a callable. Use 'field.default is not None or field.default_factory is not None' to check if a default exists.

How do I get the list of field names and their types in V2?

Use 'model.model_fields' which returns a dict of field names to FieldInfo objects. To get the type, use 'field.annotation' which gives the Python type (e.g., int, str). In V1, you used 'model.__fields__' which returned dict of field names to ModelField objects. The new way is 'model.model_fields'. Also, '__annotations__' still works as a fallback.