Python’s json module only knows how to write dictionaries, lists, tuples, strings, numbers, booleans and None, so a custom class needs a function that turns each object into one of those types before json.dumps() can write it. Without that function, the call fails with TypeError: Object of type Booking is not JSON serializable.
import json
from dataclasses import dataclass
from datetime import date
@dataclass
class Booking:
guest: str
nights: int
check_in: date
booking = Booking("Lokesh", 3, date(2026, 11, 2))
bad = json.dumps(booking) # TypeError: Object of type Booking is not JSON serializable
We fix the error four ways in this article, with a default function, a JSONEncoder subclass, dataclasses.asdict() and the quick __dict__ shortcut, and we compare them at the end. All of it was tested on Python 3.14.
1. A default Function
The default argument of json.dumps() and json.dump() takes a function. The json module calls it for every object it cannot write, and the function returns a serializable replacement or raises TypeError.
import json
def to_json(obj):
if isinstance(obj, Booking):
return {"guest": obj.guest, "nights": obj.nights, "check_in": obj.check_in}
if isinstance(obj, date):
return obj.isoformat()
raise TypeError(f"Cannot serialize {type(obj).__name__}")
text = json.dumps(booking, default=to_json)
# {"guest": "Lokesh", "nights": 3, "check_in": "2026-11-02"}
The date inside the dictionary goes through the same function, which is why one function handles both types. The final raise TypeError matters, because returning None for unknown types would write null and hide the bug.
2. A JSONEncoder Subclass
When the same rules apply across a project, we put them in a subclass of json.JSONEncoder and pass it with cls. Its default() method works like the function above and hands unknown types to the parent class, which raises the standard error.
class BookingEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, Booking):
return {"guest": obj.guest, "nights": obj.nights, "check_in": obj.check_in}
if isinstance(obj, date):
return obj.isoformat()
return super().default(obj)
text2 = json.dumps(booking, cls=BookingEncoder, indent=2)
{
"guest": "Lokesh",
"nights": 3,
"check_in": "2026-11-02"
}
3. Dataclasses with asdict()
For a dataclass, dataclasses.asdict() builds the dictionary for us, nested dataclasses included. Values such as date still need a default, and default=str is enough for dates and Decimal.
from dataclasses import asdict
text3 = json.dumps(asdict(booking), default=str)
# {"guest": "Lokesh", "nights": 3, "check_in": "2026-11-02"}
The shortcut default=str turns any unknown object into its string form, so it can write unreadable text such as <__main__.Guest object at 0x…> for classes we forgot. We use it only when the data contains types with a useful str().
4. The __dict__ Shortcut and Its Limits
Many answers online pass default=lambda o: o.__dict__. It works for plain classes, but it writes every attribute (private ones too), fails for classes with __slots__ and still cannot write a date.
class Guest:
def __init__(self, name, vip):
self.name = name
self.vip = vip
text4 = json.dumps(Guest("Lokesh", True), default=lambda o: o.__dict__)
# {"name": "Lokesh", "vip": true}
| Approach | Best for | Watch out for |
|---|---|---|
| default=function | One-off conversions, several types | Raise TypeError for unknown types |
| JSONEncoder subclass | Rules shared across a project | Call super().default() at the end |
| asdict() plus default=str | Dataclasses | A set field becomes a string such as “{1, 2}”, not an array |
| default=lambda o: o.__dict__ | Quick scripts | Private fields, __slots__, dates |
To turn the JSON back into Booking objects, see custom class deserialization in Python.
5. Nested Objects, Enums and Sets
Real data nests objects inside lists and dictionaries, and it often contains an Enum or a set. A single default function handles all of them, because the json module calls it again for every value it cannot write.
from enum import Enum
class Room(Enum):
SINGLE = "single"
DOUBLE = "double"
def to_json2(obj):
if isinstance(obj, Booking):
return asdict(obj)
if isinstance(obj, date):
return obj.isoformat()
if isinstance(obj, Enum):
return obj.value
if isinstance(obj, set):
return sorted(obj)
raise TypeError(f"Cannot serialize {type(obj).__name__}")
data = {"room": Room.DOUBLE, "tags": {"sea", "quiet"}, "bookings": [booking]}
text5 = json.dumps(data, default=to_json2)
# {"room": "double", "tags": ["quiet", "sea"], "bookings": [{"guest": "Lokesh", "nights": 3, "check_in": "2026-11-02"}]}
Sorting the set makes the output stable between runs, which helps in tests and diffs. Since Python 3.14, the TypeError for an unsupported value also carries notes that show where the value sits in the nested data.
6. Serializing Other Common Types
These types cause most “not JSON serializable” errors in practice.
6.1. How do we serialize a datetime?
We convert it with isoformat(), which gives a string such as “2026-11-02T09:30:00”. On the way back, datetime.fromisoformat() parses the same string.
6.2. How do we serialize a Decimal without losing precision?
We return str(obj) from the default function, so Decimal(“9.99”) becomes “9.99”. Converting to float can change the value slightly.
6.3. What about Pydantic models?
A Pydantic model has its own model_dump_json() method, which handles dates, enums and nested models. A model placed inside other data that goes to json.dumps() still needs a default function, for example one that returns obj.model_dump(mode=”json”).
7. Conclusion
A custom class becomes JSON once something converts it to a dictionary. A default function is the smallest fix, a JSONEncoder subclass keeps shared rules in one place, and asdict() does most of the work for dataclasses. For dates, ISO 8601 strings from isoformat() are the format other systems read most reliably.
8. References
Happy Learning !!