skip to content

How does json.dumps map Python types, and what does it refuse?

level: middleimportance: should knowfreq 60%

answer

  1. The encoder knows only six shapes
  2. Two Python types collapse into one JSON type
  3. Keys get their own, looser rule
  4. tuple in, list out
  5. Everything else raises TypeError

basics

~20 s

dict becomes an object; list and tuple both become arrays; str becomes a string; int and float become numbers; True, False and None become true, false and null. Everything else — set, bytes, datetime, Decimal — raises TypeError.

solid answer

~40 s

The encoder handles a fixed set of types: `dict`, `list` and `tuple`, `str`, `int` and `float`, and the singletons `True`, `False`, `None`. Anything else raises `TypeError: Object of type X is not JSON serializable` — `set`, `bytes`, `datetime`, `Decimal`, and any class of your own. Object **keys** are handled separately and more loosely: `str`, `int`, `float`, `bool` and `None` keys are coerced to strings, and any other key type raises unless you pass `skipkeys=True`, which silently drops that pair. The mapping is therefore lossy in one direction — a `tuple` returns as a `list`, an integer key returns as a `str` key — so `json.loads(json.dumps(x)) == x` is not guaranteed. Since 3.14 that `TypeError` carries a note naming the path to the offending value.

code

python · 6 lines
python
import json

original = {"tags": ("digest", "weekly"), "counts": {1: "one"}}
restored = json.loads(json.dumps(original))
print(restored)
print(type(restored["tags"]), list(restored["counts"]))

go deeper

for a junior

Recall the six shapes JSON has and that a set, a datetime or a Decimal simply raises TypeError. Knowing that a tuple comes out as a list is the single most useful fact at this level.

for a middle

Explain the key-coercion rule separately from the value rule, and why the round trip is not identity. Being able to read the 3.14 exception note and point straight at the offending field is the practical skill.

for a senior

Demonstrate judgment about what crosses a service boundary: allow_nan=False for non-Python consumers, sorted output for stable diffs and caching, and a deliberate policy for the types JSON cannot carry rather than ad-hoc casts at each call site.

for a principal

Own the contract question. Decide whether the wire format is a projection maintained by hand or generated from a schema, who owns the type-conversion rules, and what a consumer is allowed to assume when a field disappears or changes representation.

