21:52.
Here's the thing about a timestamp. Everybody thinks it's a small thing. It sits at the end of a dictionary like it's furniture — "created_at", a polite little value, minding its business. And you go to send it across the wire, one service to another, the way we've all been doing since JSON became the language the whole internet agreed to speak, and you write the line you've written a thousand times:
import json
import datetime
payload = {"user": "aaron", "created_at": datetime.datetime.now()}
json.dumps(payload)
And the machine looks you dead in the eye and says:
TypeError: Object of type datetime is not JSON serializable
I want you to notice that it didn't stutter. It didn't apologize. It's not a warning. It's a refusal.
21:58.
So you do what everyone does. You go to the internet, and the internet — which is very sure of itself — hands you the fix in four seconds flat:
json.dumps(payload, default=str)
And it works. The red goes away. The tests pass. You feel like a person who solves problems.
Don't trust that feeling. That feeling is how the real bug gets on the plane.
22:14.
Because here's what nobody tells you at 4 seconds. datetime.datetime.now() is a naive datetime. No timezone. No offset. It's a number that looks like a time and refuses to say which one. If you want the full tour of what the module is actually doing under all that sugar, it's here — How To Use Python datetime Module — but the one-sentence version is this:
A naive datetime is a witness who saw the whole thing and won't tell you what city it happened in.
You serialized 2026-08-02T22:14:00. No offset. You shipped it to a service in Tokyo. That service, reasonably, in good faith, reads it back and assumes it's their time. Your 10:14 at night just became somebody else's tomorrow afternoon. Nothing crashed. No exception. No red. The data is just quietly, confidently wrong, and it will stay wrong until a customer emails you asking why their invoice is dated in the future.
That's the crime. Not the TypeError. The TypeError was the building's fire alarm doing its job. The silent one is the fire.
22:31.
Okay. We're going to do this the right way. And the right way starts with a rule you don't get to break:
Every datetime that leaves this program is timezone-aware, and it's in UTC.
from datetime import datetime, timezone
now = datetime.now(timezone.utc) # aware. it knows where it lives.
now.isoformat() # '2026-08-02T22:31:00+00:00'
See that +00:00? That's the datetime finally telling you which city. That's the whole ballgame. Serialize that, and Tokyo can't misunderstand you, because you didn't leave anything to interpretation.
For the encode itself, skip the default=str reflex and say what you actually mean:
class DateTimeEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, datetime):
return obj.isoformat()
return super().default(obj)
json.dumps({"created_at": now}, cls=DateTimeEncoder)
# '{"created_at": "2026-08-02T22:31:00+00:00"}'
Need a specific zone instead of UTC on the way in? Use zoneinfo, which has been in the standard library since 3.9. If you're still importing pytz in 2026, that's not a style choice, that's a hostage situation:
from zoneinfo import ZoneInfo
datetime.now(ZoneInfo("America/New_York"))
22:49.
Now the part nobody writes. Everybody writes the article called "Python to JSON." They stop at the door. They title it "…and Back" and then don't go back.
Here's the thing they skip: JSON has no date type. It has strings, numbers, booleans, null, arrays, objects. That's the whole cast. So when you json.loads() your beautiful ISO string, Python does not hand you a datetime. It hands you a string that is cosplaying as one.
data = json.loads('{"created_at": "2026-08-02T22:31:00+00:00"}')
type(data["created_at"]) # <class 'str'> ← it lied to you
You have to walk it back in yourself. object_hook runs on every object as it's parsed — that's your checkpoint:
def revive_datetimes(obj):
for key, value in obj.items():
if isinstance(value, str):
try:
obj[key] = datetime.fromisoformat(value)
except ValueError:
pass # it was just a string being a string
return obj
data = json.loads(text, object_hook=revive_datetimes)
type(data["created_at"]) # <class 'datetime.datetime'>, aware, correct
datetime.fromisoformat() reads back everything isoformat() wrote — offset and all — so the round trip is closed. It went out knowing what city it was in. It came home knowing. If you want the full read-and-write-to-disk version of this — files, not just strings — it's here: How To Read and Write JSON Files in Python.
23:06.
And if, after all of this, you're thinking "that is a lot of ceremony for a timestamp" — you're right. You've earned the shortcut.
orjson serializes datetime natively. No encoder class. No default=. It just knows.
import orjson
from datetime import datetime, timezone
orjson.dumps({"created_at": datetime.now(timezone.utc)})
# b'{"created_at":"2026-08-02T23:06:00+00:00"}'
It's faster than the standard library and it does the aware-UTC thing correctly out of the box, which means it removes the two places you were most likely to hurt yourself. It returns bytes, not str, because it respects your time. The full tour is here — How To Use Python orjson for Fast JSON Processing.
23:11. Closing entry.
So here's what I know now that I didn't at 21:52.
The error message was never the problem. The error message was the honest one. It stood up, it told you the truth, it refused to ship a lie. The bug — the real bug, the one that costs you an evening and somebody else a support ticket — is the quiet fix. The one that makes the red go away without making the problem go away.
Make it aware. Make it UTC. Send it out knowing what city it's in. Walk it back the same way. And if you'd rather not think about any of it ever again, let orjson do the thinking.
It's a timestamp. It was never small. Nothing that tells you when is ever small.
— filed at 23:11 UTC, which is the only responsible way to file anything.
Writing more Python "the right way" walkthroughs over at Python How To Program. Come argue with me in the comments — especially if you still ship naive datetimes. Especially then.

Top comments (0)