Skip to content

POC: Switch from ujson to orjson/stdlib - #67203

Draft
rhshadrach wants to merge 10 commits into
pandas-dev:mainfrom
rhshadrach:poc_ujson_removal
Draft

POC: Switch from ujson to orjson/stdlib#67203
rhshadrach wants to merge 10 commits into
pandas-dev:mainfrom
rhshadrach:poc_ujson_removal

Conversation

@rhshadrach

@rhshadrach rhshadrach commented Aug 29, 2026

Copy link
Copy Markdown
Member

This PR shows what the final result would roughly look like if we were to remove ujson and replace with orjson (opitonal) and stdlib (used when orjson is not available). Written by Claude Fable 5, not (thoroughly) reviewed or refined by me - I don't intend to do so for this POC but will when we go to actually make the switch.

tl;dr: With a few deprecations that I think we should do anyways, we can do a pretty good swap out with orjson. Benchmarks below show this is by-and-large positive on performance.

Notable API decisions:

  • double_precision has been removed. Floats are written with shortest round-trip repr instead of being rounded to 10 decimals. Users who want fewer digits can round the data first.
  • force_ascii has been removed.
  • indent restricted to {0, 2}, and indented output uses ": " between key and value (matching json.dumps(indent=2)). We can implement our own indentation for use with orjson, it'd likely be slow and not often used.
  • precise_float of read_json has been removed; floats are always parsed with full precision.
  • The epoch date_format deprecation and the read_json convert_dates deprecation are not enforced in this PR.
  • NaN reading behavior preserved.
  • When data is read and has infinity, we use stdlib to read. Slower (~1.45x) but correct.
  • NaN/±inf continue to write as null (both engines).

