InterviewsVector

Fix TypeError: datetime Is Not JSON Serializable

Quick answer

Python's standard JSON encoder has no datetime mapping. Convert the value before encoding or pass a type-aware default function. For APIs, emit a timezone-aware ISO 8601 string, usually normalized to UTC with a trailing Z. Avoid default=str in production because it hides unsupported types and does not enforce a stable timestamp contract.

The failure usually appears at an API, cache, queue, session, or file boundary:

TypeError: Object of type datetime is not JSON serializable

The datetime is valid Python. The destination is the mismatch. JSON has objects, arrays, strings, numbers, booleans, and null; it has no date or time type. Python's standard encoder refuses to guess whether you want local time, UTC, an offset, milliseconds, or a human-formatted label.

The one-line answer, default=str, makes the traceback disappear. It does not define a reliable wire format. In production, the real fix is to choose a timestamp contract, convert at the boundary, and verify the round trip.

Quick Answer

Python's standard JSON encoder has no datetime mapping. Convert the value before encoding or pass a type-aware default function. For APIs, emit a timezone-aware ISO 8601 string, usually normalized to UTC with a trailing Z. Avoid default=str in production because it hides unsupported types and does not enforce a stable timestamp contract.

TL;DR

  • JSON has no native date type; datetime must become a string or number.
  • Prefer an aware ISO 8601/RFC 3339 value such as "2026-07-27T08:15:30.123Z".
  • For nested data, use json.dumps(payload, default=encode_json_value).
  • Make the converter reject naive datetimes and unknown object types.
  • Do not assume json.loads() reconstructs a datetime; it returns a string.
  • Django, Flask, FastAPI, Pydantic, and pandas have different serialization paths. Use the framework's supported boundary.

Symptoms

You may see the same root failure through several entry points:

SymptomWhere it commonly appearsWhat reached the encoder
Object of type datetime is not JSON serializablejson.dumps() or json.dump()datetime.datetime
Object of type date is not JSON serializableReport export or API responsedatetime.date
Object of type time is not JSON serializableSchedule/config payloaddatetime.time
Object of type Timestamp is not JSON serializablepandas/SQL resultpandas.Timestamp
HTTP 500 with the TypeError in logsFlask/Django/custom API rendererORM or service-layer timestamp
Queue or cloud SDK rejects the messageCelery, Kafka wrapper, task/event clientNested datetime in message data

The exception often points at the final json.dumps, not the field that caused it. A timestamp may be nested several lists and dictionaries deep or introduced by an ORM, pandas, a dataclass, or a Pydantic model dumped in Python mode.

Common Causes

CauseWhy it failsCorrect direction
json.dumps({"created_at": datetime.now()})Standard JSONEncoder has no datetime ruleConvert to an aware ISO string
requests.post(url, json=payload)The client serializes the dict internallyConvert before passing json=
dataclasses.asdict(model)asdict recurses but leaves datetime values intactUse a boundary encoder afterward
pydantic_model.model_dump()Pydantic v2 defaults to Python modeUse mode="json" or model_dump_json()
df.to_dict(orient="records")Records can still contain TimestampUse DataFrame.to_json(date_format="iso")
Returning an ORM model's __dict__It contains rich Python/ORM valuesMap to an explicit response DTO
Naive datetime.now()Even after string conversion, timezone meaning is missingCreate or localize an aware datetime

Root Cause

The documented Python JSONEncoder conversion table supports only a small set of Python types:

Python valueJSON value
dictobject
list, tuplearray
strstring
int, floatnumber
True, Falsetrue, false
Nonenull
datetime, date, timeno built-in mapping

When the encoder reaches an unsupported value, its default() implementation raises TypeError. This is deliberate. Several incompatible encodings would all be plausible:

  • "2026-07-27T08:15:30.123Z" — an instant in UTC.
  • "2026-07-27T13:45:30.123+05:30" — the same instant with its original offset.
  • "2026-07-27 13:45:30.123" — no offset and an ambiguous timezone.
  • 1785140130123 — epoch milliseconds, provided both sides agree on the unit.
  • "27/07/2026 13:45" — a display string, not a robust interchange format.

The serializer cannot choose among those without application context.

