skip to content

Why does json.dumps reject a plain enum.Enum member but accept an enum.IntEnum one?

level: middleimportance: should knowfreq 45%

answer

  1. json knows only a closed set of types
  2. isinstance checks, no serialization protocol
  3. int and str subclasses pass straight through
  4. `default=` or a JSONEncoder subclass
  5. Enum keys become strings, or raise

basics

~20 s

json's encoder serializes a closed set of types chosen by isinstance checks, and a plain enum.Enum member is none of them, so json.dumps raises TypeError. enum.IntEnum and enum.StrEnum members are real int and str subclasses, so their raw value is written out.

solid answer

~40 s

`json.dumps` handles only `dict`, `list`, `tuple`, `str`, `int`, `float`, `bool` and `None`, decided by `isinstance`; there is no serialization hook an arbitrary class can implement. A plain `Enum` member matches none of them, so the encoder falls through to its `default` hook and raises `TypeError: Object of type Stage is not JSON serializable`. An `enum.IntEnum` or `enum.StrEnum` member passes the check by inheritance and is written as the bare number or string - the encoder reads the underlying data, never the member's `__str__`, which is why the 3.11 text changes are invisible in JSON. Dict keys have their own rule: an IntEnum key becomes `"3"`, a plain Enum key raises. Fix it at the serialization layer with `default=lambda o: o.value` or a `json.JSONEncoder` subclass - not by redeclaring the domain type as an IntEnum.

code

python · 19 lines
python
import json
from enum import Enum, IntEnum, StrEnum

class Priority(IntEnum):
    HIGH = 3

class Queue(StrEnum):
    TRIAGE = "triage"

class Stage(Enum):
    BUILD = "build"

print(json.dumps({"priority": Priority.HIGH, "queue": Queue.TRIAGE}))
print(json.dumps({Priority.HIGH: "paged"}))
print(json.dumps({"stage": Stage.BUILD}, default=lambda o: o.value))
try:
    json.dumps({"stage": Stage.BUILD})
except TypeError as exc:
    print("plain Enum:", exc)

go deeper

for a junior

Know that dumping a plain enum member raises TypeError and that reaching for .value fixes it. Recognise the message Object of type X is not JSON serializable when you see it.

for a middle

Explain the mechanism - a closed type list checked with isinstance, an int or str subclass passing by inheritance, and the default hook as the extension point - and know that dict keys follow a separate coercion rule.

for a senior

Show the boundary discipline: one encoder or one conversion step, an explicit choice between .name and .value on the wire, and validation on the way back in rather than trusting that a parsed int happens to compare equal to a member.

for a principal

Own the contract question: whether the wire carries names or numbers, what happens when a member is added or renumbered, and why the serialization decision should not be made by picking a base class for the domain type.

