JSON Encoding Custom Objects in Python: Complete Custom Serialization and Deserialization Guide

JSON encoding custom objects in python

The moment I moved past toy examples and started serializing real application objects — custom classes representing users, orders, geometric shapes, whatever the domain called for — Python’s json module stopped being simple. json.dumps() works beautifully for dicts, lists, and primitives, but the second I hand it a custom object, it throws a TypeError. Getting comfortable with encoding and decoding custom objects properly took some trial and error on my part, and this guide is everything I wish someone had explained to me up front.

Why Custom Objects Break json.dumps() by Default

The json module’s encoder only knows how to handle a fixed set of Python types natively: dict, list, tuple, str, int, float, bool, and None. It has no built-in knowledge of your User class or Point class — there’s no universal, unambiguous way to turn an arbitrary object into JSON, since JSON has no concept of classes, methods, or object identity. When the encoder hits something it doesn’t recognize, it raises a TypeError.

import json

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(3, 4)

try:
    json.dumps(p)
except TypeError as e:
    print(f"Error: {e}")

Output:

Error: Object of type Point is not JSON serializable

Method 1: Converting to a Dict Manually

The simplest approach I use for small projects is giving the class a to_dict() method and serializing that instead of the object directly.

import json

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def to_dict(self):
        return {"x": self.x, "y": self.y}

p = Point(3, 4)
print(json.dumps(p.to_dict()))

Output:

{"x": 3, "y": 4}

This is explicit and easy to understand, but it means remembering to call .to_dict() everywhere, which doesn’t scale well once objects are nested inside lists or other objects.

Method 2: Using the default Parameter

A more powerful approach is passing a custom function to the default parameter of json.dumps(). This function is called automatically whenever the encoder encounters an object it doesn’t know how to serialize.

import json

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

def point_encoder(obj):
    if isinstance(obj, Point):
        return {"x": obj.x, "y": obj.y}
    raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")

points = [Point(1, 2), Point(3, 4)]
print(json.dumps(points, default=point_encoder))

Output:

[{"x": 1, "y": 2}, {"x": 3, "y": 4}]

Notice this works even with a list of custom objects — the default function gets called once per unrecognized object, wherever it appears in the structure, no matter how deeply nested.

Method 3: Subclassing JSONEncoder

For reusable, larger-scale encoding logic, I subclass json.JSONEncoder and override its default() method. This is my go-to approach in production code because it packages the encoding logic into something importable and testable.

import json

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

class CustomEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, Point):
            return {"x": obj.x, "y": obj.y, "__type__": "Point"}
        return super().default(obj)

data = {"origin": Point(0, 0), "destination": Point(5, 5)}
print(json.dumps(data, cls=CustomEncoder, indent=2))

Output:

{
  "origin": {
    "x": 0,
    "y": 0,
    "__type__": "Point"
  },
  "destination": {
    "x": 5,
    "y": 5,
    "__type__": "Point"
  }
}

I added a "__type__" marker here deliberately — it’s a common convention that becomes essential when I need to decode the JSON back into the original Python object later, since JSON itself has no way of remembering what class a piece of data came from.

Deserialization: Turning JSON Back Into Custom Objects

Encoding is only half the story. Once I’ve serialized custom objects, I usually need to reconstruct them from JSON at some point — reading a config file back in, or receiving a JSON payload from an API. This is where object_hook comes in.

import json

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point({self.x}, {self.y})"

def point_decoder(dct):
    if dct.get("__type__") == "Point":
        return Point(dct["x"], dct["y"])
    return dct

json_str = '{"origin": {"x": 0, "y": 0, "__type__": "Point"}, "label": "start"}'
result = json.loads(json_str, object_hook=point_decoder)

print(result)
print(type(result["origin"]))

Output:

{'origin': Point(0, 0), 'label': 'start'}
<class '__main__.Point'>

object_hook is called on every JSON object (dict) as it’s parsed, from the innermost nested objects outward. This is the internal mechanism worth understanding: the JSON parser builds the structure bottom-up, so by the time object_hook sees an outer dict, any nested dicts it contains have already been passed through the hook and potentially converted into custom objects.

Internal Working: How default() and object_hook Fit Into the Encode/Decode Cycle

For encoding, json.dumps() performs a recursive descent through the object structure. At each node, it checks whether the value’s type is one of the natively supported types. If not, it calls the default function, which must return something that is natively serializable (typically a dict). Critically, the value returned by default is then itself recursively processed — so if your default function returns an object that still isn’t serializable, you get a TypeError again, or infinite conceptual recursion if it returns itself.

For decoding, json.loads() parses the raw text into nested Python dicts and lists first, following JSON’s own grammar rules, and then — if object_hook is provided — invokes it on every dict as the parser completes it, working from the deepest nested objects up to the outermost one. This bottom-up order is why nested custom objects “just work” without extra effort: the innermost Point gets reconstructed first, and by the time the outer dict’s hook runs, it already contains a real Point object rather than a plain dict.