Convert datetime at the JSON boundary instead of hiding it with default=strA Python payload can contain rich datetime objects, but JSON only has primitive values. The standard encoder raises TypeError when it reaches datetime. A type-aware boundary serializer converts it to a timezone-aware ISO 8601 string, after which JSON encoding succeeds.Python object graphdict, list, datetimejson.dumps(payload)standard JSONEncoderdatetime has nonative JSON typeTypeErrorType-aware serializeraware datetime → UTC ISO stringValid JSON value"2026-07-27T08:15:30Z"Fix at the boundaryStable wire contract: explicit type, timezone, and precision
Convert datetime at the JSON boundary instead of hiding it with default=str

Minimal reproducible example

This complete script reproduces the exact error without a web framework:

import json
from datetime import datetime, timezone
 
payload = {
    "event": "invoice.created",
    "created_at": datetime(2026, 7, 27, 8, 15, 30, tzinfo=timezone.utc),
}
 
try:
    print(json.dumps(payload))
except TypeError as exc:
    print(f"{type(exc).__name__}: {exc}")
 
# Expected output:
# TypeError: Object of type datetime is not JSON serializable

payload is a valid Python dictionary. The failure occurs only when json.dumps walks into created_at. Adding timezone.utc makes the value unambiguous, but it does not make the type part of JSON.

Step-by-Step Solution

Step 1: Choose the wire representation

For most HTTP APIs, webhooks, JSON files, and event messages, choose a timezone-aware ISO 8601 string compatible with RFC 3339:

2026-07-27T08:15:30.123Z

That value is readable, sortable when normalized to a consistent precision and timezone, and explicit about UTC. Use an epoch number only when an existing contract requires it. If you choose epoch time, name or document the unit; 1785140130 seconds and 1785140130000 milliseconds describe the same instant but differ by a factor of 1,000.

MethodBest useAdvantagesTradeoffs
value.isoformat()One known fieldExplicit and dependency-freeCaller must enforce timezone policy
default=typed_functionNested standard-library payloadsCentral, strict, reusableMust be passed at each JSON boundary
Custom JSONEncoderExisting code standardized on cls=Encapsulates multiple supported typesMore ceremony than a function
Framework/schema serializerFastAPI, Pydantic, DjangoValidation and response integrationBehavior is framework/version-specific
Epoch numberNumeric/legacy protocolCompact, easy arithmeticUnit ambiguity; unreadable; precision limits
default=strTemporary debuggingOne lineSilently stringifies every unsupported type
Decision tree for serializing Python datetime valuesUse timezone-aware ISO 8601 strings for most public APIs. Use an explicitly documented epoch unit for numeric legacy protocols. Keep native datetime values only when no JSON boundary exists. Reject naive datetimes unless the application has an explicit timezone policy.Will this value crossa JSON boundary?NoKeep datetime nativedatabase/ORM temporal typeYesIs the datetimetimezone-aware?NoReject naive datetimeor apply an explicitsource-timezone policyYesDoes the contract requirea numeric timestamp?NoTimezone-aware ISO 8601normalize to UTC; use ZYesEpoch numberdocument seconds vs ms
Decision tree for serializing Python datetime values

Step 2: Use a strict, timezone-safe converter

This is the default implementation I use at standard-library JSON boundaries. It normalizes instants to UTC, emits milliseconds, supports date-only fields, and rejects ambiguity:

import json
from datetime import date, datetime, timedelta, timezone
from typing import Any
 
 
def encode_json_value(value: Any) -> str:
    if isinstance(value, datetime):
        if value.tzinfo is None or value.utcoffset() is None:
            raise ValueError("Refusing to serialize a naive datetime")
 
        utc_value = value.astimezone(timezone.utc)
        return utc_value.isoformat(timespec="milliseconds").replace("+00:00", "Z")
 
    if isinstance(value, date):
        return value.isoformat()
 
    raise TypeError(
        f"Object of type {type(value).__name__} is not JSON serializable"
    )
 
 
india_offset = timezone(timedelta(hours=5, minutes=30))
payload = {
    "created_at": datetime(
        2026, 7, 27, 13, 45, 30, 123456, tzinfo=india_offset
    ),
    "billing_date": date(2026, 7, 27),
}
 
