Skip to content
datahacks.dev
JSON

Faster JSON in Python With orjson and What Changes

orjson wrote a 35 MB document 6 times faster than json and parsed it 1.7 times faster. What it returns, what it rejects and what to check before migrating.

6 min read
Summarize in AI
Flat illustration of a tree of nested objects beside the document it came from, with the JSON logo.

If JSON parsing or serialization shows up in a profile, orjson is the usual answer. It is a Python library written in Rust, with the same two core functions as the standard library and a very different performance profile. It is also not a drop-in replacement: it returns bytes, it is stricter about what it accepts, and a few outputs differ from json. This article gives the numbers, then goes through the differences one at a time so a migration is a checklist and not a surprise.

The numbers

Text
operation                         json        orjson      ratio
loads (bytes input)               468 ms      278 ms      1.7x
dumps                             246 ms       39 ms      6.3x
dumps with indent=2               285 ms       70 ms      4.1x
dumps with sort_keys              326 ms       65 ms      5.0x
dumps, compact separators         250 ms       39 ms      6.4x

Serialization is where the library earns its name: six times faster, and the gap stays when you ask for indentation or sorted keys. Parsing is faster by a smaller factor, because both libraries spend much of their time creating Python objects, which neither can avoid. Memory is unchanged for the parsed data: the 200,000 dicts take the same space, about 860 MB for this file (the Python JSON guide has the memory figures and the streaming alternatives). If your problem is memory, orjson does not help; if it is CPU in a service that serializes responses, it does.

What you get back

orjson.dumps returns bytes, not str:

Python
orjson.dumps({"a": [1, 2], "Ć©": "šŸ˜€"})
# b'{"a":[1,2],"\xc3\xa9":"\xf0\x9f\x98\x80"}'
json.dumps({"a": [1, 2], "Ć©": "šŸ˜€"})
# '{"a": [1, 2], "\\u00e9": "\\ud83d\\ude00"}'

Three differences are visible in one line. The type is bytes, ready to write to a socket or a binary file; call .decode() if you need text. The output is compact, without the spaces after : and ,. And non-ASCII characters are written as UTF-8, not escaped as Ć©: there is no ensure_ascii option and no way to ask for ASCII escapes. On a plain document with no floats that differ, the output was byte-identical to json.dumps(obj, separators=(",", ":"), ensure_ascii=False), which is how to reproduce it in the standard library.

Options are a bit mask, ORed together: orjson.OPT_INDENT_2 for a two-space indent (the only indent available), OPT_SORT_KEYS ({"b": 1, "a": 2} gave {"a":2,"b":1}), OPT_APPEND_NEWLINE (a trailing \n, handy for NDJSON), OPT_NON_STR_KEYS, OPT_UTC_Z, OPT_NAIVE_UTC and OPT_SERIALIZE_NUMPY. For the stable-output considerations around sorted keys and floats, see sorting keys and getting stable JSON output.

Types it handles natively

This is the second reason to use it. Without a default function, orjson serialized:

Text
datetime (aware)       "2026-10-26T12:30:00+00:00"
date, time             "2026-10-26", "12:30:00"
uuid.UUID              "00000000-0000-0000-0000-000000000003"
dataclass instance     {"x":1,"y":"a"}
enum member            "a"
tuple                  [1,2]
numpy arrays/scalars   with OPT_SERIALIZE_NUMPY: {"a":5,"b":[1.5,2.0]}

and rejected Decimal, set and bytes with TypeError: Type is not JSON serializable: decimal.Decimal (and the equivalents). A default= function works as in the standard library: orjson.dumps(Decimal("1.5"), default=str) gave b'"1.5"'. Naive datetimes are written without a zone, as the standard isoformat does, so keep your check for them (serializing dates and decimals to JSON in Python).

Where it is stricter

Each of these either raises or changes the data, and each is a place where a migration can fail in production if you did not test for it.

