Intermediate

You have a Python object — maybe a database query result full of datetime objects, a financial report where every number is a Decimal, or an API response that mixes dataclasses with standard types — and you call json.dumps(). Python stares back at you with a TypeError: Object of type datetime is not JSON serializable. This is one of the most common Python frustrations, and it comes up in virtually every discuss.python.org Help thread that touches JSON. The built-in json module knows about strings, numbers, booleans, lists, and dicts — that is it. The moment any other Python type walks through the door, it throws its hands up.

The fix is a custom JSON encoder — a small subclass of json.JSONEncoder that you define once and reuse everywhere. It lets you teach Python exactly how to serialize any type it does not understand: format a datetime as an ISO 8601 string, turn a Decimal into a lossless string, convert a dataclass into a dict. The json module is built into Python with no extra installation needed, and the pattern for extending it is the same across Python 3.7 through 3.12+.

In this article we will cover how json.JSONEncoder works and where it fits in Python’s serialization chain, how to encode datetime and date objects correctly, how to handle Decimal without losing precision, how to serialize dataclasses and custom classes, how to extend the encoder to UUID, Enum, and set, and how to use the default function argument as a lightweight alternative to subclassing. By the end you will have a production-ready encoder you can drop into any project.

Custom JSON Encoder in Python: Quick Example

Here is the shortest path from “TypeError” to working JSON — a custom encoder that handles datetime objects in under 15 lines:

# quick_encoder.py
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 = {
    "user": "alice",
    "login_at": datetime(2026, 8, 12, 9, 30, 0),
    "score": 98,
}

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

Output:

{
  "user": "alice",
  "login_at": "2026-08-12T09:30:00",
  "score": 98
}

The key is the default() method — Python calls it whenever it encounters a type it cannot serialize. We check whether the object is a datetime, return its ISO 8601 string, and call super().default(obj) for anything else so the original TypeError is still raised on genuinely unserializable types. We pass our encoder to json.dumps() via the cls keyword argument. That is the whole pattern — the sections below show how to extend it to Decimal, dataclasses, and every other type your project throws at it.

What Is json.JSONEncoder and How Does It Work?

Python’s built-in json module uses an encoder class to convert Python objects into JSON strings. The default encoder, json.JSONEncoder, handles a fixed set of Python types. When it encounters any other type, it calls its own default() method — which by default raises a TypeError. Subclassing JSONEncoder and overriding default() is how you plug in support for additional types without touching the rest of the serialization logic.

Think of it like a hotel concierge who speaks English, French, and Spanish. When a guest speaks any of those languages, the concierge handles it directly. When a guest speaks Japanese, the concierge routes the call to a translator desk. By overriding default(), you are that translator — you decide how to convert any unknown type into something the serializer already understands.

Python TypeJSON Output (built-in)Needs Custom Encoder?
str"hello"No
int42No
float3.14No
booltrue / falseNo
NonenullNo
list, tuple[1, 2, 3]No
dict{"key": "val"}No
datetime(TypeError)Yes
date(TypeError)Yes
Decimal(TypeError)Yes
dataclass(TypeError)Yes
UUID(TypeError)Yes
Enum(TypeError)Yes
set(TypeError)Yes

The default() method receives one object at a time and must return a JSON-serializable value — a string, number, list, or dict that the encoder already knows how to handle. You can chain as many isinstance checks as you need in a single default() method, which is exactly how the combined encoder at the end of this article is built.

API Alice at JSON reception desk welcoming Python type objects
TypeError: Object of type datetime is not JSON serializable. Every API developer’s Tuesday.

Encoding datetime and date Objects

The most common cause of JSON serialization errors in Python is a datetime object. Django ORM results, SQLAlchemy rows, and Python’s datetime.now() all produce datetime objects. The safest format to serialize them to is ISO 8601 — the international standard that looks like "2026-08-12T09:30:00" — because every JSON consumer (JavaScript, Go, Rust, any SQL client) knows how to parse it.

Here is a complete encoder that handles both datetime and date objects. They are different types — a datetime has time components, a date does not — and the order you check them matters:

# datetime_encoder.py
import json
from datetime import datetime, date, timezone

class DateTimeEncoder(json.JSONEncoder):
    """Serialize datetime and date objects to ISO 8601 strings."""

    def default(self, obj):
        # Check datetime BEFORE date -- datetime is a subclass of date,
        # so checking date first would match datetime objects too early
        if isinstance(obj, datetime):
            return obj.isoformat()  # "2026-08-12T09:30:00+00:00"
        if isinstance(obj, date):
            return obj.isoformat()  # "2026-08-12"
        return super().default(obj)