encoded = json.dumps(payload, default=encode_json_value, sort_keys=True)
print(encoded)
 
# Expected output:
# {"billing_date": "2026-07-27", "created_at": "2026-07-27T08:15:30.123Z"}

Important details:

  • Check datetime before date; datetime is a subclass of date.
  • Testing both tzinfo and utcoffset() rejects objects that are effectively naive even if a custom tzinfo object exists.
  • astimezone(timezone.utc) converts the instant; it does not merely relabel it.
  • timespec="milliseconds" defines payload precision. Python truncates excluded digits rather than rounding them.
  • The final TypeError preserves normal json behavior for unknown objects.

If your contract must preserve the source offset, remove the UTC normalization and return value.isoformat(timespec="milliseconds"). If it must preserve the IANA zone identity, such as Asia/Kolkata, send that as a separate field. An offset like +05:30 does not contain future daylight-saving rules or a zone name.

Step 3: Apply conversion at the boundary

Keep datetime values native while performing date arithmetic, database queries, validation, and business logic. Convert only when data crosses a JSON boundary:

import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
 
 
def encode_json_value(value: Any) -> str:
    if isinstance(value, datetime):
        if value.tzinfo is None or value.utcoffset() is None:
            raise ValueError("Refusing to serialize a naive datetime")
        return (
            value.astimezone(timezone.utc)
            .isoformat(timespec="milliseconds")
            .replace("+00:00", "Z")
        )
    raise TypeError(
        f"Object of type {type(value).__name__} is not JSON serializable"
    )
 
 
output_path = Path("event.json")
event = {
    "id": "evt_123",
    "occurred_at": datetime(2026, 7, 27, 8, 15, 30, tzinfo=timezone.utc),
}
 
with output_path.open("w", encoding="utf-8") as file:
    json.dump(event, file, default=encode_json_value, ensure_ascii=False)
 
print(output_path.read_text(encoding="utf-8"))
 
# Expected output:
# {"id": "evt_123", "occurred_at": "2026-07-27T08:15:30.000Z"}

The file is opened in text mode because the standard json module produces strings. ensure_ascii=False preserves non-ASCII text elsewhere in the payload; it does not change timestamp handling.

Step 4: Parse the field explicitly on the way back

JSON decoding does not know that a string represents a date. Parse according to the schema:

import json
from datetime import datetime, timezone
 
wire_json = '{"occurred_at": "2026-07-27T08:15:30.123Z"}'
decoded = json.loads(wire_json)
 
# Python 3.7+ compatible handling for a UTC Z suffix.
occurred_at = datetime.fromisoformat(
    decoded["occurred_at"].replace("Z", "+00:00")
)
 
print(type(decoded["occurred_at"]).__name__)
print(occurred_at == datetime(2026, 7, 27, 8, 15, 30, 123000, tzinfo=timezone.utc))
 
# Expected output:
# str
# True

Python 3.11 and newer can parse the trailing Z directly with datetime.fromisoformat. The replacement keeps the example compatible with older Python versions that support fromisoformat. For public request data, prefer schema validation over calling a parser on arbitrary fields.

Why default=str Is Usually the Wrong Production Fix

This code runs:

import json
from datetime import datetime
from decimal import Decimal
 
payload = {
    "created_at": datetime(2026, 7, 27, 8, 15, 30),
    "amount": Decimal("19.99"),
}
 
print(json.dumps(payload, default=str, sort_keys=True))
 
# Expected output:
# {"amount": "19.99", "created_at": "2026-07-27 08:15:30"}

It also demonstrates the problem:

  • The naive datetime remains timezone-ambiguous.
  • str(datetime) uses a space instead of the conventional T.
  • Decimal is silently changed from a numeric domain value to a JSON string.
  • Any future unsupported object is stringified instead of failing close to its source.
  • A custom object's __str__ may be unstable or may expose information not intended for the API.

Use default=str for a local debug print when the exact representation does not matter. Do not let it become an accidental organization-wide serialization policy.

Alternative: A Custom JSONEncoder

A default function is simplest for most code. A custom encoder is useful when an existing integration accepts cls= or several call sites already share an encoder class:

