DEV Community

Cover image for Python Dataclasses vs Pydantic: Benchmarked, Then Decided
Muhammad Saud
Muhammad Saud

Posted on Originally published at logicstack.org

Python Dataclasses vs Pydantic: Benchmarked, Then Decided

Dataclasses and Pydantic both let you define structured data in Python, but they solve different problems. A dataclass is a lightweight container for data your code already trusts, while Pydantic is a validation layer for data coming from outside your code. Choosing wrong costs you either safety or speed, so this guide benchmarks both and gives you a decision rule you can apply in a minute.

Key takeaways

  • Use Pydantic at the boundary where untrusted data enters: API requests, config files, webhook payloads.
  • Use dataclasses inside your code, where the data is already validated and you want the fastest, simplest container.
  • Measured on Python 3.12 with Pydantic 2.14.1, Pydantic construction costs about 2.8 microseconds per object versus 0.5 for a dataclass, roughly five times slower.
  • Pydantic serialises faster than dataclasses: model_dump took 3.2 microseconds against 6.6 for dataclasses.asdict in the same test.
  • attrs sits between the two: as fast as a dataclass, with validators and converters if you want them.
  • At web-request scale the difference is noise; at millions of rows per second it decides the design.

What does each one actually do?

A dataclass, in the standard library since Python 3.7, is a class decorator that writes the boring methods for you: init, repr, eq. It adds no validation at all. If you pass a string where a float belongs, it stores the string and says nothing. That is not a bug; it is the design. A dataclass trusts its caller.

Pydantic is a validation library. Its BaseModel checks every field when an object is created, coerces compatible types (the string "129.99" becomes the float 129.99), and raises a detailed ValidationError when the data does not fit. Pydantic v2, released in June 2023, is built on pydantic-core, a Rust validation engine. The Pydantic team claims v2 is 5 to 50 times faster than v1; that is their own benchmark, not an independent one, and it compares v2 against v1, not against dataclasses.

Here is the same order record in both styles:

from dataclasses import dataclass, field
from pydantic import BaseModel

@dataclass
class Order:
order_id: int
customer: str
total: float
currency: str = "EUR"

class OrderModel(BaseModel):
order_id: int
customer: str
total: float
currency: str = "EUR"

A dataclass trusts you. This runs fine and stores a string in total.

Order(order_id=1, customer="Ada", total="not-a-number")

Pydantic does not. This raises ValidationError.

OrderModel(order_id=1, customer="Ada", total="not-a-number")

That single difference drives the whole decision. Everything else, speed, ergonomics, ecosystem, is secondary.

How fast is Pydantic really?

Most articles on this topic quote the Pydantic team's own numbers. I ran the comparison myself instead, on Python 3.12.3 with Pydantic 2.14.1 and attrs 26.1.0, using a seven-field order model with a nested address. Each figure is the best of five runs of 20,000 operations, in microseconds per operation.

OperationDataclassattrsPydantic v2Construct from typed values0.520.482.77Parse from a raw dict1.45*—3.68Parse from a JSON string——9.93Serialise to a dict6.595.593.22Serialise to JSON11.87—3.34

*The dataclass dict figure is a manual unpack with no validation at all, so it is not a like-for-like comparison. It is there to show what skipping validation buys you: about 2 microseconds.

Two findings matter. First, Pydantic construction is roughly five times slower than a dataclass, 2.77 versus 0.52 microseconds. If you parse a million rows, that is about 2.8 seconds against 0.5 seconds. Real, but only decisive at bulk scale. Second, Pydantic is faster at serialisation. Its model_dump() took 3.22 microseconds against 6.59 for dataclasses.asdict(), and model_dump_json() took 3.34 against 11.87 for the asdict-plus-json.dumps route. The dataclass helper is recursive and slow; Pydantic serialises through its Rust core. So the common claim that Pydantic is "slower" is only half true: it costs more to build, less to dump.

Keep the scale in mind. A typical web request spends milliseconds on the database and the network. Two extra microseconds of validation per object is invisible there. It only becomes a design constraint in ETL jobs, log pipelines, and event streams that construct millions of objects per second.

When should you use dataclasses?

