Introduction
Python's json module converts JSON strings to Python dictionaries by default, but often you want to work with proper objects that have attribute access (obj.name) instead of dictionary access (obj["name"]). There are several approaches: using json.loads() with an object_hook, types.SimpleNamespace, dataclasses, Pydantic models, or the json.JSONDecoder class. The right choice depends on whether you need type validation, nested objects, or just simple attribute access.
Basic JSON to Dictionary
1import json
2
3json_string = '{"name": "Alice", "age": 30, "email": "[email protected]"}'
4
5# Default: returns a dictionary
6data = json.loads(json_string)
7print(data["name"]) # Alice
8print(type(data)) # <class 'dict'>
Method 1: SimpleNamespace with object_hook
types.SimpleNamespace provides attribute-style access with minimal boilerplate.
1import json
2from types import SimpleNamespace
3
4json_string = '{"name": "Alice", "age": 30, "address": {"city": "NYC", "zip": "10001"}}'
5
6# object_hook is called for every JSON object (including nested ones)
7obj = json.loads(json_string, object_hook=lambda d: SimpleNamespace(**d))
8
9print(obj.name) # Alice
10print(obj.age) # 30
11print(obj.address.city) # NYC — nested objects work automatically
12
13# Convert back to dict
14print(vars(obj))
15# {'name': 'Alice', 'age': 30, 'address': namespace(city='NYC', zip='10001')}
Method 2: Custom Class with object_hook
1import json
2
3class User:
4 def __init__(self, name, age, email=None, **kwargs):
5 self.name = name
6 self.age = age
7 self.email = email
8 # Store unexpected fields
9 for key, value in kwargs.items():
10 setattr(self, key, value)
11
12 def __repr__(self):
13 return f"User(name={self.name!r}, age={self.age})"
14
15json_string = '{"name": "Alice", "age": 30, "email": "[email protected]"}'
16
17user = json.loads(json_string, object_hook=lambda d: User(**d))
18print(user) # User(name='Alice', age=30)
19print(user.email) # [email protected]
Method 3: Dataclasses (Python 3.7+)
1import json
2from dataclasses import dataclass
3
4@dataclass
5class Address:
6 city: str
7 zip: str
8
9@dataclass
10class User:
11 name: str
12 age: int
13 email: str
14 address: Address = None
15
16json_string = '''
17{
18 "name": "Alice",
19 "age": 30,
20 "email": "[email protected]",
21 "address": {"city": "NYC", "zip": "10001"}
22}
23'''
24
25data = json.loads(json_string)
26
27# Manual nested conversion
28address = Address(**data.pop("address")) if "address" in data else None
29user = User(**data, address=address)
30
31print(user) # User(name='Alice', age=30, ...)
32print(user.address.city) # NYC
Method 4: Pydantic (Recommended for Validation)
Pydantic provides automatic type coercion, validation, and nested model parsing.
1from pydantic import BaseModel
2
3class Address(BaseModel):
4 city: str
5 zip: str
6
7class User(BaseModel):
8 name: str
9 age: int
10 email: str
11 address: Address | None = None
12
13json_string = '{"name": "Alice", "age": "30", "email": "[email protected]", "address": {"city": "NYC", "zip": "10001"}}'
14
15# Pydantic parses JSON, coerces types, validates, and builds nested objects
16user = User.model_validate_json(json_string)
17
18print(user.name) # Alice
19print(user.age) # 30 (coerced from string "30" to int)
20print(user.address.city) # NYC
21
22# Convert back to JSON
23print(user.model_dump_json())
24
25# Validation error example
26try:
27 bad = User.model_validate_json('{"name": "Bob"}')
28except Exception as e:
29 print(e) # age field required, email field required
Method 5: json.JSONDecoder Subclass
1import json
2from types import SimpleNamespace
3
4class ObjectDecoder(json.JSONDecoder):
5 def __init__(self):
6 super().__init__(object_hook=self._decode_object)
7
8 def _decode_object(self, d):
9 return SimpleNamespace(**d)
10
11decoder = ObjectDecoder()
12obj = decoder.decode('{"name": "Alice", "items": [1, 2, 3]}')
13print(obj.name) # Alice
14print(obj.items) # [1, 2, 3]
Reading JSON from Files
1import json
2from types import SimpleNamespace
3
4# From file
5with open("data.json", "r") as f:
6 obj = json.load(f, object_hook=lambda d: SimpleNamespace(**d))
7
8# From URL
9import urllib.request
10with urllib.request.urlopen("https://api.example.com/user/1") as response:
11 obj = json.loads(response.read(), object_hook=lambda d: SimpleNamespace(**d))
Common Pitfalls
Nested objects not converted: A simple User(**data) only converts the top level. Nested JSON objects remain as dicts. Use object_hook (which recurses automatically) or manually convert nested structures.
JSON keys that are not valid Python identifiers: JSON keys like "first-name" or "class" cannot be used as Python attributes directly. SimpleNamespace allows getattr(obj, "first-name") but not obj.first-name. Use __dict__ access or rename keys during parsing.
Type mismatches with dataclasses: Dataclasses do not coerce types — passing a string "30" to an int field stores the string without error. Use Pydantic if you need automatic type coercion and validation.
object_hook called for every nested object: The object_hook function is called bottom-up for every JSON object, including nested ones. If your hook creates a specific class (e.g., User), nested objects will also be passed to the same hook, causing errors. Check the dict keys inside the hook to determine which class to instantiate.
Losing JSON arrays as Python lists: object_hook only transforms JSON objects (curly braces), not JSON arrays (square brackets). Arrays are converted to Python lists with their elements processed individually. This is usually correct but can be surprising if you expect arrays to become custom objects.
Summary
json.loads() returns dicts by default — use object_hook to convert to objects
SimpleNamespace with object_hook is the simplest approach for attribute access on nested JSON
Dataclasses provide structure but require manual nested conversion
Pydantic provides automatic validation, type coercion, and nested parsing — best for production APIs
object_hook is called recursively for all nested JSON objects automatically