import json
from datetime import date, datetime, timezone
 
 
class ApiJSONEncoder(json.JSONEncoder):
    def default(self, value: object) -> object:
        if isinstance(value, datetime):
            if value.tzinfo is None or value.utcoffset() is None:
                raise ValueError("Refusing to serialize a naive datetime")
            return (
                value.astimezone(timezone.utc)
                .isoformat(timespec="seconds")
                .replace("+00:00", "Z")
            )
        if isinstance(value, date):
            return value.isoformat()
        return super().default(value)
 
 
payload = {
    "published_at": datetime(
        2026, 7, 27, 8, 15, 30, tzinfo=timezone.utc
    )
}
print(json.dumps(payload, cls=ApiJSONEncoder))
 
# Expected output:
# {"published_at": "2026-07-27T08:15:30Z"}

Calling super().default(value) matters. Returning str(value) in the fallback would recreate the same silent-coercion problem as default=str.

Framework-Specific Fixes

Framework helpers may already support datetime, but their format may differ from your contract. Do not mix direct json.dumps, framework responses, and model serializers without testing the actual response body.

Flask

Flask's current DefaultJSONProvider supports datetime and date, but emits HTTP-date strings rather than the ISO 8601 representation most JSON APIs use. Convert explicitly when the contract requires RFC 3339:

from datetime import datetime, timezone
 
from flask import Flask, jsonify
 
app = Flask(__name__)
 
 
def api_timestamp(value: datetime) -> str:
    if value.tzinfo is None or value.utcoffset() is None:
        raise ValueError("created_at must include a timezone")
    return (
        value.astimezone(timezone.utc)
        .isoformat(timespec="seconds")
        .replace("+00:00", "Z")
    )
 
 
@app.get("/events/evt_123")
def get_event():
    created_at = datetime(2026, 7, 27, 8, 15, 30, tzinfo=timezone.utc)
    return jsonify(
        {"id": "evt_123", "created_at": api_timestamp(created_at)}
    )
 
 
if __name__ == "__main__":
    with app.test_client() as client:
        print(client.get("/events/evt_123").get_json())
 
# Expected output:
# {'created_at': '2026-07-27T08:15:30Z', 'id': 'evt_123'}

Flask 2.2 introduced the JSON provider interface. Flask 2.3 removed the old app.json_encoder and app.json_decoder customization points. For a global policy on modern Flask, subclass a JSON provider; for a single response schema, explicit field conversion is easier to review.

Django

JsonResponse uses DjangoJSONEncoder by default. That encoder supports datetime, date, time, timedelta, Decimal, and UUID. A plain call to json.dumps does not:

from datetime import datetime, timezone
 
from django.http import JsonResponse
 
 
def event_detail(request):
    return JsonResponse(
        {
            "id": "evt_123",
            "created_at": datetime(
                2026, 7, 27, 8, 15, 30, 123000, tzinfo=timezone.utc
            ),
        }
    )
 
 
# Expected response body:
# {"id": "evt_123", "created_at": "2026-07-27T08:15:30.123Z"}

The view is complete for a Django URL mapping. The request argument is unused but required by the view interface. If you pass a custom encoder to JsonResponse, extend DjangoJSONEncoder and delegate unknown values to super().default.

FastAPI and Pydantic v2

FastAPI serializes declared response models. For data sent to a non-response boundary, such as a cache or queue, use jsonable_encoder. With Pydantic v2, model_dump() defaults to Python mode and can retain a datetime; use model_dump(mode="json") or model_dump_json():

from datetime import datetime, timezone
 
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
 
 
class Event(BaseModel):
    id: str
    created_at: datetime
 
 
event = Event(
    id="evt_123",
    created_at=datetime(2026, 7, 27, 8, 15, 30, tzinfo=timezone.utc),
)
 
python_data = event.model_dump()
json_data = event.model_dump(mode="json")
fastapi_data = jsonable_encoder(event)
 
print(type(python_data["created_at"]).__name__)
print(type(json_data["created_at"]).__name__)
print(type(fastapi_data["created_at"]).__name__)
 
# Expected output:
# datetime
# str
# str

The type checks make the boundary visible: Python mode is appropriate for in-process work; JSON mode and jsonable_encoder are appropriate when the next component accepts JSON-compatible primitives.

Requests and other HTTP clients

