It usually happens right after a deploy. Everything worked in dev. Then one customer shows up with one weird record, and your API starts throwing 500s because a Decimal took an unexpected code path.
You hit an endpoint that worked fine all week, and suddenly you get:
TypeError: Object of type Decimal is not JSON serializable
Python isn't being dramatic. It's being literal. Computers are literal and petty. JSON has a tiny set of types it supports, and decimal.Decimal, datetime, UUID, Enum, and your nice tidy @dataclass are not on the list.
Why this error exists (and why it's not dumb)
JSON supports:
- strings
- numbers
- booleans
- null
- lists
- objects (dicts)
That's it. No money type. No timestamp type. No "this is clearly an ID" type.
Python's json module refuses to guess conversions because guessing creates bugs that hide until they get expensive. If Python silently turned Decimal("99.99") into a float, you'd ship rounding errors into billing. That's a real problem.
The quick fix that will hurt you later
People do this:
json.dumps(data, default=lambda obj: f"{obj}")
Yes, it stops the crash. It also turns your API into a grab bag of "whatever string happened to come out."
Concrete ways this breaks:
Decimal("99.99")becomes"99.99"(string). Your frontend may treat it like text, sort it wrong, or fail validation.datetimebecomes whateverstr(datetime)outputs. That's not a stable contract.UUIDbecomes a string (often fine), until someone accidentally returns"UUID('...')"via a__repr__path.Enumbecomes"Status.ACTIVE"instead of"active". Clients don't want to parse your Python names.- dataclasses become
"Order(id=..., total=...)"which is useless for APIs.
The crash isn't the real issue. The real issue is pretending JSON is "just strings" until a customer teaches your system a new way to break.
The fix: pick encoding rules once, and use them everywhere
You want a single encoder function that makes explicit choices. Not perfect choices. Consistent choices.
I like dataclasses because they reduce mystery-meat objects and because I'm tired and I enjoy having nice things. Here's how I handle the common types:
Decimal- string (keeps precision; money should not be float)datetime/date- ISO-8601 via.isoformat()UUID- stringEnum-.value- dataclass -
asdict()
import json
import decimal
import datetime
import uuid
from enum import Enum
from dataclasses import is_dataclass, asdict
def api_json_default(obj):
if isinstance(obj, decimal.Decimal):
return str(obj)
if isinstance(obj, datetime.datetime):
return obj.isoformat()
if isinstance(obj, datetime.date):
return obj.isoformat()
if isinstance(obj, uuid.UUID):
return str(obj)
if isinstance(obj, Enum):
return obj.value
if is_dataclass(obj) and not isinstance(obj, type):
return asdict(obj)
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
Use it like this:
payload = json.dumps(data, default=api_json_default)
Now the weird record doesn't crash production. Clients get consistent data.
Nested dataclasses: the nice part
asdict() is recursive. So if you have an Order dataclass that contains LineItem dataclasses, it handles the nesting. Any non-JSON types inside still flow through default= when json hits them.
Timezones: the footgun you already stepped on
Naive datetimes (no timezone info) are a footgun. .isoformat() will serialize them, but you're shipping ambiguity.
Your API has to decide:
- Require UTC and store timezone-aware datetimes, or
- Include offsets everywhere
Pick one and enforce it. Add a test. Put it in the code review checklist. This is boring work that saves you later.
If you're working with timestamps across systems or users in different time zones, storing everything in UTC and only converting at display time prevents DST bugs and scheduling chaos.
The "nuclear option": a custom JSONEncoder
You can also subclass json.JSONEncoder:
class CustomJSONEncoder(json.JSONEncoder):
def default(self, obj):
return api_json_default(obj)
json.dumps(data, cls=CustomJSONEncoder)
I call this the nuclear option because it's global and powerful. It can become a shared utility everyone is afraid to edit. Use it if you need it. Prefer default= when you can keep it local and obvious.
Final thought
Computers are literal and petty. Humans are flexible and emotional.
Define explicit, consistent encoding rules for non-JSON Python types once. Implement them everywhere using the default= parameter. Don't use string conversion shortcuts that create ambiguous data structures for API consumers.
If you want to eliminate this kind of serialization headache entirely, consider using Pydantic to handle validation and JSON parsing with built-in type coercion and clear error messages.
Reference: Python docs on encoders/decoders: https://docs.python.org/3/library/json.html#encoders-and-decoders
-Sethers