PEP 837: Extensible JSON serialization

I have submitted PEP 837 (PR python/peps#5031 until it renders), which proposes an extension mechanism for the json encoder. It is a revival and rework of an idea with a long history — the implementation descends from my 2017 patch on gh-71549, and the idea itself has been proposed independently since 2010.

In fact, I published a proof-of-concept patch on the tracker in 2017 and have had a full implementation in my local branches since later that year. For this proposal I reworked some design details and wrote the documentation. I tried to write a PEP earlier, but it was too difficult for me — this time I finally managed to finish it.

Summary

Serialization of custom types can be customized at three levels, each covering a different need:

  • Library level — a class defines the JSON representation of its instances with a __json__() method. No imports, inherited by subclasses:

    class Money:
        def __json__(self):
            return {"amount": str(self.amount), "currency": self.currency}
    
  • Application level — a serialization function for a type you do not control is registered with copyreg.json(), following the copyreg.pickle() precedent. The copyreg.RawJSON wrapper emits an already encoded fragment verbatim, so an application can finally choose how Decimal serializes:

    copyreg.json(decimal.Decimal, str)                              # "1.10"
    copyreg.json(decimal.Decimal, lambda d: copyreg.RawJSON(str(d)))  # 1.10
    
  • Call level — a particular encoder overrides the global registry with a dispatch_table attribute, mirroring pickle.Pickler.dispatch_table. default= is unchanged and runs last.

The more specific level takes precedence. deque, mappingproxy, ChainMap, UserDict, UserList and UserString — containers with an unambiguous JSON form — serialize out of the box. Exact basic types pay no overhead: the hooks are only consulted for objects that would otherwise head toward default= or TypeError.

Reference implementation: python/cpython#153607 (both the C and Python encoders, tests, documentation).

Deliberate non-goals

To keep the discussion focused, a few things are intentionally not in the PEP:

  • No default __json__ for Decimal, namedtuple, array.array or datetime — each has at least two legitimate JSON representations (number vs string; array vs object; array vs encoded string; many date formats). The registry exists precisely so the application declares this policy; the standard library should not guess.
  • Dict keys are not covered — the hooks apply to values only. A compatible extension for keys is sketched in Open Issues.
  • copyreg.copy() / copyreg.deepcopy() for __copy__-like hooks fit the same pattern but will be a separate proposal.

Prior discussions

This idea has come up many times; I have tried to address the points raised in all of them (see the PEP’s Motivation and Rejected Ideas):

The ecosystem already uses this convention informally: TurboGears serializes via a no-argument __json__() (with custom_encoders taking precedence over it — the same ordering as this PEP), Pyramid’s renderer calls __json__(request), simplejson added for_json() (choosing that name because dunders are reserved for the language), and projects like conda, Checkmk and TatSu define __json__ today.

Feedback particularly welcome on

  1. The registry’s home: copyreg.json() versus a function in the json module (the PEP argues for copyreg on import cost, pickle symmetry and neutrality for third-party encoders).
  2. The duck-typed __raw_json__ as the raw-output marker (see the alternatives in Rejected Ideas and the __str__-payload variant in Open Issues).
  3. Whether dict-key customization should be deferred (as proposed) or included.
7 Likes