### What the json encoder actually accepts `json.dumps` can serialize a fixed, closed set of Python types: `dict`, `list`, `tuple`, `str`, `int`, `float`, `bool` and `None`. It decides with `isinstance` checks, not by asking the object anything. There is no protocol an arbitrary class can implement to become serializable: the encoder never asks an object how to encode itself. If an object is not one of those types, the encoder calls the `default` hook, and the base implementation of that hook raises `TypeError: Object of type Stage is not JSON serializable`. A plain `enum.Enum` member is an instance of your enum class and nothing else, so it lands in that error path. A member of `enum.IntEnum` or `enum.StrEnum` *is* an `int` or a `str` by inheritance, so the `isinstance` check passes and it is written out like any other number or string. ### Why the member's own text never appears For an `int` subclass the encoder formats the value with the built-in integer's own repr; for a `str` subclass it escapes the underlying string data. The member's `__str__` and `__repr__` are never consulted. That is why the **3.11** change that made `str(Priority.HIGH)` return `"3"` instead of `"Priority.HIGH"` had no effect on JSON output whatsoever, and why the older `class Status(str, Enum)` idiom and `enum.StrEnum` produce identical JSON despite printing differently. ### Dict keys are a separate rule JSON object keys must be strings, so `json.dumps` coerces the small set of key types it allows: * an `enum.IntEnum` key becomes the **string form of the number** - `{Priority.HIGH: "paged"}` serializes to `{"3": "paged"}`, and the fact that it was an enum, or even a number, is gone; * an `enum.StrEnum` key is used as its raw text; * a plain `Enum` key raises `TypeError: keys must be str, int, float, bool or None, not Stage`, unless `skipkeys=True`, which **silently drops the entry** - a nasty way to lose a field. ### The three ways to serialize a plain Enum 1. **Convert at the boundary.** Build the payload with `member.value` (or `member.name`) where the dict is assembled. Most explicit, and it makes the wire shape reviewable. 2. **`default=`.** `json.dumps(payload, default=lambda o: o.value)` handles anything the encoder rejects. Cheap, but it fires for *every* unserializable object, so a stray datetime will now be handed to a lambda that expects an enum. 3. **A `json.JSONEncoder` subclass.** Override `default`, test `isinstance(o, Enum)`, return `o.value`, and delegate to `super().default(o)` for everything else. Pass it with `cls=EnumEncoder`. This is the reusable option and the one to name when the payload has several custom types. Whichever you pick, decide **name or value** deliberately. `.value` is compact and matches the raw protocol; `.name` is readable in a log and survives a value renumbering. Do not let the choice be made by accident. ### Serialization is not symmetric Nothing in `json.loads` reconstructs an enum. You get back `3` or `"triage"`, and turning that into a member is your job - call the enum class on the value, in one place, at the point where untrusted input enters the system, and handle the failure when the value is not a member. A ticket-triage bot that dumps `Priority.HIGH` as `3` and then compares the parsed `3` against `Priority.HIGH` gets away with it only because `IntEnum` makes that comparison `True`; with a plain enum, that same code is a silent no-match, and the escalation quietly never fires. ### The judgement an interviewer is listening for The tempting move is to declare the enum an `IntEnum` so `json.dumps` stops raising. That is choosing a type-system weakness to solve a serialization problem, and it spends the enum's isolation in every other module that touches the type. The better answer is that the domain type stays a plain `Enum` and the conversion lives in the serialization layer - one encoder, one place to change, and the wire format visible in code rather than implied by a base class.

  • What happens when an enum member is used as a dict key in json.dumps?
    JSON keys must be strings, so the encoder coerces the key types it allows. An `enum.IntEnum` key becomes the string form of its number - `{Priority.HIGH: "paged"}` gives `{"3": "paged"}` - and an `enum.StrEnum` key becomes its raw text. A plain `Enum` key raises `TypeError: keys must be str, int, float, bool or None`. Passing `skipkeys=True` makes that entry disappear silently instead, which is usually worse than the exception.
  • Is switching the enum to enum.IntEnum a good way to make json.dumps work?
    No. That solves a serialization problem by weakening the domain type everywhere else - the members now equal bare ints and same-valued members of other enums in every module that touches them. Keep the plain `Enum` and put the conversion in the serialization layer: a `default` hook or a `JSONEncoder` subclass, in one place. The exception is when the wire format genuinely *is* the integer and the enum exists to name that protocol's constants.
  • Does json.loads give you back an enum member?
    No, and nothing in the standard library does it for you. You get `3` or `"triage"` and must rebuild the member by calling the enum class on the value, ideally in one validation step at the point untrusted input enters, with an explicit failure path for values that are not members. Code that skips this often only works because an IntEnum makes the parsed int compare equal to the member by accident.

saying these in an interview costs you the question

  • Thinks json.dumps serializes any enum member out of the box
  • Expects json.loads to hand back an enum member
  • Believes the encoder calls str() or repr() on the member
  • Switches a domain enum to IntEnum purely to dodge the TypeError
  • Assumes a plain enum.Enum works as a JSON object key
  • Claims a custom serialization method on the class makes it encodable

context