It's 4:47 p.m. and a bug report lands in your inbox: "Order totals are wrong for some customers." You open the code and find a function that takes a dictionary called order. You scroll up. Is the price stored under "price", "unit_price", or "price_cents"? Is quantity an integer or a string that came straight from a form? Somewhere, three files away, someone wrote order["qty"] and never told anyone.

If this feels familiar, you've met the most common design problem in everyday Python: you never decided what your data looks like. Python makes it so easy to toss values into a dict that you can go years without choosing a proper shape for them.

This guide walks through the three tools most developers reach for — plain dictionaries, dataclasses, and validation libraries like Pydantic — and gives you a simple way to choose between them.

Why plain dictionaries feel great until they don't

A dictionary is the fastest way to get something working. There is no class to define, no import to add, and JSON maps onto it perfectly.

order = {"id": 1042, "price": 19.99, "qty": 3}
total = order["price"] * order["qty"]

For a ten-line script, this is exactly right. The trouble starts when the same dict travels through ten functions and three developers. A typo like order["quantity"] doesn't fail when you write it — it fails at runtime, possibly in production, possibly only for the one customer whose record is missing that key.

A dictionary is a promise nobody has written down.

Editors can't autocomplete dictionary keys. Type checkers can't tell you that qty should be an int. And when you add a field next month, nothing tells you which of the forty places that touch order need updating.

The takeaway: dicts are ideal for data whose keys are genuinely dynamic (word counts, lookups, caches). They are a poor fit for data that has a fixed, meaningful shape.

Dataclasses: giving your data a name and a shape

The standard library's dataclasses module lets you declare a shape in a few lines, with no third-party install.

from dataclasses import dataclass

@dataclass
class OrderLine:
    id: int
    price: float
    qty: int = 1

    @property
    def total(self) -> float:
        return round(self.price * self.qty, 2)

line = OrderLine(id=1042, price=19.99, qty=3)
print(line.total)   # 59.97

Notice what you get for free: an __init__, a readable __repr__, and equality comparison. More importantly, line.price is now something your editor understands. Misspell it and you get an immediate error instead of a silent bug later.

AD

Two options are worth knowing early. @dataclass(frozen=True) makes instances immutable, which is excellent for values that should never change after creation, and also makes them hashable. @dataclass(slots=True) (Python 3.10+) reduces memory use when you create many instances.

One common trap: never use a mutable default like a list directly. Use field(default_factory=list) instead, or every instance will share the same list.

from dataclasses import dataclass, field

@dataclass
class Cart:
    items: list[OrderLine] = field(default_factory=list)

The catch: dataclasses do not validate anything at runtime. The annotation qty: int is a hint for humans and type checkers. If you write OrderLine(id=1, price=9.5, qty="three"), Python happily accepts it. For data you built yourself, that's fine. For data that arrives from the outside world, it's a problem.

Validation libraries: when data comes from outside

Anything crossing a boundary — a web form, a JSON API response, a CSV upload, a config file — should be treated as untrusted. Values arrive as strings, fields go missing, numbers show up as "12,50".

Pydantic is the most widely used library for this job. You describe the shape once, and it parses and validates at the door.

from pydantic import BaseModel, Field, ValidationError

class OrderLine(BaseModel):
    id: int
    price: float = Field(gt=0)
    qty: int = Field(default=1, ge=1)

try:
    line = OrderLine(id="1042", price="19.99", qty=0)
except ValidationError as e:
    print(e)

Here "1042" is coerced to the integer 1042, but qty=0 is rejected with a clear message saying which field failed and why. Instead of a mysterious wrong total at 4:47 p.m., you get an error at the exact moment bad data tried to enter your system.

Pydantic is also the engine behind FastAPI's request validation, which is a big reason it's become the default choice for web APIs. The cost is an extra dependency and some runtime overhead; for hot loops processing millions of tiny records, that overhead can matter.

Validate at the edges, trust the inside.

AD

That sentence is the most useful design rule in this whole article. Parse and validate once where data enters; after that, pass around plain, well-shaped objects and stop re-checking.

A quick comparison

NeedPlain dictdataclassPydantic model
Fixed field namesNoYesYes
Editor autocompleteNoYesYes
Runtime type checksNoNoYes
Parses JSON / stringsManualManualBuilt in
Extra dependencyNoneNoneYes
Best forDynamic keys, quick scriptsInternal data you controlUntrusted external input

A practical decision rule

When you're unsure, ask three questions in order.

  1. Do the keys vary at runtime? If yes (counters, caches, lookups by id), use a dict. That's what it was designed for.
  2. Does the data come from outside your program? If yes (API, form, file, environment), validate it with Pydantic or a similar library at the boundary.
  3. Otherwise, use a dataclass. It's light, standard, and gives your editor everything it needs.

Many real projects use all three, and that's healthy. A typical flow looks like this: a Pydantic model parses the incoming JSON, the result is converted into a frozen dataclass for the business logic, and a dict holds a quick lookup table of prices by product id.

raw = {"id": "1042", "price": "19.99", "qty": "3"}

parsed = OrderLine(**raw)                       # validate at the edge
prices = {parsed.id: parsed.price}              # dict for lookup
print(prices[1042] * parsed.qty)

Migrating without a big rewrite

You don't have to refactor everything at once. Pick the one dict that causes the most confusion — the one with the most KeyError history — and give it a dataclass. Add a from_dict class method that does the conversion, and use it where the dict first appears. Because the new class has the same field names, the rest of the code can move over one function at a time.

Add type hints as you go, then run a checker such as mypy or pyright. The first run will probably surface a handful of real bugs you didn't know about. That's not a failure of the migration; it's the payoff.

Summary

Plain dicts are flexible but anonymous. Dataclasses give internal data a clear, editor-friendly shape at zero cost. Validation libraries like Pydantic protect you from the messy outside world. Choose by asking where the data comes from and whether its keys are fixed, and validate once at the boundary.

The next time you catch yourself typing data["something"] for the fifth time, take two minutes to name that data. Your future self, staring at a bug report at 4:47 p.m., will thank you.