This PR also fixes one bug (couldn't find an issue):

ser = pd.Series([np.timedelta64(1, "D"), pd.Timedelta("1D")], dtype=object)
print(ser.to_json(date_format="iso"))
# main
# {"0":1,"1":"P1DT0H0M0S"}
# This PR
# {"0":"P1DT0H0M0S","1":"P1DT0H0M0S"}

Benchmarks comparing orjson against main, new / old, so < 1 means "got faster".

  • float reads are the only ones more than 10% slower (1.12-1.19).
  • int64[pyarrow] at 100k at 1.15x looks like noise.
Details
Write

┌───────────────────────────────┬─────────┬──────┬──────┬──────┬──────┐
│             dtype             │ orient  │  1k  │ 10k  │ 100k │  1M  │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ int64                         │ columns │ 0.41 │ 0.33 │ 0.54 │ 0.51 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ int64                         │ records │ 0.43 │ 0.38 │ 0.42 │ 0.61 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ int64                         │ values  │ 0.15 │ 0.14 │ 0.15 │ 0.37 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ float64                       │ columns │ 0.56 │ 0.49 │ 0.52 │ 0.78 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ float64                       │ records │ 0.70 │ 0.66 │ 0.69 │ 0.91 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ float64                       │ values  │ 0.31 │ 0.29 │ 0.32 │ 0.53 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ bool                          │ columns │ 0.50 │ 0.37 │ 0.36 │ 0.58 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ bool                          │ records │ 0.45 │ 0.36 │ 0.38 │ 0.54 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ bool                          │ values  │ 0.17 │ 0.13 │ 0.12 │ 0.28 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ object str                    │ columns │ 0.80 │ 0.78 │ 0.71 │ 0.99 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ object str                    │ records │ 0.62 │ 0.59 │ 0.64 │ 0.78 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ object str                    │ values  │ 0.61 │ 0.60 │ 0.63 │ 0.81 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ str                           │ columns │ 0.85 │ 0.75 │ 0.75 │ 0.99 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ str                           │ records │ 0.63 │ 0.57 │ 0.64 │ 0.78 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ str                           │ values  │ 0.63 │ 0.58 │ 0.67 │ 0.82 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ datetime64 iso                │ columns │ 0.42 │ 0.34 │ 0.33 │ 0.58 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ datetime64 iso                │ records │ 0.33 │ 0.27 │ 0.28 │ 0.42 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ datetime64 iso                │ values  │ 0.31 │ 0.27 │ 0.27 │ 0.41 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ datetime64 epoch              │ columns │ 0.21 │ 0.38 │ 0.45 │ 0.69 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ datetime64 epoch              │ records │ 0.26 │ 0.45 │ 0.52 │ 0.83 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ datetime64 epoch              │ values  │ 0.13 │ 0.19 │ 0.23 │ 0.49 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ datetime64 tz                 │ columns │ 0.01 │ 0.01 │ 0.01 │ 0.02 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ datetime64 tz                 │ records │ 0.01 │ 0.01 │ 0.01 │ 0.02 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ datetime64 tz                 │ values  │ 0.01 │ 0.01 │ 0.01 │ 0.02 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ timedelta64 iso               │ columns │ 0.79 │ 0.78 │ 0.80 │ 0.86 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ timedelta64 iso               │ records │ 0.70 │ 0.68 │ 0.68 │ 0.73 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ timedelta64 iso               │ values  │ 0.69 │ 0.69 │ 0.67 │ 0.71 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ category                      │ columns │ 0.85 │ 0.78 │ 0.78 │ 0.97 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ category                      │ records │ 0.74 │ 0.60 │ 0.64 │ 0.79 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ category                      │ values  │ 0.73 │ 0.62 │ 0.67 │ 0.82 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ Int64                         │ columns │ 0.69 │ 0.56 │ 0.59 │ 0.76 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ Int64                         │ records │ 0.42 │ 0.34 │ 0.44 │ 0.54 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ Int64                         │ values  │ 0.39 │ 0.33 │ 0.44 │ 0.47 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ Float64                       │ columns │ 0.74 │ 0.65 │ 0.83 │ 0.98 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ Float64                       │ records │ 0.52 │ 0.44 │ 0.76 │ 0.68 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ Float64                       │ values  │ 0.49 │ 0.44 │ 0.64 │ 0.65 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ boolean                       │ columns │ 0.68 │ 0.56 │ 0.55 │ 0.75 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ boolean                       │ records │ 0.41 │ 0.29 │ 0.37 │ 0.44 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ boolean                       │ values  │ 0.39 │ 0.29 │ 0.35 │ 0.38 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ int64[pyarrow]                │ columns │ 0.70 │ 0.59 │ 1.15 │ 0.81 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ int64[pyarrow]                │ records │ 0.42 │ 0.36 │ 0.52 │ 0.57 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ int64[pyarrow]                │ values  │ 0.39 │ 0.35 │ 0.62 │ 0.52 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ string[pyarrow]               │ columns │ 0.82 │ 0.76 │ 0.77 │ 1.00 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ string[pyarrow]               │ records │ 0.64 │ 0.57 │ 0.68 │ 0.79 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ string[pyarrow]               │ values  │ 0.63 │ 0.58 │ 0.71 │ 0.82 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ object nested (200k max)      │ columns │ 0.24 │ 0.22 │ 0.23 │ 0.31 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ object nested                 │ records │ 0.22 │ 0.20 │ 0.24 │ 0.27 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ object nested                 │ values  │ 0.21 │ 0.20 │ 0.24 │ 0.26 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ object Timestamp (200k max)   │ columns │ 0.07 │ 0.07 │ 0.08 │ 0.11 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ object Timestamp              │ records │ 0.07 │ 0.06 │ 0.08 │ 0.09 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ object Timestamp              │ values  │ 0.06 │ 0.06 │ 0.08 │ 0.09 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ Decimal (200k max)            │ columns │ 0.59 │ 0.55 │ 0.58 │ 0.60 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ Decimal                       │ records │ 0.50 │ 0.48 │ 0.52 │ 0.55 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ Decimal                       │ values  │ 0.48 │ 0.47 │ 0.50 │ 0.53 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ mixed (int/float/str/bool/dt) │ columns │ 0.58 │ 0.48 │ 0.50 │ 0.71 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ mixed                         │ records │ 0.35 │ 0.27 │ 0.27 │ 0.39 │
├───────────────────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ mixed                         │ values  │ 0.32 │ 0.27 │ 0.25 │ 0.38 │
└───────────────────────────────┴─────────┴──────┴──────┴──────┴──────┘

Read

┌────────────────┬─────────┬──────┬──────┬──────┬──────┐
│     dtype      │ orient  │  1k  │ 10k  │ 100k │  1M  │
├────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ int64          │ columns │ 0.95 │ 0.84 │ 1.13 │ 0.96 │
├────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ int64          │ records │ 0.86 │ 0.85 │ 0.85 │ 0.84 │
├────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ float64        │ columns │ 0.99 │ 1.10 │ 1.19 │ 1.12 │
├────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ float64        │ records │ 0.91 │ 0.94 │ 0.95 │ 1.07 │
├────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ object str     │ columns │ 0.95 │ 0.88 │ 1.11 │ 1.04 │
├────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ object str     │ records │ 0.87 │ 0.88 │ 0.77 │ 0.75 │
├────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ datetime64 iso │ columns │ 0.88 │ 0.90 │ 1.04 │ 1.03 │
├────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ datetime64 iso │ records │ 0.85 │ 0.73 │ 0.85 │ 0.96 │
├────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ mixed          │ columns │ 0.90 │ 0.89 │ 1.06 │ 1.10 │
├────────────────┼─────────┼──────┼──────┼──────┼──────┤
│ mixed          │ records │ 0.93 │ 0.93 │ 1.14 │ 1.07 │
└────────────────┴─────────┴──────┴──────┴──────┴──────┘

@rhshadrach

rhshadrach commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

cc @pandas-dev/pandas-core

If I can get some head nods, I'll start the deprecation process for the three arguments above as part of 3.x. The plan would then be to add orjson and stdlib alongside ujson, and deprecate ujson all in 3.x.

@jbrockmendel

Copy link
Copy Markdown
Member

+1 on deprecating those precision keywords

Only question that comes to mind is how this handles pyarrow/nullable cases

@rhshadrach

Copy link
Copy Markdown
Member Author

Only question that comes to mind is how this handles pyarrow/nullable cases

Same as main, via EA._values_for_json. NA is written as null. Not sure if this answers, happy to dig into anything more.

@mroeschke mroeschke left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to the deprecations?

* ``encoding`` : The encoding to use to decode py3 bytes.
* ``chunksize`` : when used in combination with ``lines=True``, return a ``pandas.api.typing.JsonReader`` which reads in ``chunksize`` lines per iteration.
* ``engine``: Either ``"ujson"``, the built-in JSON parser, or ``"pyarrow"`` which dispatches to pyarrow's ``pyarrow.json.read_json``.
* ``engine``: Either ``"json"``, the default parser (`orjson <https://github.com/ijl/orjson>`__ when installed, the standard library ``json`` module otherwise), or ``"pyarrow"`` which dispatches to pyarrow's ``pyarrow.json.read_json``.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a performance difference between the stdlib json and the current ujson parser, given orjson is current optional in this PR?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants