Python dictionaries are convenient until they quietly become the shape of your whole application.
It usually starts with a small script. A few keys, one JSON response, one quick transformation. Everything is readable. Then the script becomes a service, the service becomes a product, and suddenly the same dictionary shape is being created, mutated, and guessed across dozens of files.
The problem is not that dictionaries are bad. The problem is using them as invisible domain models.
Where Dicts Start Hurting
A dictionary can store almost anything under almost any key. That flexibility is useful for transport data, but dangerous for core application logic.
If one function expects firstName, another expects first_name, and a third quietly removes permissions, there is no single obvious place to understand the final shape.
The data model becomes tribal knowledge. IDE autocomplete gets weaker. Refactoring gets slower. Bugs appear as KeyError, wrong defaults, or missing fields only after the code path runs in production.
Treat Dicts as Transport, Not Domain Objects
Raw dictionaries are perfect at system boundaries:
- incoming JSON from an API,
- decoded message queue payloads,
- loosely structured config,
- temporary key-value mappings.
But once the data enters your application, convert it into something with a name, fields, and behavior.
Imagine a user API returns this payload:
{
"user_id": 42,
"profile": {
"firstName": "Alice",
"lastName": "Wonderland"
},
"permissions": ["read", "write", "admin"]
}
Passing that raw dictionary everywhere is tempting. It is also how every future field rename becomes painful.
Option 1: A Small Domain Class
The simplest fix is a class that represents the concept your application actually uses.
import requests
class User:
def __init__(
self,
user_id: int,
first_name: str,
last_name: str,
permissions: list[str],
):
self.user_id = user_id
self.first_name = first_name
self.last_name = last_name
self.permissions = permissions
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"
def fetch_user(user_id: int) -> User:
response = requests.get(f"https://example.com/api/users/{user_id}", timeout=10)
response.raise_for_status()
data = response.json()
return User(
user_id=data["user_id"],
first_name=data["profile"]["firstName"],
last_name=data["profile"]["lastName"],
permissions=data["permissions"],
)
The code is a little longer, but now the conversion is centralized. If the external API changes, you update fetch_user instead of hunting through the whole codebase.
Option 2: Dataclasses
For plain data objects, dataclasses remove much of the boilerplate.
from dataclasses import dataclass
@dataclass(frozen=True)
class User:
user_id: int
first_name: str
last_name: str
permissions: list[str]
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"
frozen=True makes the object immutable. That is useful when you want to prevent accidental mutation after parsing.
If you need a changed version, create a new instance or use dataclasses.replace.
Option 3: Pydantic
When you need validation, type conversion, aliases, nested models, or serialization, Pydantic is often the strongest choice.
from pydantic import BaseModel, ConfigDict, Field
class Profile(BaseModel):
first_name: str = Field(alias="firstName")
last_name: str = Field(alias="lastName")
class User(BaseModel):
model_config = ConfigDict(frozen=True)
user_id: int
profile: Profile
permissions: list[str]
def full_name(self) -> str:
return f"{self.profile.first_name} {self.profile.last_name}"
Now invalid input fails at the boundary. The rest of the application receives a predictable object instead of a mystery dictionary.
Option 4: TypedDict for Gradual Refactoring
Sometimes you cannot replace dictionaries immediately. A legacy project may already pass dicts through too many layers.
TypedDict gives you a migration step. At runtime it is still a normal dictionary, but static checkers can catch missing keys and wrong value types.
from typing import TypedDict
class UserDict(TypedDict):
user_id: int
first_name: str
last_name: str
permissions: list[str]
user_data: UserDict = {
"user_id": 42,
"first_name": "Alice",
"last_name": "Wonderland",
"permissions": ["read", "write"],
}
It is not as strong as a real model, but it is much better than an untyped dict[str, object].
When Dictionaries Are Still the Right Tool
Dictionaries are still excellent for genuine mappings:
api_keys: dict[str, str] = {
"service_a": "12345ABC",
"service_b": "ABCDE12345",
}
If the structure really is a key-value store, keep it as a mapping. You can also annotate APIs with Mapping[str, str] or MutableMapping[str, str] to show whether callers are allowed to mutate it.
The key question is simple: is this a lookup table, or is this pretending to be an entity?
Practical Rule for 2026
Use dictionaries at the edges.
Convert external payloads into named models as soon as possible.
Use dataclasses for lightweight internal structures, Pydantic for validation-heavy data, and TypedDict when you need a gradual path out of dictionary-heavy legacy code.
The earlier you make data shapes explicit, the easier your Python project will be to refactor, test, and maintain.


