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.

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
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.4xSerialization 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:
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:
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
Replace
json.loadswithorjson.loadsfirst. It is the lower-risk half. Check for BOMs, non-standard constants and 64-bit-plus integers in your data.Replace
json.dumpswhere the output is consumed by code, and keep the standard library where humans or other tools expectensure_asciiescapes or particular formatting.Add
option=orjson.OPT_NON_STR_KEYS | orjson.OPT_SERIALIZE_NUMPYwhere your data needs them.Write tests with
NaN, a naive datetime, aDecimaland an integer above 2^63; those are the ones that change.Wrap the pair in your own
dumpsandloadshelpers, 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.


