Skip to main content

03 - Pydantic v2: Advanced Validation, BaseModel, TypeAdapter and ConfigDict

What changes in Pydantic v2: model_validate and model_dump, TypeAdapter, before and after validators, ConfigDict and the v1 migration, with real output.

What do you think about this article?

At a Glance

What changes in Pydantic v2: model_validate and model_dump, TypeAdapter, before and after validators, ConfigDict and the v1 migration, with real output.

Pydantic v2 rewrote its validation engine in Rust, and on a five-field model that is worth 4.4 times the speed of v1: 518,693 validations per second against 118,497, measured on the machine this article was written on. The command to repeat the measurement is at the bottom, together with everything you need to distrust it.

The part that changes your code every day, though, is not the speed: it is the new names (model_validate, model_dump, ConfigDict), the TypeAdapter that validates a type without building a model around it, and the validators with mode="before" or mode="after", where the difference is which type you receive.

The code in this article was executed

All the outputs and error messages reported below come from a real run with Pydantic 2.13.4 on CPython 3.12.13, x86_64, on 2026-11-08. They are not reconstructed from memory: wherever you see a ValidationError, that text was copied from the interpreter's stdout, including the type code in square brackets and the link to the error page.

The route

  • What the "Rust core" does and does not say
  • model_validate and model_dump: the new names and why
  • Field(): constraints, aliases, and how to read the errors
  • field_validator and model_validator, with proof of which type arrives
  • TypeAdapter: validating a list[int] without a BaseModel
  • ConfigDict, and the one option that changes the security of your API
  • The migration from v1, line by line

The first five entries are the daily work; the sixth is the one worth more than the time it costs, because extra="forbid" turns a misspelled field from "silently ignored" into "error". The last one is only for when you have code to carry forward, and in that case it is the only one that matters.

The Rust core: what it means, with a real number

In Pydantic v1 validation was Python: for every field of every object, interpreted function calls. In v2 that work is done by pydantic-core, a library compiled in Rust, and Python remains only the language in which you declare the model.

The gain is real, but the number that circulates - "from 5 to 50 times" - has no official source. The release notes of FastAPI 0.100.0, the version that adopted Pydantic v2, talk about a "core rewritten in Rust" and "better performance", without giving a multiplier, and the performance page of the Pydantic documentation gives advice, not a factor.

So I measured it, on a model with five typed fields, comparing the v2 model_validate with the v1 constructor available in the pydantic.v1 compatibility module:

Measurement run - 200,000 validations for each version
v2 model_validate : 0.386 s per 200000 -> 518,693 val/s
v1 __init__       : 1.688 s per 200000 -> 118,497 val/s
ratio v1/v2       : 4.4x

TypeAdapter list[100] : 0.570 s per 5000 -> 876,820 val/s
python loop list[100] : 0.832 s per 5000 -> 600,980 val/s
ratio loop/TA         : 1.46x

The first three lines say that on this model the factor is 4.4, not 50. The last three measure something different and useful: validating a list of 100 objects with a TypeAdapter(list[Order]) is 1.46 times faster than looping in Python and calling model_validate a hundred times, because the loop happens inside Rust instead of in the interpreter.

Take these numbers for what they are. They depend on the number and type of the fields: a model with custom validators written in Python moves the bottleneck back into the interpreter and the advantage shrinks, while a model of only primitive types widens it. The v1 used in the comparison is the one in the compatibility module shipped with Pydantic 2.13.4, not a separate installation of 1.x.

The practical consequence is less spectacular than the title and more useful: you stop avoiding validation out of fear of its cost. In an endpoint that runs a database query, a few microseconds of validation do not show up in the response-time chart.

model_validate and model_dump

v2 renamed the model's methods with the model_ prefix. It is not pedantry: in v1 a field of your model called json or dict collided with the methods of the same name. The prefix frees up the namespace of the fields.

Building a model from data that arrives from outside
from pydantic import BaseModel, Field, ConfigDict

class Customer(BaseModel):
    # populate_by_name: accepts both the alias and the Python name
    model_config = ConfigDict(populate_by_name=True)
    tax_code: str = Field(alias="taxCode")

