How to customize JSON encoding with json.JSONEncoder in Python

How to customize JSON encoding with json.JSONEncoder in Python

Python’s built-in json module is convenient, but it has clear boundaries. It works well with simple data types: strings, numbers, lists, dictionaries, booleans, and None. These are the basic building blocks of JSON, so the default encoder handles them without fuss.

However, when you try to serialize anything outside these types—say, a Python object, a datetime, or a custom class instance—the default encoder hits a wall. It raises a TypeError complaining it doesn’t know how to convert the object into JSON. This is because the encoder only knows how to handle the standard JSON-compatible types, and it has no built-in way to understand your domain-specific classes or more complex data structures.

For example, try this:

import json
from datetime import datetime

data = {
  "event": "meeting",
  "date": datetime.now()
}

json.dumps(data)

This will throw an error like:

TypeError: Object of type datetime is not JSON serializable

The encoder simply can’t flatten a datetime object into a JSON string. It expects you to convert it manually to a format it understands—usually an ISO 8601 string or a timestamp.

Another limitation is with custom classes. Ponder a simple class:

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

p = Point(1, 2)
json.dumps(p)

This raises the same type of error, because Point instances aren’t inherently serializable. The JSON encoder doesn’t peek inside objects to guess how to represent them; it only serializes known types.

At a glance, that’s frustrating. But it’s also a good design choice. JSON is meant to be a lightweight, language-agnostic format. If the encoder tried to guess how to serialize every object, you’d quickly run into inconsistencies and ambiguous representations.

The standard library’s approach is to keep the encoder simple and let you handle the specifics. You do the work of transforming your objects into something JSON-friendly—strings, dicts, lists. This keeps the serialization explicit and predictable, which especially important for debugging and interoperability.

That said, the json module offers a way to extend the encoder. It lets you subclass json.JSONEncoder and override its default method. This method is called for objects the encoder doesn’t know how to serialize. By customizing it, you can instruct Python how to convert your objects into standard JSON types.

building your own encoder for custom data types

To build a custom encoder, you subclass json.JSONEncoder and override the default method. This method receives the object that the standard encoder failed to serialize. Your job is to return something JSON serializable—usually a dictionary or string—that represents that object.

Here’s how you might handle the datetime example by converting it to an ISO 8601 string:

import json
from datetime import datetime

class DateTimeEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, datetime):
            return obj.isoformat()
        return super().default(obj)

data = {
    "event": "meeting",
    "date": datetime.now()
}

print(json.dumps(data, cls=DateTimeEncoder))

This prints the JSON string with the date encoded as a string:

{"event": "meeting", "date": "2024-04-27T15:42:10.123456"}

The key insight is that your default method should only handle the types you know about. For anything else, you should defer to the superclass’s default method, which will raise the usual TypeError if it can’t serialize the object.

Now, ponder the Point class. You might want to encode it as a dictionary with x and y keys. Here’s how the encoder could look:

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

p = Point(1, 2)
print(json.dumps(p, cls=PointEncoder))

This will output:

{"x": 1, "y": 2}

By doing this, you make your custom types transparent to JSON consumers. They get a simple, predictable structure that can easily be interpreted on the other side.

If you have many custom types, you can extend this pattern by checking for multiple classes in the default method. Alternatively, you could define a method on your classes, like to_json or to_dict, and call it from default. This keeps serialization logic encapsulated where it belongs—in the class itself.

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

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

class CustomEncoder(json.JSONEncoder):
    def default(self, obj):
        if hasattr(obj, "to_dict"):
            return obj.to_dict()
        if isinstance(obj, datetime):
            return obj.isoformat()
        return super().default(obj)

p = Point(3, 4)
data = {
    "point": p,
    "created": datetime.now()
}

print(json.dumps(data, cls=CustomEncoder))

This produces a JSON string where both Point and datetime objects are serialized properly, using their own serialization logic if available.

Subclassing the encoder is the cleanest way to keep your serialization logic reusable and separate from your business logic. It also integrates seamlessly with existing code that expects a json.dumps call, simply by passing your encoder class as the cls argument.

Source: https://www.pythonfaq.net/how-to-customize-json-encoding-with-json-jsonencoder-in-python/


You might also like this video