requests.post(url, json=payload) sets the JSON content type and encodes the payload, but it does not invent datetime semantics. Convert first:

from datetime import datetime, timezone
 
import requests
 
created_at = datetime(2026, 7, 27, 8, 15, 30, tzinfo=timezone.utc)
payload = {
    "id": "evt_123",
    "created_at": created_at.isoformat().replace("+00:00", "Z"),
}
 
request = requests.Request(
    "POST", "https://api.example.test/events", json=payload
).prepare()
 
print(request.headers["Content-Type"])
print(request.body)
 
# Expected output:
# application/json
# b'{"id": "evt_123", "created_at": "2026-07-27T08:15:30Z"}'

The example prepares the request without sending network traffic. The key point is that payload is already JSON-compatible before it reaches the client.

Dataclasses, ORMs, and Service-Layer Objects

dataclasses.asdict() converts a dataclass into nested dictionaries and lists, but it does not turn every leaf into a JSON primitive:

import json
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
 
 
@dataclass(frozen=True)
class AuditEvent:
    action: str
    occurred_at: datetime
 
 
event = AuditEvent(
    action="user.created",
    occurred_at=datetime(2026, 7, 27, 8, 15, 30, tzinfo=timezone.utc),
)
 
payload = asdict(event)
payload["occurred_at"] = payload["occurred_at"].isoformat().replace(
    "+00:00", "Z"
)
print(json.dumps(payload))
 
# Expected output:
# {"action": "user.created", "occurred_at": "2026-07-27T08:15:30Z"}

For an ORM entity, do not serialize model.__dict__. It may contain relationship loaders, internal state, secrets, and types such as Decimal, UUID, and datetime. Build a response DTO or schema that names the public fields. That keeps database representation, authorization, and API representation separate.

pandas Timestamp and NumPy datetime64

pandas.Timestamp and NumPy temporal scalars appear frequently after database queries and dataframe transformations. Converting a DataFrame to records does not guarantee JSON-safe leaf values.

For a DataFrame, let pandas serialize the column with an explicit format:

import pandas as pd
 
frame = pd.DataFrame(
    {
        "id": ["evt_123"],
        "created_at": [pd.Timestamp("2026-07-27T08:15:30.123Z")],
    }
)
 
json_text = frame.to_json(
    orient="records", date_format="iso", date_unit="ms"
)
print(json_text)
 
# Expected output:
# [{"id":"evt_123","created_at":"2026-07-27T08:15:30.123Z"}]

The official DataFrame.to_json documentation notes that defaults depend on orient; state date_format="iso" instead of relying on a default. In pandas 3.0, epoch date output is deprecated in favor of ISO output. If you need Python records rather than a JSON string, convert the timestamp column deliberately before to_dict.

For related migration traps, see the pandas 2.0 DataFrame.append fix and common pandas errors and gotchas.

How to Find a Hidden datetime in a Nested Payload

Large payloads make the final traceback frustrating because the encoder reports the type, not its path. Use a development-only walker to locate unsupported leaves:

import json
from datetime import datetime, timezone
from typing import Any, Optional, Set
 
 
def report_unsupported(
    value: Any, path: str = "$", seen: Optional[Set[int]] = None
) -> None:
    seen = seen or set()
 
    if isinstance(value, dict):
        if id(value) in seen:
            return
        seen.add(id(value))
        for key, child in value.items():
            try:
                json.dumps({key: None})
            except TypeError:
                print(f"{path} has unsupported key type: {type(key).__name__}")
            report_unsupported(child, f"{path}[{key!r}]", seen)
        return
 
    if isinstance(value, (list, tuple)):
        if id(value) in seen:
            return
        seen.add(id(value))
        for index, child in enumerate(value):
            report_unsupported(child, f"{path}[{index}]", seen)
        return
 
    try:
        json.dumps(value)
    except TypeError:
        print(f"{path}: {type(value).__name__}")
 
 
payload = {
    "event": {
        "created_at": datetime(
            2026, 7, 27, 8, 15, 30, tzinfo=timezone.utc
        ),
        "tags": {"billing", "priority"},
    }
}
report_unsupported(payload)
 
# Expected output:
# $['event']['created_at']: datetime
# $['event']['tags']: set

