Files
json/docs/mkdocs/docs/features/performance.md
T
Niels LohmannandClaude Sonnet 5.5 33f15727ce Parse JSON in one block of memory with a fused pointer-based parser (#6031)
* Parse JSON in one block of memory with a fused pointer-based parser

The parser and the lexer exchange one token at a time, and the lexer keeps
its state (the current character, a pending unget, where the token starts)
in members, which every character pays for: json::accept() of twitter.json
took 2.8x as long as json_document::accept(). detail::contiguous_parser
reads text in one block of memory through a local pointer, with the
grammar, the whitespace and the tokens in one loop (after yyjson's reader),
and drives a SAX handler: parse()'s DOM handler, accept()'s handler, or the
user's handler of sax_parse(). It is used with the default options (no
callback, no comments, no trailing commas; sax_parse() with strict = true).

Errors are reported by the library parser, so the exception, its message and
its position stay the same. contiguous_parser stops at the token that the
library parser would reject, before reporting it, so the events up to the
error are the same; then:
- parse() runs the library parser with a handler that only reports the
  error (json_sax_error_reporter), with the lexer of parse(), which is
  instantiated anyway;
- sax_parse() runs it with a handler that forwards only parse_error() to
  the user's handler (json_sax_error_forwarder), so no event arrives twice;
- accept() returns the verdict.
A handler that returns false stops the parse as before. An error at the end
of a large input costs parse() about 1.2x of what it cost before.

On an Apple M1 Max: accept() 1.6-2.0x as fast, parse() 10-16% faster,
ordered_json 11-16%, sax_parse() with a user's handler 1.36-1.46x, small
messages 12-22% (parse()) and 2.1-2.4x (accept()). On x86-64 (GCC 15,
Clang 21): accept() 1.6-1.9x, parse() 5-17%, ordered_json 12-19%,
sax_parse() 1.32-1.57x. A translation unit that parses a string compiles 3%
(-O0) and 9% (-O2) slower.

unit-contiguous_parser.cpp compares the contiguous path with the byte path
(std::list iterators) on corner cases, generated documents, their
truncations and mutations, deep nesting, and narrow number types: the same
values with the same number types and float bits, exception messages,
accept() verdicts and sax_parse() events.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Fix CI: unused parameter without exceptions, cpplint, BUILD.bazel

- json_sax_error_reporter::parse_error: mark the exception unused, as
  JSON_THROW does not use it with JSON_NOEXCEPTION
- unit-contiguous_parser.cpp: compare the exceptions of both paths only
  when exceptions are enabled (invalid input aborted the test otherwise)
- silence cpplint's readability/braces false positives on the if
  statements inside the parser's macros
- add contiguous_parser.hpp to BUILD.bazel

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* accept(): skip digits without computing their value

accept() needs the value of a number only for a float whose exponent may
reach the largest value. skip_digits() finds the end of the digits without
the multiply-adds of accumulate_digits(), and once an 8-byte word fails the
digit test, the remaining loop needs no bounds check: a non-digit is in
that word. The value is computed afterwards for the rare floats that need
it.

accept() of canada.json: -16 % on Apple M1, -22 % with GCC 15 and -21 %
with Clang 21 on x86-64; files with many numbers -9 to -15 %.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Tests: mark parser callback in unit-contiguous_parser noexcept

Fixes the -Werror=noexcept failure with GCC 26 (ci_test_standards_gcc (26)).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N5NasEsrzHhSNouxSoEwqz
Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Tests: sweep_allocator::construct() accepts pointers to const

Recent libc++ (emsdk latest) probes construct(const T*, const T&) in an
unevaluated context while checking for trivial copies; the static_cast to
void* in the noexcept specification then fails to compile.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N5NasEsrzHhSNouxSoEwqz
(cherry picked from commit 794bf70823c12a2a1cb2a8a95f34dfb6357746a8)
Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Tests: guard sweep_allocator::construct() against const types instead of const_cast

The const_cast added in the previous commit is rejected by
cppcoreguidelines-pro-type-const-cast (clang-tidy). Make construct() not
viable for const U instead, which is what libc++ probes for.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N5NasEsrzHhSNouxSoEwqz
(cherry picked from commit de099fa607001b710862fd1bbecef3609c54cbc4)
Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Fix CI: astyle and clang-tidy for sweep_allocator::construct()

astyle 3.4.13 reformats the enable_if<!...> default template argument
(Check amalgamation). Delete the const overload instead, which makes the
libc++ probe for const objects fail in the SFINAE context just the same
and needs no <type_traits>.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N5NasEsrzHhSNouxSoEwqz
Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Contiguous parser: review fixes (wording, macros, diagnostic positions)

- "default parser" instead of "library parser" in comments and tests
- the three comments in json.hpp say the same thing
- current() replaces the repeated `p != end ? *p : 0`
- the backslashes of each macro are aligned
- the docs say that JSON_DIAGNOSTIC_POSITIONS turns the fast path off

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

---------

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 07:08:44 +01:00

20 KiB

Performance

Speed was never the primary goal of this library. The design goals page says so plainly: "There are certainly faster JSON libraries out there." Intuitive syntax, trivial integration, and thorough testing came first. If a hard real-time budget or the last percent of throughput matters more than convenience, a faster, more specialized library may be a better fit.

That said, how you use this library still makes a measurable difference. This page collects practical, code-verified techniques for reducing time, memory, and compile-time cost — without repeating the detailed pages it links to.

Parsing input

parse accepts a string, a pair of iterators, a container, a #!cpp std::istream, or a #!cpp FILE* (see Parsing). Internally, every input is wrapped in an input adapter, and not all adapters are equally fast.

For inputs backed by contiguous, single-byte memory — a #!cpp std::string, a #!cpp std::vector<char>, a string literal, or a pointer range — the library uses iterator_input_adapter, wrapped in a raw pointer so the fast paths below apply on every supported standard. This adapter exposes two optimizations the lexer detects at compile time:

  • it can reconstruct already-consumed input on demand for error messages, instead of copying every character as it is read, and
  • the lexer can scan ordinary string characters, numbers, and indentation directly out of the buffer, several bytes at a time, rather than one character (and one function call) at a time.

A #!cpp std::istream (including #!cpp std::ifstream) gets the same bulk scanning: input_stream_adapter scans the characters its stream buffer already holds (the get area) and asks the stream buffer for more only when they are used up. Nothing is read ahead, so the stream is left exactly where it would be after reading one character at a time, and operator>> can still read several values from one stream. A stream buffer without a get area is read one character at a time. A #!cpp FILE* is locked once per parse instead of once per character, and with glibc and the Apple C library, the characters in its buffer are scanned in bulk as a stream's get area is. See input sources and performance for the full table. Parsing from a stream takes about 1.15 to 1.45 times as long as parsing an equivalent #!cpp std::string, which parse() reads with a parser of its own (see there).

Inputs that are still read one character at a time: iterator ranges that are not contiguous (e.g. #!cpp std::list<char> or #!cpp std::istreambuf_iterator<char>), wide strings, and user-defined adapters. An iterator pair over non-contiguous but random-access storage (e.g. #!cpp std::deque<char>::iterator) gets the first optimization but not the second, since the byte-scanning fast path requires contiguous storage.

Practically: a file can be passed directly to parse, which also avoids the memory for a copy of the file. Reading it into a string first is no longer necessary for speed (this is for #!cpp json::parse; a json_document reads a stream into an owned buffer anyway, so for it reading the file into a string and borrowing that is the faster way):

// fast: the stream buffer's get area is scanned in bulk
std::ifstream f("example.json");
json j = json::parse(f);

Two macros switch the stream optimizations off without changing any result: JSON_DISABLE_STREAM_BULK_SCAN for stream buffers that do not follow the usual #!cpp std::streambuf contract, and JSON_DISABLE_UNLOCKED_FILE_IO for programs in which another thread must use the same #!cpp FILE object while it is parsed.

For contiguous input with many non-ASCII characters, JSON_USE_SIMDUTF can additionally speed up UTF-8 validation by using the simdutf library instead of the built-in scalar validator; streaming inputs (files, #!cpp std::istream, wide strings, user-defined adapters) always use the scalar path regardless of this macro.

Large documents

Parsing always produces SAX events internally; parse simply feeds them to a consumer that builds a complete basic_json value tree (a DOM) in memory. For documents too large to comfortably hold as a DOM, three alternatives avoid building it:

  • Implement the SAX interface directly and pass it to sax_parse; only the parts of the input you choose to keep ever become basic_json values.
  • Pass a parser callback to parse. This still builds a DOM, but the callback can discard finished elements as soon as they are handled, so memory usage stays bounded by one element (plus the unparsed remainder of the input) instead of the whole document — see the recipe for streaming a large homogeneous array.
  • Parse into a json_document (#!cpp <nlohmann/json_view.hpp>) instead of a basic_json. It keeps the input text and builds a flat index of 16 bytes per value; strings and floating-point numbers are not copied, but read from the text when needed. Read-only views give the familiar element access, and only the parts you materialize() become basic_json values. A document that borrows the text instead of owning a copy needs the text to outlive it; see choosing between json, the SAX interface, and json_view.

If the data is naturally record-oriented, consider JSON Lines instead of one large JSON document: reading and parsing it line by line with #!cpp std::getline means only one line's value is ever in memory at a time, and a malformed line does not invalidate lines already processed.

Binary formats

JSON text is not a compact format. If the data is only exchanged between programs (not read by humans), the binary formats — BJData, BON8, BSON, CBOR, MessagePack, and UBJSON — encode the same values more compactly, which reduces both the bytes transferred and, for most of them, the work needed to parse them back. The size comparison on that page, measured against minified JSON for four reference documents, shows the effect varies a lot by document shape: CBOR and MessagePack come out at 50.5% of the minified JSON size for the numeric-array-heavy canada.json, but only around 87-88% for the string-heavy jeopardy.json, where there is less numeric data to encode more compactly. BON8 is the most compact option in that comparison for text-heavy documents (63.5%-87.5%), at the cost of an incomplete serializer (no unsigned integers above int64). Which format — and whether it is worth the loss of human readability at all — depends on the actual data; see the comparison tables before choosing one.

--8<-- "images/binary_sizes_canada.svg"
--8<-- "images/binary_sizes_jeopardy.svg"

Two of the four panels from the [size comparison](binary_formats/index.md#sizes); the smallest format per file is drawn in a stronger colour than the others.

Object type: json vs. ordered_json

The default json type stores object keys in a #!cpp std::map, giving logarithmic-time lookup, insertion, and erasure, at the cost of sorting keys alphabetically rather than preserving insertion order (see Object Order). ordered_json uses nlohmann::ordered_map instead, a #!cpp std::vector-backed container with no lookup index: every key-based operation is a linear scan, so building an object of n distinct keys costs O(n²) in total — this applies equally to inserting keys one by one and to parsing an object, since the parser inserts each key as it is read. The measurements on the ordered_map page show this is negligible at typical object sizes (2000 keys: 0.7 ms for json vs. 3.6 ms for ordered_json, a 5x factor) but grows steeply for large, machine-generated objects (16 000 keys: 3.3 ms vs. 181.6 ms, a 54x factor).

--8<-- "images/ordered_json_scaling.svg"

Parsing a flat object of `n` keys, measured with `-O2 -DNDEBUG`; all four sizes from the [table on the `ordered_map` page](../api/ordered_map.md#complexity), both axes logarithmic.

If insertion order matters and an object routinely has many thousands of keys, ordered_json's quadratic build cost may not be acceptable. The library's ObjectType template parameter can be set to a different container instead: #!cpp nlohmann::fifo_map keeps insertion order with a real lookup index (avoiding the quadratic cost), while #!cpp std::unordered_map, #!cpp boost::unordered_flat_map, #!cpp absl::flat_hash_map, and similar hash maps trade insertion order for average-case constant-time lookup (through an adapter, since their template argument order does not match what basic_json expects) — see Object Order for the full list.

Avoiding copies

  • Move instead of copy. Constructing a basic_json from an existing one is linear in its size for the copy constructor but constant for the move constructor. The same applies to assigning a large #!cpp std::string, #!cpp std::vector, or other container into a value: pass it as #!cpp std::move(x) rather than x whenever x is no longer needed afterwards. Since version 3.13.0, converting an rvalue container (#!cpp json j = std::move(vec);) moves the elements instead of copying them; the source is then left in a valid but unspecified state.
  • Access without copying. get<T>() returns a copy of the stored value converted to T. When a reference or pointer to the value already stored inside the basic_json is enough, get_ref() and get_ptr() access it directly: both pages state, word for word, "No copies are made." — at the cost of that reference or pointer becoming invalid once the underlying value changes.
  • Iterate by reference. #!cpp basic_json::iterator::operator*() returns a reference (an alias for #!cpp basic_json&), but a range-based for loop with a by-value loop variable (#!cpp for (auto el : j)) still copies each element, because plain #!cpp auto drops the reference. Write #!cpp for (const auto& el : j) (or #!cpp auto& for a mutable loop), and use items() the same way when the key is needed too — its own examples use #!cpp for (auto& el : j.items()).
  • Construct in place. emplace_back() (arrays, amortized constant time) and emplace() (objects, logarithmic in the size of the container for json) forward their arguments directly to a basic_json constructor, rather than requiring a temporary value to be constructed and then copied or moved in. push_back() has an rvalue overload (#!cpp push_back(basic_json&&)) for a value that already exists: #!cpp j.push_back(std::move(value)) moves it in instead of copying it.
  • Skip the bounds check when it is redundant. at() and operator[] have the same complexity (constant for a valid array index, logarithmic for an object key in json) — the difference is that at() additionally checks the key or index and throws if it is invalid, while operator[] does not (see unchecked access and checked access). Prefer operator[] when the surrounding code has already established that the access is valid.
  • Reserve array capacity. basic_json has no public reserve(), but when building a large array incrementally with a known final size, get_ref() exposes the underlying #!cpp array_t so it can be reserved directly — see "reserving array capacity" for the one-line recipe.

Memory

  • A value is small, its content is on the heap. A basic_json is 16 bytes on a 64-bit platform (a type tag and a union). Strings, arrays, objects, and binary values are allocated on the heap and owned by the value. Destroying the value frees everything it owns.
  • Copies are deep. After #!cpp json b = a;, changing b does not change a, and both own their memory. Copying a large value therefore doubles its memory; see Avoiding copies. #!cpp auto x = j["key"]; is a copy, #!cpp auto& x = j["key"]; is not.
  • A moved-from value is null. After #!cpp json c = std::move(a); the value a is #!json null and owns nothing.
  • Reading does not require a tree. If you only read the data, parse it into a json_document (#!cpp <nlohmann/json_view.hpp>, added in version 3.13.0) instead of a basic_json. It stores 16 bytes per value, does not copy floating-point numbers or strings without escape sequences out of the text, and creates no basic_json value except for the parts you materialize(). A document that borrows the text needs the text to outlive it; see Zero-copy JSON views.
  • Containers keep their capacity. clear() and erase() on an array remove the elements, but the underlying #!cpp std::vector keeps its allocated capacity, like #!cpp std::vector::clear does. To give the memory back, assign a new value (#!cpp j = json::array();) or destroy the value.
  • Freed memory does not always go back to the operating system. The library returns memory to the allocator (#!cpp std::allocator by default, or the one you configured). Whether the process size shrinks then depends on the allocator and the platform, as for any C++ program. A process that parses a large document and drops it is not leaking if its resident size stays high; run it under a tool such as Valgrind or the sanitizers to tell a leak from retained memory.

Serialization

dump() with the default #!cpp indent = -1 selects "the most compact representation" (word for word from the page); any non-negative indent pretty-prints instead, which is more readable but produces more bytes and more work. dump() builds and returns a complete #!cpp string_t containing the whole serialization. operator<< writes directly to a #!cpp std::ostream instead, through the same serializer, but without ever materializing that intermediate string — so if the destination is a stream (a file, or #!cpp std::cout), #!cpp os << j; avoids the allocation and copy that #!cpp os << j.dump(); would incur for large values.

Diagnostics overhead

Two opt-in macros add diagnostic information to exceptions and to every value, at a cost that is only worth paying while it is in use:

  • JSON_DIAGNOSTICS adds a JSON Pointer to exception messages, pointing at the value that triggered the exception. Quoting the page directly: "enabling this macro increases the size of every JSON value by one pointer and adds some runtime overhead" — every value gains a parent pointer that has to be kept up to date as the document is built and modified.
  • JSON_DIAGNOSTIC_POSITIONS adds start_pos() and end_pos(), the byte offsets a value occupied in its parsed input. Quoting the page: "enabling this macro increases the size of every JSON value by two std::size_t fields and adds slight runtime overhead to parsing, copying JSON value objects, and the generation of error messages for exceptions."

Both default to off. Enable them where better diagnostics are worth the overhead (for example, while validating untrusted input, or in a debug build), and keep them off in a release build that does not need them.

Compile time

<nlohmann/json_fwd.hpp> forward-declares basic_json, json, ordered_json, json_pointer, and adl_serializer, pulling in only a handful of lightweight standard headers instead of the full json.hpp. A header that only needs to name nlohmann::json — in a function signature or a class member declaration, for instance — can include json_fwd.hpp and leave #!cpp #include <nlohmann/json.hpp> to the source files that actually parse, build, or serialize values, the same way a project would forward-declare any other heavy class to keep it out of widely-included headers:

// my_type.hpp
#include <nlohmann/json_fwd.hpp>

class my_type
{
    nlohmann::json config() const;
};

// my_type.cpp
#include <nlohmann/json.hpp>
#include "my_type.hpp"

nlohmann::json my_type::config() const { /* ... */ }

One caveat: ABI-affecting macros such as JSON_DIAGNOSTICS and JSON_DIAGNOSTIC_POSITIONS are encoded into the library's inline namespace name. Every translation unit — whether it includes json_fwd.hpp or the full header — must define them the same way, or linking fails with undefined references instead of a compile error.

If I/O support is not needed at all, JSON_NO_IO excludes <cstdio>, <ios>, <iosfwd>, <istream>, and <ostream> outright and drops the #!cpp std::istream/#!cpp FILE* parse overloads and operator<< that depend on them (dump() itself is unaffected, since it only returns a string); it exists for environments where those headers are unavailable (such as Intel SGX), and as a side effect those headers are then never processed by the compiler at all.

See also