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 serializableThe 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;
datetimemust 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 adatetime; 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:
| Symptom | Where it commonly appears | What reached the encoder |
|---|---|---|
Object of type datetime is not JSON serializable | json.dumps() or json.dump() | datetime.datetime |
Object of type date is not JSON serializable | Report export or API response | datetime.date |
Object of type time is not JSON serializable | Schedule/config payload | datetime.time |
Object of type Timestamp is not JSON serializable | pandas/SQL result | pandas.Timestamp |
| HTTP 500 with the TypeError in logs | Flask/Django/custom API renderer | ORM or service-layer timestamp |
| Queue or cloud SDK rejects the message | Celery, Kafka wrapper, task/event client | Nested 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
| Cause | Why it fails | Correct direction |
|---|---|---|
json.dumps({"created_at": datetime.now()}) | Standard JSONEncoder has no datetime rule | Convert to an aware ISO string |
requests.post(url, json=payload) | The client serializes the dict internally | Convert before passing json= |
dataclasses.asdict(model) | asdict recurses but leaves datetime values intact | Use a boundary encoder afterward |
pydantic_model.model_dump() | Pydantic v2 defaults to Python mode | Use mode="json" or model_dump_json() |
df.to_dict(orient="records") | Records can still contain Timestamp | Use DataFrame.to_json(date_format="iso") |
Returning an ORM model's __dict__ | It contains rich Python/ORM values | Map to an explicit response DTO |
Naive datetime.now() | Even after string conversion, timezone meaning is missing | Create or localize an aware datetime |
Root Cause
The documented Python JSONEncoder conversion table
supports only a small set of Python types:
| Python value | JSON value |
|---|---|
dict | object |
list, tuple | array |
str | string |
int, float | number |
True, False | true, false |
None | null |
datetime, date, time | no 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.
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 serializablepayload 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.123ZThat 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.
| Method | Best use | Advantages | Tradeoffs |
|---|---|---|---|
value.isoformat() | One known field | Explicit and dependency-free | Caller must enforce timezone policy |
default=typed_function | Nested standard-library payloads | Central, strict, reusable | Must be passed at each JSON boundary |
Custom JSONEncoder | Existing code standardized on cls= | Encapsulates multiple supported types | More ceremony than a function |
| Framework/schema serializer | FastAPI, Pydantic, Django | Validation and response integration | Behavior is framework/version-specific |
| Epoch number | Numeric/legacy protocol | Compact, easy arithmetic | Unit ambiguity; unreadable; precision limits |
default=str | Temporary debugging | One line | Silently stringifies every unsupported type |
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
datetimebeforedate;datetimeis a subclass ofdate. - Testing both
tzinfoandutcoffset()rejects objects that are effectively naive even if a customtzinfoobject 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
TypeErrorpreserves normaljsonbehavior 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
# TruePython 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 conventionalT.Decimalis 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
# strThe 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']: setThis 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:
# TrueDo 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:
dateusually maps cleanly toYYYY-MM-DD.timewithout a date may still need an offset and business timezone.timedeltais 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.
| Environment | What to check |
|---|---|
| Linux/macOS | Do not assume the host timezone; create aware values explicitly |
| Windows | Verify the same Python/venv and timezone policy as production |
| WSL | Windows and the Linux distribution can have different timezone configuration |
| Docker | Set application timestamps explicitly; container TZ does not repair naive data |
| CI/CD | Freeze representative aware datetimes and run response contract tests |
| Cloud functions/jobs | Serialize before handing payloads to SDKs, queues, or state stores |
| CPU/GPU | No behavioral difference; datetime JSON encoding is CPU-side application logic |
| Development | Fail fast with the field path and type |
| Production | Return 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__orvars(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
Zand+00:00are 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 verifiedProduction verification checklist:
- Confirm the response/message/file contains a JSON string or documented epoch number, not a Python representation.
- Confirm an instant survives encode and decode without shifting.
- Confirm naive values fail before leaving the service.
- Confirm precision is intentional and tested.
- Confirm consumers accept
Zor the chosen offset form. - Confirm unexpected object types still raise
TypeError. - Run the test with the Python and framework versions used in production.
Troubleshooting Matrix
| Symptom after the change | Likely cause | Fix |
|---|---|---|
Error still names datetime | Converter was not passed to this call site | Trace the actual JSON boundary |
Error changes to Timestamp | pandas value uses a different type/path | Use pandas ISO serialization or handle Timestamp |
Output has no Z or offset | Input was naive | Apply an explicit timezone policy before serialization |
| Time shifts by several hours | Local time was labeled as UTC or converted twice | Distinguish replace from astimezone |
Client receives "{\"at\":...}" | JSON was encoded twice | Return the structure or raw JSON, not both |
json.loads returns str | This is normal JSON behavior | Parse the schema field explicitly |
| Works in Django, fails in a worker | DjangoJSONEncoder is not used by the worker | Share a boundary serializer |
| Works locally, fails in Docker | Different type, dependency, or implicit timezone | Log safe type paths and compare versions |
| Consumer reads a 1970 or far-future date | Seconds/milliseconds mismatch | Document and test the epoch unit |
| Custom encoder hides new bad data | Fallback 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.
Related Guides
- Convert a Python date to Unix epoch time
- Fix TypeError when concatenating
strandNoneType - Fix
DataFrame.appendafter upgrading pandas - Common pandas errors and gotchas
- Understand classes and objects in Python
- Fix
ModuleNotFoundError: No module named cv2 - Fix
ImportError: cannot import name Mapping
Official References
- Python
jsonencoder and decoder - Python
datetime.isoformat()andfromisoformat() - RFC 3339: Date and Time on the Internet
- Flask default JSON provider
- Django
JsonResponse - Django
DjangoJSONEncoder - FastAPI
jsonable_encoder - Pydantic serialization
- pandas
DataFrame.to_json
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.
Software Engineering Leader & Technical Author · Updated July 27, 2026