Mar 17, 20264 min

Stop Passing Raw Dicts Around in Python: Safer Models for 2026

A practical 2026 guide to keeping Python dictionaries under control by converting raw JSON into dataclasses, Pydantic models, TypedDict contracts, and clear mapping types.

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.