# The JSON of an external service uses camelCase
Customer.model_validate({"taxCode": "RSSMRA80A01H501U"})
# tax_code='RSSMRA80A01H501U'

# Thanks to populate_by_name the Python name passes too
Customer.model_validate({"tax_code": "RSSMRA80A01H501U"})
# tax_code='RSSMRA80A01H501U'

# On the way out you decide which form to use
c = Customer.model_validate({"taxCode": "X"})
c.model_dump()                 # {'tax_code': 'X'}
c.model_dump(by_alias=True)    # {'taxCode': 'X'}

# From a JSON string, without going through json.loads
Customer.model_validate_json('{"taxCode": "X"}')

The four outputs in the comments are the ones printed by the run. The pair to fix in your mind is model_dump() versus model_dump(by_alias=True): without by_alias you get the Python keys, with by_alias the alias keys. If you are answering a client that expects taxCode and you forget by_alias=True, the client receives a key it does not know and nobody tells you.

populate_by_name=True is the line that makes the model tolerant on input: without it, declaring an alias makes the alias mandatory and {"tax_code": ...} fails with "field required". It is the reason why this option appears in almost every model that acts as a bridge to an external API.

model_validate_json is not just a shortcut for json.loads followed by model_validate: the JSON parsing happens inside the Rust core, so the intermediate Python dictionary is never built. On large payloads that is the difference you can measure.

Constraints, and how to read a Pydantic error

Field() carries the constraints next to the type: gt, min_length, max_length, pattern. But the thing worth learning early is the shape of the error message, because you will read it a lot.

Type coercion, and the two errors that come from it
class Order(BaseModel):
    amount: float
    pieces: int

Order.model_validate({"amount": "19.99", "pieces": 3})
# amount=19.99 pieces=3        <- the string "19.99" becomes float: accepted

Order.model_validate({"amount": "19.99", "pieces": 3.5})
# 1 validation error for Order
# pieces
#   Input should be a valid integer, got a number with a fractional part
#   [type=int_from_float, input_value=3.5, input_type=float]
#     For further information visit https://errors.pydantic.dev/2.13/v/int_from_float

The two calls show where the boundary of coercion lies. The string "19.99" is converted to float without complaint, because nothing is lost; the float 3.5 towards int is rejected, because converting it would throw away the fractional part. A 3.0 in its place would pass.

The message has four useful parts. The first line counts the errors and names the model; the second is the path of the field; the third explains in plain English; the fourth, in square brackets, gives type=int_from_float, which is the code to use if you need to tell errors apart programmatically - far more reliable than comparing the text of the message.

The path of the field nests: on a model with a list of objects, in place of pieces you would read something like lines.2.pieces, that is, the third element of the list. It is the reason why you should return exc.errors() to the client and not just the message: the loc key contains that path in structured form.

The validators, and the proof of which type arrives

There are two decorators: field_validator for a single field, model_validator for the rules that involve several fields. The parameter that decides everything is mode, and the best way to understand it is to print what arrives.

mode="before" versus mode="after" - with the observed output
from pydantic import BaseModel, field_validator

class Payment(BaseModel):
    currency: str
    amount: float

    @field_validator("currency", mode="before")
    @classmethod
    def normalize(cls, v):
        print(f"[before] received {v!r} ({type(v).__name__})")
        return v.upper().strip() if isinstance(v, str) else v

    @field_validator("amount", mode="after")
    @classmethod
    def positive(cls, v):
        print(f"[after]  received {v!r} ({type(v).__name__})")
        if v <= 0:
            raise ValueError("the amount must be positive")
        return round(v, 2)

Payment.model_validate({"currency": "  eur ", "amount": "19.999"})

# Observed output:
# [before] received '  eur ' (str)
# [after]  received 19.999 (float)
# currency='EUR' amount=20.0

Look at the printed types, because they are the answer to the question. The before validator receives ' eur ', that is, the raw data as it arrived, spaces included: it is the right place to clean and normalize. The after validator receives 19.999 as a float, not the string "19.999" that was in the input: the conversion has already happened.