This helper is for diagnosis, not serialization. It avoids cycles in containers and reports every unsupported leaf it can reach. Do not log the entire production payload just to find a type; payloads may contain credentials, tokens, personal data, or payment information.

Edge Cases That Break Otherwise Correct Fixes

Naive versus aware datetime

datetime.now() returns a naive value unless you pass a timezone. A naive value does not say whether 08:15 means UTC, the server's local timezone, or the user's timezone.

Prefer:

from datetime import datetime, timezone
 
aware_now = datetime.now(timezone.utc)
print(aware_now.tzinfo is timezone.utc)
 
# Expected output:
# True

Do not call naive.astimezone(timezone.utc) as a generic fix. Python interprets the naive input using the host's local timezone, so the result can differ between a laptop, CI runner, and container. Do not call naive.replace(tzinfo=timezone.utc) unless you know the stored clock reading was defined as UTC; replace attaches a label without converting the time.

Daylight-saving transitions and fold

Local wall times can repeat when clocks move backward. Normalize a correctly localized aware datetime to UTC before serialization. UTC resolves the instant, but it does not preserve the original zone name. If future scheduling depends on regional rules, store the local date/time and IANA zone separately from an execution instant.

Precision

Choose seconds, milliseconds, or microseconds as part of the contract. JavaScript date handling and many storage systems commonly operate at millisecond precision, while Python can hold microseconds. Truncating precision can make two nearby events compare equal, affect cursor pagination, or break signatures if producer and consumer canonicalize differently.

date, time, and timedelta

These are different domain types:

  • date usually maps cleanly to YYYY-MM-DD.
  • time without a date may still need an offset and business timezone.
  • timedelta is a duration, not an instant. Do not serialize it as a clock time.

Define separate encodings instead of passing all three through str.

Dictionary keys

The default function handles unsupported values, not arbitrary dictionary keys. JSON object keys are strings. Model timestamps as values, not keys; if a mapping is naturally keyed by time, convert keys explicitly or use a list of {"at": ..., "value": ...} records.

None, missing, and sentinel values

None becomes JSON null; a missing key is absent. Those meanings are often different in PATCH requests and events. Do not replace missing timestamps with datetime.min, an empty string, or "None" just to satisfy the encoder.

Platform and Deployment Notes

The serialization rule is platform-independent. Linux, Windows, macOS, WSL, CPU, and GPU runtimes use the same JSON type mapping. Differences appear when code silently depends on the machine's timezone or Python environment.

EnvironmentWhat to check
Linux/macOSDo not assume the host timezone; create aware values explicitly
WindowsVerify the same Python/venv and timezone policy as production
WSLWindows and the Linux distribution can have different timezone configuration
DockerSet application timestamps explicitly; container TZ does not repair naive data
CI/CDFreeze representative aware datetimes and run response contract tests
Cloud functions/jobsSerialize before handing payloads to SDKs, queues, or state stores
CPU/GPUNo behavioral difference; datetime JSON encoding is CPU-side application logic
DevelopmentFail fast with the field path and type
ProductionReturn a controlled error; avoid logging sensitive full payloads

If the code works locally but fails in a container, compare the actual payload types and dependency versions. A different ORM adapter or pandas version may produce a Timestamp where local fixtures used a string.

Performance and Security Implications

datetime.isoformat() is inexpensive for normal API payloads. The larger performance mistake is double serialization: converting a structure to a JSON string and then passing that string to a framework's JSON response helper, which encodes it again. The client receives a quoted JSON string instead of an object.

For large DataFrames, use the library's bulk serializer rather than a Python loop. For event streams, use a defined format such as JSON Lines. Repeatedly calling json.dump() into one file does not create a valid framed JSON document; the standard-library documentation calls out this limitation.

Security rules:

  • Allowlist supported types in the encoder. Do not serialize arbitrary __dict__ or vars(value) output.
  • Do not include the full rejected object in an exception or log line.
  • Validate timestamp length, format, range, and offset on input.
  • Limit untrusted JSON input size; deeply nested or huge documents can consume substantial CPU and memory.
  • Keep signatures and cache keys on one canonical timestamp representation. Equivalent instants with Z and +00:00 are different byte strings.

Verification Steps

