Skip to content

How to Convert JSON to Custom Objects in Python

Learn how to convert JSON data into Python objects for cleaner code, type safety, and IDE autocompletion using SimpleNamespace, dataclasses, Pydantic, and attrs.

Working with raw dictionaries (data['user']['name']) is error-prone. For example, typos go unnoticed, IDEs can't provide autocompletion, and there's no type validation. Converting JSON to custom objects (data.user.name) enables better tooling, type safety, and cleaner code.

This guide covers approaches from simple namespace conversion to full validation with Pydantic.

Using SimpleNamespace (Quick and Easy)

For dot-notation access without defining a schema:

import json
from types import SimpleNamespace

json_str = '{"name": "Alice", "id": 100, "meta": {"active": true}}'

data = json.loads(json_str, object_hook=lambda d: SimpleNamespace(**d))

print(data.name)         # Alice
print(data.id)           # 100
print(data.meta.active)  # True

print(type(data))        # <class 'types.SimpleNamespace'>

Output:

Alice
100
True
<class 'types.SimpleNamespace'>

Reusable Parsing Function

import json
from types import SimpleNamespace

def json_to_object(json_string):
    """Convert JSON string to object with dot notation."""
    return json.loads(json_string, object_hook=lambda d: SimpleNamespace(**d))

def json_file_to_object(file_path):
    """Load JSON file as object with dot notation."""
    with open(file_path) as f:
        return json.load(f, object_hook=lambda d: SimpleNamespace(**d))

config = json_file_to_object("config.json")
print(config.database.host)

Using Dataclasses (Structured Schema)

For production code, define explicit structures with type hints:

import json
from dataclasses import dataclass
from typing import Optional

@dataclass
class Address:
    city: str
    zip_code: str

@dataclass
class User:
    name: str
    id: int
    email: Optional[str] = None
    active: bool = True

json_str = '{"name": "Bob", "id": 50, "email": "bob@example.com"}'

raw_dict = json.loads(json_str)
user = User(**raw_dict)

print(user)        # User(name='Bob', id=50, email='bob@example.com', active=True)
print(user.name)   # Bob
print(user.email)  # bob@example.com

Output:

User(name='Bob', id=50, email='bob@example.com', active=True)
Bob
bob@example.com

Handling Nested Objects

import json
from dataclasses import dataclass
from typing import Optional

@dataclass
class Address:
    street: str
    city: str
    country: str = "USA"

@dataclass
class User:
    name: str
    id: int
    address: Address

def dict_to_user(data: dict) -> User:
    """Convert nested dict to User with Address."""
    address_data = data.pop('address')
    address = Address(**address_data)
    return User(**data, address=address)

json_str = '''
{
    "name": "Alice",
    "id": 1,
    "address": {
        "street": "123 Main St",
        "city": "Boston"
    }
}
'''

raw = json.loads(json_str)
user = dict_to_user(raw)

print(user.address.city)  # Boston

Generic Dataclass Parser

from dataclasses import dataclass, fields, is_dataclass
from typing import get_type_hints, get_origin, get_args
import json

def from_dict(cls, data):
    """Recursively convert dict to dataclass instance."""
    if not is_dataclass(cls):
        return data
    
    fieldtypes = get_type_hints(cls)
    kwargs = {}
    
    for field in fields(cls):
        value = data.get(field.name)
        field_type = fieldtypes[field.name]
        
        if value is not None and is_dataclass(field_type):
            kwargs[field.name] = from_dict(field_type, value)
        elif value is not None and get_origin(field_type) is list:
            item_type = get_args(field_type)[0]
            if is_dataclass(item_type):
                kwargs[field.name] = [from_dict(item_type, item) for item in value]
            else:
                kwargs[field.name] = value
        else:
            kwargs[field.name] = value
    
    return cls(**kwargs)

@dataclass
class Order:
    id: int
    product: str

@dataclass
class Customer:
    name: str
    orders: list[Order]

json_str = '''
{
    "name": "Alice",
    "orders": [
        {"id": 1, "product": "Book"},
        {"id": 2, "product": "Laptop"}
    ]
}
'''

customer = from_dict(Customer, json.loads(json_str))
print(customer.orders[0].product)  # Book

Using Pydantic (Full Validation)

Pydantic provides automatic type conversion and validation, making it ideal for APIs:

pip install pydantic
from pydantic import BaseModel, EmailStr, validator
from typing import Optional

class Address(BaseModel):
    street: str
    city: str
    zip_code: str

class User(BaseModel):
    name: str
    age: int
    email: EmailStr
    address: Optional[Address] = None
    tags: list[str] = []
    
    @validator('age')
    def age_must_be_positive(cls, v):
        if v < 0:
            raise ValueError('Age must be positive')
        return v

json_str = '''
{
    "name": "Alice",
    "age": "25",
    "email": "alice@example.com",
    "address": {"street": "123 Main", "city": "NYC", "zip_code": "10001"}
}
'''

user = User.parse_raw(json_str)

print(user.age)           # 25 (converted from string to int)
print(type(user.age))     # <class 'int'>
print(user.address.city)  # NYC

Pydantic V2 Syntax

from pydantic import BaseModel, field_validator
from typing import Optional

class Product(BaseModel):
    name: str
    price: float
    in_stock: bool = True
    
    @field_validator('price')
    @classmethod
    def price_must_be_positive(cls, v):
        if v <= 0:
            raise ValueError('Price must be positive')
        return round(v, 2)

product = Product.model_validate_json('{"name": "Widget", "price": 19.999}')
print(product.price)  # 20.0 (rounded by validator)

product = Product.model_validate({"name": "Gadget", "price": 29.99})
print(product)

Output:

20.0
name='Gadget' price=29.99 in_stock=True

Using attrs Library

An alternative to dataclasses with additional features:

pip install attrs
import attr
from typing import Optional

@attr.s
class User:
    name: str = attr.ib()
    id: int = attr.ib()
    email: Optional[str] = attr.ib(default=None)

    @email.validator
    def check_email(self, attribute, value):
        if value and '@' not in value:
            raise ValueError("Invalid email")

u = User(name="Alice", id=1, email="alice@example.com")
print(u)

Output:

User(name='Alice', id=1, email='alice@example.com')

Converting Back to JSON

from dataclasses import dataclass, asdict
import json

@dataclass
class User:
    name: str
    id: int

user = User(name="Alice", id=1)

json_str = json.dumps(asdict(user))
print(json_str)  # {"name": "Alice", "id": 1}

from pydantic import BaseModel

class PydanticUser(BaseModel):
    name: str
    id: int

p_user = PydanticUser(name="Bob", id=2)
json_str = p_user.model_dump_json()  # Pydantic V2
print(json_str)  # {"name":"Bob","id":2}

Output:

{"name": "Alice", "id": 1}
{"name":"Bob","id":2}

Summary

Method Type Safety Validation Auto-Convert Best For
Dict None None No Simple scripts
SimpleNamespace None None No Quick exploration
Dataclass Hints only Manual No Internal logic
Pydantic Full Built-in Yes APIs, validation
attrs Configurable Built-in Optional Flexible schemas
Navigation