PEP 837 – Extensible JSON serialization
- Author:
- Serhiy Storchaka <storchaka at gmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Draft
- Type:
- Standards Track
- Created:
- 12-Jul-2026
- Python-Version:
- 3.16
- Post-History:
- 12-Jul-2026
Abstract
This PEP adds an extension mechanism to the json encoder
consisting of three complementary parts, one per level of
customization:
- library level: a serialization protocol — the special methods
__json__()and__raw_json__(); - application level: a global registry —
copyreg.json(cls, function)fillingcopyreg.json_dispatch_table, following the precedent ofcopyreg.pickle(); - call level: a per-encoder dispatch table — a
dispatch_tableattribute on ajson.JSONEncoder, mirroringpickle.Pickler.dispatch_table.
The more specific level takes precedence.
A helper class copyreg.RawJSON wraps an already-encoded JSON string
so that it is emitted verbatim, enabling representations that are not
otherwise expressible (such as serializing decimal.Decimal as a
JSON number with full precision).
Several standard library container types whose JSON form is unambiguous —
collections.deque, types.MappingProxyType,
collections.ChainMap, collections.UserDict,
collections.UserList and collections.UserString —
gain a __json__ method and therefore serialize out of the box.
The json module itself gains no new public names.
Motivation
Serializing anything beyond the basic types (dict, frozendict,
list, tuple, str, int, float, bool, None)
with json.dumps() today requires either passing a default=
function to every call, or subclassing json.JSONEncoder and
threading the subclass through every call site. Both mechanisms attach
the knowledge to the call rather than to the type:
- They do not compose. Two libraries that each need a custom
default=cannot both have it applied to one document without the application writing a merging wrapper by hand. A library that serializes internally (for logging, caching, IPC) cannot see the application’sdefault=at all. - Third-party types cannot opt in. A library defining a new type has no way to make it JSON-serializable for its users; every user must learn and repeat the incantation. Requests to fix this for NumPy scalars and arrays (python/cpython#68501, python/cpython#62503) and for D-Bus integer types (python/cpython#77978) were closed as out of scope for the standard library — correctly, but leaving the underlying need unmet.
- Some representations are impossible.
default=must return one of the basic types, so there is no way to emit a non-integer JSON number thatfloatcannot represent exactly.json.loads()can read decimal numbers losslessly viaparse_float=decimal.Decimal, but the result cannot be written back (python/cpython#67312). The same limitation blocks control over float formatting (python/cpython#81022) and non-finite float representation (python/cpython#98306, python/cpython#134717), and the general request for emitting pre-encoded JSON (python/cpython#86957).
The demand is long-standing and broad. Beyond the general proposals for
a serialization hook checked before raising TypeError
(python/cpython#71549 — open since 2016, python/cpython#79292,
python/cpython#114285, python/cpython#86931), the tracker accumulated
requests for out-of-the-box support of concrete standard library types:
decimal.Decimal (python/cpython#67312, python/cpython#118810,
python/cpython#145115), array.array (python/cpython#70451),
collections.deque (python/cpython#64973, python/cpython#73849),
types.MappingProxyType (python/cpython#79039),
collections.namedtuple() as an object (python/cpython#67661), and
datetime.datetime (python/cpython#65742); and for whole
categories: sets, frozensets, bytearrays and iterators
(python/cpython#75338), generators (python/cpython#78031), and
dictionary views (python/cpython#83377).
The idea has also been proposed independently for sixteen years, each
time reinventing part of this design: a __json__() method on
python-ideas in July 2010
and April 2020
and on discuss.python.org in 2022–2024;
raw output and a standardized encoder protocol on python-ideas in 2019;
a registration function on discuss.python.org in 2022.
Meanwhile the ecosystem adopted the convention piecemeal. TurboGears
serializes objects via a no-argument __json__() — and its
custom_encoders configuration takes precedence over it, the same
ordering as this PEP. Pyramid’s JSON renderer
calls __json__(request). Applications and libraries such as conda,
Checkmk and TatSu define __json__ today. simplejson added an opt-in for_json()
method instead — choosing that name precisely because dunder names are
reserved for the language (simplejson issue #52). In that issue’s
discussion, a GitHub code search already found more __json__
definitions in the wild than for_json ones by 2016, and formalizing
the protocol in a PEP was requested so that third-party libraries can
rely on the same signature. Only the standard library can define
__json__; this PEP does, ending the fragmentation.
The type-support requests could not simply be granted one by one. For most of the requested types the JSON representation is ambiguous:
Decimal— a JSON number (1234567890.0987654321) or a JSON string ("1234567890.0987654321")? Both are common; each is wrong for some consumers.namedtuple— a JSON Array (it is a tuple) or a JSON Object (it has named fields)?array.array— a JSON Array of numbers, or a compact encoded string? The sensible answer even depends on its type code.datetime— which of the many string representations (ISO 8601 variants, epoch seconds, RFC formats)?
No standard library default can be correct, so the standard library should provide the mechanism by which each application declares its policy. That mechanism is this PEP.
Rationale
One system covering all of the collected needs
The feature is deliberately a small system rather than a single hook: each part answers a different cluster of the requests above, and no part alone covers them all.
- The
__json__protocol answers the general extension proposals and the third-party-type reports: the type’s author makes it serializable once, with no imports, inherited by subclasses. - The registry answers the requests for types the requester does
not control or whose representation is a policy choice (
Decimal,namedtuple,array.array,datetime): the application declares its policy in one line. - Raw output (
__raw_json__/RawJSON) answers the requests for representations that no basic-type conversion can express: pre-encoded fragments, full-precision numbers, float formatting, non-finite floats. - Duck typing of hook results answers the category requests
(iterables, sets, generators, dictionary views, mappings):
copyreg.json(set, sorted)is a complete solution for sets, and a hook may return any iterable or mapping-like object rather than materializing a list or dict. - Standard library
__json__methods answer the requests for unambiguous containers (deque,mappingproxy) directly. - The per-encoder
dispatch_tablescopes any of the above to one encoder when a process needs different policies for different outputs.
Three levels of customization
The three parts correspond to the three levels at which serialization is decided, and deliberately have different shapes:
- Library level —
__json__()is for the author of a type. A method needs no imports and is inherited by subclasses: a library can make its types JSON-serializable without depending onjsonorcopyregat all. - Application level —
copyreg.json()is for users of a type they do not control: the application chooses, process-wide, how third-party or ambiguous standard library types serialize. Registration is by exact type; subclasses that want the same treatment inherit__json__instead. - Call level — the
dispatch_tableattribute on ajson.JSONEncodersubclass or instance customizes a particular call, when one program needs different policies for different outputs (an external API versus an internal cache, say). The existingdefault=parameter remains the call-level catch-all for objects no other mechanism handled.
The more specific level takes precedence: a registry entry is
consulted before the type’s __json__ (the application overrides
the library), and a per-encoder table replaces the global registry
(the call overrides the application).
Why the registry lives in copyreg
The registry could have been json.register(). It is placed in
copyreg instead, for three reasons.
Import cost and layering. A library that registers a serialization
function for a foreign type at import time should not pay for the
json module, whose import is dominated by re (roughly
fifty times the cost of importing copyreg, which is imported by
copy and pickle anyway). With the registry and
RawJSON both in copyreg, a registrant imports exactly one
small module. The json module imports copyreg (as
copy already does), never the reverse.
Precedent and symmetry. This is the pickle model, mirrored in both
halves: a registration function filling a global table
(copyreg.pickle() / copyreg.json()) and a per-instance
dispatch_table attribute overriding it (pickle.Pickler /
json.JSONEncoder). The function name follows the
copyreg.pickle() convention; thirty years of that precedent show
that a registration function named after its protocol causes no
confusion, because registration is a one-off, module-prefixed call.
Neutral ground. Third-party JSON encoders can honor the same
registry and helper class without importing the standard json
module, so the ecosystem can converge on a single registration point.
Placing the registry in copyreg also generalizes the module’s
charter — registering protocol implementations for types you do not
control. Registries for other protocols (such as __copy__- and
__deepcopy__-like hooks for the copy module) fit the same
pattern and will be proposed separately.
The raw-output mechanism
Emitting an already-encoded fragment verbatim is the only way to express
representations outside the basic types, most importantly full-precision
numbers. The chosen mechanism is a duck-typed special method,
__raw_json__(), with copyreg.RawJSON as a convenience wrapper:
- A hook (or
__json__) that wants raw output returnscopyreg.RawJSON(fragment)— one import, which the registrant has already paid — or an instance of its own wrapper class defining just__raw_json__, with no imports at all. RawJSONadditionally defines__json__returningselfto serve its second function: including a pre-serialized fragment directly in a document (as a value in a dict or list passed tojson.dumps()), not only as a hook result.
The protocol, not the class, is the interface; the class is sugar.
Performance: the encoding pipeline as an invariant
The json encoder is among the hottest code in the standard
library, and the design treats its branch order as an invariant:
- Objects of the exact basic types are encoded with no attribute lookups at all — the overwhelmingly common case pays nothing for this PEP.
- The dispatch table and
__json__are consulted next — one dict lookup plus one type-attribute lookup — before theisinstancefallbacks, solely so that subclasses of the basic types can customize their serialization at all (anintsubclass would otherwise be consumed by theisinstance(o, int)check, the very problem history records forIntEnum: python/cpython#62464). - The
isinstancefallbacks then catch non-customizing subclasses, preserving today’s behavior for them exactly (IntEnumstill serializes as a number). - Duck typing of a hook result —
__index__as a number,__float__as a number,__iter__withkeys()as an object,__iter__alone as an array,__raw_json__verbatim — applies only when a hook has fired. In particular,__raw_json__is never looked up on an object that did not come from a hook, so objects bound for the subclass checks ordefault()pay no extra lookups. - The
defaulthook runs last, unchanged.
Which stdlib types get a default __json__
The criterion: a standard library type gets a default ``__json__``
only when its JSON form is unambiguous. Transparent containers
qualify — deque, mappingproxy, ChainMap, UserDict,
UserList, UserString are semantically just arrays and objects
(resolving python/cpython#64973, python/cpython#73849 and
python/cpython#79039 directly). Decimal, namedtuple,
array.array and datetime do not qualify, for the ambiguity
reasons given in the Motivation; for them, this PEP’s answer is a
one-line registration by the application (see How to Teach This).
This criterion is also the ready-made answer to future “just add
__json__ to X” requests.
Specification
The __json__ protocol
A class may define a method __json__(self). When the json
encoder encounters an object that is not of an exactly-supported basic
type and has no dispatch-table entry, it looks up __json__ on the
object’s type (like all special methods) and, if present, calls it.
The result is then serialized in place of the object:
- A result of a basic type, or an instance of their subclasses, is serialized as usual.
- Otherwise the result is interpreted by duck typing: an object with
__index__is serialized as a JSON number viaoperator.index(); else one with__float__as a JSON number viafloat; else an iterable with akeys()method (and__getitem__) as a JSON object; else an iterable as a JSON array; else an object whose type defines__raw_json__is serialized by calling it (see below). OtherwiseTypeErroris raised.
The result of __json__ is not recursively re-submitted to the
protocol: a result that is itself of a hook-defining type is not
converted again.
The __raw_json__ protocol
__raw_json__(self) must return a str, which the encoder
emits verbatim, without validation of its content. It is consulted
only for objects produced by a dispatch-table function or __json__
(see the pipeline invariant above), so a wrapper class used as a hook
result needs only __raw_json__. RawJSON also defines
__json__ returning self, which lets its instances appear
directly in the input document.
The registry
The following are added to copyreg:
copyreg.json(ob_type, json_function)- The official registration interface. Registers
json_functionas the serialization function forob_type. RaisesTypeErrorifjson_functionis not callable. The function is called with the object as its only argument and its result is interpreted exactly like the result of__json__. Registration is by exact type: it does not apply to subclasses ofob_type. copyreg.json_dispatch_table- The dict mapping types to registered functions, consulted by the
jsonmodule (and available to other JSON encoders honoring the registry). Applications register viacopyreg.json()rather than mutating the table directly. copyreg.RawJSON(encoded_json)- A wrapper whose instances are serialized by emitting
encoded_jsonverbatim.str()of the instance returns the fragment.
Registered functions take precedence over __json__. Entries for
the exactly-supported basic types themselves (dict, list,
str, int, …) are never consulted, because the fast paths for
those types run first.
The encoder
json.JSONEncoder (and the C accelerator) consult
getattr(encoder, 'dispatch_table', copyreg.json_dispatch_table), so
a subclass may set a dispatch_table class or instance attribute to
replace the global registry for that encoder, mirroring
pickle.Pickler.
Dict keys are not affected by any part of this PEP: key serialization
accepts exactly the types it accepts today, and neither the dispatch
table nor __json__ is consulted for keys (see Open Issues).
The check_circular machinery is unchanged: containers and
default results are tracked as today. Hook results are not
separately tracked; a pathological hook that returns a fresh cycle on
every call ends in RecursionError like any runaway recursion.
Standard library __json__ methods
__json__ returning a view of the obvious container is added to:
collections.deque and types.MappingProxyType (in C,
returning self; the encoder consumes them via the array and object
paths), and collections.ChainMap,
collections.UserDict, collections.UserList,
collections.UserString (in Python, returning self or the
underlying data).
Backwards Compatibility
The json module gains no new public names and no existing
behavior changes for objects that define none of the new hooks:
- All exactly-supported basic types serialize byte-for-byte as before.
- Subclasses of basic types without hooks serialize as before
(
IntEnumas a number, str subclasses as strings, dict and list subclasses as objects and arrays). - Objects previously rejected with
TypeErrorthat define__json__will now serialize. Classes following the TurboGears convention (a no-argument__json__) get exactly the behavior they intended. Classes written for Pyramid’s renderer, whose__json__(self, request)takes an argument, keep raisingTypeError— now from the missing argument rather than fromdefault— so code catching the exception keeps working, though the message changes. A class using the name for something unrelated would change behavior, but dunder names are reserved by the language reference, and no plausible unrelated meaning is known. - The signature of the private
_json.make_encodergains adispatch_tableargument. - The six standard library classes gaining
__json__previously raisedTypeErrorunderjson.dumps()(unless handled bydefault=). Code usingdefault=to serialize them keeps working, because those objects no longer reachdefault— but the built-in representation (array/object) may differ from what a customdefaultproduced. This is the usual risk of any new default behavior and is judged acceptable for unambiguous containers. from copyreg import jsonshadows thejsonmodule name in the importing namespace, asfrom copyreg import picklealways has. Documentation will show module-prefixed calls.
Security Implications
__raw_json__ and RawJSON emit strings without validation, so a
hook returning attacker-controlled fragments could produce invalid or
misleading JSON. This is no new capability: default= already
executes arbitrary code during encoding, and producing malformed output
requires the application to have installed the hook. The documentation
will note that raw fragments must come from trusted producers.
How to Teach This
Four recipes, one per customization level:
- Library level — your own class: define
__json__:class Money: def __json__(self): return {"amount": str(self.amount), "currency": self.currency}
- Application level — someone else’s class: register it:
import copyreg, decimal copyreg.json(decimal.Decimal, str)
- Application level — a representation JSON cannot otherwise
express: return a raw fragment:
copyreg.json(decimal.Decimal, lambda d: copyreg.RawJSON(str(d)))
- Call level — one encoder, different policy:
class APIEncoder(json.JSONEncoder): dispatch_table = {decimal.Decimal: str}
The existing default= remains the call-level catch-all for objects
no other mechanism handled and is documented as running last.
Reference Implementation
python/cpython#153607 (branch json-customize4), a rebase and rework of the author’s
2017–2022 json-customize branches: the protocol and registry with C
and Python encoder support, standard library __json__ methods, and
tests for both encoder implementations, including the encoding-pipeline
invariant recorded as code comments.
Rejected Ideas
json.register()instead of copyreg- Registration in the
jsonmodule forces registrants to import it (~50× the import cost ofcopyreg, dominated byre), breaks the pickle symmetry, and gives third-party encoders no neutral registry. Discoverability is addressed by documentation in thejsondocs pointing tocopyreg. copyreg.register_json()-style names- Inconsistent with
copyreg.pickle(), the thirty-year precedent. - Default
__json__for Decimal, namedtuple, array.array, datetime - Each has at least two legitimate JSON representations (number vs string; array vs object; array vs encoded string, depending on type code; many string formats). A default would be wrong for a large fraction of consumers; the ambiguity is the argument for a registry, not for a default.
- Serializing all iterables and mappings without opt-in
- Requested in python/cpython#75338 and implied by
python/cpython#78031 and python/cpython#83377, but a silent behavior
change: every object with
__iter__(sets, generators, file objects) would begin serializing as an array instead of reachingdefault=or raising. Duck typing therefore applies only to hook results — an explicit opt-in. isinstance(o, RawJSON)instead of__raw_json__- Rejected because it taxes the wrong audience: a type that opts in
via
__json__with zero imports would need to importcopyregthe moment it needs raw output. With the duck-typed protocol, the class is the convenience, not the mechanism. - Recognizing raw wrappers by class name
- Dispatching on names has precedent in pickle, which recognizes the
__newobj__and__newobj_ex__callables in reduce tuples by their__name__, but this PEP does not follow that example. Recognizing raw wrappers bytype(o).__name__ == "RawJSON"would inspect an ordinary, plausible class name on arbitrary objects: any unrelated class that happens to use it would silently change encoding behavior. The duck-typed__raw_json__keeps the marker in the reserved dunder namespace. - Consulting
__raw_json__directly on all objects - Would let a class self-serialize raw with a single method, but adds
a type-attribute lookup for every object on the way to the subclass
checks, to the duck-typed interpretations (
__index__,__iter__, etc.) and todefault()— a real cost in one of the hottest paths in the standard library. The gated design makes the raw object pay one method call instead. - Reusing
__repr__/__str__or a generic__serialize__ - Proposed in python/cpython#114285. These methods cannot serve as
the marker for raw output: nearly every type defines them (and
their results are usually not valid JSON), so the encoder could not
tell raw-capable objects apart; a format-agnostic
__serialize__likewise cannot say which format it produces.__str__can, however, serve as the payload once a separate marker exists — see Open Issues. - MRO-based (isinstance) dispatch for the registry
- The registry uses exact-type matching like
copyreg.dispatch_table. Subclass dispatch is the protocol’s job: a base class defines__json__once and subclasses inherit it. Exact matching keeps lookup one dict access and avoids MRO scans on the hot path.
Open Issues
- Dict keys. Neither the registry nor
__json__applies to dict keys; requests exist (python/cpython#63020, python/cpython#117391, python/cpython#85741, python/cpython#117592). A compatible future extension is to consult the hooks exactly where thekeys must be str...TypeErroris raised today (before theskipkeysskip), so conforming keys pay nothing; the hook result would re-enter key normalization, and container or raw results would be rejected. Deferred from this PEP. - Cycle detection through hooks. A hook returning a fresh
equal-but-not-identical cycle each call exhausts the recursion limit
(
RecursionError) rather than reportingCircular reference detected. Tracking hook results in thecheck_circularmarkers would close this at some cost to the hook path; deferred pending evidence it matters in practice. - __raw_json__ as a pure marker. An open alternative keeps
__raw_json__as the marker but takes the emitted fragment fromstr(o)instead of the method’s return value —RawJSONdefines__str__returning the fragment for exactly this reason, and the same would hold if raw wrappers were recognized by identity or name. Calling the__str__slot is faster than a full method call, but the design would require every raw wrapper to define both__raw_json__and__str__.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.