How do you make json.dumps serialize a datetime or a Decimal?
answer
- The encoder asks only when stuck
- One hook out, a different hook back
- Return an object, never encoded text
- isoformat, not strftime
- str keeps the digits, float rounds them
basics
~20 sGive the encoder a fallback: pass default=fn, a callable json.dumps invokes only for objects it cannot handle, or subclass json.JSONEncoder, override default and pass it as cls=. Return a substitute — isoformat() for a datetime, str() for a Decimal.
solid answer
~40 s`json.dumps` accepts `default=`, a one-argument callable it invokes **only** when its built-in mapping has already failed on an object. Your function returns a serializable stand-in, and the encoder then encodes that recursively; for anything you do not recognise you must re-raise `TypeError` so unknown types still fail loudly. When the rules are reused across a codebase, subclass `json.JSONEncoder`, override `default`, end it with `return super().default(o)`, and pass the class as `cls=`. For a `datetime` return `o.isoformat()` — never `str(o)` and never a `strftime` pattern with locale-dependent fields. For a `Decimal` return `str(o)` to keep the exact digits, since `float(o)` rounds to a binary double. Decoding is a separate, explicit step: `object_hook=` rebuilds objects from dicts and `parse_float=Decimal` restores precision.
code
python · 22 linesimport datetime
import decimal
import json
class DigestEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, (datetime.datetime, datetime.date)):
return o.isoformat()
if isinstance(o, decimal.Decimal):
return str(o)
if isinstance(o, (set, frozenset)):
return sorted(o)
return super().default(o)
digest = {
"sent_at": datetime.datetime(2026, 9, 5, 10, 30),
"cost_usd": decimal.Decimal("19.90"),
"team": {"dana", "ana", "bo", "cy"},
}
print(json.dumps(digest, cls=DigestEncoder, sort_keys=True))go deeper
Recall that the TypeError is the encoder saying it has no rule for your type, and that default= is where you supply one. Knowing isoformat() as the datetime answer covers most of what is asked here.
Explain that default fires only as a fallback, that its return value is re-encoded rather than emitted verbatim, and that decoding needs its own hook. Be able to write the JSONEncoder subclass with super().default(o) at the end.
Show that conversions are wire-contract decisions: deterministic formats, sorted output for unordered collections, Decimal as string with a documented convention, and one shared encoder rather than per-call-site rules that drift apart.
Own how types cross the boundary at all. Decide whether hand-written hooks are the right mechanism versus a schema-driven layer, who reviews a change to the conversion rules, and how consumers are notified when a field's representation changes.
## Where the hook fits The encoder walks your object graph, and for every value it cannot map to one of JSON's six shapes it calls one function: `default`. `json.dumps(obj, default=fn)` supplies that function inline; `json.JSONEncoder.default` is the method you override when you want the rules reusable, and you activate the subclass with `json.dumps(obj, cls=MyEncoder)`. The two are the same hook reached two ways. Three properties matter and are routinely got wrong in interviews: 1. **It is a fallback, not a filter.** `default` is never called for a `dict`, `list`, `str`, number, bool or `None`. It fires only after the built-in mapping has failed, so it costs nothing on the happy path. 2. **Its return value is encoded recursively.** Return a `str`, a `list`, a `dict` of already-serializable parts — not JSON *text*. Returning `json.dumps(o)` from inside `default` produces a doubly-encoded string that the consumer must parse twice. 3. **Unknown types must still raise.** End your subclass's `default` with `return super().default(o)`, which raises the standard `TypeError`. Swallowing the unknown case — returning `str(o)` for everything — turns a loud failure into silent, undebuggable garbage on the wire. ## The two canonical conversions For a `datetime`, return `o.isoformat()`. It is unambiguous, sorts lexicographically, keeps the offset when the value is aware, and has an exact inverse in `datetime.datetime.fromisoformat`. For a `Decimal`, you are choosing between two losses. `float(o)` fits the JSON number grammar but rounds to a binary double, so `Decimal("19.90")` stops being exactly 19.90. `str(o)` keeps every digit but ships a JSON *string*, which the consumer must know to re-parse. For money, `str(o)` plus a documented convention is almost always right; the mistake is picking `float` by reflex because "it is a number". ```python import datetime, decimal, json class DigestEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, (datetime.datetime, datetime.date)): return o.isoformat() if isinstance(o, decimal.Decimal): return str(o) return super().default(o) ``` ## A concrete way this goes wrong An email-digest sender maintained by a four-person team serialized each run's summary to JSON for the downstream consumer. Someone needed a timestamp in the payload, hit the `TypeError`, and reached for the shortest fix that made it go away: a `default` returning `o.strftime("%c")`. That works on a laptop and produces `Sat Sep 5 10:30:00 2026`. `%c` is a **locale-dependent** format: the same code on a host with a different locale emits a differently ordered, differently worded string, and any consumer parsing it breaks the moment the base image or the locale environment changes — with no error at the point of serialization, only at the point of reading. `isoformat()` has no such dependency. The general rule the story teaches: a conversion inside `default` is part of your wire contract, so it must be as deterministic as the rest of it — which is also why a `set` should become `sorted(o)` rather than `list(o)`, whose order is not stable across processes. ## Coming back the other way Nothing about `default` is automatic in reverse. The decoder returns plain dicts, strings and numbers, and you opt into reconstruction with three hooks: - `object_hook=fn` — called with every decoded JSON object as a `dict`; whatever you return replaces it. This is where you spot a marker field and rebuild a `datetime` or a domain object. - `object_pairs_hook=fn` — the same, but receives an ordered list of key/value pairs before a dict is built, which is what you use when key order or repeated keys matter. - `parse_float=`, `parse_int=`, `parse_constant=` — swap the callable used for every number. `parse_float=decimal.Decimal` is the standard way to keep monetary precision on the way in. ```python import datetime, decimal, json def revive(obj): if "sent_at" in obj: obj["sent_at"] = datetime.datetime.fromisoformat(obj["sent_at"]) return obj json.loads(payload, object_hook=revive, parse_float=decimal.Decimal) ``` Note `object_hook` fires for **every** object in the document, innermost first, so keep it cheap and make its detection specific — a hook that triggers on a common key name will happily mangle an unrelated nested object. ## Design consequence Once a codebase has more than one place calling `json.dumps`, the encoder subclass and the matching hook belong together in one module, and every call site goes through a thin wrapper. Otherwise the conversion rules drift: one endpoint emits a `Decimal` as a string, another as a float, and the consumer's parser has to guess.
- When exactly does json.dumps call the function you pass as default=?Only after its built-in mapping has failed for that object, once per unsupported value — never for dicts, lists, strings, numbers, bools or None. Whatever you return is then encoded recursively, so it must itself be serializable. For any type you do not handle, call `super().default(o)` or raise `TypeError` yourself, so an unrecognised object still fails loudly instead of silently becoming a repr.
- Why return str(value) for a Decimal rather than float(value)?`float()` converts to a binary double, so an exact decimal such as 19.90 stops being exact — unacceptable for money. `str()` keeps every digit, at the cost of shipping a JSON string the consumer must re-parse. If the consumer is Python, `json.loads(..., parse_float=decimal.Decimal)` restores precision for plain JSON numbers too. The choice is a documented wire-contract decision, not an implementation detail.
- What is the difference between object_hook and object_pairs_hook?`object_hook` receives each decoded JSON object as a finished `dict`; `object_pairs_hook` receives an ordered list of key/value pairs *before* the dict is built, and takes priority when both are given. Use the pairs form when key order matters or when you must detect repeated keys, which a dict has already collapsed. Both fire for every object in the document, innermost first.
saying these in an interview costs you the question
- Overrides JSONEncoder.encode instead of default
- Uses str(datetime) or a locale-dependent strftime format
- Casts a Decimal to float and loses precision
- Thinks default= is called for every value
- Returns already-encoded JSON text from default=
- Expects object_hook to reverse default= automatically