Do more than assert that serialization no longer throws. Verify the contract:

import json
from datetime import datetime, timezone
 
 
def encode_datetime(value: object) -> str:
    if not isinstance(value, datetime):
        raise TypeError(f"Unsupported type: {type(value).__name__}")
    if value.tzinfo is None or value.utcoffset() is None:
        raise ValueError("Timestamp must be timezone-aware")
    return (
        value.astimezone(timezone.utc)
        .isoformat(timespec="milliseconds")
        .replace("+00:00", "Z")
    )
 
 
source = datetime(
    2026, 7, 27, 8, 15, 30, 123000, tzinfo=timezone.utc
)
wire = json.dumps({"at": source}, default=encode_datetime)
decoded = json.loads(wire)
restored = datetime.fromisoformat(decoded["at"].replace("Z", "+00:00"))
 
assert decoded == {"at": "2026-07-27T08:15:30.123Z"}
assert restored == source
 
try:
    json.dumps({"at": datetime(2026, 7, 27, 8, 15, 30)}, default=encode_datetime)
except ValueError as exc:
    assert str(exc) == "Timestamp must be timezone-aware"
else:
    raise AssertionError("Naive datetime should have been rejected")
 
print("serialization contract verified")
 
# Expected output:
# serialization contract verified

Production verification checklist:

  1. Confirm the response/message/file contains a JSON string or documented epoch number, not a Python representation.
  2. Confirm an instant survives encode and decode without shifting.
  3. Confirm naive values fail before leaving the service.
  4. Confirm precision is intentional and tested.
  5. Confirm consumers accept Z or the chosen offset form.
  6. Confirm unexpected object types still raise TypeError.
  7. Run the test with the Python and framework versions used in production.

Troubleshooting Matrix

Symptom after the changeLikely causeFix
Error still names datetimeConverter was not passed to this call siteTrace the actual JSON boundary
Error changes to Timestamppandas value uses a different type/pathUse pandas ISO serialization or handle Timestamp
Output has no Z or offsetInput was naiveApply an explicit timezone policy before serialization
Time shifts by several hoursLocal time was labeled as UTC or converted twiceDistinguish replace from astimezone
Client receives "{\"at\":...}"JSON was encoded twiceReturn the structure or raw JSON, not both
json.loads returns strThis is normal JSON behaviorParse the schema field explicitly
Works in Django, fails in a workerDjangoJSONEncoder is not used by the workerShare a boundary serializer
Works locally, fails in DockerDifferent type, dependency, or implicit timezoneLog safe type paths and compare versions
Consumer reads a 1970 or far-future dateSeconds/milliseconds mismatchDocument and test the epoch unit
Custom encoder hides new bad dataFallback returns str(value)Delegate to super().default

Prevention

  • Define one timestamp representation per API or event contract.
  • Create timezone-aware datetimes at the source.
  • Normalize instants to UTC unless the source offset is a contract requirement.
  • Use a type-aware serializer that rejects unknown objects.
  • Convert at I/O boundaries, not throughout business logic.
  • Use response schemas or DTOs instead of serializing ORM internals.
  • Test string format, timezone, precision, invalid input, and round trips.
  • Pin and test framework versions when relying on built-in encoders.
  • Never log a complete sensitive payload merely to locate an unsupported value.

Key Takeaways

The exception is not a datetime defect. It is an undefined boundary contract. JSON cannot represent a date until the application chooses a string or numeric encoding.

For most systems, the durable fix is straightforward: keep aware datetime objects inside Python, convert them to UTC ISO 8601 strings at the JSON boundary, reject naive or unknown values, and parse them through a schema on input. That approach fixes the traceback without creating a quieter timezone bug.

Official References

FAQs

How do I fix Object of type datetime is not JSON serializable?

Convert the datetime to a JSON-supported value before json.dumps. For an API, use a timezone-aware ISO 8601 string such as 2026-07-27T08:15:30.123Z. For nested payloads, pass a strict converter: json.dumps(payload, default=encode_json_value).

Can JSON store a datetime directly?

No. JSON has no date or datetime type. It can store the timestamp as a string or number, but the producer and consumer must agree on the format, timezone, and precision.

Is default=str safe?