## The table the encoder actually implements `json.dumps` walks your object graph with a small, closed set of rules: | Python | JSON | |---|---| | `dict` | object | | `list`, `tuple` | array | | `str` | string | | `int`, `float` | number | | `True` / `False` | `true` / `false` | | `None` | `null` | That is the whole table. Note two things immediately: two distinct Python types collapse onto one JSON type (`list` and `tuple` both become arrays), and subclasses of these types are accepted because the encoder checks with `isinstance`. Anything not in the table raises `TypeError: Object of type X is not JSON serializable`. The types that bite in real code are `set` and `frozenset`, `bytes`, `datetime.datetime` and `datetime.date`, `decimal.Decimal`, `uuid.UUID`, `pathlib.Path`, `enum.Enum` members that are not `int` or `str` subclasses, and every domain class you have written. ## Keys follow different rules JSON object keys can only be strings, so the encoder coerces. A `str` key is used as-is; `int`, `float`, `bool` and `None` keys are converted to their JSON scalar spelling — `1` becomes `"1"`, `True` becomes `"true"`, `None` becomes `"null"`. Any other key type raises `TypeError: keys must be str, int, float, bool or None, not tuple`. `skipkeys=True` changes that raise into a silent drop, and it is narrower than most people assume: it only suppresses **unsupported keys**, never unsupported values. `json.dumps({(1, 2): "x"}, skipkeys=True)` returns `'{}'` — the pair vanishes with no warning, which is exactly the kind of quiet data loss worth flagging in review. One more key subtlety: `sort_keys=True` sorts the original Python keys *before* coercion, so a dict mixing `int` and `str` keys raises `TypeError: '<' not supported between instances of 'str' and 'int'` — a confusing failure whose cause is the sort, not the encoding. ## The round trip is not identity The single most-asked consequence: `json.loads(json.dumps(obj)) == obj` can be `False` for perfectly valid input. ```python import json original = {"tags": ("digest", "weekly"), "counts": {1: "one"}} restored = json.loads(json.dumps(original)) # {'tags': ['digest', 'weekly'], 'counts': {'1': 'one'}} ``` The tuple came back a list; the integer key came back a string. Neither is a bug — JSON has no tuple and no non-string key, so the information simply has nowhere to live. If a downstream `if key in counts` used an `int`, it now misses. The practical rule: **treat JSON as a lossy projection of your data, not a serialization of your objects.** If you need the Python types back, you must reverse the coercion deliberately on the decode side. Ordering, by contrast, *is* preserved: `dict` has kept insertion order since 3.7, and the encoder emits keys in that order unless you pass `sort_keys=True`. ## The special floats `float('nan')`, `float('inf')` and `float('-inf')` are not part of the JSON number grammar, yet `json.dumps` emits the bare tokens `NaN`, `Infinity` and `-Infinity` by default, and `json.loads` accepts them back. That is deliberate CPython leniency, and it is a genuine interoperability hazard when the consumer is not Python. `allow_nan=False` turns the encode into `ValueError: Out of range float values are not JSON compliant`, and `parse_constant=` lets you intercept those tokens on the way in. Any service publishing JSON to non-Python consumers should be setting `allow_nan=False` and dealing with the values explicitly. ## Diagnosing the TypeError The message names only the offending *type*, which in a deeply nested payload tells you almost nothing. Python 3.14 improved this: the raised `TypeError` now carries an exception **note** identifying where in the structure the failure happened. ```pycon >>> import json, traceback >>> try: ... json.dumps({"recipients": {"[email protected]"}}) ... except TypeError as exc: ... print("".join(traceback.format_exception_only(exc)), end="") ... TypeError: Object of type set is not JSON serializable when serializing dict item 'recipients' ``` On 3.13 and earlier you get the type name alone and have to bisect the payload by hand. ## What to do about the refusals The refusal is the encoder telling you it has no opinion about your type, and the fix is to supply one: a `default=` callable, or a `json.JSONEncoder` subclass overriding `default`, that maps each unsupported type to something in the table above. The important design point is that this is a *conversion decision*, not a technicality — deciding a `Decimal` becomes a JSON string rather than a float, or a `set` becomes a sorted array, changes what the consumer sees, and the sort matters because Python set iteration order is not stable across processes.

  • Why is json.loads(json.dumps(obj)) == obj not always true?
    Because the mapping is not injective. A `tuple` and a `list` both encode to a JSON array, so tuples come back as lists; non-string dict keys are coerced to strings, so an `int` key comes back as `str`. Sets and other unsupported types never even get that far — they raise. JSON is a lossy projection of Python data, so any type fidelity you need must be restored deliberately on decode.
  • What exactly does skipkeys=True skip?
    Only dict **keys** whose type is not `str`, `int`, `float`, `bool` or `None` — those pairs are dropped silently instead of raising `TypeError`. It does nothing for unsupported *values*: `json.dumps({'a': {1, 2}}, skipkeys=True)` still raises. Because the drop is silent, it is usually the wrong tool; converting the key explicitly says what you meant.
  • What does json.dumps do with float('nan') or float('inf')?
    By default it emits the bare tokens `NaN`, `Infinity` and `-Infinity`, which `json.loads` will read back — a CPython extension that other parsers may reject. Pass `allow_nan=False` to raise `ValueError` instead, which is the safer setting for any payload leaving a Python-only world, and use `parse_constant=` on decode if you need to intercept those tokens.

saying these in an interview costs you the question

  • Thinks a tuple round-trips back as a tuple
  • Says json.dumps can serialize any Python object
  • Expects integer dict keys to survive as integers
  • Believes skipkeys=True skips unserializable values
  • Claims json.dumps raises on NaN by default
  • Assumes key order is arbitrary rather than insertion order

context