Use dataclasses for data that your own code creates and controls: internal domain objects, the results of a calculation, values passed between your own functions. There is nothing to validate because you built the values yourself, and the standard library dependency means zero install cost and zero version risk. Dataclasses are also the right pick when your objects are short-lived and numerous, for example rows in a hot loop that you have already parsed once.

from dataclasses import dataclass

@dataclass(frozen=True)
class Money:
amount: float
currency: str = "EUR"

def __add__(self, other: "Money") -> "Money":
    if other.currency != self.currency:
        raise ValueError("cannot add different currencies")
    return Money(self.amount + other.amount, self.currency)
Enter fullscreen mode Exit fullscreen mode

Frozen dataclasses give you immutability for free, which makes them safe to share between threads and to use as dictionary keys once all fields are hashable. If you need a little more, slots=True cuts memory use, and field(default_factory=list) handles mutable defaults safely.

When is Pydantic the right call?

Use Pydantic wherever data crosses a trust boundary: HTTP request bodies, CLI arguments, environment config, webhook payloads, rows read from a CSV you did not produce. FastAPI is built on Pydantic for exactly this reason: your endpoint declares a model and invalid requests are rejected before your code runs. Pydantic also shines for configuration, where a wrong type in an environment variable should fail loudly at startup instead of silently corrupting behaviour hours later.

from pydantic import BaseModel, Field, ValidationError
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
database_url: str
max_retries: int = Field(default=3, ge=0, le=10)
debug: bool = False

try:
settings = Settings() # reads env vars, validates types and ranges
except ValidationError as exc:
print(exc) # every bad field listed, with a path and a message

Pydantic's error messages are the feature people underestimate. A ValidationError lists every failing field with its location and a human-readable reason, which is far more useful in a log than a bare TypeError three functions deep. If you want the validation without the coercion, strict mode (model_config = ConfigDict(strict=True)) rejects "129.99" as a float instead of converting it.

What about attrs?

attrs is the third option and the oldest of the three. It predates dataclasses and inspired them. In my benchmark it was the fastest constructor at 0.48 microseconds, and it offers optional validators and converters, so it covers the middle ground: dataclass speed with opt-in validation. The catch is that its validation is per-field and manual, nothing like Pydantic's schema-driven parsing, and the ecosystem around it is smaller. Pick attrs when you want dataclass ergonomics with a few validators and you do not need JSON schema generation or FastAPI integration. For most teams, though, the choice is binary: dataclasses or Pydantic, and attrs is a specialist tool.

How do you decide in practice?

Apply one rule: validate at the boundary, use plain containers inside. Your API handler, your config loader, and your file importer take Pydantic models. The moment the data is validated, convert it to a dataclass or a plain object and pass that around internally. This is the pattern FastAPI itself encourages, and it gives you both guarantees: bad data is rejected early, and your hot paths pay no validation tax.

Boundary: Pydantic validates the incoming request.

class CreateOrderRequest(BaseModel):
order_id: int
customer: str
total: float

Inside: a dataclass carries the validated data.

@dataclass(frozen=True)
class Order:
order_id: int
customer: str
total: float

def handle(payload: dict) -> Order:
req = CreateOrderRequest.model_validate(payload) # raises on bad data
return Order(req.order_id, req.customer, req.total)

There is one exception. If your whole application is a thin wrapper around validated data, a small API with a handful of endpoints, keeping Pydantic models everywhere is simpler than converting back and forth, and the performance cost is negligible. Do not build a conversion layer you do not need.

What is still unknown?

My numbers come from one small model on one machine; a model with fifty fields, heavy use of unions, or deeply nested lists will shift the ratios, and Pydantic's relative cost grows with validation complexity. The Pydantic team's 5 to 50 times claim about v2 versus v1 is their own measurement and I have not independently reproduced it. Finally, Pydantic v2 is a separate install with its own release cadence, so pin it in production and check the changelog before upgrading, the v1 to v2 migration changed several behaviours.

Conclusion

Dataclasses and Pydantic are not rivals. A dataclass is the fastest way to hold data you trust; Pydantic is the most reliable way to accept data you do not. Measure your own workload if you parse at bulk scale, but for most applications the decision is architectural, not numerical: put Pydantic at the boundary, dataclasses inside, and let each do the job it was built for.


Originally published on LogicStack. Run the code examples right in your browser with our free interactive Python and SQL playgrounds — no setup, no sign-up.

Top comments (0)