From here comes the rule. In before you cannot take the type for granted, hence the isinstance you see in the code; in after the type is guaranteed and you write the domain rule without defences. Putting the currency normalization in after would not work for the opposite case: a string that cannot be converted would fail before reaching your code.

Note also that after can transform: round(v, 2) turns 19.999 into 20.0, and the final model reports it. A validator is not only a check, it is a function whose return value becomes the field - forgetting the return puts None in the field, and it is the most common mistake with these decorators.

The @classmethod under the decorator is not optional: the function is called on the class, not on an instance, because the instance does not exist yet. Omitting it produces an error when the model is defined, so at least you notice right away.

model_validator: a rule that touches several fields
from pydantic import BaseModel, model_validator

class Discount(BaseModel):
    gross: float
    percentage: float = 0.0
    net: float = 0.0

    @model_validator(mode="after")
    def compute(self):
        self.net = round(self.gross * (1 - self.percentage / 100), 2)
        return self

Discount.model_validate({"gross": 100.0, "percentage": 22})
# gross=100.0 percentage=22.0 net=78.0

The difference in signature is the point: model_validator(mode="after") receives self, the complete instance, and not a single value. That is why it can compute net from gross and percentage, something no field_validator could do - a field validator does not see the other fields.

The final return self is mandatory and easy to forget: without it, the validated model becomes None. And beware that this version mutates the instance: it works because the validator runs once validation is finished, but if the model is configured with frozen=True the assignment raises an error, and the calculation has to be done in a computed_field.

TypeAdapter: validating without a model

There is a case that in v1 forced you to write a fake model: validating something that is not an object. A list of integers, a dictionary, a union of types. TypeAdapter applies Pydantic's validation to any Python type annotation.

Coercion and failure on list[int] - observed output
from pydantic import TypeAdapter, ValidationError

ta = TypeAdapter(list[int])

ta.validate_python([1, "2", 3.0])
# [1, 2, 3]        <- "2" and 3.0 converted, no error

ta.validate_python([1, "two"])
# 1 validation error for list[int]
# 1
#   Input should be a valid integer, unable to parse string as an integer
#   [type=int_parsing, input_value='two', input_type=str]
#     For further information visit https://errors.pydantic.dev/2.13/v/int_parsing

In the first call the heterogeneous list [1, "2", 3.0] becomes [1, 2, 3]: the string "2" and the float 3.0 are convertible without loss, so they pass. In the second, "two" is not convertible and the result is a ValidationError.

The line to notice in the error is the one containing only 1: it is the path of the field, and here the path is the index in the list. It tells you that the wrong element is the second one, information you would lose if you validated with a loop and a try/except per element.

Two usage warnings. TypeAdapter(...) compiles a schema, so it must be built once at module level and reused: recreating it inside a function that is called often throws away the gain, which is precisely the 1.46x measured above. And validate_python is not validate_json: the first expects Python objects, the second a string or bytes.

ConfigDict, and the option that concerns security

The old inner class Config is replaced by model_config = ConfigDict(...). There are many options; the ones you will really use are few.

The options in use, and the difference between ignore and forbid
from pydantic import BaseModel, ConfigDict

class ApiResponse(BaseModel):
    model_config = ConfigDict(
        from_attributes=True,     # build from ORM objects, not only from dicts
        populate_by_name=True,    # accept alias AND Python name on input
        use_enum_values=True,     # serialize enums with their value
        extra="forbid",           # "ignore" (default), "allow", "forbid"
        str_strip_whitespace=True # strip whitespace at the edges of every str
    )
    name: str

# With extra="forbid", an extra field is an error:
ApiResponse.model_validate({"name": "a", "typo": 1})
# 1 validation error for ApiResponse
# typo
#   Extra inputs are not permitted
#   [type=extra_forbidden, input_value=1, input_type=int]
#     For further information visit https://errors.pydantic.dev/2.13/v/extra_forbidden

The error output is the observed one. The line that changes the behaviour of your application is extra="forbid", because the default is "ignore": with the default, a client that sends {"nmae": "Mario"} instead of name gets an error about the missing name, but if the field were optional it would get 200 and its data would be silently discarded.

With forbid, the same typo becomes an explicit error that names the guilty field. The trade-off is backward compatibility: if an old client sends fields you no longer use, forbid rejects them. The reasonable choice is forbid on the input models of your internal APIs, ignore where you receive data from third parties you do not control.

from_attributes=True is the former orm_mode of v1, and it is needed when you pass an SQLAlchemy object to model_validate instead of a dictionary: without it Pydantic does not look for attributes and fails. use_enum_values=True makes UserStatus.active serialize as the string "active" instead of the enum object, which is almost always what a JSON client expects.

Migrating from v1: the six lines that cover almost everything

The migration is boring but mechanical. These are the renames you will meet, in order of frequency.

Correspondences v1 → v2
# 1. serialization
user.dict()                          ->  user.model_dump()
user.json()                          ->  user.model_dump_json()

# 2. building from external data
User.parse_obj(data)                 ->  User.model_validate(data)
User.parse_raw(json_str)             ->  User.model_validate_json(json_str)

# 3. configuration
class Config:                        ->  model_config = ConfigDict(
    orm_mode = True                  ->      from_attributes=True
                                     ->  )

# 4. validators
@validator("field")                  ->  @field_validator("field")
@root_validator                      ->  @model_validator(mode="after")

# 5. optional fields: in v2 Optional does NOT imply a default
name: Optional[str]                  ->  name: Optional[str] = None

# 6. constraints
name: str = Field(..., min_length=2) ->  name: str = Field(min_length=2)

The first four entries are textual substitutions you can make with confidence. The fifth is the only one that changes the behaviour and not the name, and it is the one that breaks code silently: in v1 Optional[str] implied None as the default, in v2 it only declares that the field may be None and it stays mandatory.

In practice, after the migration a payload that used to pass starts answering "field required" on every optional field to which you had not added = None. It is the first problem you will meet and the first thing to look for. The sixth entry is cosmetic: Field(...) with the ellipsis meant "required" and is now implied by the absence of a default.

For the rest, the official migration guide is exhaustive and worth reading once before you start, because it also lists the edge cases that do not fit here - the constr types, generic fields, models with circular references that require model_rebuild().

What you can do now

You can build models from external data with aliases in both directions, read a ValidationError down to the type code, choose between before and after knowing which type you will receive, validate a bare type with TypeAdapter, and close your input models with extra="forbid".

The point where these models become the documentation of your API - because FastAPI generates the OpenAPI schema from them - is in setup, type hints and OpenAPI. The reason why endpoints are async, and what must not be put inside a coroutine, is in async and await. And if you want the broader picture, validation at the boundary of the system is a defence before it is a convenience: the subject is covered in input validation and backend security.

Sources

  • Pydantic - Validators - the semantics of mode="before" and mode="after", the requirement of the @classmethod and the return value that becomes the field. Consulted on 2026-11-08.
  • Pydantic - API TypeAdapter - validate_python versus validate_json, and the construction cost of the adapter. Consulted on 2026-11-08.
  • Pydantic - Configuration - meaning of extra, from_attributes, populate_by_name and use_enum_values. Consulted on 2026-11-08.
  • Pydantic - Migration Guide - the table of v1 → v2 correspondences, including the change on Optional without a default. Consulted on 2026-11-08.
  • Pydantic - Performance - the official performance advice, which does not contain a multiplier relative to v1. Consulted on 2026-11-08.
  • Measurements and outputs of this article - Pydantic 2.13.4 on CPython 3.12.13, x86_64, 2026-11-08. The 4.4x comparison comes from timeit with 200,000 iterations per version on a five-field model, using pydantic.v1 as the v1 reference; the 1.46x ratio from 5,000 iterations on lists of 100 elements. All the ValidationError texts are copied from the interpreter's stdout.

The first five entries are the project's documentation, the only source that changes together with the library: for any ConfigDict option not cited here, that is where to look. The last one is of a different nature and holds only for the machine on which it was taken - which is why it reports versions and architecture. If you repeat the measurement and get 3x or 7x, the right figure is yours.

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects