Serialize Models to JSON
Serialize models to JSON outputs — Python web development tutorial, lesson 41. Learn the core concept, hands-on steps, and troubleshooting in this focused guide.
Focus: serialize models to json outputs
You've built a clean data model, but the moment your API returns objects directly, you get errors like TypeError: Object of type User is not JSON serializable. This is the classic serialization pain point in Python web development: your in-memory model is full of rich types — datetimes, UUIDs, nested relationships — and JSON simply can't speak that language. In this lesson, you'll learn exactly how to serialize models to JSON outputs reliably, whether you're building a REST API, a serverless function, or a background job that needs to emit structured data.
The problem this lesson solves
Every Python web application eventually hits the same wall: json.dumps() fails on custom objects. Consider a simple User model:
from dataclasses import dataclass
from datetime import datetime
@dataclass
class User:
id: int
name: str
created_at: datetime
user = User(id=1, name="Alice", created_at=datetime.now())
import json
print(json.dumps(user))
Expected output:
TypeError: Object of type User is not JSON serializable
This isn't a rare edge case — it's the default behavior. JSON has only a handful of data types (string, number, boolean, null, array, object), while your models are full of Python-specific types like datetime, Decimal, UUID, and custom classes. The problem is threefold:
- What you see in Python isn't what the client sees. You want
created_atas an ISO 8601 string, not adatetimeobject. - Manual conversion is brittle. Writing a
to_dict()method per model works for two models, but becomes unmaintainable at ten. - Consistency matters. One API returns dates as
"2025-03-14T12:30:00", another returns"2025-03-14 12:30:00"— clients will hate you.
Without a systematic approach, you'll end up with ad-hoc json.dumps() calls, custom encoders, and a mess of dict comprehensions scattered across your codebase.
Why this matters now: You're at lesson 41 in the Python web development track. You already know how to build HTTP routes and handle requests. Serialization is the bridge between your domain logic and the JSON your API returns. Get this wrong, and every endpoint becomes a debugging nightmare.
Core concept / mental model
Serialization is the process of converting an in-memory object (like a User instance) into a format that can be stored or transmitted — in our case, JSON. Deserialization is the reverse:
Python object → JSON string → Python object
Think of it as a translation layer. Your model speaks Python, JSON speaks a universal subset. The serializer's job is to translate faithfully and consistently.
The serialization pipeline
The standard library json module only understands basic types. When it meets a custom object, it raises a TypeError. The solution is to teach json how to handle your types — and that's where three main approaches come in:
- Custom
defaultfunction — passed tojson.dumps()to handle unknown types. to_dict()methods — each model provides its own plain-dict representation.- Model serializers — frameworks like Django REST Framework's
ModelSerializeror Pydantic'smodel_dump().
All three aim for the same goal: a deterministic, type-safe mapping from Python objects to JSON-compatible data.
A word on "JSON outputs"
When we say "serialize models to JSON outputs," we mean the complete path: from model instance → JSON string → HTTP response body. In practice, this often involves not just the object itself but also nested relationships, lists of objects, and metadata like timestamps.
How it works step by step
Let's trace the serialization flow with a custom encoder approach:
- Identify your custom types. List all types in your models that aren't JSON-native:
datetime,UUID,Decimal,Enum,CustomClass. - Create a custom
defaultfunction. This function receives an object and returns a JSON-serializable representation. - Pass it to
json.dumps(). Every time the encoder meets an unknown type, it calls your function. - Handle nested objects. Your
defaultfunction recurses automatically —jsonwill call it for any nested object it encounters. - Fallback to the built-in types. For standard types,
jsonhandles them natively; your function only fires for the rest.
Example: custom encoder
import json
from datetime import datetime, date
from uuid import UUID, uuid4
from decimal import Decimal
def json_default(obj):
# Handle common types
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, UUID):
return str(obj)
if isinstance(obj, Decimal):
return float(obj)
# Fallback to repr for anything else
return str(obj)
class Order:
def __init__(self, order_id: UUID, amount: Decimal, placed_at: datetime):
self.order_id = order_id
self.amount = amount
self.placed_at = placed_at
order = Order(order_id=uuid4(), amount=Decimal("19.99"), placed_at=datetime.now())
# Use the custom default
json_str = json.dumps(order, default=json_default)
print(json_str)
Expected output (timestamps vary):
{"order_id": "8f3e6a4e-8f2e-4b2a-9b3a-9a2b3c4d5e6f", "amount": 19.99, "placed_at": "2025-03-14T12:30:00.123456"}
Adding a to_dict() method
For more control, you can define a to_dict() method directly on your model:
from dataclasses import dataclass, asdict
from datetime import datetime
from typing import List
@dataclass
class Address:
street: str
city: str
@dataclass
class User:
id: int
name: str
email: str
created_at: datetime
address: Address
roles: List[str]
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"email": self.email,
"created_at": self.created_at.isoformat(),
"address": self.address.to_dict() if hasattr(self.address, "to_dict") else vars(self.address),
"roles": self.roles,
}
# Use it
user = User(1, "Alice", "alice@example.com", datetime.now(), Address("123 Main St", "Springfield"), ["admin", "user"])
print(json.dumps(user.to_dict(), indent=2))
Expected output:
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"created_at": "2025-03-14T12:30:00.123456",
"address": {"street": "123 Main St", "city": "Springfield"},
"roles": ["admin", "user"]
}
Framework-level serializers
If you're already using a web framework, you often get serialization for free:
# FastAPI + Pydantic
from fastapi import FastAPI
from pydantic import BaseModel
from datetime import datetime
class UserOut(BaseModel):
id: int
name: str
created_at: datetime
app = FastAPI()
@app.get("/users/{user_id}")
def get_user(user_id: int):
# In a real app, you'd get this from a DB
user_data = {"id": user_id, "name": "Alice", "created_at": datetime.now()}
return UserOut(**user_data) # FastAPI serializes automatically
Hands-on walkthrough
Let's build a complete, runnable example that you can try right now. We'll create a small JSON serializer utility that handles a realistic set of model types.
Step 1: Define your models
# models.py
from dataclasses import dataclass, field
from datetime import datetime
from uuid import UUID, uuid4
from typing import List, Optional
from enum import Enum
class Status(Enum):
ACTIVE = "active"
INACTIVE = "inactive"
@dataclass
class Product:
id: UUID
name: str
price: float
stock: int
@dataclass
class OrderItem:
product: Product
quantity: int
@dataclass
class Order:
order_id: UUID
customer_name: str
items: List[OrderItem]
placed_at: datetime
status: Status
def to_dict(self):
return {
"order_id": str(self.order_id),
"customer_name": self.customer_name,
"items": [item.to_dict() for item in self.items],
"placed_at": self.placed_at.isoformat(),
"status": self.status.value,
}
@dataclass
class OrderItem:
product: Product
quantity: int
def to_dict(self):
return {
"product": {
"id": str(self.product.id),
"name": self.product.name,
"price": self.product.price,
"stock": self.product.stock
},
"quantity": self.quantity
}
Wait, there's a subtle bug — we defined OrderItem before Product. Let's fix the code to run properly:
# models.py (fixed)
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID, uuid4
from typing import List
from enum import Enum
class Status(Enum):
ACTIVE = "active"
INACTIVE = "inactive"
@dataclass
class Product:
id: UUID
name: str
price: float
stock: int
def to_dict(self):
return {
"id": str(self.id),
"name": self.name,
"price": self.price,
"stock": self.stock,
}
@dataclass
class OrderItem:
product: Product
quantity: int
def to_dict(self):
return {
"product": self.product.to_dict(),
"quantity": self.quantity,
}
@dataclass
class Order:
order_id: UUID
customer_name: str
items: List[OrderItem]
placed_at: datetime
status: Status
def to_dict(self):
return {
"order_id": str(self.order_id),
"customer_name": self.customer_name,
"items": [item.to_dict() for item in self.items],
"placed_at": self.placed_at.isoformat(),
"status": self.status.value,
}
Step 2: Write the serializer and test it
# main.py
import json
from datetime import datetime
from uuid import uuid4
from models import Product, Order, OrderItem, Status
# Create instance
product = Product(id=uuid4(), name="Keyboard", price=89.99, stock=10)
item = OrderItem(product=product, quantity=2)
order = Order(
order_id=uuid4(),
customer_name="Alice",
items=[item],
placed_at=datetime.now(),
status=Status.ACTIVE
)
# Serialize with to_dict
json_str = json.dumps(order.to_dict(), indent=2)
print(json_str)
Expected output (UUIDs differ):
{
"order_id": "1f90a4e2-8b7e-4c2a-9f3d-5e1a2b3c4d5e",
"customer_name": "Alice",
"items": [
{
"product": {
"id": "3b2a1c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"name": "Keyboard",
"price": 89.99,
"stock": 10
},
"quantity": 2
}
],
"placed_at": "2025-03-14T12:30:00.123456",
"status": "active"
}
Step 3: Handle the case where you can't modify the model
Sometimes you don't control the model (e.g., third-party library). Use a custom default:
import json
from datetime import datetime, date
from uuid import UUID
from decimal import Decimal
def json_default(obj):
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, UUID):
return str(obj)
if isinstance(obj, Decimal):
return float(obj)
if hasattr(obj, "to_dict"):
return obj.to_dict()
raise TypeError(f"Type {type(obj)} not serializable")
# Suppose you have a third-party class
class ThirdParty:
def __init__(self, value):
self.value = value
tp = ThirdParty("hello")
# Without default this fails
json.dumps({"data": tp}) # TypeError
# With default it works
print(json.dumps({"data": tp}, default=json_default))
Expected output: {"data": "hello"} (fallback to str).
Step 4: Put it together in a Flask route
from flask import Flask, jsonify
from datetime import datetime
app = Flask(__name__)
def to_json_response(data, status=200):
return app.response_class(
response=json.dumps(data, default=json_default),
status=status,
mimetype="application/json"
)
@app.route("/health")
def health():
return to_json_response({"status": "ok", "time": datetime.now()})
if __name__ == "__main__":
app.run()
Run it and hit http://localhost:5000/health — you'll see a proper JSON response with the current timestamp in ISO format.
Compare options / when to choose what
Here's a practical comparison of the three main approaches:
| Approach | Pros | Cons | Best for |
|---|---|---|---|
Custom default function |
Centralized, handles any type, no model changes | Can become a dumping ground, harder to control per-field | Quick fixes, third-party models, mixed data |
to_dict() methods |
Full control, explicit, easy to test | Repetitive across models, easy to forget updates when fields change | Small projects, simple models, when you want fine-grained control |
| Framework serializers (Pydantic/DRF) | Declarative, validation, nested relations, auto-generated OpenAPI | Adds dependency, learning curve, sometimes overkill | Large APIs, production apps, when you need validation and docs |
When to choose what:
- Prototyping? Use
to_dict()or even a quick customdefault. It's fast and transparent. - Third-party models you can't modify? Custom
defaultis your only choice. - Building a serious API with FastAPI or Django REST Framework? Embrace Pydantic
model_dump()or DRFModelSerializer— they handle relationships, field renaming, and validation automatically.
Pro tip: Even if you use a framework serializer, you can still keep a
to_dict()on your core models. It makes unit testing easier and decouples your domain logic from the web layer.
Troubleshooting & edge cases
1. "Object of type X is not JSON serializable"
- Cause: Your custom types aren't handled in
defaultorto_dict. - Fix: Check your encoder. Add explicit handling for
datetime,UUID,Decimal,Enum, and any custom classes you use.
2. Datetime serialized as a string, but client expects a timestamp
- Cause:
isoformat()returns a string; some clients expect epoch milliseconds. - Fix: Convert intentionally:
def datetime_to_epoch(dt):
return int(dt.timestamp() * 1000)
3. Nested objects still fail even with to_dict
- Cause: Your
to_dictdoesn't recursively convert nested members. - Fix: Ensure every nested object has its own
to_dict, or usejson.dumps(..., default=json_default)as a safety net.
4. Circular references
- Cause: Model A references Model B, and B references A.
- Fix: Use
defaultthat detects already-seen objects or design yourto_dictto avoid cycles by only including IDs.
def json_default(obj):
if isinstance(obj, Reference):
return {"id": str(obj.id)} # not the full object
# ...
5. Enum serialization
- Cause:
jsondoesn't know how to handleEnum. - Fix: Use
enum_obj.valueexplicitly, or handle indefault:
if isinstance(obj, Enum):
return obj.value
6. Decimal serialization loses precision
- Cause: Default
float()representation may round. - Fix: If precision matters, serialize as string:
if isinstance(obj, Decimal):
return str(obj) # "19.99" not 19.99
7. Object contains lazy-loaded ORM attributes
- Cause: Accessing unloaded relationships triggers
DetachedInstanceErroror includes unexpected data. - Fix: Use explicit schema projections in your
to_dictor pre-fetch relationships in the query.
What you learned & what's next
You now have a clear, repeatable process to serialize models to JSON outputs: you understand the core problem, the mental model of a translation layer, and the step-by-step mechanics of custom encoders, to_dict methods, and framework serializers. You can handle datetime, UUID, Decimal, Enum, nested objects, and even third-party classes. You've also seen how to troubleshoot the most common pitfalls.
Next in the Python web development track, you'll build on this by learning how to validate incoming JSON payloads — the mirror image of serialization. You'll take raw JSON from requests and turn it into validated model instances. This completes the full loop: model → JSON output (this lesson) and JSON input → model (next).
Keep this lesson handy as a reference — you'll use it in every API you build from here on.
Practice recap
Now it's your turn: create a small Product and Order model (or adapt your current project) and implement a to_dict() method that handles a nested relationship and a datetime field. Then write a Flask or pure json.dumps() call to output the JSON. For extra practice, rewrite the same logic using Pydantic's model_dump() and compare the output formats.
Common mistakes
- Forgetting that
datetimeandUUIDaren't JSON-native types — always include explicit handlers in yourdefaultfunction orto_dict. - Relying on
str()fallback in custom encoders; it masks errors and produces ambiguous output like"<User object at 0x7f...>". - Not handling
Enum— serializing anEnummember directly raisesTypeError; always use.value. - Assuming
json.dumps()will recurse into nested objects automatically; it only callsdefaulton unknown types, not on dicts/lists containing them.
Variations
- Use Pydantic V2's
model_dump(mode='json')ormodel_dump_json()for automatic ISO timestamps and UUID strings. - In Django REST Framework, define a
ModelSerializerto serialize querysets directly withserializer.dataand render as JSON. - For pure CLI tools, use
dataclasses.asdict()and then the built-injson.dumps()— it's simpler but doesn't solve nested custom objects.
Real-world use cases
- REST API response in FastAPI: return a Pydantic model, and FastAPI auto-serializes
datetimeto ISO 8601 strings. - Background job (e.g., Celery) that sends a result: serialize an order model to JSON to store in Redis.
- Serverless function (e.g., AWS Lambda) that returns an API Gateway response: convert your model to JSON with a custom encoder before returning
body.
Key takeaways
- Different approaches to serialize models to JSON outputs: custom
default,to_dict(), and framework serializers. - Always handle
datetime,UUID,Decimal, andEnumexplicitly. - Use
to_dict()for fine-grained control; customdefaultwhen you can't modify the model. - Framework serializers (Pydantic/DRF) are the production-grade choice for complex APIs.
- Watch out for circular references, precision loss, and lazy-loaded ORM attributes.
- Troubleshoot by checking the exact error type and adding handlers iteratively.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.