**NaN and Infinity become null.** orjson.dumps([float("nan"), float("inf"), 1.5]) gave [null,null,1.5]. That is valid JSON, unlike the NaN the standard library writes by default, and it also silently changes meaning: a computation that produced NaN is now a missing value in the output. On the way in, orjson.loads("[NaN]") raised JSONDecodeError: unexpected character, expected a JSON value: orjson does not read the non-standard constants.

Integers are limited to 64 bits on output. orjson.dumps(2**63) worked (9223372036854775808, it fits in an unsigned 64-bit integer), -2**63 worked, and 2**64 raised TypeError: Integer exceeds 64-bit range. Python's json writes any integer. For larger values, pass them as strings.

Large integers on input degrade. orjson.loads("12345678901234567890") returned the exact int, and orjson.loads("123456789012345678901234567890") returned the float 1.2345678901234568e+29, silently losing digits, where json.loads returns the exact integer. If identifiers can exceed 64 bits, this is a data-loss difference.

Keys must be strings. orjson.dumps({1: "a"}) raised TypeError: Dict key must be str. OPT_NON_STR_KEYS allows int, float, bool, None and other types, converting to text. The same collision applies as with json: {1: "a", 2.5: "b", True: "c"} became {"1":"c","2.5":"b"}, because True == 1.

Input must be valid UTF-8 and have no BOM. orjson.loads(b'\xef\xbb\xbf{"a":1}') raised UTF-8 byte order mark (BOM) is not supported; the standard library accepts a BOM on bytes (why JSON parse fails on a byte order mark). Invalid UTF-8 bytes raise, and a lone surrogate in a str fails to serialize (TypeError: str is not valid UTF-8: surrogates not allowed), where json.dumps("\ud800") writes "\ud800".

Parsing is strict about syntax. A trailing comma ([1,]) raised trailing comma is not allowed, an empty string raised Input is a zero-length, empty document, and None raised Input must be bytes, bytearray, memoryview, or str. Duplicate keys are not an error: the last wins ({'a': 2}), as in json; there is no object_pairs_hook to change it.

Nesting is limited. 500 levels parsed, 1,030 raised depth limit exceeded; the standard library raises RecursionError for much deeper input, so the failure modes differ.

Subclasses of built-in types are handled specially. A str subclass serialized as a plain string; with OPT_PASSTHROUGH_SUBCLASS it raised, which is how you route subclasses to default.

No hooks. There is no object_hook, parse_float, cls or sort_keys argument; sorting is an option, and decoding customization is done after parsing.

Errors

orjson.JSONDecodeError subclasses both json.JSONDecodeError and ValueError, so except json.JSONDecodeError: and except ValueError: blocks written for the standard library still catch it. Messages differ (for example, Input is a zero-length, empty document instead of Expecting value), so code that matches message text will break; the position attributes lineno, colno and pos are there.

A migration path

  1. Replace json.loads with orjson.loads first. It is the lower-risk half. Check for BOMs, non-standard constants and 64-bit-plus integers in your data.

  2. Replace json.dumps where the output is consumed by code, and keep the standard library where humans or other tools expect ensure_ascii escapes or particular formatting.

  3. Add option=orjson.OPT_NON_STR_KEYS | orjson.OPT_SERIALIZE_NUMPY where your data needs them.

  4. Write tests with NaN, a naive datetime, a Decimal and an integer above 2^63; those are the ones that change.

  5. Wrap the pair in your own dumps and loads helpers, so the library choice is one file, not five hundred call sites.

A framework that already integrates orjson (FastAPI's ORJSONResponse, for example) does much of this for you.

When to stay with the standard library

The JSON is small or rare. A 10 microsecond call does not need a faster library.

You need hooks. parse_float=Decimal, object_pairs_hook and custom JSONEncoder classes have no orjson equivalent.

You need exact big integers. Standard json keeps them.

You must write ASCII-only output for a legacy consumer.

You cannot add a compiled dependency. orjson ships wheels for common platforms, and a platform without one needs a Rust toolchain to build it.

The library's own documentation, at github.com/ijl/orjson, lists every option and the exact serialization rules, and the standard library behavior it is compared with is in the Python json documentation. To check what an output looks like, paste it into the JSON formatter.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.