# --- Example usage ---
event = {
    "title": "Python meetup",
    "event_date": date(2026, 9, 1),
    "created_at": datetime(2026, 8, 12, 9, 30, 0, tzinfo=timezone.utc),
    "updated_at": datetime(2026, 8, 12, 14, 0, 0),  # naive datetime (no tzinfo)
}

print(json.dumps(event, cls=DateTimeEncoder, indent=2))

Output:

{
  "title": "Python meetup",
  "event_date": "2026-09-01",
  "created_at": "2026-08-12T09:30:00+00:00",
  "updated_at": "2026-08-12T14:00:00"
}

The subclass ordering matters: datetime is a subclass of date, so if you check date first, every datetime will match it and you will lose the time component entirely. Always check the more specific type first. Aware datetimes (those with a tzinfo) produce an offset suffix like +00:00; naive datetimes produce no suffix. If your API consumers always expect a UTC offset, use datetime.now(tz=timezone.utc) when creating timestamps so the offset is never absent from the output.

Encoding Decimal Objects

Financial applications and any code that uses Python’s decimal.Decimal type hit the same wall. Decimal is not a subclass of float, so the JSON encoder rejects it. There are two sensible ways to serialize it: convert to float (fast but can introduce floating-point rounding errors) or convert to str (lossless but produces a JSON string instead of a number). Which you choose depends on your API contract and how precise the data needs to be.

# decimal_encoder.py
import json
from decimal import Decimal

class DecimalEncoder(json.JSONEncoder):
    """Serialize Decimal objects -- use str for financial data, float for analytics."""

    def default(self, obj):
        if isinstance(obj, Decimal):
            # Option A -- lossless string: "10.99"  (safe for money)
            return str(obj)
            # Option B -- float: 10.99  (only if rounding errors are acceptable)
            # return float(obj)
        return super().default(obj)

# --- Example usage ---
invoice = {
    "item": "Python course",
    "price": Decimal("10.99"),
    "tax": Decimal("1.099"),
    "total": Decimal("12.089"),
}

print(json.dumps(invoice, cls=DecimalEncoder, indent=2))

Output (string mode):

{
  "item": "Python course",
  "price": "10.99",
  "tax": "1.099",
  "total": "12.089"
}

Returning str(obj) preserves every digit exactly as stored — critical for invoice amounts and exchange rates where even a sub-cent rounding error compounds over thousands of transactions. If your JSON consumer is a JavaScript frontend, keep in mind that JavaScript’s parseFloat("10.99") reads a string back into a float anyway, so either representation lands in roughly the same place on the consumer side. The rule of thumb: pick str for financial APIs and ledgers, pick float for analytics payloads where a tiny rounding error is acceptable.

Debug Dee carefully measuring Decimal precision for JSON output
float(Decimal(‘0.1’)) is not 0.1. Your accounting team noticed.

Encoding Dataclasses and Custom Objects

When you work with Python 3.7+ dataclasses, you need to tell the encoder how to turn an instance into a dict. The dataclasses standard library module provides asdict(), which recursively converts a dataclass — and any nested dataclasses — into a plain dict. Once the encoder has a plain dict, it continues processing any non-standard values inside it (like Decimal or datetime fields) by calling default() on each one.

# dataclass_encoder.py
import json
import dataclasses
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal

@dataclass
class Product:
    name: str
    price: Decimal
    in_stock: bool
    last_updated: datetime

@dataclass
class Order:
    order_id: str
    customer: str
    items: list  # list of Product dataclasses

class DataclassEncoder(json.JSONEncoder):
    """Serialize dataclasses along with Decimal and datetime fields."""

    def default(self, obj):
        if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
            return dataclasses.asdict(obj)
        if isinstance(obj, datetime):
            return obj.isoformat()
        if isinstance(obj, Decimal):
            return str(obj)
        return super().default(obj)

# --- Example usage ---
order = Order(
    order_id="ORD-2026-001",
    customer="alice@example.com",
    items=[
        Product("Python Course", Decimal("10.99"), True,
                datetime(2026, 8, 1, tzinfo=timezone.utc)),
        Product("Debugging Guide", Decimal("7.50"), False,
                datetime(2026, 7, 15, tzinfo=timezone.utc)),
    ],
)

print(json.dumps(order, cls=DataclassEncoder, indent=2))

Output:

{
  "order_id": "ORD-2026-001",
  "customer": "alice@example.com",
  "items": [
    {
      "name": "Python Course",
      "price": "10.99",
      "in_stock": true,
      "last_updated": "2026-08-01T00:00:00+00:00"
    },
    {
      "name": "Debugging Guide",
      "price": "7.50",
      "in_stock": false,
      "last_updated": "2026-07-15T00:00:00+00:00"
    }
  ]
}

The guard not isinstance(obj, type) is important: dataclasses.is_dataclass() returns True for both the class itself and instances of it. Without this guard, passing the Order class (not an instance) to the encoder would trigger a confusing error. After asdict() converts the dataclass to a plain dict, the encoder recurses through it automatically — which is how the nested Decimal and datetime values in the Product fields get serialized without any extra code on your part.

Encoding UUID, Enum, and set

Three more types appear constantly in real-world APIs: UUID (used as primary keys in ORMs and databases), Enum (used for status fields and category values), and set (used for tag collections and permission sets). All three require custom handling, and all three follow the same pattern — return a JSON-native type that represents the same information:

# extended_encoder.py
import json
import uuid
from enum import Enum

class OrderStatus(Enum):
    PENDING = "pending"
    CONFIRMED = "confirmed"
    SHIPPED = "shipped"

class ExtendedEncoder(json.JSONEncoder):
    """Handles UUID, Enum, and set in addition to standard JSON types."""

    def default(self, obj):
        if isinstance(obj, uuid.UUID):
            return str(obj)  # "550e8400-e29b-41d4-a716-446655440000"
        if isinstance(obj, Enum):
            return obj.value  # The value, not "OrderStatus.CONFIRMED"
        if isinstance(obj, (set, frozenset)):
            return sorted(list(obj))  # Sorted list for deterministic output
        return super().default(obj)

# --- Example usage ---
user_record = {
    "id": uuid.UUID("550e8400-e29b-41d4-a716-446655440000"),
    "status": OrderStatus.CONFIRMED,
    "roles": {"admin", "editor", "viewer"},
}

print(json.dumps(user_record, cls=ExtendedEncoder, indent=2))

Output:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "confirmed",
  "roles": [
    "admin",
    "editor",
    "viewer"
  ]
}

For set and frozenset, calling sorted(list(obj)) rather than just list(obj) gives deterministic output — sets have no guaranteed order in Python, so without sorting you get different JSON strings for the same data, which breaks caching, checksumming, and tests that compare strings. For Enum, returning obj.value instead of str(obj) avoids leaking the class name into your API response — "confirmed" is a cleaner contract than "OrderStatus.CONFIRMED". For UUID, the canonical hyphenated string is universally understood by databases, JavaScript, and any language that has a UUID library.

Cache Katie sorting UUID tokens and Enum chips into JSON output boxes
Sets have no order. Your integration tests found out before your consumers did.

Using the default Function Argument

Subclassing JSONEncoder is the cleanest pattern for production code, but Python’s json.dumps() also accepts a default keyword argument that takes a plain callable. This is useful for one-off serialization in scripts, tests, or debugging sessions where defining a class feels like overkill.

# default_function.py
import json
from datetime import datetime, date
from decimal import Decimal

def json_default(obj):
    """Fallback serializer for use with json.dumps(default=...)."""
    if isinstance(obj, datetime):
        return obj.isoformat()
    if isinstance(obj, date):
        return obj.isoformat()
    if isinstance(obj, Decimal):
        return str(obj)
    raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")

# --- Example usage ---
data = {
    "event": "product launch",
    "date": date(2026, 9, 1),
    "revenue": Decimal("149999.99"),
    "recorded_at": datetime(2026, 8, 12, 9, 30, 0),
}

print(json.dumps(data, default=json_default, indent=2))

Output:

{
  "event": "product launch",
  "date": "2026-09-01",
  "revenue": "149999.99",
  "recorded_at": "2026-08-12T09:30:00"
}

The default function approach is simpler but slightly less flexible: it only handles the fallback path and cannot override how the encoder processes lists or dicts. The function must also raise TypeError itself for unknown types — it has no super().default() to fall back to. For consistent API-wide serialization, prefer the cls= subclass pattern. For quick scripts and tests, default=json_default is perfectly fine.

Building a Combined Production Encoder

In a real application you rarely hit just one non-standard type — you hit all of them at once. Here is a single encoder that handles every type covered in this article, ready to copy into a utils/json_encoder.py module in your project:

# utils/json_encoder.py
import json
import dataclasses
import uuid
from datetime import datetime, date
from decimal import Decimal
from enum import Enum

class AppEncoder(json.JSONEncoder):
    """
    Production-ready JSON encoder.
    Handles: datetime, date, Decimal, dataclass, UUID, Enum, set, frozenset.
    """

    def default(self, obj):
        # Dataclasses -- process before generic dict so nested types are handled
        if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
            return dataclasses.asdict(obj)
        # datetime before date -- datetime subclasses date, so order matters
        if isinstance(obj, datetime):
            return obj.isoformat()
        if isinstance(obj, date):
            return obj.isoformat()
        # Decimal -- string preserves precision; use float only if rounding is acceptable
        if isinstance(obj, Decimal):
            return str(obj)
        # UUID -- canonical hyphenated string
        if isinstance(obj, uuid.UUID):
            return str(obj)
        # Enum -- return the value, not the repr
        if isinstance(obj, Enum):
            return obj.value
        # set / frozenset -- sorted list for deterministic output
        if isinstance(obj, (set, frozenset)):
            return sorted(list(obj))
        return super().default(obj)

Use it anywhere you call json.dumps():

# usage_example.py
import json
from utils.json_encoder import AppEncoder

result = json.dumps(your_data, cls=AppEncoder, indent=2)

If you are using Django, you can set this as the global encoder in settings.py so JsonResponse uses it automatically:

# settings.py (Django 3.2+)
JSON_ENCODER = "utils.json_encoder.AppEncoder"
Sudo Sam assembling combined AppEncoder toolbox from individual type handlers
Write it once. Import it in every project for the rest of your career.

Real-Life Example: API Response Serializer for an E-Commerce Backend

Here is a practical example that puts everything together: a complete order serializer for an e-commerce API. It mixes dataclasses, Decimal prices, datetime timestamps, UUID identifiers, an Enum status, and a set of tags — exactly the kind of object graph you find in Django or FastAPI projects.

# ecommerce_serializer.py
import json
import dataclasses
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from decimal import Decimal
from enum import Enum
from typing import List

# --- Domain models ---
class OrderStatus(Enum):
    PENDING = "pending"
    CONFIRMED = "confirmed"
    SHIPPED = "shipped"

@dataclass
class LineItem:
    product_id: uuid.UUID
    name: str
    quantity: int
    unit_price: Decimal

@dataclass
class Order:
    order_id: uuid.UUID
    customer_email: str
    status: OrderStatus
    items: List[LineItem]
    created_at: datetime
    tags: set = field(default_factory=set)

# --- Production encoder ---
class AppEncoder(json.JSONEncoder):
    def default(self, obj):
        if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
            return dataclasses.asdict(obj)
        if isinstance(obj, datetime):
            return obj.isoformat()
        if isinstance(obj, Decimal):
            return str(obj)
        if isinstance(obj, uuid.UUID):
            return str(obj)
        if isinstance(obj, Enum):
            return obj.value
        if isinstance(obj, (set, frozenset)):
            return sorted(list(obj))
        return super().default(obj)

# --- Build a sample order ---
order = Order(
    order_id=uuid.UUID("12345678-1234-5678-1234-567812345678"),
    customer_email="alice@example.com",
    status=OrderStatus.CONFIRMED,
    items=[
        LineItem(uuid.uuid4(), "Python Course", 2, Decimal("10.99")),
        LineItem(uuid.uuid4(), "Debugging Guide", 1, Decimal("7.50")),
    ],
    created_at=datetime.now(tz=timezone.utc),
    tags={"digital", "education", "python"},
)

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

Output (UUIDs and timestamp vary each run):

{
  "order_id": "12345678-1234-5678-1234-567812345678",
  "customer_email": "alice@example.com",
  "status": "confirmed",
  "items": [
    {
      "product_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "name": "Python Course",
      "quantity": 2,
      "unit_price": "10.99"
    },
    {
      "product_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
      "name": "Debugging Guide",
      "quantity": 1,
      "unit_price": "7.50"
    }
  ],
  "created_at": "2026-08-12T09:30:00+00:00",
  "tags": [
    "digital",
    "education",
    "python"
  ]
}

The encoder processes each object in the graph exactly once. Nested dataclasses are handled when asdict() recursively converts them to dicts, and the encoder then processes any non-standard values in those dicts by calling default() on each one. To extend this for Pydantic v2 models, add a check for hasattr(obj, 'model_dump') and return obj.model_dump() — the same pattern works across the board. To include computed properties that asdict() skips (because they are not fields), you can add them after the asdict() call: d = dataclasses.asdict(obj); d['total'] = str(obj.total); return d.