Handling More Complex Types: datetime, Decimal, and Enums

These come up constantly in real applications, so I keep a general-purpose encoder around:

import json
from datetime import datetime, date
from decimal import Decimal
from enum import Enum

class Status(Enum):
    ACTIVE = "active"
    INACTIVE = "inactive"

class AppEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, (datetime, date)):
            return obj.isoformat()
        if isinstance(obj, Decimal):
            return float(obj)
        if isinstance(obj, Enum):
            return obj.value
        if hasattr(obj, '__dict__'):
            return obj.__dict__
        return super().default(obj)

data = {
    "created": datetime(2026, 7, 30, 10, 0),
    "price": Decimal("19.99"),
    "status": Status.ACTIVE
}

print(json.dumps(data, cls=AppEncoder, indent=2))

Output:

{
  "created": "2026-07-30T10:00:00",
  "price": 19.99,
  "status": "active"
}

The hasattr(obj, '__dict__') fallback near the end is a convenient catch-all for arbitrary custom objects, since most regular Python class instances store their attributes in __dict__ — but I’m careful with this, since it will also expose private/internal attributes unless I filter them out deliberately.

Performance Considerations

Custom encoders add a function call for every unsupported type encountered, which is negligible for typical application objects but can add measurable overhead when serializing very large collections of custom objects (tens of thousands or more). In those cases, I’ve found it faster to pre-convert objects to plain dicts in a single batch pass (e.g., with a list comprehension calling to_dict()) and then run the fast, native json.dumps() on the resulting plain structure, rather than relying on default being invoked repeatedly during encoding.

import json

class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price

    def to_dict(self):
        return {"name": self.name, "price": self.price}

products = [Product(f"item_{i}", i * 1.5) for i in range(50000)]
plain_data = [p.to_dict() for p in products]
json_output = json.dumps(plain_data)  # faster than relying on default= for every item

Common Mistakes I’ve Made

  • Forgetting to call super().default(obj) for unhandled types in a custom encoder, which swallows the original, more descriptive TypeError message.
  • Returning something still unserializable from default(), causing confusing recursive errors.
  • Assuming object_hook runs top-down — it doesn’t, it runs bottom-up, which matters when your decoding logic depends on nested objects already being converted.
  • Losing type information on the round trip by not including a type marker like "__type__", then being unable to tell whether a decoded dict was originally a plain dict or a custom object.
  • Mutating shared default arguments inside to_dict() methods, a classic Python gotcha unrelated to JSON but that shows up often in serialization code.

Real-World Use Cases

  1. API responses in web frameworks where domain model objects (users, orders, products) need to become JSON.
  2. Caching complex Python objects in Redis or similar stores that only accept strings.
  3. Config files that need custom types like Path objects or enums represented cleanly.
  4. Cross-language data exchange, where a Python service needs to hand off structured data to a JavaScript or Java service that has no concept of Python classes.

FAQs

What’s the difference between default and object_hook? default is used during encoding (Python → JSON) to handle objects the encoder doesn’t recognize. object_hook is used during decoding (JSON → Python) to reconstruct custom objects from parsed dicts.

Can I encode and decode without subclassing JSONEncoder? Yes — the default parameter to json.dumps() works for one-off cases without needing a full subclass. I only subclass when I want reusable, importable encoding logic.

How do I know what type a decoded object should become? Typically by including a type marker key (like "__type__") in the encoded JSON, which your object_hook checks for.

Does JSON support object references or circular references? No. JSON has no concept of object identity or references, so circular references between custom objects will cause infinite recursion during encoding unless you handle them manually.

Is there a faster alternative for custom object serialization? For performance-critical, Python-to-Python serialization, pickle is faster and preserves types automatically, but it isn’t safe for untrusted data and isn’t cross-language compatible the way JSON is.

Summary

Serializing custom objects to JSON in Python requires bridging JSON’s limited native type system with Python’s much richer object model. The default parameter and JSONEncoder subclassing handle the encoding side, while object_hook handles reconstructing objects on decode — and understanding the bottom-up order in which object_hook runs explains why nested custom objects reconstruct correctly without extra plumbing. Once I built a couple of reusable encoder/decoder classes for my common types (datetimes, decimals, enums, domain objects), custom JSON serialization stopped being a repeated source of TypeError debugging and became a solved problem I could reuse across projects.

References

Total
0
Shares

Leave a Reply

Previous Post
Formatting JSON output in python

Formatting JSON Output in Python: Complete JSON Pretty Printing and Output Customization Guide

Next Post
Creating JSON from Python dict in python

Creating JSON from Python Dict: Complete JSON Serialization and Data Conversion Implementation Guide

Related Posts