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

Sponsored

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:

  1. What you see in Python isn't what the client sees. You want created_at as an ISO 8601 string, not a datetime object.
  2. Manual conversion is brittle. Writing a to_dict() method per model works for two models, but becomes unmaintainable at ten.
  3. 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:

  1. Custom default function — passed to json.dumps() to handle unknown types.
  2. to_dict() methods — each model provides its own plain-dict representation.
  3. Model serializers — frameworks like Django REST Framework's ModelSerializer or Pydantic's model_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:

  1. Identify your custom types. List all types in your models that aren't JSON-native: datetime, UUID, Decimal, Enum, CustomClass.
  2. Create a custom default function. This function receives an object and returns a JSON-serializable representation.
  3. Pass it to json.dumps(). Every time the encoder meets an unknown type, it calls your function.
  4. Handle nested objects. Your default function recurses automatically — json will call it for any nested object it encounters.
  5. Fallback to the built-in types. For standard types, json handles 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 custom default. It's fast and transparent.
  • Third-party models you can't modify? Custom default is your only choice.
  • Building a serious API with FastAPI or Django REST Framework? Embrace Pydantic model_dump() or DRF ModelSerializer — 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 default or to_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_dict doesn't recursively convert nested members.
  • Fix: Ensure every nested object has its own to_dict, or use json.dumps(..., default=json_default) as a safety net.

4. Circular references

  • Cause: Model A references Model B, and B references A.
  • Fix: Use default that detects already-seen objects or design your to_dict to 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: json doesn't know how to handle Enum.
  • Fix: Use enum_obj.value explicitly, or handle in default:
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 DetachedInstanceError or includes unexpected data.
  • Fix: Use explicit schema projections in your to_dict or 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 datetime and UUID aren't JSON-native types — always include explicit handlers in your default function or to_dict.
  • Relying on str() fallback in custom encoders; it masks errors and produces ambiguous output like "<User object at 0x7f...>".
  • Not handling Enum — serializing an Enum member directly raises TypeError; always use .value.
  • Assuming json.dumps() will recurse into nested objects automatically; it only calls default on unknown types, not on dicts/lists containing them.

Variations

  1. Use Pydantic V2's model_dump(mode='json') or model_dump_json() for automatic ISO timestamps and UUID strings.
  2. In Django REST Framework, define a ModelSerializer to serialize querysets directly with serializer.data and render as JSON.
  3. For pure CLI tools, use dataclasses.asdict() and then the built-in json.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 datetime to 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, and Enum explicitly.
  • Use to_dict() for fine-grained control; custom default when 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.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.