API Alice serving a JSON response tray to an API consumer robot
asdict(), isoformat(), str() — it’s all just ‘make it a dict’ wearing a different hat.

Frequently Asked Questions

When exactly does Python call the default() method?

Python calls default() only for objects it cannot serialize natively — types not in its built-in set of str, int, float, bool, None, list, and dict. It is never called for those built-in types, even when they are nested inside a container. This means if you have a dict containing a datetime value, the dict itself goes through normal processing and only the datetime value triggers default(). This is why the dataclass pattern works: asdict() returns a plain dict, and the encoder recurses into that dict and calls default() on any non-standard values it finds inside — including nested dataclasses, Decimal fields, and datetime fields — automatically.

Should I serialize Decimal as float or string?

For financial data — prices, tax amounts, invoice totals — always use str(obj). Python’s Decimal("10.99") stores the value exactly, but converting to float can introduce errors like 10.989999999999999 due to IEEE 754 floating-point representation. For analytics or machine learning payloads where the downstream consumer needs a numeric type and tiny rounding errors are acceptable, float(obj) is fine. If your API contract specifies that money fields are strings (as Stripe’s API does, for example), using str also makes your output match the spec without any frontend parsing step.

Should I use orjson or Pydantic instead of a custom encoder?

orjson is a Rust-backed JSON library that natively supports datetime, UUID, dataclass, and numpy arrays without any custom encoder, and it serializes roughly 5x to 10x faster than the built-in json module. If you are serializing large payloads or doing high-throughput work, orjson is worth the dependency. Pydantic v2 models serialize themselves via .model_dump_json() without needing a custom encoder at all. The custom JSONEncoder approach is the right choice when you want zero extra dependencies and full control over how each type is represented, or when you are working in a restricted environment where you cannot add third-party packages.

How does the encoder handle deeply nested objects?

The encoder recurses automatically through any dict or list it processes. If your default() method returns a dict (as it does for dataclasses via asdict()), the encoder walks into that dict and calls default() on any non-standard values it finds inside. You do not need to manually recurse. The one case where this breaks down is if default() returns a custom object instead of a JSON-native type — the encoder will call default() on it again, and you risk infinite recursion. Always return a string, number, list, dict, or None from default(). Never return an object that would trigger another default() call.

Can I silently skip unserializable objects instead of raising TypeError?

Yes — in default(), instead of calling super().default(obj) as the final fallback, return a sentinel value like None or "[UNSERIALIZABLE]". This suppresses the TypeError but silently drops data, which is almost always the wrong choice for production APIs where missing fields cause bugs downstream. A better pattern is to log the unexpected type and return None so you have a record of what was dropped: import logging; logging.warning(f"Skipping unserializable type: {type(obj).__name__}"). Reserve silent-skip for debugging and logging scenarios where partial output is better than no output.

What other json.dumps() options are worth knowing?

Three options are especially useful alongside a custom encoder. sort_keys=True alphabetically sorts all dict keys in the output — great for producing deterministic JSON for checksums, diffs, and caching. separators=(", ", ": ") controls whitespace; for compact production JSON with no formatting overhead, use separators=(",", ":") to strip all spaces. ensure_ascii=True (the default) converts non-ASCII characters to \uXXXX escape sequences; set it to False if you want UTF-8 characters to appear literally in the output, which is more readable for non-English text. All three work alongside the cls= argument — they are not mutually exclusive.

Conclusion

In this article we covered how json.JSONEncoder works and when Python calls default(), how to serialize datetime and date to ISO 8601 with correct subclass ordering, how to handle Decimal safely as a string or float depending on your precision requirements, how to use dataclasses.asdict() to serialize dataclasses and their nested types, how to extend the encoder to cover UUID, Enum, set, and frozenset, and how to use the default function argument as a lightweight alternative to subclassing.

A good next step is to extend the AppEncoder from the real-life example: add Pydantic v2 model support via hasattr(obj, 'model_dump'), add numpy array support if you work in data science (isinstance(obj, numpy.ndarray) returns obj.tolist()), or wire the encoder into Django’s JSON_ENCODER setting or Flask’s app.json.encoder so it is the default everywhere in your application. Once you have a shared AppEncoder in your project, you can stop copy-pasting serialization workarounds across files.

The official documentation for the json module lives at docs.python.org/3/library/json.html and covers additional encoder options like sort_keys, separators, and ensure_ascii. The orjson project is worth reading if you need higher throughput or native support for types like numpy arrays and bytes.