[{"data":1,"prerenderedAt":769},["ShallowReactive",2],{"post-en-dataclasses-pydantic-typeddict":3},{"id":4,"title":5,"body":6,"date":755,"description":16,"excerpt":756,"extension":757,"meta":758,"navigation":86,"path":759,"readTime":114,"seo":760,"slug":761,"stem":762,"tags":763,"__hash__":768},"en_blog/en/blog/dataclasses-pydantic-typeddict.md","Dataclasses, Pydantic, TypedDict: Which to Choose and Why",{"type":7,"value":8,"toc":747},"minimark",[9,13,17,22,25,32,51,57,60,64,153,156,163,167,241,244,250,294,300,304,425,428,485,491,495,498,569,572,575,579,582,740,743],[10,11,5],"h1",{"id":12},"dataclasses-pydantic-typeddict-which-to-choose-and-why",[14,15,16],"p",{},"This is a question every Python team eventually confronts. The answers found online tend toward the superficial: \"Pydantic for APIs, dataclasses for everything else\" — which is a starting point, but fails to address the situations where the choice actually matters. Here are the decision rules applied in practice.",[18,19,21],"h2",{"id":20},"understanding-what-each-tool-actually-does","Understanding What Each Tool Actually Does",[14,23,24],{},"Before the rules, a clear-headed reminder of what each tool is for:",[14,26,27,31],{},[28,29,30],"strong",{},"TypedDict"," is a pure type annotation. It does nothing at runtime — it informs the type checker (mypy, pyright) about the shape of a dictionary. Zero overhead, zero validation.",[14,33,34,37,38,42,43,46,47,50],{},[28,35,36],{},"Dataclass"," is a Python class generator. It automatically creates ",[39,40,41],"code",{},"__init__",", ",[39,44,45],{},"__repr__",", and ",[39,48,49],{},"__eq__"," from annotations. No runtime type validation.",[14,52,53,56],{},[28,54,55],{},"Pydantic BaseModel"," is a complete validation system. It converts and validates data at runtime, raises detailed errors, serialises and deserialises JSON, and generates JSON Schema.",[14,58,59],{},"These are not three ways to accomplish the same thing — they are three tools with distinct responsibilities.",[18,61,63],{"id":62},"rule-1-typeddict-for-dictionaries-you-do-not-control","Rule 1: TypedDict for Dictionaries You Do Not Control",[65,66,71],"pre",{"className":67,"code":68,"language":69,"meta":70,"style":70},"language-python shiki shiki-themes github-dark github-light","from typing import TypedDict\n\n# Data returned by an external API — you read it, you don't construct it\nclass GrxCertificate(TypedDict):\n    id: str\n    volume: float\n    period_from: str\n    period_to: str\n    status: str\n\n# Usage\ndef process_certificate(cert: GrxCertificate) -> float:\n    return cert[\"volume\"] * 1.05  # Type checker validates the field access\n","python","",[39,72,73,81,88,94,100,106,112,118,124,130,135,141,147],{"__ignoreMap":70},[74,75,78],"span",{"class":76,"line":77},"line",1,[74,79,80],{},"from typing import TypedDict\n",[74,82,84],{"class":76,"line":83},2,[74,85,87],{"emptyLinePlaceholder":86},true,"\n",[74,89,91],{"class":76,"line":90},3,[74,92,93],{},"# Data returned by an external API — you read it, you don't construct it\n",[74,95,97],{"class":76,"line":96},4,[74,98,99],{},"class GrxCertificate(TypedDict):\n",[74,101,103],{"class":76,"line":102},5,[74,104,105],{},"    id: str\n",[74,107,109],{"class":76,"line":108},6,[74,110,111],{},"    volume: float\n",[74,113,115],{"class":76,"line":114},7,[74,116,117],{},"    period_from: str\n",[74,119,121],{"class":76,"line":120},8,[74,122,123],{},"    period_to: str\n",[74,125,127],{"class":76,"line":126},9,[74,128,129],{},"    status: str\n",[74,131,133],{"class":76,"line":132},10,[74,134,87],{"emptyLinePlaceholder":86},[74,136,138],{"class":76,"line":137},11,[74,139,140],{},"# Usage\n",[74,142,144],{"class":76,"line":143},12,[74,145,146],{},"def process_certificate(cert: GrxCertificate) -> float:\n",[74,148,150],{"class":76,"line":149},13,[74,151,152],{},"    return cert[\"volume\"] * 1.05  # Type checker validates the field access\n",[14,154,155],{},"TypedDict is ideal for typing dictionaries that come from outside the system — JSON API responses, SQL query results, YAML configs — without converting them into objects. The runtime overhead is zero; it is purely a static construct.",[14,157,158,159,162],{},"The limitation: TypedDict validates nothing at runtime. If the API returns ",[39,160,161],{},"volume"," as a string, the code will fail further downstream rather than at deserialisation.",[18,164,166],{"id":165},"rule-2-dataclasses-for-internal-models-without-validation","Rule 2: Dataclasses for Internal Models Without Validation",[65,168,170],{"className":67,"code":169,"language":69,"meta":70,"style":70},"from dataclasses import dataclass, field\nfrom datetime import datetime\n\n@dataclass\nclass CertificateAggregate:\n    account_id: str\n    total_volume: float\n    certificate_count: int\n    computed_at: datetime = field(default_factory=datetime.now)\n\n    def average_volume(self) -> float:\n        if self.certificate_count == 0:\n            return 0.0\n        return self.total_volume / self.certificate_count\n",[39,171,172,177,182,186,191,196,201,206,211,216,220,225,230,235],{"__ignoreMap":70},[74,173,174],{"class":76,"line":77},[74,175,176],{},"from dataclasses import dataclass, field\n",[74,178,179],{"class":76,"line":83},[74,180,181],{},"from datetime import datetime\n",[74,183,184],{"class":76,"line":90},[74,185,87],{"emptyLinePlaceholder":86},[74,187,188],{"class":76,"line":96},[74,189,190],{},"@dataclass\n",[74,192,193],{"class":76,"line":102},[74,194,195],{},"class CertificateAggregate:\n",[74,197,198],{"class":76,"line":108},[74,199,200],{},"    account_id: str\n",[74,202,203],{"class":76,"line":114},[74,204,205],{},"    total_volume: float\n",[74,207,208],{"class":76,"line":120},[74,209,210],{},"    certificate_count: int\n",[74,212,213],{"class":76,"line":126},[74,214,215],{},"    computed_at: datetime = field(default_factory=datetime.now)\n",[74,217,218],{"class":76,"line":132},[74,219,87],{"emptyLinePlaceholder":86},[74,221,222],{"class":76,"line":137},[74,223,224],{},"    def average_volume(self) -> float:\n",[74,226,227],{"class":76,"line":143},[74,228,229],{},"        if self.certificate_count == 0:\n",[74,231,232],{"class":76,"line":149},[74,233,234],{},"            return 0.0\n",[74,236,238],{"class":76,"line":237},14,[74,239,240],{},"        return self.total_volume / self.certificate_count\n",[14,242,243],{},"Dataclasses are the right choice for objects you construct yourself within business logic — aggregation results, intermediate processing objects, domain value objects. They are lighter than Pydantic and more explicit than raw dictionaries.",[14,245,246,249],{},[39,247,248],{},"@dataclass(frozen=True)"," makes them immutable — useful for value objects:",[65,251,253],{"className":67,"code":252,"language":69,"meta":70,"style":70},"@dataclass(frozen=True)\nclass DateRange:\n    start: str\n    end: str\n\n    def __post_init__(self):\n        if self.start > self.end:\n            raise ValueError(f\"start ({self.start}) must be before end ({self.end})\")\n",[39,254,255,260,265,270,275,279,284,289],{"__ignoreMap":70},[74,256,257],{"class":76,"line":77},[74,258,259],{},"@dataclass(frozen=True)\n",[74,261,262],{"class":76,"line":83},[74,263,264],{},"class DateRange:\n",[74,266,267],{"class":76,"line":90},[74,268,269],{},"    start: str\n",[74,271,272],{"class":76,"line":96},[74,273,274],{},"    end: str\n",[74,276,277],{"class":76,"line":102},[74,278,87],{"emptyLinePlaceholder":86},[74,280,281],{"class":76,"line":108},[74,282,283],{},"    def __post_init__(self):\n",[74,285,286],{"class":76,"line":114},[74,287,288],{},"        if self.start > self.end:\n",[74,290,291],{"class":76,"line":120},[74,292,293],{},"            raise ValueError(f\"start ({self.start}) must be before end ({self.end})\")\n",[14,295,296,299],{},[39,297,298],{},"__post_init__"," allows adding simple invariant validation without Pydantic — sufficient for most domain-level constraints.",[18,301,303],{"id":302},"rule-3-pydantic-for-everything-that-touches-system-boundaries","Rule 3: Pydantic for Everything That Touches System Boundaries",[65,305,307],{"className":67,"code":306,"language":69,"meta":70,"style":70},"from pydantic import BaseModel, Field, field_validator\nfrom typing import Literal\n\nclass CertificateRequest(BaseModel):\n    account_id: str = Field(min_length=3, max_length=50)\n    volume: float = Field(gt=0, description=\"Volume in MWh\")\n    technology: Literal[\"WIND\", \"SOLAR\", \"HYDRO\", \"BIOMASS\"]\n    period_from: str\n    period_to: str\n\n    @field_validator(\"period_to\")\n    @classmethod\n    def period_to_after_from(cls, v: str, info) -> str:\n        if \"period_from\" in info.data and v \u003C= info.data[\"period_from\"]:\n            raise ValueError(\"period_to must be after period_from\")\n        return v\n\nclass CertificateResponse(BaseModel):\n    id: str\n    volume: float\n    status: Literal[\"ACTIVE\", \"CANCELLED\", \"TRANSFERRED\"]\n\n    model_config = {\"from_attributes\": True}  # ORM compatibility\n",[39,308,309,314,319,323,328,333,338,343,347,351,355,360,365,370,375,381,387,392,398,403,408,414,419],{"__ignoreMap":70},[74,310,311],{"class":76,"line":77},[74,312,313],{},"from pydantic import BaseModel, Field, field_validator\n",[74,315,316],{"class":76,"line":83},[74,317,318],{},"from typing import Literal\n",[74,320,321],{"class":76,"line":90},[74,322,87],{"emptyLinePlaceholder":86},[74,324,325],{"class":76,"line":96},[74,326,327],{},"class CertificateRequest(BaseModel):\n",[74,329,330],{"class":76,"line":102},[74,331,332],{},"    account_id: str = Field(min_length=3, max_length=50)\n",[74,334,335],{"class":76,"line":108},[74,336,337],{},"    volume: float = Field(gt=0, description=\"Volume in MWh\")\n",[74,339,340],{"class":76,"line":114},[74,341,342],{},"    technology: Literal[\"WIND\", \"SOLAR\", \"HYDRO\", \"BIOMASS\"]\n",[74,344,345],{"class":76,"line":120},[74,346,117],{},[74,348,349],{"class":76,"line":126},[74,350,123],{},[74,352,353],{"class":76,"line":132},[74,354,87],{"emptyLinePlaceholder":86},[74,356,357],{"class":76,"line":137},[74,358,359],{},"    @field_validator(\"period_to\")\n",[74,361,362],{"class":76,"line":143},[74,363,364],{},"    @classmethod\n",[74,366,367],{"class":76,"line":149},[74,368,369],{},"    def period_to_after_from(cls, v: str, info) -> str:\n",[74,371,372],{"class":76,"line":237},[74,373,374],{},"        if \"period_from\" in info.data and v \u003C= info.data[\"period_from\"]:\n",[74,376,378],{"class":76,"line":377},15,[74,379,380],{},"            raise ValueError(\"period_to must be after period_from\")\n",[74,382,384],{"class":76,"line":383},16,[74,385,386],{},"        return v\n",[74,388,390],{"class":76,"line":389},17,[74,391,87],{"emptyLinePlaceholder":86},[74,393,395],{"class":76,"line":394},18,[74,396,397],{},"class CertificateResponse(BaseModel):\n",[74,399,401],{"class":76,"line":400},19,[74,402,105],{},[74,404,406],{"class":76,"line":405},20,[74,407,111],{},[74,409,411],{"class":76,"line":410},21,[74,412,413],{},"    status: Literal[\"ACTIVE\", \"CANCELLED\", \"TRANSFERRED\"]\n",[74,415,417],{"class":76,"line":416},22,[74,418,87],{"emptyLinePlaceholder":86},[74,420,422],{"class":76,"line":421},23,[74,423,424],{},"    model_config = {\"from_attributes\": True}  # ORM compatibility\n",[14,426,427],{},"Pydantic wins at every system boundary: HTTP inputs (request bodies, query parameters), JSON responses, configuration files, environment variables.",[65,429,431],{"className":67,"code":430,"language":69,"meta":70,"style":70},"from pydantic_settings import BaseSettings\n\nclass Settings(BaseSettings):\n    database_url: str\n    redis_url: str\n    secret_key: str\n    debug: bool = False\n\n    model_config = {\"env_file\": \".env\"}\n\nsettings = Settings()  # Raises a clear error if DATABASE_URL is missing\n",[39,432,433,438,442,447,452,457,462,467,471,476,480],{"__ignoreMap":70},[74,434,435],{"class":76,"line":77},[74,436,437],{},"from pydantic_settings import BaseSettings\n",[74,439,440],{"class":76,"line":83},[74,441,87],{"emptyLinePlaceholder":86},[74,443,444],{"class":76,"line":90},[74,445,446],{},"class Settings(BaseSettings):\n",[74,448,449],{"class":76,"line":96},[74,450,451],{},"    database_url: str\n",[74,453,454],{"class":76,"line":102},[74,455,456],{},"    redis_url: str\n",[74,458,459],{"class":76,"line":108},[74,460,461],{},"    secret_key: str\n",[74,463,464],{"class":76,"line":114},[74,465,466],{},"    debug: bool = False\n",[74,468,469],{"class":76,"line":120},[74,470,87],{"emptyLinePlaceholder":86},[74,472,473],{"class":76,"line":126},[74,474,475],{},"    model_config = {\"env_file\": \".env\"}\n",[74,477,478],{"class":76,"line":132},[74,479,87],{"emptyLinePlaceholder":86},[74,481,482],{"class":76,"line":137},[74,483,484],{},"settings = Settings()  # Raises a clear error if DATABASE_URL is missing\n",[14,486,487,490],{},[39,488,489],{},"pydantic-settings"," is particularly valuable — it reads environment variables, casts them to the correct types, and raises explicit errors at startup if a required variable is absent.",[18,492,494],{"id":493},"performance-when-it-actually-matters","Performance: When It Actually Matters",[14,496,497],{},"Pydantic v2 (rewritten in Rust) is substantially faster than v1, but still slower than dataclasses for object construction:",[499,500,501,520],"table",{},[502,503,504],"thead",{},[505,506,507,511,514,517],"tr",{},[508,509,510],"th",{},"Tool",[508,512,513],{},"Construction (relative)",[508,515,516],{},"Validation",[508,518,519],{},"JSON Serialisation",[521,522,523,537,553],"tbody",{},[505,524,525,528,531,534],{},[526,527,30],"td",{},[526,529,530],{},"1x",[526,532,533],{},"None",[526,535,536],{},"Manual",[505,538,539,541,544,548],{},[526,540,36],{},[526,542,543],{},"1.2x",[526,545,546],{},[39,547,298],{},[526,549,550],{},[39,551,552],{},"dataclasses.asdict()",[505,554,555,558,561,564],{},[526,556,557],{},"Pydantic v2",[526,559,560],{},"3–5x",[526,562,563],{},"Complete",[526,565,566],{},[39,567,568],{},".model_dump_json()",[14,570,571],{},"On a FastAPI endpoint handling 1,000 requests per second with simple models, the difference between Pydantic and dataclasses is negligible. It becomes visible in data processing pipelines that instantiate millions of objects — ETL jobs, large file processing.",[14,573,574],{},"The practical rule: do not optimise prematurely. Pydantic at boundaries, dataclasses internally — this separation delivers good performance by default without micro-optimisation.",[18,576,578],{"id":577},"combining-all-three","Combining All Three",[14,580,581],{},"In a real project, all three coexist naturally:",[65,583,585],{"className":67,"code":584,"language":69,"meta":70,"style":70},"from typing import TypedDict\nfrom dataclasses import dataclass\nfrom pydantic import BaseModel\n\n# TypedDict: raw response from the external API\nclass RawApiResponse(TypedDict):\n    data: list[dict]\n    meta: dict\n\n# Pydantic: validates and parses the response at the boundary\nclass Certificate(BaseModel):\n    id: str\n    volume: float\n    status: str\n\n# Dataclass: internal business object after processing\n@dataclass\nclass CertificateReport:\n    total_volume: float\n    active_count: int\n    cancelled_count: int\n\ndef process_response(raw: RawApiResponse) -> CertificateReport:\n    certificates = [Certificate.model_validate(item) for item in raw[\"data\"]]\n    active = [c for c in certificates if c.status == \"ACTIVE\"]\n    cancelled = [c for c in certificates if c.status == \"CANCELLED\"]\n    return CertificateReport(\n        total_volume=sum(c.volume for c in certificates),\n        active_count=len(active),\n        cancelled_count=len(cancelled),\n    )\n",[39,586,587,591,596,601,605,610,615,620,625,629,634,639,643,647,651,655,660,664,669,673,678,683,687,692,698,704,710,716,722,728,734],{"__ignoreMap":70},[74,588,589],{"class":76,"line":77},[74,590,80],{},[74,592,593],{"class":76,"line":83},[74,594,595],{},"from dataclasses import dataclass\n",[74,597,598],{"class":76,"line":90},[74,599,600],{},"from pydantic import BaseModel\n",[74,602,603],{"class":76,"line":96},[74,604,87],{"emptyLinePlaceholder":86},[74,606,607],{"class":76,"line":102},[74,608,609],{},"# TypedDict: raw response from the external API\n",[74,611,612],{"class":76,"line":108},[74,613,614],{},"class RawApiResponse(TypedDict):\n",[74,616,617],{"class":76,"line":114},[74,618,619],{},"    data: list[dict]\n",[74,621,622],{"class":76,"line":120},[74,623,624],{},"    meta: dict\n",[74,626,627],{"class":76,"line":126},[74,628,87],{"emptyLinePlaceholder":86},[74,630,631],{"class":76,"line":132},[74,632,633],{},"# Pydantic: validates and parses the response at the boundary\n",[74,635,636],{"class":76,"line":137},[74,637,638],{},"class Certificate(BaseModel):\n",[74,640,641],{"class":76,"line":143},[74,642,105],{},[74,644,645],{"class":76,"line":149},[74,646,111],{},[74,648,649],{"class":76,"line":237},[74,650,129],{},[74,652,653],{"class":76,"line":377},[74,654,87],{"emptyLinePlaceholder":86},[74,656,657],{"class":76,"line":383},[74,658,659],{},"# Dataclass: internal business object after processing\n",[74,661,662],{"class":76,"line":389},[74,663,190],{},[74,665,666],{"class":76,"line":394},[74,667,668],{},"class CertificateReport:\n",[74,670,671],{"class":76,"line":400},[74,672,205],{},[74,674,675],{"class":76,"line":405},[74,676,677],{},"    active_count: int\n",[74,679,680],{"class":76,"line":410},[74,681,682],{},"    cancelled_count: int\n",[74,684,685],{"class":76,"line":416},[74,686,87],{"emptyLinePlaceholder":86},[74,688,689],{"class":76,"line":421},[74,690,691],{},"def process_response(raw: RawApiResponse) -> CertificateReport:\n",[74,693,695],{"class":76,"line":694},24,[74,696,697],{},"    certificates = [Certificate.model_validate(item) for item in raw[\"data\"]]\n",[74,699,701],{"class":76,"line":700},25,[74,702,703],{},"    active = [c for c in certificates if c.status == \"ACTIVE\"]\n",[74,705,707],{"class":76,"line":706},26,[74,708,709],{},"    cancelled = [c for c in certificates if c.status == \"CANCELLED\"]\n",[74,711,713],{"class":76,"line":712},27,[74,714,715],{},"    return CertificateReport(\n",[74,717,719],{"class":76,"line":718},28,[74,720,721],{},"        total_volume=sum(c.volume for c in certificates),\n",[74,723,725],{"class":76,"line":724},29,[74,726,727],{},"        active_count=len(active),\n",[74,729,731],{"class":76,"line":730},30,[74,732,733],{},"        cancelled_count=len(cancelled),\n",[74,735,737],{"class":76,"line":736},31,[74,738,739],{},"    )\n",[14,741,742],{},"Each tool in its place: TypedDict for raw external data, Pydantic for boundary validation, dataclass for internal logic. This separation is what keeps the code readable and maintainable over the long term.",[744,745,746],"style",{},"html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":70,"searchDepth":83,"depth":83,"links":748},[749,750,751,752,753,754],{"id":20,"depth":83,"text":21},{"id":62,"depth":83,"text":63},{"id":165,"depth":83,"text":166},{"id":302,"depth":83,"text":303},{"id":493,"depth":83,"text":494},{"id":577,"depth":83,"text":578},"2025-01-06",null,"md",{},"/en/blog/dataclasses-pydantic-typeddict",{"title":5,"description":16},"dataclasses-pydantic-typeddict","en/blog/dataclasses-pydantic-typeddict",[764,765,766,767],"Python","Pydantic","Typing","FastAPI","jLHOcPkFi8d7wu19zJKqlx0ZpaaqmasUdrgixQie4v0",1774645635810]