Python Dataclasses and Pydantic: Modern Data Validation for Production APIs

Python Dataclasses and Pydantic: Modern Data Validation for Production APIs
18 views

Before dataclasses, Python developers wrote dozens of lines of boilerplate for simple data containers. __init__, __repr__, __eq__ — all by hand. Dataclasses generate them automatically. Pydantic goes further: validation, serialization, JSON schema, and FastAPI integration. This guide covers both with real production patterns.

⚡ TL;DR: Use dataclasses for internal data structures where you trust the data. Use Pydantic for external data (API requests, config files, user input) where validation is critical. Pydantic v2 is 5-50x faster than v1. Both integrate with FastAPI.

Python dataclasses — zero-boilerplate data containers

from dataclasses import dataclass, field
from datetime import datetime

@dataclass
class User:
 id: int
 name: str
 email: str
 created_at: datetime = field(default_factory=datetime.now)
 tags: list[str] = field(default_factory=list)

 def __post_init__(self):
 # Validation after auto-generated __init__
 if not self.email or '@' not in self.email:
 raise ValueError(f'Invalid email: {self.email}')

# Auto-generated: __init__, __repr__, __eq__
user = User(id=1, name='Alice', email='alice@example.com')
print(user) # User(id=1, name='Alice', email='alice@example.com', ...)

# Immutable dataclass:
@dataclass(frozen=True) # __hash__ generated, fields immutable
class Point:
 x: float
 y: float

Pydantic v2 — validation + serialization

from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Optional
from datetime import datetime

class CreateUserRequest(BaseModel):
 name: str = Field(min_length=1, max_length=100)
 email: str = Field(pattern=r'^[\w.-]+@[\w.-]+\.[a-zA-Z]{2,}$')
 age: int = Field(ge=18, le=120)
 role: str = Field(default='user')

 @field_validator('email')
 @classmethod
 def lowercase_email(cls, v: str) -> str:
 return v.lower().strip()

 @field_validator('role')
 @classmethod
 def valid_role(cls, v: str) -> str:
 if v not in ('user', 'admin', 'moderator'):
 raise ValueError(f'Invalid role: {v}')
 return v

# Parse and validate:
try:
 user = CreateUserRequest(name='Alice', email='ALICE@Example.COM', age=25)
 print(user.email) # 'alice@example.com' (auto-lowercased)
except ValidationError as e:
 print(e.errors()) # Structured error list with field details

FastAPI integration

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

class UserCreate(BaseModel):
 name: str
 email: str
 age: int

class UserResponse(BaseModel):
 id: int
 name: str
 email: str

@app.post('/users', response_model=UserResponse, status_code=201)
async def create_user(body: UserCreate): # Auto-validated from request JSON
 # body is already validated UserCreate instance
 user = await db.create_user(body.model_dump())
 return UserResponse(**user) # Only returns fields in response_model
# FastAPI generates OpenAPI docs automatically from Pydantic models

What @dataclass generates, and the class features it builds on

A dataclass is an ordinary Python class with generated methods: __init__, __repr__ and __eq__ by default. A few behaviors catch people out. With the default eq=True and no frozen=True, the class sets __hash__ to None, so instances can’t go into sets or be dict keys. With frozen=True and eq=True, a hash is generated from the fields. Mutable defaults must use field(default_factory=...), and validation goes in __post_init__.

python
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
import math


@dataclass
class Account:
    owner: str
    balance: float = 0.0
    tags: list[str] = field(default_factory=list)   # never a shared mutable default

    def __post_init__(self):
        if self.balance < 0:
            raise ValueError(f"balance cannot be negative: {self.balance}")


@dataclass(frozen=True)
class Money:
    amount: int
    currency: str

    def __add__(self, other: "Money") -> "Money":
        if other.currency != self.currency:
            raise ValueError("currency mismatch")
        return Money(self.amount + other.amount, self.currency)


print(Account("ana"))                       # generated __repr__
print(Account("ana") == Account("ana"))     # generated __eq__: True
print(Account.__hash__)                     # None: eq=True without frozen makes it unhashable
print({Money(5, "USD") + Money(7, "USD")})  # frozen + eq: hashable, usable in sets


class Shape(ABC):
    @abstractmethod
    def area(self) -> float: ...

    def describe(self) -> str:
        return f"{type(self).__name__}: area={self.area():.2f}"


@dataclass
class Circle(Shape):
    r: float

    def area(self) -> float:
        return math.pi * self.r ** 2


print(Circle(5).describe())                 # Circle: area=78.54


class A:
    def method(self): return "A"
class B(A):
    def method(self): return "B" + super().method()
class C(A):
    def method(self): return "C" + super().method()
class D(B, C):
    pass


print(D().method(), [k.__name__ for k in D.__mro__])   # BCA ['D', 'B', 'C', 'A', 'object']
try:
    Shape()
except TypeError as e:
    print("TypeError:", e)

The output confirms each point: the generated repr and equality, Account.__hash__ is None, the frozen Money works in a set, and an abstract base class can’t be instantiated until a subclass implements every abstract method.

Dataclasses still use the normal class machinery. super() follows the method resolution order (MRO), which Python computes with C3 linearization: for class D(B, C) the order is D, B, C, A, so D().method() returns 'BCA'. Dunder methods such as __add__ work on dataclasses as on any class. If you write __eq__ yourself on a regular class, you must also define __hash__ if instances should be hashable. See the dataclasses documentation and the Python data model.

dataclasses vs Pydantic — when to use each

  • ✅ dataclasses: internal data structures, performance-critical, trusted data
  • ✅ Pydantic: API request bodies, config files, external data sources
  • ✅ Pydantic v2: 5-50x faster than v1 — upgrade if still on v1
  • ✅ Use model_dump() for dict conversion, model_json() for JSON string
  • ❌ Never use plain dicts for structured data — no validation, no type hints
  • ❌ Never use Pydantic for pure performance-critical inner loops — overhead

Pydantic models power FastAPI which is covered in the production API patterns. For validation in AWS Lambda, Lambda cold start shows why keeping Pydantic import lean matters. External reference: Pydantic v2 documentation.

Free Weekly Newsletter

🚀 Don’t Miss the Next Cheat Code

✓ No spam✓ Unsubscribe anytime

Related: Python OOP: Classes, Inheritance, Dunder Methods, and Dataclasses Explained · Python Dataclasses: 10 Advanced Features That Make __init__ Obsolete · Python Generators and Yield: Lazy Evaluation for Memory-Efficient Data Processing

Related: How to Build a Modern AWS Data Lake for Operational and Business Analytics with S3, Glue, Athena, Kinesis, and EventBridge · Python Decorators Explained: From Simple Wrappers to Production Patterns · Python asyncio vs Threading vs Multiprocessing: When to Use Each

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.