It is acceptable for temporary debugging where representation does not matter. It is not a strong production fix because it converts every unsupported type and can hide unexpected objects, ambiguous timezones, and unstable string formats.

Should I use ISO 8601 or a Unix timestamp?

Use ISO 8601/RFC 3339 for most public APIs. It is readable and can carry an offset. Use epoch time only when a protocol requires it, and document the unit. Seconds and milliseconds are not interchangeable.

Why does isoformat() omit the timezone?

The datetime is naive. isoformat() includes an offset only for aware values. Create UTC timestamps with datetime.now(timezone.utc) or attach the correct source zone according to an explicit policy before converting to UTC.

Does json.loads() return a datetime?

No. It returns the JSON string as Python str. Parse the known field with datetime.fromisoformat or use a schema library that validates and reconstructs the datetime.

How do I serialize a pandas Timestamp?

For a DataFrame, use to_json with date_format="iso" and an explicit orientation. For individual values, call isoformat() after verifying the timezone. A dictionary returned by to_dict() may still contain Timestamp objects.

Why does Django or Flask serialize it when json.dumps() does not?

Their response helpers use extended encoders or providers. Django's JsonResponse defaults to DjangoJSONEncoder; Flask's default provider handles datetime as an HTTP date. A direct json.dumps call still uses Python's standard encoder unless configured explicitly.

Sources

Key takeaways

  • JSON has no date or datetime type; a Python datetime must become a string or number before json.dumps can encode it.
  • For most APIs, use a timezone-aware ISO 8601/RFC 3339 string and normalize instants to UTC.
  • Use an explicit default function that handles known types and raises TypeError for everything else.
  • Treat default=str as a debugging shortcut, not a production serialization policy.
  • Reject naive datetimes at the boundary unless the application has an explicit timezone rule.
  • Test the wire value, timezone, precision, and decode path—not only that json.dumps stops throwing.

Frequently asked questions

How do I fix Object of type datetime is not JSON serializable?

Convert the datetime to a JSON-supported value before calling json.dumps. For an API, use a timezone-aware ISO 8601 string such as 2026-07-27T08:15:30.123Z. For nested payloads, pass a type-aware function with json.dumps(payload, default=encode_datetime). The function should handle datetime explicitly and raise TypeError for unknown types.

Is json.dumps(payload, default=str) a safe fix?

It stops the immediate exception, but it is a weak production fix. It converts every unsupported object, not only datetime, and the resulting formats are not governed by an API contract. A custom function that accepts datetime and rejects unknown types is safer, testable, and less likely to hide a later data bug.

Should a datetime be JSON as ISO 8601 or a Unix timestamp?

Use an ISO 8601/RFC 3339 string for most public APIs because it is readable and carries an offset. Use a Unix timestamp only when the protocol requires one, and document whether the unit is seconds, milliseconds, microseconds, or nanoseconds. Unit ambiguity is a common source of production date errors.

Why does datetime.isoformat sometimes omit the timezone?

isoformat includes an offset only when the datetime is timezone-aware. datetime.now() is naive by default, so its output has no offset. Create an aware value with datetime.now(timezone.utc), or attach the correct source timezone according to an explicit policy before converting it to UTC.

Does json.loads convert an ISO date string back to datetime?

No. JSON has only strings, so json.loads returns a Python str. Parse the specific field with datetime.fromisoformat or let a schema library such as Pydantic validate it. Python 3.11 and newer accept a trailing Z directly; for older supported versions, replace Z with +00:00 before parsing.

How do I serialize pandas Timestamp values to JSON?

For a DataFrame, prefer DataFrame.to_json with date_format='iso' and an explicit orient. For individual Timestamp values, convert them with isoformat after confirming their timezone. Do not rely on DataFrame.to_dict followed by json.dumps, because the resulting dictionary can still contain pandas Timestamp objects.

Why does this work in Django or Flask but fail with json.dumps?

Framework response helpers can use extended encoders. Django JsonResponse defaults to DjangoJSONEncoder, while Flask's default JSON provider supports datetime using HTTP-date formatting. Direct calls to Python's json.dumps still use the standard encoder unless you pass a default function or custom JSONEncoder.

By Mohammad Wasi

Software Engineering Leader & Technical Author · Updated July 27, 2026


Related Posts