Skip to content

How nlohmann/json 3.13.0 got 3× faster

Since 2016, the README of this library has said this about speed:

There are certainly faster JSON libraries out there. However, if your goal is to speed up your development by adding JSON support with a single header, then this library is the way to go.

That sentence is still true, but the gap is a lot smaller now. Swap the 3.12.0 header for the 3.13.0 one, recompile, and the 182 benchmarks of the library's own suite run 3.0× as fast on an Apple M1 Max (geometric mean; 3.1× on an Intel MacBook Pro from 2016). Not a line of your code changes. Parsing typical documents got 3.1–4.3× as fast, and writing them 3.0–6.7×. The new version also adds json_document, a second way to hold a parsed document, which competes with the fastest C++ and Rust JSON libraries; How it compares has the numbers, including where it loses. The price is compile time: two small example programs take 34–62% longer to compile. A precompiled header or a C++20 module wins back most or all of that in debug builds, and 41–91% of it in optimized builds (The price).

This post is about how. Hardly any of the techniques are new: we borrowed them from simdjson, yyjson, fast_float, Żmij, serde_json, Glaze, SQLite, Boost.JSON, or a textbook, and we name the source at each step. What is worth writing about is the combination, and the conditions it had to work under. It is a header-only C++11 library whose 3.x API has been stable for almost nine years, and whose value type is a template over its string, number, object, array, and allocator types. Input comes from strings, streams, FILE*, iterator pairs, wide strings, and user-defined adapters. Output had to stay the same byte for byte, and error messages had to keep pointing at the same byte. The few deliberate changes of output are listed in Appendix F.

Every number comes from a benchmark we ran for this post or from the pull request that made the change. The appendix describes the test files and the setup, has the full tables behind every chart, and lists the pull requests behind each theme.

Fast paths are where bugs hide: they run only for some inputs, so a mistake in one can go unnoticed until the right input comes along. So every fast path was tested against the slow path it replaces, on millions of generated and fuzzed inputs. Both had to return the same value, or fail with the same error message at the same byte. Appendix J lists the checks.

The short version

The test files are the three of Milo Yip's nativejson-benchmark that JSON libraries have been measured with for a decade, twitter.json (strings, much of it Japanese), citm_catalog.json (many small objects, indented) and canada.json (floating-point numbers), plus the 56 MB jeopardy.json. Appendix A says what makes each of them hard.

Speedup of 3.13.0 over 3.12.0 per workload, Apple M1 Max, Apple clang Horizontal bars of the speedup, more is better. twitter.json 3.1x; citm_catalog.json 4.2x; canada.json 3.4x; canada.json, indented 6.5x; citm, ordered_json 6.8x; twitter, std::ifstream 2.5x; citm, FILE* 4.7x; citm, accept() 7.1x; dump() twitter 6.7x; dump() canada 3.0x; dump(4) citm 4.7x; to_cbor() twitter 4.6x; twitter, memory 3.4x; twitter, FILE* 6.6x; diff() canada 10x; iterate canada 2.7x; copy + destroy citm 1.13x; == citm 1.06x. Parsing Writing Reading C​BOR Values 1× 2× 3× 4× 5× 6× 7× 8× 9× 10× 11× twitter.json twitter.json: 3.1× 3.1× citm_catalog.json citm_catalog.json: 4.2× 4.2× canada.json canada.json: 3.4× 3.4× canada.json, indented canada.json, indented: 6.5× 6.5× citm, ordered_json citm, ordered_json: 6.8× 6.8× twitter, std::ifstream twitter, std::ifstream: 2.5× 2.5× citm, FILE* citm, FILE*: 4.7× 4.7× citm, accept() citm, accept(): 7.1× 7.1× dump() twitter dump() twitter: 6.7× 6.7× dump() canada dump() canada: 3.0× 3.0× dump(4) citm dump(4) citm: 4.7× 4.7× to_cbor() twitter to_cbor() twitter: 4.6× 4.6× twitter, memory twitter, memory: 3.4× 3.4× twitter, FILE* twitter, FILE*: 6.6× 6.6× diff() canada diff() canada: 10× 10× iterate canada iterate canada: 2.7× 2.7× copy + destroy citm copy + destroy citm: 1.13× 1.13× == citm == citm: 1.06× 1.06× speedup over 3.12.0 (3.12.0 = 1×), more is better

Speedup of 3.13.0 over 3.12.0 for a selection of workloads: the old time divided by the new one, Google Benchmark suite on an Apple M1 Max, median of 8 runs. More is better; the vertical line at 1× is 3.12.0. The times are in Appendix B.

The rows above are chosen to show the range. All 182 benchmarks of the suite:

Speedup of 3.13.0 over 3.12.0 for all 182 benchmarks of the suite Bars counting the benchmarks per speedup range: below 1x: 1; 1 to 1.5x: 22; 1.5 to 2x: 12; 2 to 3x: 60; 3 to 5x: 58; 5 to 10x: 25; 10x or more: 4. Median 2.9x, geometric mean 3.0x. 0 20 40 60 below 1×: 1 1 below 1× 1 to 1.5×: 22 22 1 to 1.5× 1.5 to 2×: 12 12 1.5 to 2× 2 to 3×: 60 60 2 to 3× 3 to 5×: 58 58 3 to 5× 5 to 10×: 25 25 5 to 10× 10× or more: 4 4 10× or more speedup over 3.12.0, more is better number of benchmarks

How many of the suite's 182 benchmarks got how much faster. The one below 1× is copying citm_catalog.json without destroying the copy. Every row is in Appendix B.

The median benchmark got 2.9× as fast. On an Intel MacBook Pro (Skylake), the median is 2.9× as well, but parsing floating-point numbers gained more (canada.json 6.1× instead of 3.4×) and writing integers less (dump() 1.4–2.3× instead of 2.2–3.8×); Appendix B has both columns. Copying and comparing values gained the least, and the largest gains come from fixing algorithms rather than tuning them (theme 7).

Eight themes did most of the work:

Where the time went: a timeline

The suite compares two points. To see which change did what, we compiled one small benchmark against the header of each of the 351 commits on develop that changed json.hpp or json_view.hpp from 3.12.0 to November 3, and of 3.13.0, and ran all of them interleaved. Four of the eight workloads we followed:

  • parse() of citm_catalog.json: the lexer's structure and whitespace handling, and building many small objects;
  • parse() of canada.json: converting text to floating-point numbers;
  • dump() of twitter.json: writing strings;
  • from_cbor() of twitter.json: reading a binary format.

Each panel shows the speedup over 3.12.0 after each commit, on a logarithmic scale. The badges above the steps name the theme below that explains them. Moving code around changes a build's speed by a few percent on its own, so only the larger steps mean something.

parse() citm_catalog.json: speedup over 3.12.0 by commit parse() citm_catalog.json: speedup over 3.12.0 at each of 353 commits, ending at 3.3x. Labeled steps, by theme: 4, 1, 4, 4, 2, 4. parse() citm_catalog.json 1× 2× 4× 8× 3.12.0 Sep 2026 Oct #5234: 1.55× 4 #5283: 1.95× 1 #5592: 2.2× 4 #5809: 2.4× #5810: 2.8× 4 2 #5882: 3.3× 4 speedup over 3.12.0 (log scale), more is better

parse() canada.json: speedup over 3.12.0 by commit parse() canada.json: speedup over 3.12.0 at each of 353 commits, ending at 2.8x. Labeled steps, by theme: 4, 1, 3, 3, 3, 4. parse() canada.json 1× 2× 4× 3.12.0 Sep 2026 Oct #5234: 1.11× 4 #5283: 1.47× 1 #5617: 1.48× 3 #5738: 1.95× 3 #5851: 2.2× 3 #5882: 2.8× 4 speedup over 3.12.0 (log scale), more is better

dump() twitter.json: speedup over 3.12.0 by commit dump() twitter.json: speedup over 3.12.0 at each of 353 commits, ending at 6.8x. Labeled steps, by theme: 6, 2, 2, 2. dump() twitter.json 1× 2× 4× 8× 16× 3.12.0 Sep 2026 Oct #5285: 3.3× 6 #5738: 3.8× 2 #5878: 4.5× #5883: 6.8× 2 speedup over 3.12.0 (log scale), more is better

from_cbor() twitter: speedup over 3.12.0 by commit from_cbor() twitter: speedup over 3.12.0 at each of 353 commits, ending at 2.6x. Labeled steps, by theme: 1, 4, U, U. from_cbor() twitter 1× 2× 4× 3.12.0 Sep 2026 Oct #5233: 1.47× 1 #5501: 2.2× 4 #5185: 1.76× U #5746: 2.3× U speedup over 3.12.0 (log scale), more is better

Speedup over 3.12.0 after each of the 351 commits that changed the headers up to November 3, from 3.12.0 (left) to 3.13.0 (right). More is better; each point is the median of 4 runs of one build, taken in random order (with 4 runs, the mean of the two middle ones). The badges are theme numbers; "U" is a UTF-8 check that came and went. The other four workloads, and every labeled step with its pull request and size, are in Appendix C.

A few things stand out. A percentage for a step is relative to the time just before that step, not to the time of 3.12.0:

  • There is no single big step. Parsing citm_catalog.json got faster in about six visible steps. The largest removed a buffer that only error messages read, which alone saved 35% of the time. The next ones added the fast paths for contiguous input, derived positions only on error, and skipped indentation eight bytes at a time; most other changes moved it by a few percent.
  • Writing had a few large steps. dump() of twitter.json lost 70% of its time in one change (a write buffer and bulk string copies), and dump() of canada.json 65% in another (Żmij).
  • Regressions happen in between. A UTF-8 check of every text string in the binary readers, byte by byte, made from_cbor() of twitter.json 37% slower for 66 commits, until the readers were made lenient again by default, as in 3.12.0, with the check as an option. That decision was about the format specifications, which don't require a decoder to check, not about speed. The cost went with it. It never shipped in a release.
  • The input decides how big a step is. Removing the error-message buffer measured 8% on the benchmark of its pull request; on the indented citm_catalog.json it saved 35%, because every space of the indentation went through that buffer.

1 Specialize for each input source

This is the function the 3.12.0 lexer called for every byte of input:

char_int_type get()
{
    ++position.chars_read_total;
    ++position.chars_read_current_line;

    if (next_unget)
    {
        // just reset the next_unget variable and work with current
        next_unget = false;
    }
    else
    {
        current = ia.get_character();
    }

    if (JSON_HEDLEY_LIKELY(current != char_traits<char_type>::eof()))
    {
        token_string.push_back(char_traits<char_type>::to_char_type(current));
    }

    if (current == '\n')
    {
        ++position.lines_read;
        position.chars_read_current_line = 0;
    }

    return current;
}

The 3.12.0 lexer's get(), called for every byte of input.

Two counters in memory, a branch for a pending unget(), a call into the input adapter, a push_back() into a buffer that only an error message reads, and a branch for line breaks. The counters are members of the lexer, so the compiler has to load, add, and store them for every byte. A string or a number then copied each byte a second time into their token buffer. That is a clean design: one interface works for a std::string, an std::istream, a FILE*, a pair of iterators over a std::list<char>, or a user's own adapter. But it makes every input pay for the most limited one.

3.13.0 has a dedicated reader for each kind of input and picks it at compile time:

flowchart LR
    src[/"input"/] --> q{{"what is it?"}}
    q -->|"std::string, std::vector,<br>pointers, contiguous ranges"| c["contiguous:<br>scan the whole buffer"]
    q -->|"std::istream"| w["window:<br>scan the streambuf's get area"]
    q -->|"FILE*"| f["one lock per parse,<br>unlocked reads"]
    q -->|"other iterators,<br>user adapters"| b["byte at a time"]

The four kinds of input the lexer distinguishes in 3.13.0. Everything on the fast paths falls back to the byte path on anything unusual, so the result and the error messages stay the same.

Detecting the input in C++11

An input adapter can declare what it supports as static members (supports_seek, supports_bulk_scan, and a few more). The lexer detects them with the detection idiom, so a user's adapter that declares nothing keeps the byte path:

template<typename A> using detect_supports_seek = decltype(A::supports_seek);
template<typename A>
constexpr bool input_adapter_supports_seek(std::true_type) { return A::supports_seek; }
template<typename A>
constexpr bool input_adapter_supports_seek(std::false_type) { return false; }

static constexpr bool seekable = input_adapter_supports_seek<InputAdapterType>(
    is_detected<detect_supports_seek, InputAdapterType> {});
static constexpr bool lazy_position = seekable;

Detecting a static member of the input adapter with the detection idiom: an adapter that doesn't declare supports_seek is not seekable.

Without if constexpr, the flags select overloads by tag dispatch, f(std::integral_constant<bool, lazy_position>{}), which also keeps MSVC from warning about constant conditions. get() still looks like the old one, but its position and token bookkeeping are now calls. For an adapter that can seek, their std::true_type overloads are empty and cost nothing, because the position and the text of a token are derived only when an error message needs them (theme 4). For a std::string, what remains is the branch on next_unget and the adapter's bounds check and pointer increment.

Contiguous input

Containers with data() and size() over single-byte elements now become a pointer range in every C++ standard, not only with C++20's contiguous iterators. data() and size() alone would be duck typing, so the trait also requires the container's value_type to be the element type behind data(). A type whose size() counts something else keeps the iterator path: slower, but correct. On a pointer range, the strings, the numbers and the indentation after a line break are scanned in bulk (themes 2 and 3). Every fast path defers to the byte path on anything malformed, so error messages and byte positions stay the same, with one deliberate correction (the column after a number at the end of a line). Appendix J describes how that was checked.

Streams

An std::istream already has a buffer: the get area of its streambuf, gptr() to egptr(). The lexer now scans that window in bulk and keeps the current token as a pointer into it. Those are protected members of std::streambuf, and reaching them takes neither a cast nor a derived object:

struct streambuf_access : std::streambuf
{
    static const char* get_ptr(std::streambuf* s) { return (s->*(&streambuf_access::gptr))(); }
    static const char* get_end(std::streambuf* s) { return (s->*(&streambuf_access::egptr))(); }
    static void bump(std::streambuf* s, int n)    { (s->*(&streambuf_access::gbump))(n); }
};

Reaching the protected get area of any std::streambuf through pointers to members, without a cast.

A derived class may name a protected member of its base in a pointer to member, as long as it qualifies the name with its own class. The resulting pointer has the type char* (std::streambuf::*)() const, and access is checked when the name is used, not when the pointer is called, so it works on any std::streambuf*. The common alternative, static_cast<streambuf_access*>(sb)->gptr(), is undefined behavior, because the object is not a streambuf_access.

Before the stream refills its get area, the adapter appends the part of the current token that is in it to a carry buffer; only an error message ever reads the token text back. Nothing is read ahead: the adapter advances gptr() by exactly what the lexer consumes, so the stream is left where it was before. On an M1 Max, parsing from an std::ifstream took 1.8–2.5× as long as reading the file into a string and parsing that, in the pull request's measurement before the change; now it takes just 9–16% longer.

FILE*

std::fgetc locks the FILE on every call. 3.13.0 locks it once per parse (flockfile, then getc_unlocked; _lock_file and _fgetc_nolock with the Microsoft runtime). flockfile is recursive, so the binary readers' fread() and a SAX handler's own ftell() keep working. Whereas parsing citm_catalog.json from a FILE* got 4.4× as fast on macOS in the pull request's measurement, with glibc it got only 1.1–1.3× as fast, because glibc's per-call locking is already cheap. Two details: MemorySanitizer builds keep fgetc, because glibc's inline getc_unlocked reads a buffer that uninstrumented glibc filled. And a SAX handler that waits for another thread using the same FILE now deadlocks, as the documentation says. Both paths can be switched off, with JSON_DISABLE_STREAM_BULK_SCAN and JSON_DISABLE_UNLOCKED_FILE_IO.

2 Work on words, not bytes

The core of a JSON lexer is "copy bytes until something interesting happens". Theme 1 gave it ranges of bytes. This theme reads them a word at a time.

Eight bytes in a word

In a string, the interesting bytes are the closing quote, a backslash, a control character, and anything above 0x7F (UTF-8). This test checks eight bytes at once with ordinary 64-bit arithmetic:

// return a word whose high bit is set in every byte of v that needs
// individual handling; zero if all 8 bytes can be copied as they are
inline std::uint64_t swar_string_special(std::uint64_t v) noexcept
{
    constexpr std::uint64_t ones = 0x0101010101010101ull;
    constexpr std::uint64_t high = 0x8080808080808080ull;
    const std::uint64_t q = v ^ 0x2222222222222222ull; // '"'
    const std::uint64_t b = v ^ 0x5C5C5C5C5C5C5C5Cull; // '\\'
    const std::uint64_t has_quote     = (q - ones) & ~q & high;
    const std::uint64_t has_backslash = (b - ones) & ~b & high;
    const std::uint64_t has_control   = (v - 0x2020202020202020ull) & ~v & high; // < 0x20
    const std::uint64_t has_non_ascii = v & high;                                // >= 0x80
    return has_quote | has_backslash | has_control | has_non_ascii;
}

The eight-byte test of the string scanner, in portable C++11.

XOR with the quote turns every quote byte into zero, and (x - 0x01…01) & ~x & 0x80…80 sets the high bit of the bytes that were zero: the "determine if a word has a zero byte" trick from Sean Eron Anderson's Bit Twiddling Hacks. The subtraction's borrow runs only upwards, so it can flag a byte after a real hit, but never one before it: the lowest flag is always right, and the number of trailing zero bits divided by eight is the index of the first special byte.

Finding the end of a run of ordinary string characters eight bytes at a time The eight bytes w, i, t, t, e, r, quote and comma are loaded as one word. The test sets the high bit only in byte 6, the quote. Counting trailing zero bits gives 55, divided by eight gives 6: bytes 0 to 5 are copied at once, then the quote ends the string. eight input bytes, loaded as one 64-bit word (byte 0 is the lowest) w 0x77 byte 0 i 0x69 byte 1 t 0x74 byte 2 t 0x74 byte 3 e 0x65 byte 4 r 0x72 byte 5 " 0x22 byte 6 , 0x2C byte 7 swar_string_special(): the high bit of each byte that needs handling 0x00 0x00 0x00 0x00 0x00 0x00 0x80 0x00 count_trailing_zeros(0x0080000000000000) = 55 55 / 8 = 6: copy bytes 0 to 5 in one go, then handle the quote No branch per byte: a few subtractions, ANDs and ORs per word, then one count of trailing zeros.

swar_string_special() on the last eight bytes of "twitter",: one word in, one flag out, and the count of trailing zero bits says how many bytes can be copied before the quote.

Two details make this portable C++11. The word is loaded with std::memcpy, which compilers turn into one load and which, unlike a reinterpret_cast, doesn't break the aliasing rules. And the load always produces the bytes in little-endian order, so the first byte of the text is the lowest byte of the word everywhere; a big-endian target assembles the word from bytes, which compilers fold into a load and a byte swap. Every loop checks that eight bytes remain before it loads, and finishes the remaining one to seven bytes one at a time, so nothing is read past the input.

The same kind of word test now runs in several places:

  • Strings in the lexer, including runs of well-formed multi-byte UTF-8, which are validated one sequence after the other without going back to the byte loop.
  • Numbers, eight digits per step (theme 3).
  • Indentation. After a newline, word ^ 0x2020…20 is zero for eight spaces; the first non-zero byte ends the indentation, and its trailing-zero count says how many spaces of the last word to skip as well. The idea is from Glaze's skip_matching_ws, which compares with the previous line's indentation; comparing with spaces needs no state and works for any indentation width. A version that skipped only whole words validated 5–12% slower and gave nothing on two-space indentation, where the runs are often shorter than a word. With four-space indentation, validating citm_catalog.json got 22–30% faster.
  • dump(), for runs of characters that need no escaping, with and without ensure_ascii.
  • The binary formats, for the zero byte that ends a BSON key, and for the UTF-8 check of strings with error_handler_t::strict. On the M1, reading canada.json from BSON got 14% faster, and writing twitter.json as UTF-8-checked CBOR 14%.

16 bytes with SSE2 and NEON

Where a vector unit is part of the baseline instruction set (SSE2 on x86-64, NEON on 64-bit Arm), the string paths take 16 bytes per step. Two well-known details are worth repeating. A signed comparison byte < 0x20 catches control characters and every byte from 0x80, because those are negative as int8_t, so four classes of bytes take three comparisons. And NEON has no equivalent of x86's pmovmskb, which turns a vector of comparison results into a bit mask. The usual replacement, by Danila Kutenin, shifts each 16-bit lane right by four and narrows it to eight bits, which leaves four bits per input byte in a 64-bit integer:

while (e - p >= 16)
{
    const uint8x16_t in = vld1q_u8(p);
    const uint8x16_t quote_or_backslash = vorrq_u8(vceqq_u8(in, vdupq_n_u8('"')),
                                                   vceqq_u8(in, vdupq_n_u8('\\')));
    const uint8x16_t special = vorrq_u8(quote_or_backslash,
                                        vcltq_s8(vreinterpretq_s8_u8(in), vdupq_n_s8(0x20)));
    // each byte of special is 0x00 or 0xFF; shifting each 16-bit lane right by 4 and
    // keeping its low byte leaves 4 bits per input byte
    const uint8x8_t nibbles = vshrn_n_u16(vreinterpretq_u16_u8(special), 4);
    const std::uint64_t bits = vget_lane_u64(vreinterpret_u64_u8(nibbles), 0);
    if (bits != 0)
    {
        return p + (count_trailing_zeros(bits) >> 2u);   // four bits per byte
    }
    p += 16;
}

The NEON string scan: three comparisons for four classes of bytes, and a narrowing shift in place of pmovmskb.

On twitter.json, the vector scan made json::parse() 5% faster with Clang and 9% with GCC on an M1 Max; on long strings, 19–24%. It did not win from the first byte. On the M1, starting with NEON made short strings 3–4% slower and the rest of the lexer bigger. So ARM runs four 8-byte words first and keeps the vector loop out of line, while x86-64 runs one word first. Inlining the vector code into the lexer cost 5% of validating jeopardy.json on the M1, although no string of that file is long enough to reach it: the scan function, which runs once per token, saved more registers and needed a stack slot.

json_document, the new document type of json_view.hpp, also skips the digits after a decimal point 16 bytes at a time with SSE2: a compare and a bit mask instead of a branch per digit, about 33% of the instructions for a 12-digit fraction. Parsing canada.json got 22% faster on x86-64, a file of 10,000 floats 28–32%.

One row got slower: with GCC 16, citm_catalog.json took 8% longer with the same number of instructions, because more of its hot jumps now crossed a 32-byte boundary. The microcode fix for Intel's JCC erratum makes that slow on our server. Assembling with -Wa,-mbranches-within-32B-boundaries shrinks the loss to about 4%, but a header-only library can't choose the assembler flags of the programs that include it, so we left the code as it is, and all numbers in this post are measured without that flag.

Validating UTF-8 16 bytes at a time

Validating UTF-8 16 bytes at a time uses simdjson's "lookup4" algorithm (Keiser and Lemire, 2021): three 16-entry tables, indexed by nibbles of a byte and of the byte before it, each give the set of errors that nibble allows, and a pair of bytes is invalid if all three sets share an error. It needs pshufb, which is SSSE3 and not part of baseline x86-64. The function is compiled with __attribute__((target("ssse3"))), out of line, and called only if cpuid says so. The result of that query is cached in a constant-initialized static std::atomic<int>, so that neither a guarded local static nor a global constructor is needed; threads that ask at the same time all store the same answer. On an error, the code backs up to the start of the sequence and lets the byte path produce the error message. JSON_NO_SIMD turns all vector code off.

Short strings

Short strings are a different problem. Most keys and many values are short: citm_catalog.json averages 8 bytes per string and jeopardy.json 14. For them, setting up a scan costs more than the scan. The first step took the idea of SQLite's jsonAppendString(): check each byte of a string of up to 32 bytes against a 256-entry table of bytes that can be written unchanged, and copy the string in one go if they all pass. 32 bytes was the best threshold on the M1; 16 gained less, and 64 lost again. The second step replaced the table, where SSE2 or NEON is available, with overlapping loads, the trick that memcpy implementations such as glibc's use for short copies:

// 16 <= n <= 32: two loads that overlap in the middle cover the whole string
const uint8x16_t a = vld1q_u8(p);
const uint8x16_t b = vld1q_u8(p + n - 16);
const uint8x16_t special = /* '"', '\\', < 0x20 or 0x7F, in a or in b */;
if (vmaxvq_u8(special) != 0)
{
    return false;          // nothing has been written yet; take the general path
}
vst1q_u8(d, a);
vst1q_u8(d + n - 16, b);   // the two stores overlap too, with the same bytes

Writing a string of 16–32 bytes with two overlapping loads and stores (NEON).

Writing an 18-byte string with two overlapping 16-byte loads and stores The 18 bytes of profile_link_color. Load a covers bytes 0 to 15, load b covers bytes 2 to 17, so bytes 2 to 15 are in both. Neither contains a byte that needs escaping, so a is stored at the destination and b 2 bytes later; the second store writes bytes 2 to 15 again with the same values. This works for any length from 16 to 32 bytes without a loop. a string of n = 18 bytes: too long for one 16-byte vector, too short for a loop p r o f i l e _ l i n k _ c o l o r 0 2 15 17 a = load 16 bytes at p b = load 16 bytes at p + n − 16 Both loads lie inside the string, and together they cover all of it; bytes 2 to 15 are in both. Test a and b for a quote, a backslash, a control character or 0x7F: none, so write store a at d, store b at d + n − 16 The second store writes bytes 2 to 15 again, with the same values. For any n from 16 to 32: two loads, one test, two stores, no loop and no branch on the length.

The code above on profile_link_color, a key of twitter.json.

On twitter.json, this cut the instructions of dump() by 21%. Shorter strings use the same trick with two overlapping 8-, 4-, or 2-byte loads, so no memcpy with a length known only at run time is left.

Escapes from a table

When the scanner stops at a quote, a backslash, or a control character, the writer copies its escape sequence from a table of 8-byte rows, padded with zeros, with the length in the last byte:

{{'\\', 'u', '0', '0', '0', '0', 0, 6}},   // 0x00
{{'\\', 'n', 0, 0, 0, 0, 0, 2}},           // 0x0A
{{'\\', '"', 0, 0, 0, 0, 0, 2}},           // 0x22

std::memcpy(write_buffer.data() + write_buffer_pos, escape.data(), 8);   // always 8 bytes
write_buffer_pos += escape[7];                                         // keep only the real length

Three rows of the escape table, and how a row is written: always 8 bytes, then the real length.

One 8-byte store and one add, with no branch on the length; the padding lies behind the write position and is overwritten by the next write. The writer makes sure that 8 bytes are free before it copies. The idea is from orjson's QUOTE_TAB, which orjson took from sonic-rs. Although dumping 100,000 log lines full of \n, \t, and quotes got 23% faster on an M1 Max, most of that came from writing straight into the write buffer instead of an intermediate one. The table alone gained 2–7%.

3 Own the number conversions

3.12.0 converted every floating-point number with strtod and every integer with strtoull or strtoll, after copying the token into a buffer. strtod is correct, but general: it consults the locale for the decimal point, sets errno, and handles any length of input. std::from_chars would avoid the locale, but it doesn't exist before C++17, and libc++ has its floating-point overloads without defining __cpp_lib_to_chars, so macOS builds could not detect it.

canada.json, a GeoJSON file of 111,126 numbers, shows what that costs. 108,528 of its numbers have 16 or 17 significant digits, like -65.613616999999977. The classic shortcut is Clinger's fast path (Clinger, 1990): write the number as its digits w times a power of ten, convert w to a double, and multiply or divide it by that power of ten. If both are exact as double, that is, w is at most 2⁵³ (9,007,199,254,740,992) and the power at most 10²², the operation rounds only once, and the result is the correctly rounded double. For most of canada.json's numbers, w doesn't fit into 53 bits.

3.13.0 converts float and double numbers itself, without the C library and without the locale:

flowchart TB
    t["number token"] --> d{{"at most 19<br>significant digits?"}}
    d -->|"yes"| c{{"digits and power of ten<br>exact as double?"}}
    c -->|"yes"| cl["Clinger's fast path:<br>one multiplication or division"]
    c -->|"no"| el["Eisel-Lemire:<br>128-bit integer arithmetic"]
    d -->|"no"| el2["Eisel-Lemire on the first 19 digits"]
    el2 --> amb{{"w and w+1 round<br>to the same double?"}}
    amb -->|"yes"| done(["done"])
    amb -->|"no"| big["compare with the exact midpoint<br>(big-integer arithmetic)"]

How 3.13.0 converts a decimal token to a double. Of canada.json's 111,080 floating-point numbers, 9,742 take Clinger's fast path and 101,338 Eisel-Lemire; none has more than 19 digits.

Eight digits at a time

On contiguous input and stream windows, the lexer accumulates the value while it validates the token, so the digits are read once. The core is fast_float's eight-digit conversion: three multiplications instead of eight.

// v: eight ASCII digits, the first digit in the lowest byte
std::uint32_t parse_eight_digits(std::uint64_t v) noexcept
{
    v = ((v & 0x0F0F0F0F0F0F0F0Fu) * 2561u) >> 8u;            // 2561 = 10 * 2^8 + 1
    v = ((v & 0x00FF00FF00FF00FFu) * 6553601u) >> 16u;        // 6553601 = 100 * 2^16 + 1
    v = ((v & 0x0000FFFF0000FFFFu) * 42949672960001u) >> 32u; // 10000 * 2^32 + 1
    return static_cast<std::uint32_t>(v);
}

fast_float's conversion of eight ASCII digits to their value, with three multiplications.

Converting eight A​SCII digits to their value with three multiplications The text 12345678 as one word. Masking gives the digit values 1 to 8, one per byte. Multiplying by 10 times 2 to the 8 plus 1 and shifting gives 12, 34, 56 and 78 in 16-bit lanes; multiplying by 100 times 2 to the 16 plus 1 gives 1234 and 5678 in 32-bit lanes; multiplying by 10000 times 2 to the 32 plus 1 gives 12345678. eight A​SCII digits in one 64-bit word (byte 0 is the lowest) 1 0x31 2 0x32 3 0x33 4 0x34 5 0x35 6 0x36 7 0x37 8 0x38 & 0x0F0F0F0F0F0F0F0F: one digit value per byte 1 2 3 4 5 6 7 8 × (10 · 2⁸ + 1) >> 8, every second byte kept: two digits per 16 bits 12 34 56 78 × (100 · 2¹⁶ + 1) >> 16, every second lane kept: four digits per 32 bits 1234 5678 × (10000 · 2³² + 1) >> 32: the value 12345678 Each step combines neighboring lanes, in all lanes of the word at once: three multiplications instead of eight, and no branch.

parse_eight_digits() on "12345678": multiplying by 10·2⁸ + 1 adds ten times each byte to the byte above it, and the shift and the next mask keep every second lane.

Checking that all eight bytes are digits takes one more expression. A digit is a byte from 0x30 to 0x39. In hexadecimal, its upper half is 3 and its lower half is at most 9. Adding 6 tests the lower half: 0x39 + 6 = 0x3F still has the upper half 3, but 0x3A + 6 = 0x40 carries into it. So a byte is a digit exactly if its upper half is 3 both before and after adding 6, and with masks, that is one comparison for all eight bytes. An integer of up to 18 digits always fits into an int64_t, and 19 digits into a uint64_t, so they need no overflow check.

Clinger's fast path

Clinger's fast path is guarded by more than the two conditions above:

#if !defined(FLT_EVAL_METHOD) || FLT_EVAL_METHOD == 0
if (exponent >= -22 && exponent <= 22 && w <= (std::uint64_t{1} << 53))
{
    double value = static_cast<double>(w);
    value = exponent < 0 ? value / exact_power_of_ten(-exponent)
                         : value * exact_power_of_ten(exponent);
    return negative ? -value : value;
}
#endif

Clinger's fast path: one correctly rounded multiplication or division, only without x87 extended precision.

With x87 arithmetic (FLT_EVAL_METHOD != 0), the intermediate result has extended precision and is rounded twice, so those targets skip the fast path and take the integer-only route below.

Eisel-Lemire

Eisel-Lemire, adapted from fast_float's compute_float, handles everything else with integers only: it multiplies the digits with a 128-bit approximation of 5^q from a table and takes the mantissa from the top of the product. For up to 19 digits, that always suffices (Mushtak and Lemire, 2023). Longer numbers, rare in JSON, are bracketed by their first 19 digits and that plus one; only if the two round differently are all digits compared with the exact midpoint. Appendix I has the details. Because it is integer arithmetic, the result no longer depends on the C locale. (JSON always uses ., but strtod expects the locale's decimal separator, so 3.12.0 replaced the . before calling it, which failed for separators longer than one byte.) Appendix J describes how the conversion was checked against strtod.

Writing: Żmij

For output, 3.12.0 used Grisu2, which is fast but sometimes writes a 17th digit where 16 suffice. 3.13.0 ports Żmij by Victor Zverovich to C++11. It writes the shortest decimal that reads back as the same double, and the closest one if there are several. Its digit conversion, by Xiang JunBo, is the parser's divide-by-multiplying idea run backwards on all lanes of a word at once (Appendix I shows it), and with SSE2 or NEON, Żmij's vector code (the NEON version by Dougall Johnson, the SSE version by Tobias Schlüter) converts all 16 digits in one vector register.

One stall turned up on the way. To make room for the decimal point, the code first wrote all 16 digits, as two 8-byte stores, and then moved the digits after the point one byte to the right: it loaded them and stored them one byte further. That load came right after the two stores and read part of each. A CPU can hand a load the data of one store that hasn't reached the cache yet, but it can't combine two, so the load had to wait until both stores were written. The commit measured that wait as about 25% of the time to write a double. The vector version avoids memory: it keeps the digits in a register, moves the ones after the point up by one byte there, puts '.' into the gap, and stores all 16 bytes once.

In the pull request's measurement, dump() of canada.json got about 3× as fast, on an M1 Max as well as on an x86-64 server with GCC and Clang. The changed output is listed in Appendix F: 0.14% of random doubles are written differently, all of them with the same or fewer digits, and all of them read back as the same value.

4 Don't do unnecessary work

A lot of what the 3.12.0 parser did was correct, cheap per call, and read by nobody in the common case.

The token buffer

The old get() of theme 1 pushed every character into token_string, a buffer that only the "last read" part of an error message uses. For seekable input, the lexer now remembers the offset where the token started and copies the text from the input if an error happens. This is the 35% step of the timeline: in the indented citm_catalog.json, every space of the indentation went through the buffer. For streams, the carry buffer of theme 1 does the same job; the per-character copy had been about 20% of what was left of a stream parse.

accept() went one step further: it doesn't copy strings and keys at all. The lexer and the parser take a template parameter that drops values, so accept() gets its own instantiation without the copies. That made validating twitter.json 11–13% faster on x86-64, and 19 files of simdjson's examples 12% by geometric mean.

Positions only on error

For input that can be seeked, the lexer no longer counts lines and columns. The byte offset is the distance from the start, and lines and columns are recomputed only when an error message needs them:

std::size_t count_consumed_lines(std::size_t last_index, std::size_t& line_start) const
{
    const auto to = std::next(begin, last_index);
    using reverse_it = std::reverse_iterator<IteratorType>;
    const auto last_newline = std::find(reverse_it(to), reverse_it(begin), '\n');
    line_start = std::distance(begin, last_newline.base());   // column: offset - line_start
    return std::count(begin, last_newline.base(), '\n');      // line: newlines before it
}

Line and column of an error, computed from the byte offset only when an error message needs them.

That is linear in the input, but only an error pays for it. It is what serde_json's slice reader does (position_of_index() with memrchr and a newline count). A pending unget() is a flag, and the offset subtracts one for it, so unget() does no position work at all. Measuring the two parts of the old get() separately on an M1 showed that the newline branch cost more than the counters: removing only the branch saved 16% of validating an indented twitter.json, removing only the increments 4%. Parsing citm_catalog.json got 12% faster.

The text of float tokens

The lexer copied every float token into a buffer so that a SAX handler's number_float(val, s) could see the original text: an out-of-line std::string::append per float, about 10% of parsing canada.json. The handlers behind parse() and accept() never look at it. The parser now recognizes them by their exact type:

template<typename SAX> struct sax_ignores_number_text : std::false_type {};
template<typename B, typename IA, bool Collect, typename L>
struct sax_ignores_number_text<json_sax_dom_parser<B, IA, Collect, L>> : std::true_type {};
template<typename B>
struct sax_ignores_number_text<json_sax_acceptor<B>> : std::true_type {};
// json_sax_dom_callback_parser likewise

// at the call site
sax->number_float(res, number_float_text(sax));   // an empty string for the handlers above

Recognizing the library's own SAX handlers, which never read the text of a float token.

A class derived from json_sax_dom_parser does not match the specialization, so it keeps getting the exact text. For every other handler, the lexer only records a pointer to the token and its length, and copies on the first get_string(). That is safe for contiguous input, which has to outlive the parse anyway; in a stream's window, the next refill could overwrite the text, so there the copy is made at once. The first version put the "is there pending text?" check into the get_string() that keys and strings use too, and lost 2–3% on string-heavy input. Keys and strings now call a separate function without the check. Parsing canada.json got 9–11% faster.

A copy that should have been a move

from_cbor() and the other binary readers ended like this:

return res ? result : basic_json(value_t::discarded);   // 3.12.0

The end of the 3.12.0 binary readers: the conditional operator copies result.

result is a local, but the conditional operator's operand is an lvalue expression, and the implicit move on return applies only to a returned name. So every successfully parsed value was deep-copied once and then destroyed. 3.13.0 assigns discarded to result on failure and returns result by name. Decoding a 2 MB CBOR document went from 26.99 ms to 14.65 ms. The copy was also a stack-overflow risk: the copy constructor recursed once per level, for a value the reader had already accepted.

ordered_json growth

ordered_json stores its members in a std::vector<std::pair<const std::string, json>>. Because the key is const, the pair's "move" constructor copies the key and can throw. When std::vector grows, it uses std::move_if_noexcept to keep the strong exception guarantee, so for this element type it copies every element, including the whole subtree behind every value. The library now grows the storage itself, in two phases:

// phase 1, may throw but only touches tmp:
// copy the keys, default-construct the values, add the new element
Container tmp(this->get_allocator());
tmp.reserve((std::max)(size_type{1}, 2 * this->size()));
for (const auto& element : *this)
    tmp.emplace_back(std::piecewise_construct, std::forward_as_tuple(element.first),
                     std::forward_as_tuple());
tmp.emplace_back(std::forward<Args>(args)...);
// phase 2, cannot throw: move-assign the values, adopt the storage
auto it = tmp.begin();
for (auto& element : *this) { it->second = std::move(element.second); ++it; }
Container::swap(tmp);

How ordered_map grows its storage in two phases, without copying the values.

The new element is constructed before any value is moved, so j.emplace("new", std::move(j["old"])) still works, and the strong guarantee holds without a try, which matters because the library also builds with -fno-exceptions. Parsing twitter.json into ordered_json needs 33% of the allocations and, in the pull request's measurement, about 50% of the time. Unfortunately, compilers with the EDG front end (Intel's icpc, NVIDIA's nvc++) consider that pair's move constructor noexcept. There, the vector already "moves", the new path is not taken, and a key copy that throws during growth calls std::terminate.

Containers built one element at a time

The DOM parser used to push_back every element into its array, and insert every member into its object as soon as it was parsed. Now all open arrays share one stack of elements; when an array of up to 16 elements ends, it is constructed at its exact size from a range of move iterators, one allocation. ordered_json objects collect up to 32 members the same way, with a linear duplicate-key check over that short slice, and larger objects get a side table of hashes for that check from 64 members on. An ordered_json object with very many members went from 43 million instructions to 1.9 million, and ordered_json now parses 1–15% faster than json in our suite, since json's std::map still allocates a node per member. The binary readers know their sizes up front and use a separate handler without the stack; sharing it had made them 5–12% slower with GCC.

The binary readers also stopped taking every element through their general dispatch. When an array opens, the CBOR and MessagePack readers read the numbers, booleans, and nulls that follow in one loop, and the UBJSON and BJData readers do the same for arrays with a type marker. Reading an array of a million integers from CBOR or MessagePack got 42–47% faster, and canada.json 10%. Arrays of one element skip the loop: in 1,000 arrays nested in each other, each entered it only to find another array, which cost 6–8%. Now the loop costs nested arrays nothing measurable, and costs 100,000 arrays [1,2] at most 1.5%.

Smaller ones

The parser checks for the expected : or , before it falls into the lexer's general token switch (−7% on citm_catalog.json, a contribution by @mvanslobbe), and to_cbor() no longer creates a temporary basic_json for every object key it writes (−55% on twitter.json, by @alexprabhat99).

The same idea, measured twice

The SAX interface hands the lexer's buffer to the handler as string_t& and allows it to be moved from; the lexer starts each token with an empty buffer anyway. In September, moving string values instead of copying them saved 2–3%. We measured moving keys too, and dropped it. The buffer is sized for the longest token so far, and keys are short while values are long. So moving a key hands a large buffer to a short string, and the next value has to grow a new one: 11.9% slower on 40,000 short strings. In October, with much of the lexer around it rewritten, moving keys was measured again: parse() of twitter.json got 3.3% faster with Clang and 5.0% with GCC, so keys are moved now too. An idea that lost once is worth measuring again when the code around it changes.

5 Read the assembly

A good part of 3.13.0 came from reading what the compiler made of the hot loops, with Clang's -Rpass-missed=inline, profiles, and instruction counts. None of the following shows up in the source as a problem.

A stack canary on every token

The function that dispatches on the first byte of a token built the expected text of true, false, and null in local arrays:

case 't':   // 3.12.0
{
    std::array<char_type, 4> true_literal = {{'t', 'r', 'u', 'e'}};
    return scan_literal(true_literal.data(), true_literal.size(), token_type::literal_true);
}

The 3.12.0 lexer's dispatch for true: a local array, and with it a stack canary.

With -fstack-protector-strong, which Ubuntu's GCC enables by default and many distributions use for their packages, any local array gives a function a stack canary: the prologue loads a secret (on x86-64 Linux from thread-local storage) and stores it next to the locals, and every return compares it. Even worse, that happens on every call, also for the [, , and " tokens that never touch the array. The canary's load alone was 11% of the function's samples. (Plain -fstack-protector only protects character arrays of at least 8 bytes, so it didn't trigger here.) Now the literal is a string literal whose length is a template parameter, compared on contiguous input with one memcmp of constant size, and the dispatch has no canary and no array on the stack:

case 't': return scan_literal("rue", token_type::literal_true);

template<std::size_t N>
JSON_HEDLEY_NEVER_INLINE token_type scan_literal(const char (&rest)[N], token_type return_type);

The 3.13.0 dispatch: a string literal whose length is a template parameter.

scan_literal() is deliberately out of line: inlined, it made Clang's dispatch grow from 233 to 278 instructions, and slower. Together with the next change, validating canada.json got 10.5% faster with GCC on x86-64. The same change replaced the parser's std::vector<bool> nesting stack, whose push_back GCC called out of line, with std::vector<std::uint8_t>; a bit vector saves memory that a stack of nesting levels doesn't need.

Exceptions that block inlining

A function that can throw builds the message, allocates the exception, and needs a landing pad for every temporary, even if the throw never happens:

if (JSON_HEDLEY_LIKELY(ptr != nullptr)) { return *ptr; }   // 3.12.0, get_ref()
JSON_THROW(type_error::create(303, detail::concat(
    "incompatible ReferenceType for get_ref, actual type is ", obj.type_name()), &obj));

The throw in 3.12.0's get_ref(): building the message makes the function too big to inline.

That made get_ref(), operator[], at() and the iterator operators too big to inline, and gave callers large frames (192 bytes for get_ref() with GCC). 3.13.0 moves construction and throw into helpers marked [[noreturn]] and never inlined, so the hot function keeps a test and a call:

if (JSON_HEDLEY_LIKELY(ptr != nullptr)) { return *ptr; }
throw_incompatible_reference_type(obj);   // JSON_HEDLEY_NO_RETURN JSON_HEDLEY_NEVER_INLINE

The same function in 3.13.0: a test and a call of a helper that is never inlined.

That fixed 27 throw sites. Iterator arithmetic got 6× as fast with Clang, and std::sort over a json array of 10,000 integers in user code 6–9% faster.

The value's own lifecycle had the same problem: the destructor, the copy constructor, and operator== contained the code for every type, so they were too big to inline even for a number. Clang reported about 380 call sites where they were not inlined. Now the destructor and the copy constructor start with an inline test whether the value owns memory, which Clang compiles to one bit test (1 << type & 0x10E), operator== with one for two scalars of the same type, and the rest is out of line. j == 5 went from 3.55 ns to 0.65 ns with Clang. It is not free: a test file of extern "C" wrappers around the library's functions, which we compile to check what gets inlined, grew by 13% at -O2, and with GCC, operator== on large documents got about 7% slower.

Aliasing

C++ allows a char to access any object, so a store through a char pointer may change any object whose address the compiler can't rule out. In a writer that keeps its write position pos in a member, buffer[pos++] = c therefore costs more than it looks: the compiler can't prove that the store of c didn't overwrite pos itself, so it writes pos to memory before every character and loads it again after. A local variable whose address never escapes can't be reached by any store, and stays in a register:

std::size_t pos = write_buffer_pos;   // a local: no char store can reach it
// ... write_buffer[pos++] = digit; ...
write_buffer_pos = pos;               // published once at the end

Keeping the write position in a local, so that the compiler can keep it in a register.

On a 4 MiB binary value, dump() went from 18.0 ms to 7.4 ms with this change alone. json_document's writer and the binary writers keep their cursors in locals for the same reason, and publish them before any call that could see them.

Loop shape beats branch count

A change that removed one branch per whitespace byte made scanning long runs of indentation 1.75–3.2× slower with Clang and GCC. It had rewritten do { get(); } while (is_whitespace()); into get(); while (is_whitespace()) { get_ignoring_pending_unget(); }, and the assembly showed why it lost: neither compiler kept the input's read and end pointers in registers any more, and both reloaded them from memory on every character. Restoring the do-while shape, get(); if (is_whitespace()) { do { ... } while (is_whitespace()); }, fixed it. We don't know why the while loop lost the registers, only that the do-while shape gets them back.

Inlining that disappears

Inlining is a budget per function. Once the string writer had grown, GCC 13 and 16 stopped inlining two tiny, unchanged helpers into the pretty-printing loop, and indented output got up to 13% slower. They are now forced inline. A change in one place silently made an unrelated loop slower; only a benchmark of indented output noticed.

Branches the compiler chose

json_document writes most floats of canada.json by deciding, from where the double lies relative to its source text, which of two or three candidate digit strings to write. The conditions of those selections depend on the digits, so they are as good as random:

const bool up = from > 37 * one;
const bool shorter = biased_distance(from - (up ? 10 * one : 0)) < radius;
const std::uint64_t a = w - r10 + (up ? 10u : 0u);
return shorter ? a : b;

Selecting a candidate digit string: GCC 13 compiled the conditional operators as jumps.

Clang compiled the ?: as conditional moves; GCC 13 compiled them as jumps. With nothing to tell it that the conditions are unpredictable, a branch that skips some work looks cheaper than computing both sides. Callgrind's branch simulation counted 220,000 mispredictions per dump of canada.json with GCC, against 97,000 with Clang. Written as masks, which have no branch form to reconstruct, the selections stay branch-free with GCC and Clang:

// all ones or zero
std::uint64_t mask_if(bool b) { return std::uint64_t{0} - static_cast<std::uint64_t>(b); }
std::uint64_t select_if(bool b, std::uint64_t a, std::uint64_t c)
{
    return c ^ ((a ^ c) & mask_if(b));
}

const bool shorter = biased_distance(from - (mask_if(up) & (10 * one))) < radius;
const std::uint64_t a = w - r10 + (mask_if(up) & 10u);
return select_if(shorter, a, b);

The same selections written as masks, which stay branch-free with GCC and Clang.

With GCC on x86-64, the mispredictions went down to 98,000, and the dump of canada.json got about 15% faster with 5% more instructions. With Clang, the time stayed the same. We found this one while writing this post.

Code size as a cost

get<std::array<int, 1000>>() used a pack expansion, {{ j.at(Idx).get<T>()... }}. A pack expansion is unrolled before the optimizer decides anything, so once at() had become small enough to inline, Clang emitted one function of about 28,000 instructions (with 3.12.0, about 8,000): a thousand inlined copies of the bounds and type checks. Its time moved from 657 to 957 ns just by aligning the code differently. A loop needs 49 instructions.

Instruction counts, not only time

On the x86-64 machine we used for checking, moving code by a few bytes changed the time of unrelated benchmarks by up to ±5%. Many decisions were therefore made on instruction counts (callgrind on Linux, /usr/bin/time -l on macOS), checked against timings of builds with all functions and loops aligned to 64 bytes, and timed interleaved in one binary with several code layouts. The target, applied strictly in the later changes, was: at least 3% faster, and no benchmark more than 1% slower. Some changes accepted a small loss elsewhere and say so, such as the 7% on GCC's operator== above. The branch example shows the limit of that rule: it is faster with more instructions.

6 Write through a cursor

In 3.12.0, every dump() created its output adapter with std::make_shared, built a 512-character indentation string, and the serializer wrote every structural character through a virtual call:

o->write_characters("{\n", 2);
o->write_characters(indent_string.c_str(), new_indent);
o->write_character('\"');

How the 3.12.0 serializer wrote an object's opening brace and the start of a key: three virtual calls.

The writing side changed in five steps, one per subsection below:

flowchart LR
    subgraph before["3.12.0"]
        direction LR
        d1["dump()"] --> a1["make_shared<br>output adapter"] --> v1["virtual call<br>per write"] --> s1[("std::string")]
    end
    subgraph after["3.13.0"]
        direction LR
        d2["dump()"] --> a2["adapter on<br>the stack"] --> b2["1 KiB buffer,<br>bulk append"] --> s2[("std::string")]
    end

The text serializer's output path before and after the first two steps.

A buffer in the serializer

All writes go into a 1 KiB buffer that is flushed in bulk, so the virtual call happens once per KiB, and neighboring writes can be merged. Runs longer than the buffer are written straight through, so they are not copied twice.

void put_char(char c)
{
    if (JSON_HEDLEY_UNLIKELY(write_buffer_pos == write_buffer.size())) { flush(); }   // virtual
    write_buffer[write_buffer_pos++] = c;
}

Writing one character: the virtual call happens only when the buffer is full.

No allocation per call

The adapter lives on the stack, and there is no indentation string any more: dump(4) writes the spaces straight into the buffer. A compact dump() of a small object went from 4 heap allocations to 1 with libstdc++ (3 to 1 with libc++): the returned string, and none if it fits into the string's small buffer.

A concrete sink for the binary writers

The overloads of to_cbor() and friends that return a std::vector use a writer that is a template over the sink type, with no virtual call. Byte order is now swapped with __builtin_bswap* (or _byteswap_* with MSVC) instead of std::reverse on a byte array: compilers can recognize the reversal as a byte swap in a small function, but in the CBOR number path, Clang emitted no bswap at all. Writing into a vector also allows to_bson() to make one pass instead of two, because a vector can be written to at any position: BSON puts the size of every document in front of it, so the writer used to walk the value once for the sizes and once to write. It now writes a placeholder and fills in the size when the document is complete, 12–20% faster on the M1. Streams and other output adapters, which can only append, still take two passes.

A cursor instead of vector::insert()

After the previous step, writing an array of integers as CBOR was still no faster than in 3.12.0, and slower with GCC. The profile said why: each number's one to eight bytes were appended with vector::insert(), which both standard libraries implement as an out-of-line insert-anywhere routine; it took 30% of the time, more than encoding the numbers. The sink now keeps a cursor and a limit pointer, reserves space for a whole run of numbers at once, and writes through the cursor:

CharType* const start = oa.reserve(max_run * integer_encoding_space);   // one check per run
CharType* out = start;
for (n = 0; n < max_run && it != end && it->is_number_integer(); ++n, ++it)
{
    out += encode_integer(out, *it);
}
oa.commit(out - start);

Encoding a run of integers through a cursor, with one capacity check for the whole run.

Integers without branches

The last step also took the branches out of encoding an integer. After the bounds checks, mispredicted branches accounted for about 40% of the remaining time: the type, the sign, and the 1-, 2-, 4-, or 8-byte size class of each integer in a stream are hard to predict. The encoder reads signed and unsigned integers through one masked path, looks the size class up from the number of leading zero bits, and stores the head as one byte plus one 8-byte word, of which the caller keeps only as many bytes as the class needs:

// 1, 2, 3 or 4 if x fits 1, 2, 4 or 8 bytes, without a branch
static unsigned integer_size_class(const std::uint64_t x) noexcept
{
    static constexpr std::array<std::uint8_t, 64> size_class_by_leading_zeros = { /* ... */ };
    return size_class_by_leading_zeros[count_leading_zeros(x | 1u)];
}

std::size_t encode_first_byte_and_tail(CharType* out, std::uint8_t first_byte,
                                       unsigned size_class, std::uint64_t value) const noexcept
{
    out[0] = to_char_type(first_byte);
    const unsigned unused_bits = 64u - ((8u << size_class) >> 1u);
    const std::uint64_t tail = is_little_endian ? byteswap(value) >> unused_bits
                                                : value << unused_bits;
    // always 8 bytes; the caller keeps the first 0, 1, 2, 4 or 8
    std::memcpy(out + 1, &tail, sizeof(tail));
    return ((std::size_t{1} << size_class) >> 1u) + 1;
}

Encoding the head of a CBOR integer without a branch: the size class from a table, the tail as one 8-byte store.

The table is a table because Clang vectorized the equivalent comparisons into slower code, and the unconditional 8-byte store is why reserve() asks for 9 bytes per integer. Writing an array of small signed integers as CBOR, which had not gotten faster before, is now 4.2× as fast as with 3.12.0 with Clang on an M1 Max, and was 2.8× as fast with GCC 13 on x86-64 when the change was made. One more lesson from these changes: the loop over an array is a separate function that is never inlined. Inlined into the function that writes any value, its register needs made every call of that function save more registers, once per nested value, which cost citm_catalog.json 13%.

7 Fix the algorithm first

None of the micro-optimizations above matter much next to an algorithm that is quadratic. Seven operations were quadratic or worse in 3.12.0. They are fixes of cases most programs never hit, not tuning, so they are not part of the 3.0× above (except flatten(), which the suite happens to measure); Appendix D has the numbers:

Operations that grew quadratically or worse: time with 3.12.0 and 3.13.0, log scale Dot plot on a logarithmic time axis, less is better; an arrow leads from each 3.12.0 time to the 3.13.0 time. diff() 64k numbers vs []: 4237 ms to 10.8 ms; diff() 20k objects vs half: 122 ms to 4.13 ms; parse() callback, 32k objects: 1398 ms to 12.1 ms; operator< nested 24 deep: 64.4 ms to 0.002 ms; operator< citm, 10x: 284 ms to 3.94 ms; flatten() citm, ordered_json: 271 ms to 1.45 ms; to_bson() 200 deep, 100x: 20.3 ms to 0.352 ms; push_back() 100k, diagnostics: 1589 ms to 5.32 ms; at(i) 10k, diagnostics: 45.9 ms to 0.088 ms. 1 µs 10 µs 0.1 ms 1 ms 10 ms 0.1 s 1 s 10 s runtime (log scale), less is better 3.12.0 3.13.0 diff() 64k numbers vs [] diff() 64k numbers vs [], 3.12.0: 4237 ms diff() 64k numbers vs [], 3.13.0: 10.8 ms 392× diff() 20k objects vs half diff() 20k objects vs half, 3.12.0: 122 ms diff() 20k objects vs half, 3.13.0: 4.13 ms 30× parse() callback, 32k objects parse() callback, 32k objects, 3.12.0: 1398 ms parse() callback, 32k objects, 3.13.0: 12.1 ms 116× operator< nested 24 deep operator< nested 24 deep, 3.12.0: 64.4 ms operator< nested 24 deep, 3.13.0: 0.002 ms 32200× operator< citm, 10x operator< citm, 10x, 3.12.0: 284 ms operator< citm, 10x, 3.13.0: 3.94 ms 72× flatten() citm, ordered_json flatten() citm, ordered_json, 3.12.0: 271 ms flatten() citm, ordered_json, 3.13.0: 1.45 ms 187× to_bson() 200 deep, 100x to_bson() 200 deep, 100x, 3.12.0: 20.3 ms to_bson() 200 deep, 100x, 3.13.0: 0.352 ms 58× push_back() 100k, diagnostics push_back() 100k, diagnostics, 3.12.0: 1589 ms push_back() 100k, diagnostics, 3.13.0: 5.32 ms 299× at(i) 10k, diagnostics at(i) 10k, diagnostics, 3.12.0: 45.9 ms at(i) 10k, diagnostics, 3.13.0: 0.088 ms 522×

Operations that were quadratic or worse in 3.12.0, on a logarithmic scale; each arrow leads from the time with 3.12.0 to the time with 3.13.0. Less is better.

Comparing in one pass

The most instructive one is operator<. Before C++20, json's operator< on two arrays or objects called the operator< of std::vector or std::map, which is std::lexicographical_compare. For every pair of elements it asks a < b and, if that is false, b < a. When the elements are themselves containers, both questions recurse into the whole subtree, and when they are equal, both have to go all the way down. So each level of nesting doubles the work: two equal values nested 20 levels deep took 5.7 ms, 30 levels 3.8 seconds, and 40 levels would take about an hour. That matters to anyone who keeps json values in a std::set, uses them as std::map keys, or sorts them. Since C++20, operator< is derived from operator<=>, which asks once and was never affected.

3.13.0 compares in one pass over an explicit stack of iterator pairs and gets "less", "equal", "greater" or "unordered" for each pair at once, so the ordering question is asked once per element. We found two subtleties by comparing 68,121 results with the old code, not by reading it. std::lexicographical_compare steps over a pair it cannot order (a NaN) and goes on, while the three-way version stops there, so the pass has a flag for which behavior the caller needs. And object keys must be compared with operator== when testing equality, not with the object's comparator: for ordered_json, that comparator is std::equal_to, and using it as "less" reports every equal key as smaller. The single pass has a price: ordering flat arrays of numbers got 27% slower, while flat objects got 34% faster, in the pull request's measurement.

Bounded recursion

The other side of this theme is depth. A value nested 100,000 levels deep used to crash dump(), the copy constructor, std::hash, flatten() and the BSON and UBJSON writers with a stack overflow, and in optimized builds operator==, diff() and the other binary writers at 300,000 levels; servers that copy JSON from clients hit that. Other libraries limit the depth of what they parse (Boost.JSON at 32, serde_json at 128, simdjson at 1024) or parse and write iteratively (yyjson), and RapidJSON parses recursively unless asked otherwise. We tried making every walk iterative, but that made dump(), copying and merging up to 20% slower on ordinary documents. So 3.13.0 bounds the recursion instead:

constexpr std::size_t recursion_depth_limit() noexcept { return 128; }

static void destroy_bounded(basic_json& v, std::size_t depth) noexcept
{
    if (JSON_HEDLEY_UNLIKELY(depth >= detail::recursion_depth_limit()))
    {
        destroy_walk(v);   // deeper than 128 levels: no recursion at all (below)
        return;
    }
    // for each child that is a container: destroy_bounded(child, depth + 1)
    free_container(v);
}

Bounded recursion: recursive down to 128 levels, iterative below.

The first 128 levels run the old recursive code, and only below that does an explicit stack, or the walk below, take over. A document nested less deeply pays one comparison per container. Testing for equality walks std::vector and std::map itself and passes the depth as an argument. Copying can't do that cheaply: the containers' copy constructors call ours for every element, and a copy that inserts element by element so that it can pass the depth measured 4–16% slower, because std::map's own copy clones its tree without comparing a single key. So copying counts the levels in a thread_local counter instead, about ten nanoseconds per copy of a small object. Three more operations use the same counter: converting a value from another basic_json specialization, the ordering comparisons (<, <=> and the operators derived from them), and equality for custom container types, whose own operator== calls ours.

Destroying without a stack

The destructor's walk for deep values uses pointer reversal, the idea behind the Schorr-Waite graph marking algorithm: while it empties a container, the slot of that container's last child holds the link back to the container's parent, so the way back needs no memory (Appendix I shows the loop). The walk allocates nothing and calls nothing that can throw, which fixed two older bugs: the previous destructor flattened children into a std::vector, which ignored the allocator type and could let std::bad_alloc escape from a noexcept destructor. A test checks with a counting allocator that destroying any value allocates nothing. Against 3.12.0, destroying a parsed document is 1.24–1.55× as fast in our suite, and destroying an array of one million small objects 1.39×.

8 Build a different data structure

After all of the above, json::parse() is still bound by what it builds: one heap allocation per string, array and object, and a std::map node per member, all of which have to be freed again. Many programs only parse a document, read some fields and throw it away, or change a few values and write it back. For them, 3.13.0 adds json_document and json_view in a new header, <nlohmann/json_view.hpp>. The two figures below show what each parser makes of this 54-byte text:

{"port": 6379, "price": 19.90, "tags": ["eu", "a\"b"]}

The example text of the next two figures.

A J​SON text parsed by basic_json::parse() The text {"port": 6379, "price": 19.90, "tags": ["eu", "a\"b"]} parsed by basic_json::parse(): the root json value points to a std::map whose three nodes are separate heap allocations; the price is stored as the double 19.9; the tags array is a std::vector of two json values, each pointing to its own std::string. basic_json::parse(): a tree, every array, object, and string in its own heap allocation json std::map "port" 6379 "price" 19.9 double: 19.90 is gone "tags" • std::vector json json std::string eu std::string a"b a separate heap allocation

What json::parse() builds: a tree in which every object, array, and string is a separate heap allocation (dashed). The float is converted to the nearest double while parsing, so the original text 19.90 is gone: the stored value is 19.899999999999998578…, which dump() writes as 19.9, the shortest text that reads back as the same double.

The same J​SON text parsed by json_document::parse() The same text parsed by json_document::parse(): it keeps the 54 bytes of source text and builds a flat index of nine 16-byte nodes in document order: object, key port, unsigned integer 6379 (the converted value is in the node), key price, float, key tags, array, string eu, and an escaped string. Keys, the float, and the plain string point back into the source text; the escaped string points into the document's buffer of decoded strings. A json_view is two pointers: one to the document and one to a node, here the array. json_document::parse(): the source text stays where it is; one flat index of 16-byte nodes source text, 54 bytes (borrowed, or owned by the document) { " p o r t " : 6 3 7 9 , " p r i c e " : 1 9 . 9 0 , " t a g s " : [ " e u " , " a \ " b " ] } 0 10 20 30 40 50 json_document: owns the node index and the buffer of decoded strings node index: 9 nodes × 16 bytes = 144 bytes, in document order 0 object len 3 next 9 1 key off 2 len 4 2 uint 6379 (converted) 3 key off 16 len 5 4 float off 24 len 5 5 key off 32 len 4 6 array len 2 next 3 7 string off 41 len 2 8 string escaped len 3 decoded strings a " b json_view v = doc.root()["tags"] doc* node* integers are converted while parsing and kept in their node; floats and strings point back into the text

What json_document::parse() builds from the same text: one flat index of nine 16-byte nodes in document order, which point back into the source text, plus a buffer for the one string with an escape. A json_view is two pointers, here to the array.

One flat index

The design is not ours either. simdjson's tape and yyjson's immutable document are both flat arrays of fixed-size entries in document order instead of trees of separate allocations, and yyjson's entries are 16 bytes, too. Ours:

struct node
{
    node_kind     kind;    // object = 1, array = 2, string, number, ...
    std::uint8_t  flags;   // escaped, edited, moved, ...
    std::uint16_t extra;   // numbers: digits before and after the point; big objects: hash index
    std::uint32_t off;     // where the value starts in the source text (or in the arena)
    std::uint32_t len;     // strings and float tokens: bytes; containers: number of elements
    std::uint32_t next;    // containers: number of nodes in the subtree, including this one
};
static_assert(sizeof(node) == 16, "node must stay 16 bytes");

A node of json_document's index: 16 bytes for every value.

  • Skipping is an addition. A container stores the size of its subtree, so the value after it is n + (is_container(*n) ? n->next : 1), and the members of an object are a key node followed by the value's subtree. There are no pointers in the parsed index (edits add links, below); the parser fills in next when the container closes. is_container() is a single subtraction and comparison, kind - 1u <= 1u, which is why objects and arrays are numbered 1 and 2.
  • Strings stay in the input. Only strings with escapes are decoded, into an arena. A document makes only a handful of allocations, no matter how many values it has. The price is that the text must outlive the document, unless the document owns it: parse_copy() copies the text, and parsing an rvalue std::string moves it into the document. Input that can't be read as one contiguous block of bytes, such as a stream or a FILE*, is read into a buffer that the document owns, too. Offsets are 32 bits, which limits a document to 4 GiB.
  • Integers are converted while their digits are in the cache, into the last 8 bytes of their node.
  • Floats stay text until you read them. The node records the number of digits before and after the point, and reading converts with the same Clinger/Eisel-Lemire code as json::parse. We tried converting them during the parse. Although parsing canada.json and reading every number got 4–7% faster, parsing alone got 36–90% slower, so we dropped it. A default dump() writes the shortest form of a float, as json does; number_format::source copies the source text of every number instead.
  • SIMD and tables as in theme 2, and \u escapes are decoded with a table, as in yyjson.
  • A hash index for big objects, an idea from Boost.JSON. An object with 128 or more members gets an open-addressing table after parsing; looking up every key of a 10,000-member object went from 59.8 ms to 0.16 ms. The hash is not seeded, so crafted keys could make building the table quadratic. Probing is therefore capped at 64 slots: a key that would need more drops the table, and that one object falls back to a linear search. Parsing can't become quadratic, and a lookup is never slower than the linear search of a small object.
  • Validating writes no index. json_document::accept() runs a second instantiation of the same builder with a template parameter that turns every store off, so the checks are the same code, but no node is written and no escaped string is decoded. On x86-64 that made it 17–31% faster on twitter.json, citm_catalog.json and canada.json, and the 56 MB jeopardy.json no longer needs 52 MB of fresh memory just to be validated.
  • First duplicate wins. Lookups return the first of duplicate keys, as yyjson and simdjson do, because returning the last one meant scanning every object to its end and made lookups in small objects 1.6–3.4× slower. json::parse() keeps the last one, so for a text with duplicate keys, the two can disagree; the documentation says so.

It can be edited, too

A read-only index is the easy case, and a fair objection is that json_document is only fast because it can't change. json_editable_document has set(), insert(), erase() and push_back(). An edit never writes into the source text and never moves the parsed index:

  • a new scalar is written over its own node, with new string and number text in an append-only arena;
  • new arrays and objects are allocated in chunks of nodes that never move;
  • a container whose elements change is marked as moved and gets a separate list of its entries: links to the values, which stay where they are. The list doubles when it grows, so push_back() is amortized constant.

Views stay valid across edits, because values never move. Reading an edited container costs one flag test per container and one test per element for a link. The link case is out of line, so that it compiles to a predicted branch rather than a conditional select that would delay the following loads. Whereas a read-only document carries none of this, an editable one that hasn't been edited reads within 2% of it on most rows and within 8% on the worst. And because untouched values keep their source text, dump() with number_format::source writes back a patched configuration file with its member order and the spelling of every number (19.90) intact, which json (sorted keys) and ordered_json (19.9) can't.

The limits are documented: set() of a new key searches the object's members, so building a large object key by key is quadratic, as with ordered_json, and an object that was edited loses its hash index and is searched linearly. How it compares times the editable document against the mutable trees of other libraries.

How it compares

"Which JSON library parses fastest?" has no answer, because "parsing" means different things in different libraries. They hold a parsed JSON text in four different ways:

approach what you get what it costs libraries we measured
mutable tree a value per JSON value, every string and number converted and owned; change anything an allocation per container and often per string, all freed again nlohmann/json json, Boost.JSON, RapidJSON, sonic-cpp, Glaze generic, yyjson's mutable document; serde_json, sonic-rs, simd-json values
flat document one array of small entries in document order; read in any order, many times a few allocations; read-only, or extra bookkeeping to edit nlohmann/json json_document (editable as json_editable_document), yyjson, simdjson DOM
forward-only reading each value once, in document order: events (SAX) or a cursor nothing is built; you can't go back nlohmann/json sax_parse, RapidJSON Reader, Boost.JSON basic_parser, simdjson On-Demand, Glaze lazy_json; serde visitors in Rust
into your own types the fields you declared, nothing else a struct per document shape; other fields are skipped Glaze reading into structs, serde with derive, nlohmann/json from_json() from json or from a view

The kinds of representation the libraries in this comparison offer. Most libraries offer more than one.

So a comparison of parse times across the rows of this table compares different amounts of work. Instead, we compare use cases: tasks a program actually has, each of which every library may solve in whatever way it does best. In nlohmann/json, json is the mutable tree and json_document the flat document, so we show the library twice: its best way with json_document and its best way without it.

Six use cases

Each starts from the file's text in a std::string (or a byte slice in Rust) and ends with everything the library built freed again:

  • validate: is this text valid JSON? Nothing is kept, so this measures the parser alone, as in a server that checks a request before it passes it on.
  • sum: visit every value in document order, convert every number, and add them up. This measures walking a whole document and converting all of its numbers.
  • select: read a few fields of every record, such as the id, the user name, and the retweet count of every tweet, as a program does that needs only part of a response. A library that reads lazily can skip the rest.
  • edit: change a value, remove a member, and add a member in every record, then write the result as compact JSON: the round trip of a program that patches a message or a configuration.
  • minify: read the text and write it as compact JSON, without whitespace. This measures reading and writing together, without changes in between.
  • write: write a document that was parsed beforehand as compact JSON; only the writing is timed.

Each use case runs on each of the four test files, and each library may solve it in every way it offers: in place, with a SAX handler, into structs, with a parser that is reused (measured, but not chosen). Before anything is timed, every way's result is checked: the same values, the same string bytes, the same sum of all numbers (to the last bit where they are added in the same order), and an output that reads back as the expected document. The README of the programs lists every way, the fields and edits per file, and the checks.

Where the input comes from

Starting from a string in memory is how most JSON benchmarks work: it measures the library, not the disk or the operating system. It is not neutral in every detail, though:

  • The string has 64 bytes of spare capacity, so simdjson, which reads up to 64 bytes past the end, can use it without a copy. A program would load the file into such a buffer in the first place.
  • yyjson, RapidJSON, and simd-json can parse in place, which changes the buffer. So every call gets a fresh copy of the string, and the copy counts toward their time: 11–14 µs for twitter.json on the Mac, and 30–46 µs on x86-64, where fresh memory costs page faults. yyjson's normal read makes the same copy internally, which is why both ways take about as long. A server that validates a request and then passes it on needs the copy. A program that reads a file into a buffer it owns can skip it, so we also timed the in-place ways with the copy made before the timer starts. On the Mac, yyjson then comes 2–4% closer and stays second, and on twitter.json it sums and selects 2–3% faster than json_document. On x86-64, it ties json_document in sum with Clang and takes 1.06× and 1.19× its time with GCC 16 and GCC 13.
  • json_document reads the string in place and keeps pointing into it. A document that owns its text (parse_copy()) costs one copy more: 3–5% on the Mac.
  • Reading from files and streams is not part of the comparison.
flowchart TB
    f[/"test file"/] -->|"read once, untimed"| s["std::string with 64 spare bytes"]
    s --> timed
    subgraph timed ["timed, every call"]
        direction LR
        c["copy<br>(in-place ways)"] --> p["parse"] --> u["use case"] --> d["free<br>everything"]
    end

What one timed call of a use case contains. For write, the document is parsed before the timer starts, so only the writing is timed. Every way's result is checked before anything is timed.

Checking less, on purpose

Some libraries can skip parts of the validation, and that is often why they are fast. A lazy reader such as simdjson's On-Demand API or Glaze's lazy_json only looks at what you ask for; to step over the rest, it matches brackets without looking inside, so reading a few fields of a large record costs little more than finding them. A minifier only needs to find whitespace, not understand the values. Skipping the UTF-8 check is RapidJSON's default and sonic-cpp's too. Each is a good choice for input that is known to be valid, for example text your own program wrote a moment ago. To tell these ways apart, every way is also fed four broken inputs: the file cut short by one byte, a stray comma, an invalid UTF-8 byte in a string, and garbage after the value. A way that rejects all four validates fully, and each cell shows the fastest such way. A way that checks or does less appears in the right half of the cell, with a letter that the caption explains.

A word on who set the rules: we wrote every library's benchmark code, chose the files and the rules, and tuned 3.13.0 on the same machines and files. No maintainer of the other libraries has reviewed their code. If you know a faster way for any of them, please open an issue, and we will rerun the comparison. To check ourselves, we also ran files and a conformance suite we never tuned on (below).

Use cases: time of each library relative to the fastest, Apple M1 Max Table of relative times, libraries by use case. nlohmann/json json_document validate: 1.00; nlohmann/json json_document sum: 1.00; nlohmann/json json_document select: 1.00; nlohmann/json json_document edit: 1.00 (0.85 doing less, N); nlohmann/json json_document minify: 1.00 (0.85 doing less, N); nlohmann/json json_document write: 1.00 (0.71 doing less, N); yyjson validate: 1.37; yyjson sum: 1.11; yyjson select: 1.25; yyjson edit: 1.45; yyjson minify: 1.23 (1.16 doing less, N); yyjson write: 1.26; simdjson validate: 1.52; simdjson sum: 1.20; simdjson select: 1.33 (0.75 doing less, P); simdjson edit: n/a; simdjson minify: 1.73 (0.18 doing less, P); simdjson write: 2.41; sonic-rs (Rust) validate: 1.93; sonic-rs (Rust) sum: 1.53; sonic-rs (Rust) select: 1.83; sonic-rs (Rust) edit: 2.08; sonic-rs (Rust) minify: 1.93; sonic-rs (Rust) write: 2.38; sonic-cpp validate: no fully validating way (2.53 doing less, U); sonic-cpp sum: no fully validating way (1.86 doing less, U); sonic-cpp select: no fully validating way (2.31 doing less, U); sonic-cpp edit: no fully validating way (1.68 doing less, U); sonic-cpp minify: no fully validating way (2.03 doing less, U); sonic-cpp write: 1.59; simd-json (Rust) validate: 2.07; simd-json (Rust) sum: 1.62; simd-json (Rust) select: 2.38; simd-json (Rust) edit: 2.84; simd-json (Rust) minify: 3.40; simd-json (Rust) write: 3.10; Glaze validate: 2.04; Glaze sum: 3.85; Glaze select: 1.79 (0.82 doing less, P); Glaze edit: 4.41; Glaze minify: 5.19 (1.03 doing less, P); Glaze write: 2.23; Boost.J​SON validate: 2.56; Boost.J​SON sum: 1.99; Boost.J​SON select: 3.27; Boost.J​SON edit: 3.21; Boost.J​SON minify: 3.87; Boost.J​SON write: 3.75; serde_json (Rust) validate: 2.46 (2.23 doing less, U); serde_json (Rust) sum: 2.59; serde_json (Rust) select: 2.39 (2.24 doing less, U); serde_json (Rust) edit: 4.99; serde_json (Rust) minify: 5.85; serde_json (Rust) write: 2.95; nlohmann/json json validate: 2.71; nlohmann/json json sum: 1.96; nlohmann/json json select: 6.87; nlohmann/json json edit: 3.73; nlohmann/json json minify: 4.89; nlohmann/json json write: 2.10; RapidJSON validate: 4.72 (3.09 doing less, U); RapidJSON sum: 4.15 (2.85 doing less, U); RapidJSON select: 5.91 (3.82 doing less, U); RapidJSON edit: 4.82 (3.77 doing less, U); RapidJSON minify: 4.86 (3.60 doing less, U N); RapidJSON write: 5.88. validate sum select edit minify write nlohmann/json json_document 1.00 1.00 1.00 1.00 0.85 N 1.00 0.85 N 1.00 0.71 N yyjson 1.37 1.11 1.25 1.45 1.23 1.16 N 1.26 simdjson 1.52 1.20 1.33 0.75 P n/a 1.73 0.18 P 2.41 sonic-rs (Rust) 1.93 1.53 1.83 2.08 1.93 2.38 sonic-cpp 2.53 U 1.86 U 2.31 U 1.68 U 2.03 U 1.59 simd-json (Rust) 2.07 1.62 2.38 2.84 3.40 3.10 Glaze 2.04 3.85 1.79 0.82 P 4.41 5.19 1.03 P 2.23 Boost.J​SON 2.56 1.99 3.27 3.21 3.87 3.75 serde_json (Rust) 2.46 2.23 U 2.59 2.39 2.24 U 4.99 5.85 2.95 nlohmann/json json 2.71 1.96 6.87 3.73 4.89 2.10 RapidJSON 4.72 3.09 U 4.15 2.85 U 5.91 3.82 U 4.82 3.77 U 4.86 3.60 U N 5.88 time relative to the fastest fully validating library, less is better below 1.15 1.15 to 1.5 1.5 to 2.5 2.5 to 5 5 and more n/a no A​PI for it

Time per library and use case on an Apple M1 Max: each library's fastest fully validating way, as the geometric mean over twitter.json, citm_catalog.json, canada.json and jeopardy.json, so that every file counts equally, divided by the fastest library's mean, which therefore shows 1.00. Less is better. Where a way that checks or does less is at least 3% faster, it is shown in the right half of the cell: "U" trusts UTF-8, "P" reads lazily, only removes whitespace or, in one Glaze cell on x86-64, doesn't check what follows the value, and "N" copies number text instead of formatting it. "n/a" means the library has no API for the use case. Hover over or tap a cell for the times per file and the way; the tables are in Appendix E. sonic-cpp's README lists only x86-64 Linux; we show it on the Mac anyway.

Results on the Mac

On the Apple M1 Max, among the ways that reject all four broken inputs, json_document has the lowest mean on the four files in all six use cases, and yyjson is second in every column. That includes editing, where the editable document is faster than yyjson's mutable document: the speed doesn't depend on being read-only.

Some caveats belong next to that:

  • The lead is not the same on every file. On twitter.json, yyjson is within 3–7% in validate, sum, and select. Most of the lead comes from citm_catalog.json, the floats of canada.json (next point) and the size of jeopardy.json.
  • Floats are converted only when read. That helps json_document in validate and select on canada.json, where most others convert 111,080 floats nobody looks at. Libraries that can skip the conversion use that mode there, but only Boost.JSON gains much: it validates canada.json in 47% of the time. sum reads every number, so it is the fair comparison for floats; there, json_document is still slightly ahead of yyjson.
  • Checking less pays in select. If the input needn't be checked fully, simdjson's On-Demand API and Glaze reading into structs select in 0.75× and 0.82× json_document's time (Glaze is faster only on citm_catalog.json and canada.json), and in 0.54–0.62× on x86-64: the right halves of the select column.
  • Indentation. Two of the four files are indented, which theme 2 helps. On minified copies of twitter.json and citm_catalog.json, yyjson comes closer in sum (1.11 → 1.04) but not in validate (1.28 → 1.27), and json_document stays first.
  • Number text can be copied. With number_format::source, json_document writes each number by copying its text instead of formatting it, and yyjson and RapidJSON can read numbers as text and write them back unchanged. The output keeps the source's spelling of each number, which is all a minifier needs. It makes writing canada.json with json_document 4.0× as fast and minifying it 1.9× as fast; the "N" halves of edit, minify, and write show it.
  • Small documents. On a 2.5 KB message, the fastest libraries take about a microsecond on the Mac and 1.2–2 on x86-64. yyjson validates and sums it as fast as json_document on the Mac, and with GCC 16 on x86-64, simdjson and yyjson sum it slightly faster.

Reading into structs

A view can be converted into your own types with the same from_json() as json, including the ones the NLOHMANN_DEFINE_TYPE_* macros generate. That is as fast as picking the fields by hand from the view; only for the many small records of jeopardy.json does it take 23% longer. On the Mac, Glaze, serde_json, and Boost.JSON's parse_into, reading into the same structs and checking everything, take 1.7×, 2.2×, and 3.1× as long (geometric mean over the four files). On a json value, reading into the structs takes about as long as json::parse() alone.

nlohmann/json without json_document

And nlohmann/json without json_document? json builds a tree of separate allocations, and it shows: for select, edit, and minify it takes several times as long as json_document, like Boost.JSON and RapidJSON. Where no tree is needed, it is closer: on the Mac, a SAX handler is on par with Boost.JSON's, and json::accept() takes 6% longer than Boost.JSON without number conversion (on x86-64, Boost.JSON validates in 0.65–0.69× and sums in 0.75–0.79× the time). dump() writes faster than Boost.JSON, RapidJSON and serde_json, and about as fast as Glaze.

Much of Boost.JSON's lead in building a tree comes from its allocator: here it builds the tree in a monotonic_resource, an arena that is freed in one go, which saves it 28–41%. With its default allocator, which allocates every string, array, and object on its own as json does, boost::json::parse() with precise numbers takes as long as json::parse() on twitter.json, 5% longer on citm_catalog.json, and 17% less time on canada.json (on the Mac, freeing the tree included).

Results on x86-64

On x86-64, the same programs ran on a virtual server with Ubuntu 26.04 whose CPU presents itself as Haswell, pinned to one core. We built them with -march=haswell and three compilers: the newest GCC, 16.2 (the figure below), GCC 13.4, and Clang 23 (both in Appendix E). The compiler matters less than one might think. On the Mac, GCC 16 and Clang 23 put the same library first in every column as Apple clang, and on x86-64, the three compilers disagree only about sum.

Use cases: time of each library relative to the fastest, x86-64 with G​CC 16.2 Table of relative times, libraries by use case. nlohmann/json json_document validate: 1.00; nlohmann/json json_document sum: 1.08; nlohmann/json json_document select: 1.00; nlohmann/json json_document edit: 1.00 (0.79 doing less, N); nlohmann/json json_document minify: 1.00 (0.80 doing less, N); nlohmann/json json_document write: 1.00 (0.64 doing less, N); yyjson validate: 1.94; yyjson sum: 1.14; yyjson select: 1.18; yyjson edit: 1.37; yyjson minify: 1.17 (0.95 doing less, N); yyjson write: 1.11; sonic-cpp validate: no fully validating way (2.32 doing less, U); sonic-cpp sum: no fully validating way (1.24 doing less, U); sonic-cpp select: no fully validating way (1.45 doing less, U); sonic-cpp edit: no fully validating way (1.21 doing less, U); sonic-cpp minify: no fully validating way (1.36 doing less, U); sonic-cpp write: 1.45; simdjson validate: 2.11; simdjson sum: 1.00; simdjson select: 1.27 (0.54 doing less, P); simdjson edit: n/a; simdjson minify: 1.54 (0.17 doing less, P); simdjson write: 2.10; sonic-rs (Rust) validate: 1.93; sonic-rs (Rust) sum: 1.13; sonic-rs (Rust) select: 1.34; sonic-rs (Rust) edit: 1.90; sonic-rs (Rust) minify: 1.71; sonic-rs (Rust) write: 2.04; Boost.J​SON validate: 1.99; Boost.J​SON sum: 1.27; Boost.J​SON select: 2.20; Boost.J​SON edit: 2.30; Boost.J​SON minify: 2.65; Boost.J​SON write: 2.36; simd-json (Rust) validate: 3.62; simd-json (Rust) sum: 1.91; simd-json (Rust) select: 2.56; simd-json (Rust) edit: 2.87; simd-json (Rust) minify: 3.35; simd-json (Rust) write: 2.32; RapidJSON validate: 3.71 (3.41 doing less, U); RapidJSON sum: 2.23 (2.09 doing less, U); RapidJSON select: 2.91 (2.73 doing less, U); RapidJSON edit: 2.41 (2.34 doing less, U); RapidJSON minify: 2.68 (2.42 doing less, U N); RapidJSON write: 2.97; serde_json (Rust) validate: 2.39 (2.14 doing less, U); serde_json (Rust) sum: 2.36; serde_json (Rust) select: 1.71; serde_json (Rust) edit: 4.53; serde_json (Rust) minify: 4.89; serde_json (Rust) write: 2.25; Glaze validate: 2.86; Glaze sum: 4.16; Glaze select: 1.79 (0.58 doing less, P); Glaze edit: 4.47; Glaze minify: 4.81 (0.78 doing less, P); Glaze write: 1.96; nlohmann/json json validate: 2.96; nlohmann/json json sum: 1.70; nlohmann/json json select: 7.12; nlohmann/json json edit: 3.83; nlohmann/json json minify: 4.79; nlohmann/json json write: 1.91. validate sum select edit minify write nlohmann/json json_document 1.00 1.08 1.00 1.00 0.79 N 1.00 0.80 N 1.00 0.64 N yyjson 1.94 1.14 1.18 1.37 1.17 0.95 N 1.11 sonic-cpp 2.32 U 1.24 U 1.45 U 1.21 U 1.36 U 1.45 simdjson 2.11 1.00 1.27 0.54 P n/a 1.54 0.17 P 2.10 sonic-rs (Rust) 1.93 1.13 1.34 1.90 1.71 2.04 Boost.J​SON 1.99 1.27 2.20 2.30 2.65 2.36 simd-json (Rust) 3.62 1.91 2.56 2.87 3.35 2.32 RapidJSON 3.71 3.41 U 2.23 2.09 U 2.91 2.73 U 2.41 2.34 U 2.68 2.42 U N 2.97 serde_json (Rust) 2.39 2.14 U 2.36 1.71 4.53 4.89 2.25 Glaze 2.86 4.16 1.79 0.58 P 4.47 4.81 0.78 P 1.96 nlohmann/json json 2.96 1.70 7.12 3.83 4.79 1.91 time relative to the fastest fully validating library, less is better below 1.15 1.15 to 1.5 1.5 to 2.5 2.5 to 5 5 and more n/a no A​PI for it

The same comparison on the x86-64 server with GCC 16.2, with the same cells and halves as the figure above. Less is better; hover over or tap a cell for the times and the way. The figures for GCC 13 and Clang 23 are in Appendix E.

json_document has the lowest mean in five of the six columns with GCC, where simdjson is ahead in sum, and in all six with Clang. This machine is noisy, though: between two runs, a library's time relative to json_document's moved by up to 9–10% (95th percentile; 2.5% on the Mac). So Clang's 7% lead in sum over sonic-rs is no result, and without twitter.json, sonic-rs is first. In validate, the next library takes 1.8–1.9× as long, or 1.6–1.7× if every library may reuse its parser, because validating no longer builds an index (theme 8). Behind those means, it loses on single files:

  • One pass over every value, with GCC. simdjson's On-Demand API sums all numbers of twitter.json, citm_catalog.json, and canada.json in 0.83–0.99× json_document's time with GCC 16 (0.86–0.91× with GCC 13). With Clang, json_document is ahead there, except on canada.json, where yyjson, which converts the floats during the parse, is faster than converting them on access.
  • Fresh large documents. For the 56 MB jeopardy.json, a fresh document needs a fresh 52 MB index, and on Linux the page faults of that much new memory are a large part of the time: copying the file into fresh memory alone takes about 50% as long as json_document's parse. Boost.JSON's SAX parser and sonic-rs, which build nothing, sum it in 0.66–0.72× the time. Reusing the document cuts json_document's time by 40–43%, to 51–53 ms, less than sonic-rs's 60 ms. Validating needs no index: json_document::accept() takes 35 ms, and no library validates jeopardy.json faster, even reused (the next is simdjson's reused parser at 47–49 ms). On the Mac, a fresh document costs hardly more than a reused one.
  • Writing floats. yyjson writes canada.json in 0.87–0.97× the time (and Glaze, with GCC 13, in 0.96×), because json_document first converts most floats from their text. Writing the source text instead (number_format::source, the "N" half) avoids that.
  • simdjson's DOM. It selects from citm_catalog.json 16% faster with GCC 16 and 1–4% faster with GCC 13 and Clang 23, and from twitter.json 1–6% faster. Boost.JSON, Glaze, sonic-cpp, and yyjson are 1–3% ahead in a few more cells, which is within the noise.

The full tables, with each library's variants, are in Appendix E.

Files and checks we didn't choose

The four files are the ones 3.13.0 was tuned on, so we also ran validate, sum, minify, and write on the 19 other files of simdjson's jsonexamples. By geometric mean, json_document stays the fastest in validate, minify, and write on every compiler, and in sum with Clang and on the Mac; with GCC, simdjson sums in 0.86–0.87× its time. File by file, for validate:

Validating 19 files 3.13.0 was not tuned on: json_document's time relative to the fastest other library per file Dot plot per file, less is better; below 1.0 json_document is the fastest fully validating library. mesh: Mac 0.68, x86-64 0.44 (serde_json (Rust)); marine_ik: Mac 0.59, x86-64 0.64 (Boost.J​SON); semanticscholar-corpus: Mac 0.79, x86-64 0.65 (sonic-rs (Rust)); gsoc-2018: Mac 0.60, x86-64 0.66 (Boost.J​SON); twitter_api_compact_response: Mac 0.91, x86-64 0.70 (simdjson); twitterescaped: Mac 0.93, x86-64 0.76 (simdjson); numbers: Mac 0.67, x86-64 0.77 (Boost.J​SON); github_events: Mac 0.79, x86-64 0.77 (simdjson); update-center: Mac 0.88, x86-64 0.79 (simdjson); twitter_timeline: Mac 0.97, x86-64 0.80 (yyjson); twitter_api_response: Mac 0.93, x86-64 0.81 (simdjson); google_maps_api_compact_response: Mac 0.86, x86-64 0.81 (yyjson); repeat: Mac 0.65, x86-64 0.82 (simdjson); random: Mac 0.81, x86-64 0.88 (simdjson); apache_builds: Mac 1.08, x86-64 0.90 (simdjson); instruments: Mac 0.90, x86-64 0.92 (simdjson); mesh.pretty: Mac 0.86, x86-64 0.92 (Boost.J​SON); tree-pretty: Mac 0.93, x86-64 0.96 (simdjson); google_maps_api_response: Mac 0.93, x86-64 0.99 (simdjson). 0.4× 0.5× 0.6× 0.7× 0.8× 0.9× 1.0× 1.1× json_document’s time / the fastest other library’s, less is better Apple M1 Max x86-64, G​CC 16.2 next on x86-64 mesh mesh, Apple M1 Max: 0.68× the time of Glaze mesh, x86-64, G​CC 16.2: 0.44× the time of serde_json (Rust) serde_json (Rust) marine_ik marine_ik, Apple M1 Max: 0.59× the time of Glaze marine_ik, x86-64, G​CC 16.2: 0.64× the time of Boost.J​SON Boost.J​SON semanticscholar-corpus semanticscholar-corpus, Apple M1 Max: 0.79× the time of sonic-rs (Rust) semanticscholar-corpus, x86-64, G​CC 16.2: 0.65× the time of sonic-rs (Rust) sonic-rs (Rust) gsoc-2018 gsoc-2018, Apple M1 Max: 0.60× the time of sonic-rs (Rust) gsoc-2018, x86-64, G​CC 16.2: 0.66× the time of Boost.J​SON Boost.J​SON twitter_api_compact_response twitter_api_compact_response, Apple M1 Max: 0.91× the time of yyjson twitter_api_compact_response, x86-64, G​CC 16.2: 0.70× the time of simdjson simdjson twitterescaped twitterescaped, Apple M1 Max: 0.93× the time of yyjson twitterescaped, x86-64, G​CC 16.2: 0.76× the time of simdjson simdjson numbers numbers, Apple M1 Max: 0.67× the time of Boost.J​SON numbers, x86-64, G​CC 16.2: 0.77× the time of Boost.J​SON Boost.J​SON github_events github_events, Apple M1 Max: 0.79× the time of simdjson github_events, x86-64, G​CC 16.2: 0.77× the time of simdjson simdjson update-center update-center, Apple M1 Max: 0.88× the time of sonic-rs (Rust) update-center, x86-64, G​CC 16.2: 0.79× the time of simdjson simdjson twitter_timeline twitter_timeline, Apple M1 Max: 0.97× the time of yyjson twitter_timeline, x86-64, G​CC 16.2: 0.80× the time of yyjson yyjson twitter_api_response twitter_api_response, Apple M1 Max: 0.93× the time of simdjson twitter_api_response, x86-64, G​CC 16.2: 0.81× the time of simdjson simdjson google_maps_api_compact_response google_maps_api_compact_response, Apple M1 Max: 0.86× the time of yyjson google_maps_api_compact_response, x86-64, G​CC 16.2: 0.81× the time of yyjson yyjson repeat repeat, Apple M1 Max: 0.65× the time of simdjson repeat, x86-64, G​CC 16.2: 0.82× the time of simdjson simdjson random random, Apple M1 Max: 0.81× the time of simdjson random, x86-64, G​CC 16.2: 0.88× the time of simdjson simdjson apache_builds apache_builds, Apple M1 Max: 1.08× the time of simdjson apache_builds, x86-64, G​CC 16.2: 0.90× the time of simdjson simdjson instruments instruments, Apple M1 Max: 0.90× the time of yyjson instruments, x86-64, G​CC 16.2: 0.92× the time of simdjson simdjson mesh.pretty mesh.pretty, Apple M1 Max: 0.86× the time of Glaze mesh.pretty, x86-64, G​CC 16.2: 0.92× the time of Boost.J​SON Boost.J​SON tree-pretty tree-pretty, Apple M1 Max: 0.93× the time of simdjson tree-pretty, x86-64, G​CC 16.2: 0.96× the time of simdjson simdjson google_maps_api_response google_maps_api_response, Apple M1 Max: 0.93× the time of simdjson google_maps_api_response, x86-64, G​CC 16.2: 0.99× the time of simdjson simdjson as fast as the next

validate on the 19 files: json_document's fastest fully validating way against the fastest other library's, per file; below 1.0× it is the fastest. Less is better. The right column names the next library on x86-64 (GCC 16.2).

json_document validates all 19 files fastest with GCC 16 and Clang 23, and 18 with GCC 13 and on the Mac: simdjson validates apache_builds 8% faster on the Mac and google_maps_api_response 2% faster with GCC 13. Before validating without an index (theme 8) and skipping fraction digits 16 bytes at a time (theme 2), Boost.JSON validated the number-heavy files up to 1.5× as fast on x86-64.

The four probes are broken inputs that tell full validation from less (Appendix E lists them). As a further check, every validate way also ran the 318 files of JSONTestSuite. Appendix E lists the exceptions, one of them ours. The suite also found that Glaze's generic reader accepts garbage after the value by default, so the figures use its strict option.

We did not measure memory use, a CPU with AVX-512, MSVC, writing from structs, or streaming input.

What didn't work

The pull requests record the ideas that measured worse, which is as useful as the ones that made it:

idea result
make every walk iterative up to 20% slower on ordinary documents
move the key out of the lexer's reused buffer 11.9% slower (later a win, see theme 4)
inline scan_literal() Clang's dispatch grew from 233 to 278 instructions and got slower
short-string table for strings up to 64 bytes lost against 32
leave the serializer's buffers uninitialized timings moved 18% apart in opposite directions on two similar workloads, so dropped; done later, when instruction counts showed 170 instructions saved per dump()
move the throws of the JSON Pointer lookups out of line too no measurable gain, dropped; they stay inline
json_document: an "all ASCII" flag per string dump() with ensure_ascii faster, but parsing 1–13% slower
json_document: convert floats while parsing canada.json parse 36–90% slower
json_document: note a large object inline in the parse loop canada.json 37% slower
json_document: vector-scan every string in set() 14–16% slower on 16-byte strings

Ideas that were measured and dropped. Appendix H links the pull requests that tried them.

The price

None of this is free at compile time. json.hpp grew from 25,526 to 46,630 lines (json_view.hpp is a separate header of 11,898 lines that you only pay for if you include it). To see what that means for a program, we compiled two small ones with both versions:

  • count_numbers reads twitter.json and adds up all of its numbers, like the sum use case in How it compares;
  • server_config reads a configuration file into a struct with to_json() and from_json(), changes it, and writes it back with dump(2).

Each is one translation unit. We timed it with the 3.12.0 and the 3.13.0 header, and then with 3.13.0 as a precompiled header and as a C++20 module, in a debug build (-O0) and an optimized one (-O2):

Compile time of two programs with 3.13.0, alone, with a precompiled header and as a module, compared with 3.12.0 Horizontal bars of the change in compile time against 3.12.0, less is better. Apple clang 21, -O0 (debug), count_numbers, 3.13.0: +38%; Apple clang 21, -O0 (debug), count_numbers, 3.13.0, precompiled header: -28%; Apple clang 21, -O0 (debug), count_numbers, 3.13.0, module: -4%; Apple clang 21, -O0 (debug), server_config, 3.13.0: +34%; Apple clang 21, -O0 (debug), server_config, 3.13.0, precompiled header: -23%; Apple clang 21, -O0 (debug), server_config, 3.13.0, module: +2%; Apple clang 21, -O2, count_numbers, 3.13.0: +49%; Apple clang 21, -O2, count_numbers, 3.13.0, precompiled header: +5%; Apple clang 21, -O2, count_numbers, 3.13.0, module: +21%; Apple clang 21, -O2, server_config, 3.13.0: +45%; Apple clang 21, -O2, server_config, 3.13.0, precompiled header: +10%; Apple clang 21, -O2, server_config, 3.13.0, module: +24%; G​CC 16, -O0 (debug), count_numbers, 3.13.0: +62%; G​CC 16, -O0 (debug), count_numbers, 3.13.0, precompiled header: +10%; G​CC 16, -O0 (debug), count_numbers, 3.13.0, module: +8%; G​CC 16, -O0 (debug), server_config, 3.13.0: +47%; G​CC 16, -O0 (debug), server_config, 3.13.0, precompiled header: +14%; G​CC 16, -O0 (debug), server_config, 3.13.0, module: +15%; G​CC 16, -O2, count_numbers, 3.13.0: +58%; G​CC 16, -O2, count_numbers, 3.13.0, precompiled header: +28%; G​CC 16, -O2, count_numbers, 3.13.0, module: +25%; G​CC 16, -O2, server_config, 3.13.0: +51%; G​CC 16, -O2, server_config, 3.13.0, precompiled header: +28%; G​CC 16, -O2, server_config, 3.13.0, module: +30%. 3.13.0 3.13.0, precompiled header 3.13.0, module as fast as 3.12.0 Apple clang 21, -O0 (debug) count_numbers count_numbers, Apple clang 21, -O0 (debug), 3.13.0: 1,320 ms against 955 ms with 3.12.0 (+38%) +38% count_numbers, Apple clang 21, -O0 (debug), 3.13.0, precompiled header: 688 ms against 955 ms with 3.12.0 (−28%) −28% count_numbers, Apple clang 21, -O0 (debug), 3.13.0, module: 918 ms against 955 ms with 3.12.0 (−4%) −4% server_config server_config, Apple clang 21, -O0 (debug), 3.13.0: 1,496 ms against 1,113 ms with 3.12.0 (+34%) +34% server_config, Apple clang 21, -O0 (debug), 3.13.0, precompiled header: 863 ms against 1,113 ms with 3.12.0 (−23%) −23% server_config, Apple clang 21, -O0 (debug), 3.13.0, module: 1,136 ms against 1,113 ms with 3.12.0 (+2%) +2% Apple clang 21, -O2 count_numbers count_numbers, Apple clang 21, -O2, 3.13.0: 2,081 ms against 1,393 ms with 3.12.0 (+49%) +49% count_numbers, Apple clang 21, -O2, 3.13.0, precompiled header: 1,458 ms against 1,393 ms with 3.12.0 (+5%) +5% count_numbers, Apple clang 21, -O2, 3.13.0, module: 1,685 ms against 1,393 ms with 3.12.0 (+21%) +21% server_config server_config, Apple clang 21, -O2, 3.13.0: 2,546 ms against 1,754 ms with 3.12.0 (+45%) +45% server_config, Apple clang 21, -O2, 3.13.0, precompiled header: 1,925 ms against 1,754 ms with 3.12.0 (+10%) +10% server_config, Apple clang 21, -O2, 3.13.0, module: 2,169 ms against 1,754 ms with 3.12.0 (+24%) +24% G​CC 16, -O0 (debug) count_numbers count_numbers, G​CC 16, -O0 (debug), 3.13.0: 2,548 ms against 1,569 ms with 3.12.0 (+62%) +62% count_numbers, G​CC 16, -O0 (debug), 3.13.0, precompiled header: 1,725 ms against 1,569 ms with 3.12.0 (+10%) +10% count_numbers, G​CC 16, -O0 (debug), 3.13.0, module: 1,700 ms against 1,569 ms with 3.12.0 (+8%) +8% server_config server_config, G​CC 16, -O0 (debug), 3.13.0: 2,985 ms against 2,025 ms with 3.12.0 (+47%) +47% server_config, G​CC 16, -O0 (debug), 3.13.0, precompiled header: 2,302 ms against 2,025 ms with 3.12.0 (+14%) +14% server_config, G​CC 16, -O0 (debug), 3.13.0, module: 2,326 ms against 2,025 ms with 3.12.0 (+15%) +15% G​CC 16, -O2 count_numbers count_numbers, G​CC 16, -O2, 3.13.0: 3,702 ms against 2,336 ms with 3.12.0 (+58%) +58% count_numbers, G​CC 16, -O2, 3.13.0, precompiled header: 2,987 ms against 2,336 ms with 3.12.0 (+28%) +28% count_numbers, G​CC 16, -O2, 3.13.0, module: 2,913 ms against 2,336 ms with 3.12.0 (+25%) +25% server_config server_config, G​CC 16, -O2, 3.13.0: 4,714 ms against 3,122 ms with 3.12.0 (+51%) +51% server_config, G​CC 16, -O2, 3.13.0, precompiled header: 4,006 ms against 3,122 ms with 3.12.0 (+28%) +28% server_config, G​CC 16, -O2, 3.13.0, module: 4,055 ms against 3,122 ms with 3.12.0 (+30%) +30% −40% −20% 0 +20% +40% +60% compile time compared with 3.12.0, less is better

How much longer or shorter each program takes to compile than with 3.12.0, less is better: Apple clang 21 and GCC 16.2, CPU time of the compiler, median of 5 interleaved runs, C++20, Apple M1 Max. The one-off cost of building the precompiled header or the module is not included. Hover over a bar for the time in milliseconds; Appendix G has all times, the programs, and the commands.

3.13.0 takes 34–62% longer to compile these programs. A good part of that is reading the header, and a precompiled header or a module does that once instead of in every translation unit:

  • In debug builds, which are what you wait for while you edit, a precompiled 3.13.0 compiles faster than 3.12.0 with Apple clang, and within 8–15% of it with GCC.
  • In optimized builds, less of the increase goes away, because the optimizer still works through all the code a program instantiates. A precompiled header or a module wins back 41–91% of it.
  • The one-off cost is 1.0 s (Apple clang) or 2.6 s (GCC) to build the precompiled header, and 1.6 s or 2.5 s to build the module, once per set of compiler flags. A precompiled header pays for itself after two to four translation units, a module after three to five.
  • Modules have caveats. Apple clang needs -fcxx-modules, module support still depends heavily on the compiler version, and macros don't cross a module boundary: with import nlohmann.json;, the NLOHMANN_DEFINE_TYPE_* macros aren't available, which is why server_config writes to_json() and from_json() by hand.

The compile-time page measures these and the other options, such as including <nlohmann/json_fwd.hpp> in headers that only name json, and JSON_NO_AUTOMATIC_UDLS. We did not measure binary size or peak memory.

Summing up

Hardly any of the techniques in this post are new. What we think made the difference is their combination, under the conditions of a header-only C++11 library whose API and output had to stay the same.

Thanks

Most of what is in this post was invented by other people.

Code we adapted. The library contains code adapted from these four projects. We use it under the MIT License, and the source files that contain it carry the authors' copyright lines and say what was taken; the README lists them too.

  • fast_float (Apache-2.0, MIT, or BSL-1.0) by Daniel Lemire and contributors: the Eisel-Lemire algorithm, its table of powers of five, its digit comparison for long numbers, and its parsing of eight digits at a time (N. Mushtak and D. Lemire, "Fast number parsing without fallback", 2023). W. D. Clinger for the fast path, M. Eisel for the algorithm.
  • Żmij (MIT) by Victor Zverovich, with the digit conversion of Xiang JunBo, the NEON sequence and compressed powers of ten of Dougall Johnson, and the SSE support of Tobias Schlüter: ported to C++11.
  • simdjson (Apache-2.0 or MIT) by Daniel Lemire, Geoff Langdale, John Keiser, and contributors: the "lookup4" UTF-8 check and its tables (J. Keiser and D. Lemire, "Validating UTF-8 In Less Than One Instruction Per Byte", 2021), in json.hpp's lexer and in json_view.hpp.
  • yyjson (MIT) by YaoYuan: table-driven \u decoding and the unrolled checks at fixed offsets in json_view.hpp's parser.

Ideas we took. From these we took the idea, not their code, in the order they appear above:

  • serde_json (MIT or Apache-2.0): deriving positions from the offset only on error.
  • Sean Eron Anderson's Bit Twiddling Hacks (snippets in the public domain): the zero-byte test behind every word-at-a-time scan.
  • Glaze (MIT): skipping indentation in 8-byte steps (skip_matching_ws).
  • SQLite (public domain): writing short strings with a table loop, as its jsonAppendString() does.
  • orjson (Apache-2.0 or MIT; its escape table MPL-2.0) and sonic-rs (Apache-2.0), from which orjson adapted it: escape sequences as fixed-size table rows (QUOTE_TAB).
  • simdjson's tape and yyjson's 16-byte immutable document: a flat index in document order instead of a tree.
  • Boost.JSON (BSL-1.0): switching to a hash table for large objects.
  • RapidJSON (MIT): recursion as the default, iterative parsing as an option.

I thank the people who sent or shaped pull requests:

  • @gregmarr reviewed most of them and suggested the sized-sentinel fast path, the peek design for exact stream positions, one SIMD switch for the whole library, and the vector check of json_view's strings on edit;
  • @veselink1 and @michaelsam94 wrote the allocator and exception-safety fixes for the destructor that 3.13.0 took over; the non-recursive destructor itself goes back to a pull request by @nickaein in 2019;
  • @commit-msuyog gave the 128-bit multiplication behind Eisel-Lemire MSVC intrinsics;
  • @ayush-singh-0601 started the fix for deep values in the binary writers;
  • @avionicharshit-byte made diff() linear for shrinking arrays;
  • @mvanslobbe sped up the parser's separator check;
  • @alexprabhat99 removed the temporary values behind CBOR and MessagePack object keys.

twitter.json, citm_catalog.json and canada.json come from Milo Yip's nativejson-benchmark (MIT).

Appendix

A. Test files

file size what it is what makes it hard
twitter.json 632 KB 100 tweets from Twitter's search API, with their users strings: 58% of the bytes are keys and strings, 15% of the bytes are non-ASCII (almost all of it Japanese text), and 13,345 keys
citm_catalog.json 1.7 MB a concert hall's catalog of events, performances and seat categories, in French structure: 21,388 small objects and arrays, 14,392 integers, keys that average 8 bytes, and 71% whitespace from indentation
canada.json 2.3 MB the outline of Canada in GeoJSON numbers: 111,080 floating-point coordinates, most with 16 or 17 significant digits, in 56,045 arrays, and almost no whitespace
jeopardy.json 56 MB 216,930 Jeopardy! questions as one array of objects size: 1.5 million short strings in objects of 7 members; far larger than the CPU caches

The test files. The first three are from Milo Yip's nativejson-benchmark. The counts are of the parsed values; the share of whitespace, string, and non-ASCII bytes is of the file as distributed. Several changes target the indentation of citm_catalog.json: in the timeline benchmark, a minified copy of it parses 1.9× as fast as with 3.12.0, against 3.4× for the file as distributed.

B. 3.12.0 against 3.13.0

The rows of the first chart:

workload 3.12.0 3.13.0 speedup Intel
json::parse() of twitter.json 3.30 ms 1.05 ms 3.1× 2.6×
json::parse() of citm_catalog.json 7.56 ms 1.78 ms 4.3× 3.5×
json::parse() of canada.json 15.5 ms 4.63 ms 3.4× 6.1×
json::parse() of canada.json, indented by 4 spaces 35.0 ms 5.37 ms 6.5× 8.0×
ordered_json::parse() of citm_catalog.json 11.5 ms 1.68 ms 6.9× 6.6×
json::parse() of twitter.json from an std::ifstream 3.43 ms 1.37 ms 2.5× 2.3×
json::parse() of citm_catalog.json from a FILE* 36.0 ms 7.58 ms 4.7× 5.7×
json::accept() of citm_catalog.json 6.53 ms 0.923 ms 7.1× 8.4×
dump() of twitter.json 1.44 ms 0.214 ms 6.7× 5.7×
dump() of canada.json 6.95 ms 2.34 ms 3.0× 3.3×
dump(4) of citm_catalog.json 1.99 ms 0.423 ms 4.7× 4.1×
to_cbor() of twitter.json 0.659 ms 0.143 ms 4.6× 6.7×
from_cbor() of twitter.json from memory 3.23 ms 0.953 ms 3.4× 2.8×
from_cbor() of twitter.json from a FILE* 10.4 ms 1.57 ms 6.6× 6.5×
diff() of canada.json with every eighth scalar changed 39.8 ms 3.80 ms 10.5× 11.2×
iterating over every value of canada.json 0.897 ms 0.328 ms 2.7× 2.0×
copying citm_catalog.json and destroying the copy 1.70 ms 1.51 ms 1.13× 1.12×
== of citm_catalog.json and a copy 0.280 ms 0.263 ms 1.06× 0.95×

Time per call with 3.12.0 and 3.13.0, Google Benchmark suite on an Apple M1 Max, median of 8 runs; the speedup is the old time divided by the new one. The Intel column is the speedup on the Intel MacBook Pro of Appendix G. The parse rows do not include destroying the result (the suite pauses the timer for that).

All 182 benchmarks of the suite
benchmark 3.12.0 3.13.0 speedup Intel
Accept/canada 12.9 ms 2.35 ms 5.51× 13×
Accept/citm_catalog 6.53 ms 923 µs 7.07× 8.45×
Accept/floats 116 ms 22.7 ms 5.10× 12.2×
Accept/jeopardy 221 ms 59.3 ms 3.72× 6.64×
Accept/signed_ints 117 ms 28.3 ms 4.15× 4.99×
Accept/small_signed_ints 57.5 ms 18 ms 3.19× 3.90×
Accept/twitter 2.51 ms 484 µs 5.20× 6.89×
Accept/unsigned_ints 116 ms 29.7 ms 3.91× 4.95×
BinaryToCbor/1024 298 ns 107 ns 2.79× 3.67×
BinaryToCbor/128 249 ns 117 ns 2.13× 2.27×
BinaryToCbor/16384 952 ns 414 ns 2.30× 3.46×
BinaryToCbor/16 245 ns 49.6 ns 4.93× 6.33×
BinaryToCbor/2048 328 ns 125 ns 2.62× 3.74×
BinaryToCbor/256 265 ns 90.9 ns 2.91× 3.33×
BinaryToCbor/32768 1.54 µs 803 ns 1.92× 2.11×
BinaryToCbor/32 252 ns 50.4 ns 4.99× 6.24×
BinaryToCbor/4096 407 ns 177 ns 2.29× 3.48×
BinaryToCbor/512 290 ns 102 ns 2.84× 3.02×
BinaryToCbor/64 255 ns 84.6 ns 3.01× 3.78×
BinaryToCbor/8192 514 ns 256 ns 2.01× 3.30×
BinaryToCbor/8 248 ns 49.4 ns 5.01× 6.18×
Copy/canada 3.78 ms 2.93 ms 1.29× 1.17×
Copy/citm_catalog 1.7 ms 1.51 ms 1.13× 1.12×
Copy/twitter 840 µs 767 µs 1.09× 1.14×
CopyAssign/canada 3.86 ms 2.94 ms 1.31× 1.19×
CopyAssign/citm_catalog 1.77 ms 1.59 ms 1.11× 1.12×
CopyAssign/twitter 885 µs 791 µs 1.12× 1.07×
CopyOnly/canada 1.6 ms 1.58 ms 1.02× 1.00×
CopyOnly/citm_catalog 786 µs 817 µs 0.96× 0.93×
CopyOnly/twitter 424 µs 421 µs 1.01× 1.02×
CopyRing/canada 3.86 ms 3 ms 1.29× 1.20×
CopyRing/citm_catalog 1.84 ms 1.61 ms 1.15× 1.20×
CopyRing/twitter 952 µs 818 µs 1.16× 1.15×
DestroyOnly/canada 2.22 ms 1.4 ms 1.58× 1.32×
DestroyOnly/citm_catalog 926 µs 711 µs 1.30× 1.20×
DestroyOnly/twitter 416 µs 336 µs 1.24× 1.15×
Diff/canada 39.8 ms 3.8 ms 10.5× 11.2×
Diff/citm_catalog 7.19 ms 1.12 ms 6.40× 7.78×
Diff/twitter 5.11 ms 661 µs 7.73× 8.79×
Dump/canada / - 6.95 ms 2.34 ms 2.98× 3.34×
Dump/canada / 4 9.1 ms 3.1 ms 2.93× 2.82×
Dump/citm_catalog / - 1.42 ms 296 µs 4.81× 3.93×
Dump/citm_catalog / 4 1.99 ms 423 µs 4.70× 4.09×
Dump/floats / - 64.3 ms 23.8 ms 2.71× 2.50×
Dump/floats / 4 72.6 ms 24.6 ms 2.95× 2.71×
Dump/jeopardy / - 207 ms 31.7 ms 6.51× 3.75×
Dump/jeopardy / 4 228 ms 35.4 ms 6.45× 2.61×
Dump/signed_ints / - 24.1 ms 10.7 ms 2.24× 1.44×
Dump/signed_ints / 4 32.5 ms 11.3 ms 2.87× 2.01×
Dump/small_signed_ints / - 18.1 ms 8.12 ms 2.23× 1.81×
Dump/small_signed_ints / 4 25.5 ms 8.81 ms 2.90× 2.21×
Dump/twitter / - 1.44 ms 214 µs 6.70× 5.75×
Dump/twitter / 4 1.63 ms 253 µs 6.45× 5.18×
Dump/unsigned_ints / - 24 ms 8.12 ms 2.95× 1.58×
Dump/unsigned_ints / 4 32.3 ms 8.59 ms 3.76× 2.30×
Equal/canada 474 µs 349 µs 1.36× 1.38×
Equal/citm_catalog 280 µs 263 µs 1.06× 0.95×
Equal/twitter 146 µs 131 µs 1.11× 1.02×
Flatten/canada 29.4 ms 11.1 ms 2.65× 2.68×
Flatten/citm_catalog 6.72 ms 3.07 ms 2.19× 2.45×
Flatten/twitter 3.66 ms 2.11 ms 1.74× 2.19×
FlattenOrdered/citm_catalog 262 ms 1.43 ms 184× 185×
FlattenOrdered/twitter 51.3 ms 890 µs 57.6× 46.1×
FromBinaryBuffer/bjdata / canada 8.82 ms 4.17 ms 2.12× 2.02×
FromBinaryBuffer/bjdata / twitter 3.32 ms 976 µs 3.40× 2.87×
FromBinaryBuffer/bjdata_optimized / canada 9.24 ms 2.48 ms 3.73× 3.51×
FromBinaryBuffer/bjdata_optimized / twitter 3.31 ms 986 µs 3.35× 2.86×
FromBinaryBuffer/bson / canada 10 ms 4.32 ms 2.33× 2.20×
FromBinaryBuffer/bson / citm_catalog 4.08 ms 1.61 ms 2.54× 2.59×
FromBinaryBuffer/bson / twitter 3.26 ms 937 µs 3.48× 3.08×
FromBinaryBuffer/cbor / canada 8.9 ms 2.12 ms 4.21× 3.86×
FromBinaryBuffer/cbor / citm_catalog 3.94 ms 1.45 ms 2.71× 2.81×
FromBinaryBuffer/cbor / floats 30.5 ms 5.48 ms 5.56× 3.64×
FromBinaryBuffer/cbor / jeopardy 373 ms 115 ms 3.25× 3.43×
FromBinaryBuffer/cbor / signed_ints 36.7 ms 9.89 ms 3.71× 2.37×
FromBinaryBuffer/cbor / twitter 3.23 ms 953 µs 3.39× 2.83×
FromBinaryBuffer/msgpack / canada 8.86 ms 2.12 ms 4.17× 3.92×
FromBinaryBuffer/msgpack / citm_catalog 3.85 ms 1.45 ms 2.65× 2.94×
FromBinaryBuffer/msgpack / jeopardy 373 ms 111 ms 3.36× 3.51×
FromBinaryBuffer/msgpack / twitter 3.24 ms 955 µs 3.39× 3.02×
FromBinaryBuffer/ubjson / canada 8.84 ms 4.2 ms 2.10× 2.13×
FromBinaryBuffer/ubjson / citm_catalog 4.07 ms 1.79 ms 2.28× 2.44×
FromBinaryBuffer/ubjson / jeopardy 379 ms 117 ms 3.24× 3.40×
FromBinaryBuffer/ubjson / twitter 3.3 ms 981 µs 3.36× 2.90×
FromBinaryBuffer/ubjson_optimized / canada 8.89 ms 2.43 ms 3.66× 3.67×
FromBinaryBuffer/ubjson_optimized / twitter 3.3 ms 975 µs 3.38× 2.88×
FromBinaryFile/bjdata / twitter 10.9 ms 1.95 ms 5.61× 5.89×
FromBinaryFile/bson / twitter 10.7 ms 1.85 ms 5.80× 6.21×
FromBinaryFile/cbor / canada 14.9 ms 4.49 ms 3.32× 3.68×
FromBinaryFile/cbor / twitter 10.4 ms 1.57 ms 6.59× 6.46×
FromBinaryFile/ubjson / canada 14.3 ms 6.43 ms 2.23× 2.50×
FromBinaryFile/ubjson / twitter 10.9 ms 1.97 ms 5.54× 5.90×
FromBinaryShape/containers / bjdata 14.7 ms 7.23 ms 2.03× 2.05×
FromBinaryShape/containers / bson 17.6 ms 7.89 ms 2.23× 2.20×
FromBinaryShape/containers / cbor 14.6 ms 4.16 ms 3.52× 3.49×
FromBinaryShape/containers / msgpack 14.6 ms 3.89 ms 3.75× 3.52×
FromBinaryShape/containers / ubjson_optimized 15.5 ms 4.56 ms 3.40× 3.34×
FromBinaryShape/containers / ubjson 15 ms 7.24 ms 2.07× 2.03×
FromBinaryShape/nested / bjdata 96.8 µs 38.3 µs 2.53× 2.50×
FromBinaryShape/nested / bson 101 µs 42.6 µs 2.38× 2.42×
FromBinaryShape/nested / cbor 94.9 µs 35.8 µs 2.65× 2.57×
FromBinaryShape/nested / msgpack 92.6 µs 35.1 µs 2.64× 2.60×
FromBinaryShape/nested / ubjson 96.1 µs 38.3 µs 2.51× 2.50×
FromBinaryShape/scalars / bjdata 25.3 ms 11.6 ms 2.18× 2.22×
FromBinaryShape/scalars / cbor 26.2 ms 5.89 ms 4.45× 3.56×
FromBinaryShape/scalars / msgpack 25.1 ms 5.65 ms 4.44× 3.69×
FromBinaryShape/scalars / ubjson 25.3 ms 11.6 ms 2.18× 2.03×
FromCborChunkedString/10000 chunks 91.8 µs 82.9 µs 1.11× 1.09×
FromMsgpack/canada 14.9 ms 4.56 ms 3.26× 3.63×
FromMsgpack/citm_catalog 9.36 ms 2.26 ms 4.14× 4.60×
FromMsgpack/floats 61.3 ms 28.7 ms 2.14× 2.89×
FromMsgpack/jeopardy 1,170 ms 190 ms 6.14× 6.80×
FromMsgpack/signed_ints 63.9 ms 32.9 ms 1.94× 2.44×
FromMsgpack/small_signed_ints 64.5 ms 34.4 ms 1.87× 2.33×
FromMsgpack/twitter 10.3 ms 1.49 ms 6.92× 6.76×
FromMsgpack/unsigned_ints 57.8 ms 28.3 ms 2.04× 2.77×
GetArray<std::array<int, 1000>> 2.27 µs 654 ns 3.48× 5.38×
GetArray<std::deque<int>> 8.29 µs 1.18 µs 7.05× 4.75×
GetArray<std::set<int>> 60 µs 42.5 µs 1.41× 1.30×
GetArray<std::vector<int>> 6.79 µs 682 ns 9.95× 4.10×
GetMap/citm_catalog 3.6 ms 2.49 ms 1.45× 1.48×
GetVectorDouble/floats 6.84 ms 656 µs 10.4× 2.98×
GetVectorInt/signed_ints 8.22 ms 3.46 ms 2.37× 2.02×
Iterate/canada 897 µs 328 µs 2.73× 2.02×
Iterate/citm_catalog 305 µs 171 µs 1.78× 1.53×
Iterate/twitter 90 µs 62 µs 1.45× 1.20×
JsonPointerAt/citm_catalog 1.9 ms 1.18 ms 1.61× 1.47×
JsonPointerAt/twitter 818 µs 612 µs 1.34× 1.14×
JsonPointerContains/citm_catalog 2.82 ms 1.27 ms 2.22× 2.51×
JsonPointerContains/twitter 1.34 ms 629 µs 2.12× 2.16×
JsonPointerParse/citm_catalog 5.81 ms 2.25 ms 2.59× 3.74×
JsonPointerParse/twitter 2.48 ms 1.02 ms 2.43× 3.18×
JsonPointerToString/citm_catalog 6.79 ms 1.94 ms 3.50× 4.61×
JsonPointerToString/twitter 2.23 ms 834 µs 2.67× 2.66×
ParseFILE/canada 54.2 ms 14.7 ms 3.69× 5.46×
ParseFILE/citm_catalog 36 ms 7.58 ms 4.74× 5.73×
ParseFILE/twitter 14.1 ms 3.45 ms 4.08× 4.24×
ParseFile/canada 18.3 ms 6.47 ms 2.83× 4.67×
ParseFile/citm_catalog 8.13 ms 2.52 ms 3.22× 2.98×
ParseFile/floats 131 ms 48.1 ms 2.73× 5.68×
ParseFile/jeopardy 338 ms 162 ms 2.09× 2.23×
ParseFile/signed_ints 129 ms 46.2 ms 2.78× 3.41×
ParseFile/small_signed_ints 64.7 ms 33.4 ms 1.94× 2.57×
ParseFile/twitter 3.43 ms 1.37 ms 2.49× 2.33×
ParseFile/unsigned_ints 124 ms 47.4 ms 2.61× 3.52×
ParseIndented/canada / 4 35 ms 5.37 ms 6.51× 8.02×
ParseIndented/citm_catalog / 4 7.52 ms 1.81 ms 4.15× 3.49×
ParseIndented/jeopardy / 4 345 ms 133 ms 2.59× 2.46×
ParseIndented/twitter / 4 3.72 ms 1.13 ms 3.30× 2.60×
ParseStreamValues/ndjson 7.16 ms 3.77 ms 1.90× 2.06×
ParseStreamValues/numbers 1.93 ms 383 µs 5.03× 6.06×
ParseString/canada 15.5 ms 4.63 ms 3.35× 6.13×
ParseString/citm_catalog 7.56 ms 1.78 ms 4.25× 3.46×
ParseString/floats 122 ms 28.3 ms 4.30× 8.68×
ParseString/jeopardy 317 ms 131 ms 2.42× 2.32×
ParseString/signed_ints 123 ms 32 ms 3.84× 4.29×
ParseString/small_signed_ints 62.7 ms 25 ms 2.51× 3.38×
ParseString/twitter 3.3 ms 1.05 ms 3.14× 2.60×
ParseString/unsigned_ints 121 ms 32.9 ms 3.68× 4.23×
ParseStringOrdered/canada 15.4 ms 4.53 ms 3.40× 6.21×
ParseStringOrdered/citm_catalog 11.5 ms 1.68 ms 6.85× 6.59×
ParseStringOrdered/jeopardy 374 ms 113 ms 3.29× 3.38×
ParseStringOrdered/twitter 4.6 ms 949 µs 4.85× 4.93×
Patch/canada 10.4 ms 3.48 ms 2.98× 2.46×
Patch/citm_catalog 2.75 ms 1.74 ms 1.58× 1.38×
Patch/twitter 1.58 ms 889 µs 1.78× 1.45×
SaxParse/canada 13.1 ms 3.27 ms 4.01× 8.84×
SaxParse/citm_catalog 6.55 ms 967 µs 6.77× 6.85×
SaxParse/twitter 2.53 ms 483 µs 5.24× 5.91×
ToCbor/canada 1.41 ms 557 µs 2.54× 2.27×
ToCbor/citm_catalog 1.05 ms 259 µs 4.04× 5.60×
ToCbor/floats 9.05 ms 1.9 ms 4.76× 1.94×
ToCbor/jeopardy 73.9 ms 22.1 ms 3.35× 2.87×
ToCbor/signed_ints 11.5 ms 2.83 ms 4.08× 1.83×
ToCbor/small_signed_ints 10.4 ms 2.48 ms 4.19× 2.78×
ToCbor/twitter 659 µs 143 µs 4.59× 6.67×
ToCbor/unsigned_ints 8.18 ms 2.82 ms 2.90× 1.48×
ToJsonStrings/copy 6.12 ms 5.65 ms 1.08× 1.05×
ToJsonStrings/move 6.13 ms 3.13 ms 1.96× 1.78×
Unflatten/canada 42.5 ms 11.1 ms 3.83× 4.32×
Unflatten/citm_catalog 10.2 ms 3.62 ms 2.81× 3.32×
Unflatten/twitter 4.75 ms 2.21 ms 2.15× 2.42×

C. The timeline

The other four workloads we followed:

  • accept() of twitter.json: the lexer alone, since accept() only validates and builds nothing;
  • parse() of twitter.json from an std::ifstream: reading from a stream instead of a string;
  • dump() of canada.json: writing floating-point numbers;
  • to_cbor() of twitter.json: writing a binary format.

accept() twitter.json: speedup over 3.12.0 by commit accept() twitter.json: speedup over 3.12.0 at each of 353 commits, ending at 5.1x. Labeled steps, by theme: 4, 1, 4, 4, 2, 2. accept() twitter.json 1× 2× 4× 8× 3.12.0 Sep 2026 Oct #5234: 1.22× 4 #5283: 2.6× 1 #5592: 2.9× 4 #5809: 3.7× #5810: 4.0× 4 2 #5853: 4.9× 2 speedup over 3.12.0 (log scale), more is better

parse() twitter.json, std::ifstream: speedup over 3.12.0 by commit parse() twitter.json, std::ifstream: speedup over 3.12.0 at each of 353 commits, ending at 2.2x. Labeled steps, by theme: 1, 1. parse() twitter.json, std::ifstream 1× 2× 4× 3.12.0 Sep 2026 Oct #5828: 1.97× 1 #5884: 2.2× 1 speedup over 3.12.0 (log scale), more is better

dump() canada.json: speedup over 3.12.0 by commit dump() canada.json: speedup over 3.12.0 at each of 353 commits, ending at 3.3x. Labeled steps, by theme: 6, 3. dump() canada.json 1× 2× 4× 8× 3.12.0 Sep 2026 Oct #5285: 1.09× 6 #5634: 3.1× 3 speedup over 3.12.0 (log scale), more is better

to_cbor() twitter: speedup over 3.12.0 by commit to_cbor() twitter: speedup over 3.12.0 at each of 353 commits, ending at 5.0x. Labeled steps, by theme: 4, 6, 6. to_cbor() twitter 1× 2× 4× 8× 3.12.0 Sep 2026 Oct #5328: 2.2× 4 #5824: 3.5× 6 #5885: 5.1× 6 speedup over 3.12.0 (log scale), more is better

Speedup over 3.12.0 after each commit that changed the headers, for the four workloads not shown in the timeline section. More is better; the badges are theme numbers.

The parse panels include destroying the result: the benchmark returns json::parse(text).size(), so the changes to the destructor (#5762, #5835) show up there too, as steps of up to 6% in either direction. When accept() of twitter.json got 2.5% slower over the last 20 commits, we timed the two ends against each other in one binary with six code layouts: they were equal within 0.2%.

workload theme pull request change step
parse() citm_catalog.json 4 #5234 the token buffer is filled only for error messages −35%
1 #5283 fast paths for contiguous input −19%
4 #5592 check for the expected : or , before the lexer's token switch −7%
4 #5809 positions derived only on error −12%
2 #5810 indentation skipped eight bytes at a time −12%
4 #5882 arrays allocated once, at their final size −6%
parse() canada.json 4 #5234 the token buffer is filled only for error messages −10%
1 #5283 fast paths for contiguous input −24%
3 #5617 Eisel-Lemire instead of strtod −9%
3 #5738 own float parser, string scan, \u table −14%
3 #5851 digits accumulated while validating, eight at a time −12%
4 #5882 arrays allocated once, at their final size −15%
accept() twitter.json 4 #5234 the token buffer is filled only for error messages −18%
1 #5283 fast paths for contiguous input −53%
4 #5592 check for the expected : or , before the lexer's token switch −11%
4 #5809 positions derived only on error −23%
2 #5810 indentation skipped eight bytes at a time −9%
2 #5853 strings scanned 16 bytes at a time with SSE2 or NEON −11%
parse() twitter.json, std::ifstream 1 #5828 scanning the stream's get area in bulk −48%
1 #5884 inline get-area path, binary reads −5%
dump() twitter.json 6 #5285 a write buffer and bulk copies of strings −70%
2 #5738 own float parser, string scan, \u table −19%
2 #5878 short strings checked with a table −6%
2 #5883 strings of 16–32 bytes as two overlapping vectors −34%
dump() canada.json 6 #5285 a write buffer and bulk copies of strings −8%
3 #5634 Żmij instead of Grisu2 −65%
from_cbor() twitter.json 1 #5233 binary strings read in bulk −32%
4 #5501 the result moved instead of copied −33%
U #5185, #5531 UTF-8 validation of every text string +37%
U #5746 the validation made an option (#5741) −27%
to_cbor() twitter.json 4 #5328 no temporary basic_json per object key (by @alexprabhat99) −55%
6 #5824 a cursor instead of vector::insert() −38%
6 #5885 runs of numbers encoded in a loop −32%

The labeled steps of the timeline: the change in time at the first commit of each pull request, against the commit before it. #5592 is by @mvanslobbe.

D. Algorithmic fixes

operation 3.12.0 3.13.0 speedup cause PR
diff() of an array of 64,000 numbers against an empty array 4,237 ms 10.8 ms 394× each remove was inserted at the front of the patch #5461
diff() of an array of 20,000 objects against every second one of them 122 ms 4.13 ms 29× the same #5461
parse() with a callback that rejects half of 32,000 objects 1,398 ms 12.1 ms 116× each rejected value was searched for from the start of its parent #5457
< on two equal values nested 24 levels deep (C++11 to C++17) 64.4 ms 0.002 ms exponential std::vector's < asks a < b and b < a, and both recurse #5390
< on citm_catalog.json and a copy, 10× (C++11 to C++17) 284 ms 3.94 ms 72× the same #5390
flatten() of citm_catalog.json as ordered_json 271 ms 1.45 ms 187× every new key was looked up linearly #5834
to_bson() of a value nested 200 levels deep, 100× 20.3 ms 0.352 ms 58× each document's size was computed by walking everything below it again #5553, #5979
100,000 × push_back() on an ordered_json with JSON_DIAGNOSTICS 1,589 ms 5.32 ms 299× every insert re-pointed the parent pointers of all elements #5832
10,000 × at(i) on an ordered_json array with JSON_DIAGNOSTICS 45.9 ms 0.088 ms 522× the same #5832

Operations that were quadratic or worse in 3.12.0, timed with both versions (C++17, -O3, median of 3 interleaved runs).

E. Comparison with other libraries

Method. The programs are in tests/benchmarks/json_view/others, whose README lists every variant, the pinned versions and checksums of every library, and the build commands. The libraries: yyjson 0.13.0, simdjson 5.1.1, sonic-cpp 1.0.2, RapidJSON (master, 24b5e7a, as its last release is from 2016), Glaze 9.1.0, Boost.JSON 1.92, and in Rust sonic-rs 0.5.10, simd-json 0.19.0 and serde_json 1.0.154. C++23 with -O3; on the Mac Apple clang 21 (the compiler of the current Xcode), on x86-64 GCC 16.2, GCC 13.4 and Clang 23 with -march=haswell on Ubuntu 26.04, pinned to one core; Rust 1.99 with -C target-cpu=haswell on x86-64. The Rust times on the Mac are from October 29; the Rust programs haven't changed since. Every variant runs in every round, in a different order each round (a shuffle with a fixed seed), each timed call after an untimed one; the time is the best of 2 × 30 rounds (2 × 6 for jeopardy.json). Boost.JSON uses a monotonic resource and precise number parsing, RapidJSON kParseFullPrecisionFlag (except in validate, which reads no number), serde_json float_roundtrip: every number a library converts has to be exact. That rule, like the others here, is our choice. Between the two runs, a library's time relative to json_document's moved by 0.8% at the median and 2.5% at the 95th percentile on the Mac, and by 2.5–3% and 9–10% on x86-64.

Validation levels. A way validates fully if it rejects all four broken inputs (cut short by one byte, a stray comma, an invalid UTF-8 byte in a string, garbage after the value); "U" accepts the invalid UTF-8 byte; "P" accepts one of the other three. The probes separate the ways that check everything from those that check less; JSONTestSuite is the conformance check. sonic-cpp 1.0.2 does not check UTF-8. RapidJSON does only with kParseValidateEncodingFlag, and serde_json reading into IgnoredAny or structs from bytes doesn't check the strings it skips, unless the input is checked with std::str::from_utf8 first; both are shown both ways. simdjson's On-Demand API, Glaze's lazy_json, simdjson::minify() and Glaze's minify_json() don't validate what they skip, Glaze's structs only with validate_skipped, and Glaze's generic reader rejects garbage after the value only with validate_trailing_whitespace (its strict variants also set escape_control_characters, without which it writes control characters unescaped).

Conformance. Every validate way ran the 318 files of JSONTestSuite, on the Mac with the headers of November 3 and on x86-64 with those of October 30 (the Mac's results didn't change). No way rejected a file that must be accepted. Of those that must be rejected, a few got through: 123 followed by a NUL byte in nlohmann/json and RapidJSON (nlohmann/json treats NUL as the end of the input; JSON_STRICT_NUL_HANDLING changes that for json and json_document), an incomplete UTF-8 byte-order mark in RapidJSON, an invalid \u escape in sonic-rs's IgnoredAny, and, in 17 files, garbage after the value in Glaze's generic reader without validate_trailing_whitespace. sonic-rs, and RapidJSON's SAX reader with GCC 16.2 on x86-64, crash with a stack overflow on 100,000 nested arrays. Neither machine has AVX-512, so simdjson's fastest kernel never ran, and yyjson and simdjson are compiled as separate files without link-time optimization, while the header-only libraries inline into the benchmark. Built with link-time optimization, and simdjson as C++23 like the rest, they moved by less than 3% against json_document on average (Apple clang and GCC 16).

Choices. A library's time is its fastest fully validating way per file; "(U …)", "(P …)" and "(N …)" give a faster way that checks or does less. A cell of the figures is the geometric mean of a library's four times, divided by the smallest such mean in its column. Variants that keep a parser or document across calls are measured but not chosen, and so are in-place ways with the copy made before the timer starts; with every library reused, the first place in each column stays the same. Variants that leave numbers unconverted while reading are chosen where they pass the checks (yyjson's and RapidJSON's raw numbers, Boost.JSON's number_precision::none). Variants that write each number by copying its text instead of formatting it (json_document with number_format::source, yyjson's and RapidJSON's raw numbers) count as doing less, "N": their output keeps the source's spelling of each number. They matter most for canada.json. Glaze's generic with double numbers fails the checks wherever it has to write the 64-bit ids of twitter.json. The struct variants of nlohmann/json, Glaze, simdjson, and serde read into the same types; Boost.JSON's parse_into has no type that skips a value, so it holds a little more.

Use cases: time of each library relative to the fastest, x86-64 with Clang 23 Table of relative times, libraries by use case. nlohmann/json json_document validate: 1.00; nlohmann/json json_document sum: 1.00; nlohmann/json json_document select: 1.00; nlohmann/json json_document edit: 1.00 (0.81 doing less, N); nlohmann/json json_document minify: 1.00 (0.83 doing less, N); nlohmann/json json_document write: 1.00 (0.69 doing less, N); yyjson validate: 1.87; yyjson sum: 1.00; yyjson select: 1.23; yyjson edit: 1.43; yyjson minify: 1.16 (1.00 doing less, N); yyjson write: 1.14; sonic-cpp validate: no fully validating way (2.32 doing less, U); sonic-cpp sum: no fully validating way (1.20 doing less, U); sonic-cpp select: no fully validating way (1.53 doing less, U); sonic-cpp edit: no fully validating way (1.24 doing less, U); sonic-cpp minify: no fully validating way (1.38 doing less, U); sonic-cpp write: 1.28; simdjson validate: 2.03; simdjson sum: 1.13; simdjson select: 1.32 (0.57 doing less, P); simdjson edit: n/a; simdjson minify: 1.58 (0.18 doing less, P); simdjson write: 2.00; sonic-rs (Rust) validate: 1.88; sonic-rs (Rust) sum: 1.07; sonic-rs (Rust) select: 1.39; sonic-rs (Rust) edit: 1.90; sonic-rs (Rust) minify: 1.73; sonic-rs (Rust) write: 1.94; Boost.J​SON validate: 1.79; Boost.J​SON sum: 1.12; Boost.J​SON select: 2.24; Boost.J​SON edit: 2.39; Boost.J​SON minify: 2.68; Boost.J​SON write: 2.31; simd-json (Rust) validate: 3.52; simd-json (Rust) sum: 1.80; simd-json (Rust) select: 2.64; simd-json (Rust) edit: 2.88; simd-json (Rust) minify: 3.38; simd-json (Rust) write: 2.21; serde_json (Rust) validate: 2.33 (2.08 doing less, U); serde_json (Rust) sum: 2.22; serde_json (Rust) select: 1.77; serde_json (Rust) edit: 4.54; serde_json (Rust) minify: 4.92; serde_json (Rust) write: 2.15; Glaze validate: 2.23; Glaze sum: 4.03 (3.78 doing less, P); Glaze select: 1.43 (0.62 doing less, P); Glaze edit: 5.53; Glaze minify: 5.77 (0.70 doing less, P); Glaze write: 1.90; nlohmann/json json validate: 2.77; nlohmann/json json sum: 1.44; nlohmann/json json select: 7.56; nlohmann/json json edit: 3.99; nlohmann/json json minify: 4.91; nlohmann/json json write: 1.86; RapidJSON validate: 4.94 (3.76 doing less, U); RapidJSON sum: 2.98 (2.28 doing less, U); RapidJSON select: 4.20 (3.27 doing less, U); RapidJSON edit: 3.06 (2.60 doing less, U); RapidJSON minify: 3.61 (2.65 doing less, U N); RapidJSON write: 2.79. validate sum select edit minify write nlohmann/json json_document 1.00 1.00 1.00 1.00 0.81 N 1.00 0.83 N 1.00 0.69 N yyjson 1.87 1.00 1.23 1.43 1.16 1.00 N 1.14 sonic-cpp 2.32 U 1.20 U 1.53 U 1.24 U 1.38 U 1.28 simdjson 2.03 1.13 1.32 0.57 P n/a 1.58 0.18 P 2.00 sonic-rs (Rust) 1.88 1.07 1.39 1.90 1.73 1.94 Boost.J​SON 1.79 1.12 2.24 2.39 2.68 2.31 simd-json (Rust) 3.52 1.80 2.64 2.88 3.38 2.21 serde_json (Rust) 2.33 2.08 U 2.22 1.77 4.54 4.92 2.15 Glaze 2.23 4.03 3.78 P 1.43 0.62 P 5.53 5.77 0.70 P 1.90 nlohmann/json json 2.77 1.44 7.56 3.99 4.91 1.86 RapidJSON 4.94 3.76 U 2.98 2.28 U 4.20 3.27 U 3.06 2.60 U 3.61 2.65 U N 2.79 time relative to the fastest fully validating library, less is better below 1.15 1.15 to 1.5 1.5 to 2.5 2.5 to 5 5 and more n/a no A​PI for it

The use cases on the x86-64 server with Clang 23. Less is better; hover over or tap a cell for the times and the way.

Use cases: time of each library relative to the fastest, x86-64 with G​CC 13 Table of relative times, libraries by use case. nlohmann/json json_document validate: 1.00; nlohmann/json json_document sum: 1.08; nlohmann/json json_document select: 1.00; nlohmann/json json_document edit: 1.00 (0.83 doing less, N); nlohmann/json json_document minify: 1.00 (0.83 doing less, N); nlohmann/json json_document write: 1.00 (0.71 doing less, N); yyjson validate: 1.81; yyjson sum: 1.28; yyjson select: 1.22; yyjson edit: 1.49; yyjson minify: 1.27 (0.97 doing less, N); yyjson write: 1.14; sonic-cpp validate: no fully validating way (2.24 doing less, U); sonic-cpp sum: no fully validating way (1.28 doing less, U); sonic-cpp select: no fully validating way (1.53 doing less, U); sonic-cpp edit: no fully validating way (1.26 doing less, U); sonic-cpp minify: no fully validating way (1.42 doing less, U); sonic-cpp write: 1.50; simdjson validate: 2.01; simdjson sum: 1.00; simdjson select: 1.35 (0.57 doing less, P); simdjson edit: n/a; simdjson minify: 1.58 (0.22 doing less, P); simdjson write: 2.07; sonic-rs (Rust) validate: 1.79; sonic-rs (Rust) sum: 1.11; sonic-rs (Rust) select: 1.37; sonic-rs (Rust) edit: 1.91; sonic-rs (Rust) minify: 1.73; sonic-rs (Rust) write: 2.07; Boost.J​SON validate: 1.88; Boost.J​SON sum: 1.33; Boost.J​SON select: 2.30; Boost.J​SON edit: 2.55; Boost.J​SON minify: 2.92; Boost.J​SON write: 2.56; simd-json (Rust) validate: 3.35; simd-json (Rust) sum: 1.88; simd-json (Rust) select: 2.61; simd-json (Rust) edit: 2.89; simd-json (Rust) minify: 3.39; simd-json (Rust) write: 2.35; serde_json (Rust) validate: 2.22 (1.98 doing less, U); serde_json (Rust) sum: 2.32; serde_json (Rust) select: 1.75; serde_json (Rust) edit: 4.55; serde_json (Rust) minify: 4.94; serde_json (Rust) write: 2.28; Glaze validate: 2.21; Glaze sum: 4.04; Glaze select: 1.44 (0.60 doing less, P); Glaze edit: 4.85; Glaze minify: 5.10 (0.73 doing less, P); Glaze write: 1.84; nlohmann/json json validate: 2.74; nlohmann/json json sum: 1.67; nlohmann/json json select: 7.24; nlohmann/json json edit: 3.81; nlohmann/json json minify: 4.79; nlohmann/json json write: 1.99; RapidJSON validate: 4.50 (3.40 doing less, U); RapidJSON sum: 2.98 (2.29 doing less, U); RapidJSON select: 3.84 (3.06 doing less, U); RapidJSON edit: 2.92 (2.58 doing less, U); RapidJSON minify: 3.64 (2.64 doing less, U N); RapidJSON write: 3.11. validate sum select edit minify write nlohmann/json json_document 1.00 1.08 1.00 1.00 0.83 N 1.00 0.83 N 1.00 0.71 N yyjson 1.81 1.28 1.22 1.49 1.27 0.97 N 1.14 sonic-cpp 2.24 U 1.28 U 1.53 U 1.26 U 1.42 U 1.50 simdjson 2.01 1.00 1.35 0.57 P n/a 1.58 0.22 P 2.07 sonic-rs (Rust) 1.79 1.11 1.37 1.91 1.73 2.07 Boost.J​SON 1.88 1.33 2.30 2.55 2.92 2.56 simd-json (Rust) 3.35 1.88 2.61 2.89 3.39 2.35 serde_json (Rust) 2.22 1.98 U 2.32 1.75 4.55 4.94 2.28 Glaze 2.21 4.04 1.44 0.60 P 4.85 5.10 0.73 P 1.84 nlohmann/json json 2.74 1.67 7.24 3.81 4.79 1.99 RapidJSON 4.50 3.40 U 2.98 2.29 U 3.84 3.06 U 2.92 2.58 U 3.64 2.64 U N 3.11 time relative to the fastest fully validating library, less is better below 1.15 1.15 to 1.5 1.5 to 2.5 2.5 to 5 5 and more n/a no A​PI for it

The use cases on the x86-64 server with GCC 13.4. Less is better; hover over or tap a cell for the times and the way.

Times in µs on the Apple M1 Max

validate

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document accept 170 313 886 20,873 0.9
yyjson insitu (copy untimed) 172 451 1,457 30,240 0.9
simdjson DOM 212 452 1,888 28,843 1.1
sonic-rs (Rust) Value 326 681 1,939 31,450 1.4
Glaze validate_json 331 862 1,326 45,192 1.4
simd-json (Rust) tape (copy) 289 698 2,278 38,874 1.7
sonic-cpp Document [U] – (U 382) – (U 985) – (U 2,578) – (U 41,617) – (U 1.6)
serde_json (Rust) IgnoredAny [U], from_utf8 + IgnoredAny 452 (U 354) 1,103 (U 1,047) 1,556 (U 1,451) 46,517 (U 44,984) 2.0 (U 1.7)
Boost.JSON SAX, SAX (numbers: none) 535 994 1,467 53,939 2.3
nlohmann/json json accept 451 888 2,405 55,286 2.4
RapidJSON SAX (default precision), SAX (string stream, default precision), SAX (string stream, default precision) (no encoding check) [U], insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] 1,307 (U 686) 1,397 (U 1,032) 1,982 134,679 (U 63,589) 6.0 (U 2.6)

sum

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document parse 224 485 1,816 28,987 1.2
yyjson insitu (copy untimed) 219 551 1,906 37,512 1.1
simdjson DOM, On-Demand 249 571 2,374 35,111 1.3
sonic-rs (Rust) visitor 364 778 2,703 41,361 1.7
simd-json (Rust) visitor (copy) 354 866 2,717 47,518 2.0
sonic-cpp Document [U] – (U 428) – (U 1,087) – (U 2,993) – (U 48,688) – (U 1.9)
nlohmann/json json sax 465 925 3,072 64,089 2.4
Boost.JSON SAX 535 1,010 3,067 53,704 2.3
serde_json (Rust) visitor 652 1,242 4,772 66,568 2.5
Glaze generic (double) [P], generic (double, strict), lazy_json [P] 968 2,049 6,331 99,763 4.9 (P 3.5)
RapidJSON SAX, SAX (string stream), SAX (string stream) (no encoding check) [U], insitu (copy untimed) (no encoding check) [U] 1,329 (U 735) 1,455 (U 1,136) 6,453 136,103 (U 69,604) 6.1 (U 2.9)

select

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document parse 184 363 956 24,991 1.0
yyjson insitu (copy untimed) 178 456 1,461 32,514 1.0
simdjson DOM, On-Demand (key selectors) [P], On-Demand [P], On-Demand structs [P] 196 (P 127) 450 (P 354) 1,886 (P 566) 30,504 (P 19,562) 1.2 (P 0.8)
Glaze structs (validate_skipped), structs [P] 311 (P 241) 854 (P 255) 1,316 (P 313) 46,924 (P 37,578) 1.4 (P 1.2)
sonic-rs (Rust) Value 333 691 1,936 40,660 1.7
sonic-cpp Document [U] – (U 385) – (U 990) – (U 2,586) – (U 45,858) – (U 1.7)
simd-json (Rust) structs (copy) 364 914 2,657 57,697 2.1
serde_json (Rust) from_utf8 + structs, structs [U] 480 (U 410) 1,125 (U 1,084) 1,560 (U 1,455) 62,378 2.3 (U 1.9)
Boost.JSON parse, parse_into structs 617 1,205 3,650 67,255 2.7
RapidJSON Document (string stream), insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] 1,304 (U 679) 1,554 (U 1,126) 6,776 141,509 (U 65,625) 6.5 (U 2.6)
nlohmann/json json parse, structs (from_json) 1,360 2,416 6,047 179,533 7.6

edit

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document editable, editable (source numbers) [N] 291 560 2,765 (N 1,484) 58,057 1.9
yyjson insitu (copy untimed) + mut copy 427 882 3,642 84,239 2.4
sonic-cpp Document [U] – (U 486) – (U 1,224) – (U 5,638) – (U 62,688) – (U 2.6)
sonic-rs (Rust) Value 634 1,257 4,396 140,936 3.8
simd-json (Rust) borrowed (copy) 948 2,102 7,654 110,693 5.4
Boost.JSON parse 1,368 1,992 6,573 155,237 6.6
nlohmann/json json ordered_json, parse 1,273 2,344 8,334 203,232 7.1
Glaze generic (strict), generic [P] 1,539 3,235 11,083 178,333 8.3
RapidJSON Document, Document (string stream), insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] 1,922 (U 1,344) 2,388 (U 1,964) 13,836 221,270 (U 144,741) 10.3 (U 6.5)
serde_json (Rust) Value 2,009 3,316 9,666 251,931 9.6

minify

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document parse + dump, parse + dump (source numbers) [N] 259 493 2,582 (N 1,360) 37,882 1.4
yyjson insitu (copy untimed) + write, read (raw numbers) + write [N] 279 625 3,196 (N 2,550) 50,821 1.6
simdjson DOM + to_string, minify [P] 423 (P 62.3) 848 (P 171) 5,120 (P 227) 61,059 (P 5,762) 2.4 (P 0.3)
sonic-rs (Rust) Value 535 1,134 4,377 64,832 3.0
sonic-cpp Document + Serialize [U] – (U 500) – (U 1,220) – (U 5,676) – (U 61,236) – (U 2.5)
simd-json (Rust) borrowed (copy) 952 2,111 7,658 108,130 5.5
Boost.JSON parse + serialize 1,393 1,986 6,542 154,677 6.7
RapidJSON Document + Writer, SAX to Writer, SAX to Writer (raw numbers) (no encoding check) [U N], SAX to Writer (string stream), SAX to Writer (string stream) (no encoding check) [U] 1,525 (U 1,058) 1,808 (U 1,485) 14,460 (U N 10,908) 174,558 (U 123,119) 8.1 (U 5.0)
nlohmann/json json parse + dump 1,600 2,706 8,389 197,745 8.8
Glaze generic (strict), minify_json [P] 1,505 (P 281) 3,225 (P 729) 11,092 (P 1,722) 168,855 (P 39,976) 8.1 (P 1.3)
serde_json (Rust) Value 2,004 3,290 9,792 227,369 9.5

write

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document dump, dump (source numbers) [N] 74.3 134 1,621 (N 401) 14,757 0.5
yyjson write 103 169 1,731 20,087 0.7
sonic-cpp Serialize 108 230 3,093 19,651 0.8
nlohmann/json json dump 223 287 2,276 31,825 1.2
simdjson to_string 199 385 3,241 32,208 1.3
Glaze generic, generic (strict) 216 469 1,720 33,934 1.3
sonic-rs (Rust) Value 212 446 2,419 33,366 1.5
serde_json (Rust) Value 322 490 2,286 49,967 2.0
simd-json (Rust) borrowed 355 478 2,728 47,412 1.8
Boost.JSON serialize 507 508 2,923 62,583 2.9
RapidJSON Writer 603 830 6,995 81,150 3.9
Times in µs on x86-64 with GCC 16.2

validate

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document accept 258 513 934 34,514 1.2
yyjson insitu (copy untimed), read (raw numbers) 352 837 2,199 93,043 1.5
sonic-rs (Rust) IgnoredAny 501 970 2,249 54,369 1.8
Boost.JSON SAX, SAX (numbers: none) 690 917 1,676 62,781 2.9
simdjson DOM 301 695 3,337 120,982 1.7
sonic-cpp Document [U] – (U 403) – (U 932) – (U 3,349) – (U 98,511) – (U 1.7)
serde_json (Rust) IgnoredAny [U], from_utf8 + IgnoredAny 624 (U 484) 1,440 (U 1,370) 2,406 (U 2,314) 64,720 (U 58,247) 2.4 (U 1.9)
Glaze validate_json 712 1,983 2,625 76,759 2.5
nlohmann/json json accept 683 1,419 4,559 74,331 3.1
simd-json (Rust) tape (copy) 567 1,430 4,359 207,551 2.9
RapidJSON SAX (default precision) (no encoding check) [U], SAX (string stream, default precision), SAX (string stream, default precision) (no encoding check) [U], insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] 1,166 (U 1,012) 1,553 (U 1,434) 3,875 114,704 (U 102,635) 4.8 (U 3.2)

sum

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
simdjson On-Demand 348 805 3,452 92,769 1.9
nlohmann/json json_document parse 398 972 3,501 89,756 2.0
yyjson insitu (copy untimed) 415 986 3,818 97,586 1.9
sonic-rs (Rust) visitor 501 1,123 4,397 59,583 2.5
sonic-cpp Document [U] – (U 456) – (U 1,115) – (U 3,980) – (U 105,081) – (U 2.0)
Boost.JSON SAX 699 957 5,835 59,656 3.0
nlohmann/json json sax 814 1,666 5,690 97,414 4.0
simd-json (Rust) visitor (copy) 681 1,650 5,083 210,185 3.5
RapidJSON SAX (string stream), SAX (string stream) (no encoding check) [U], insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] 1,221 (U 1,063) 1,717 (U 1,635) 9,435 112,158 (U 105,845) 5.1 (U 3.5)
serde_json (Rust) visitor 1,311 2,288 7,127 129,497 4.6
Glaze generic (double) [P], generic (double, strict), lazy_json [P] 2,483 4,413 10,532 231,505 8.5 (P 6.4)

select

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document parse, structs (from_json) 333 828 1,368 89,876 1.7
yyjson insitu (copy untimed), read (raw numbers) 366 840 2,204 96,172 1.7
sonic-rs (Rust) structs 504 1,033 2,328 90,486 2.0
simdjson DOM, On-Demand (key selectors) [P], On-Demand [P] 313 (P 180) 699 (P 496) 3,325 (P 690) 122,556 (P 48,272) 1.9 (P 1.1)
sonic-cpp Document [U] – (U 407) – (U 948) – (U 3,464) – (U 110,885) – (U 1.8)
serde_json (Rust) from_utf8 + structs, structs [U] 701 1,532 2,529 (U 2,402) 107,316 3.0
Glaze structs (validate_skipped), structs [P] 606 (P 344) 2,476 (P 402) 2,557 (P 417) 89,957 (P 67,467) 2.1 (P 1.5)
simd-json (Rust) structs (copy) 679 1,731 5,144 239,164 3.5
Boost.JSON parse, parse_into structs 887 1,270 7,292 95,866 3.7
RapidJSON Document (string stream), insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] 1,180 (U 1,022) 1,693 10,390 116,394 (U 108,290) 4.9 (U 3.4)
nlohmann/json json parse, structs (from_json) 3,360 5,268 12,006 410,221 17.7

edit

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document editable, editable (source numbers) [N] 536 1,162 5,567 (N 2,188) 192,323 3.0
sonic-cpp Document [U] – (U 620) – (U 1,438) – (U 8,495) – (U 186,078) – (U 3.4)
yyjson insitu (copy untimed) + mut copy 746 1,526 7,479 275,025 3.6
sonic-rs (Rust) Value 1,197 2,389 7,744 390,952 6.9
Boost.JSON parse 1,767 2,493 12,767 331,310 8.5
RapidJSON Document (string stream), insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] 1,865 (U 1,737) 2,616 18,812 245,724 (U 234,669) 8.7 (U 7.2)
simd-json (Rust) borrowed (copy), owned (copy) 1,862 4,194 14,896 391,369 9.0
nlohmann/json json ordered_json 2,832 4,970 15,806 647,704 13.1
Glaze generic (strict), generic [P] 3,389 6,002 21,598 607,767 14.8
serde_json (Rust) Value 4,042 6,586 16,631 632,766 17.6

minify

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document parse + dump, parse + dump (source numbers) [N] 518 (N 496) 1,045 5,240 (N 2,196) 143,737 2.5 (N 2.4)
yyjson insitu (copy untimed) + write, insitu (copy) + write, read (raw numbers) + write [N] 626 1,160 6,701 (N 2,901) 156,605 2.8
sonic-cpp Document + Serialize [U] – (U 675) – (U 1,417) – (U 8,958) – (U 164,630) – (U 3.3)
simdjson DOM + to_string, minify [P] 714 (P 99.9) 1,318 (P 278) 9,135 (P 312) 269,597 (P 42,644) 3.7 (P 0.5)
sonic-rs (Rust) Value 1,000 2,097 7,795 215,280 5.4
Boost.JSON parse + serialize 1,974 2,525 12,748 314,299 8.6
RapidJSON SAX to Writer (raw numbers) [N], SAX to Writer (string stream), SAX to Writer (string stream) (no encoding check) [U] 1,949 (U 1,880) 2,382 19,548 (N 14,400) 231,979 (U 224,249) 8.2 (U 6.2)
simd-json (Rust) borrowed (copy) 1,889 4,196 15,716 414,377 9.2
Glaze generic (strict), minify_json [P] 3,685 (P 442) 5,942 (P 1,292) 22,138 (P 2,726) 450,322 (P 97,377) 14.0 (P 1.9)
serde_json (Rust) Value 4,012 6,583 17,306 509,702 16.5
nlohmann/json json parse + dump 4,196 5,806 16,626 529,560 19.2

write

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document dump, dump (source numbers) [N] 156 (N 140) 251 (N 230) 3,842 (N 823) 67,769 (N 65,376) 0.9 (N 0.8)
yyjson write 215 319 3,360 66,669 1.3
sonic-cpp Serialize 249 509 5,074 69,916 1.6
nlohmann/json json dump 444 640 4,089 117,316 1.8
simdjson to_string 361 614 5,609 158,492 1.8
Glaze generic 381 730 4,071 131,946 2.0
sonic-rs (Rust) Value 434 927 3,987 109,176 2.8
Boost.JSON serialize 469 830 5,659 144,174 2.4
simd-json (Rust) borrowed 582 800 5,147 122,623 2.8
serde_json (Rust) Value 550 887 4,005 134,163 3.3
RapidJSON Writer 678 979 9,095 131,408 3.8
Times in µs on x86-64 with GCC 13.4

validate

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document accept 271 609 1,011 34,722 1.2
sonic-rs (Rust) IgnoredAny 501 970 2,249 54,369 1.8
yyjson insitu (copy untimed), read (raw numbers) 348 878 2,256 90,959 1.6
Boost.JSON SAX, SAX (numbers: none) 710 969 1,718 61,476 3.2
simdjson DOM 311 780 3,351 116,704 1.8
Glaze validate_json 590 1,545 1,980 76,009 2.3
sonic-cpp Document [U] – (U 408) – (U 1,028) – (U 3,622) – (U 96,080) – (U 1.6)
serde_json (Rust) IgnoredAny [U], from_utf8 + IgnoredAny 624 (U 484) 1,440 (U 1,370) 2,406 (U 2,314) 64,720 (U 58,247) 2.4 (U 1.9)
nlohmann/json json accept 677 1,433 4,584 73,561 3.3
simd-json (Rust) tape (copy) 567 1,430 4,359 207,551 2.9
RapidJSON SAX (default precision) (no encoding check) [U], SAX (string stream, default precision), insitu (copy untimed), insitu (copy untimed) (no encoding check) [U], insitu (copy) (no encoding check) [U] 1,552 (U 1,082) 2,210 (U 1,570) 4,104 168,747 (U 110,856) 6.9 (U 3.7)

sum

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
simdjson On-Demand 356 812 3,435 96,250 2.0
nlohmann/json json_document parse 392 946 3,888 89,044 2.0
sonic-rs (Rust) visitor 501 1,123 4,397 59,583 2.5
sonic-cpp Document [U] – (U 460) – (U 1,210) – (U 4,295) – (U 107,092) – (U 1.9)
yyjson insitu (copy untimed) 407 1,045 6,021 99,764 2.0
Boost.JSON SAX 734 1,030 6,103 64,382 3.3
nlohmann/json json sax 797 1,548 6,004 101,516 4.0
simd-json (Rust) visitor (copy) 681 1,650 5,083 210,185 3.5
serde_json (Rust) visitor 1,311 2,288 7,127 129,497 4.6
RapidJSON SAX (no encoding check) [U], SAX (string stream), SAX (string stream) (no encoding check) [U], insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] 1,616 (U 1,130) 2,290 (U 1,743) 11,183 181,500 (U 119,706) 7.5 (U 3.9)
Glaze generic (double) [P], generic (double, strict), lazy_json [P] 2,345 4,080 10,576 250,685 (P 231,181) 9.0 (P 6.4)

select

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document parse 332 765 1,390 88,202 1.6
yyjson insitu (copy untimed), read (raw numbers) 361 880 2,236 98,637 1.7
sonic-rs (Rust) structs 504 1,033 2,328 90,486 2.0
simdjson DOM, On-Demand (key selectors) [P], On-Demand [P] 324 (P 184) 756 (P 509) 3,409 (P 700) 123,214 (P 48,614) 2.0 (P 1.2)
Glaze structs (validate_skipped), structs [P] 529 (P 342) 1,435 (P 410) 1,913 (P 431) 91,253 (P 66,069) 2.0 (P 1.5)
sonic-cpp Document [U] – (U 413) – (U 1,043) – (U 3,695) – (U 108,297) – (U 1.7)
serde_json (Rust) from_utf8 + structs, structs [U] 701 1,532 2,529 (U 2,402) 107,316 3.0
simd-json (Rust) structs (copy) 679 1,731 5,144 239,164 3.5
Boost.JSON parse, parse_into structs 884 1,380 7,217 99,078 3.9
RapidJSON Document (string stream) (no encoding check) [U], insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] 1,560 (U 1,079) 2,127 (U 1,817) 11,547 176,066 (U 122,354) 7.1 (U 3.7)
nlohmann/json json structs (from_json) 3,325 5,126 11,908 423,362 17.0

edit

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document editable, editable (source numbers) [N] 532 1,116 5,714 (N 2,702) 192,363 3.1
sonic-cpp Document [U] – (U 637) – (U 1,559) – (U 8,936) – (U 186,232) – (U 3.3)
yyjson insitu (copy untimed) + mut copy 761 1,590 9,860 271,227 3.7
sonic-rs (Rust) Value 1,197 2,389 7,744 390,952 6.9
Boost.JSON parse 1,963 2,936 13,414 356,244 9.1
simd-json (Rust) borrowed (copy), owned (copy) 1,862 4,194 14,896 391,369 9.0
RapidJSON Document (string stream) (no encoding check) [U], insitu (copy untimed), insitu (copy untimed) (no encoding check) [U], insitu (copy) 2,333 (U 1,838) 3,133 (U 2,836) 20,518 315,892 (U 271,888) 11.0 (U 7.7)
nlohmann/json json ordered_json 2,912 4,999 15,603 604,744 13.5
Glaze generic (strict), generic [P] 3,525 6,551 23,644 661,941 (P 632,543) 15.6
serde_json (Rust) Value 4,042 6,586 16,631 632,766 17.6

minify

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document parse + dump, parse + dump (source numbers) [N], parse_copy + dump 483 1,014 5,459 (N 2,620) 146,320 2.4
yyjson insitu (copy untimed) + write, read (raw numbers) + write [N], read + write 592 1,205 9,008 (N 3,106) 158,577 2.9
sonic-cpp Document + Serialize [U] – (U 695) – (U 1,541) – (U 8,838) – (U 168,901) – (U 3.3)
simdjson DOM + to_string, minify [P] 742 (P 123) 1,393 (P 337) 8,808 (P 433) 266,718 (P 47,040) 3.8 (P 0.6)
sonic-rs (Rust) Value 1,000 2,097 7,795 215,280 5.4
Boost.JSON parse + serialize 2,086 2,919 13,974 334,468 9.2
simd-json (Rust) borrowed (copy) 1,889 4,196 15,716 414,377 9.2
RapidJSON Document + Writer, SAX to Writer, SAX to Writer (no encoding check) [U], SAX to Writer (raw numbers) (no encoding check) [U N], SAX to Writer (string stream), SAX to Writer (string stream) (no encoding check) [U] 3,015 (U 1,978) 3,012 (U 2,493) 21,177 (U N 15,512) 355,727 (U 249,235) 13.9 (U 7.5)
Glaze generic (strict), minify_json [P] 3,780 (P 422) 6,416 (P 1,144) 23,765 (P 2,350) 458,311 (P 96,405) 14.7 (P 1.9)
serde_json (Rust) Value 4,012 6,583 17,306 509,702 16.5
nlohmann/json json parse + dump 4,143 5,702 16,463 528,092 19.5

write

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document dump, dump (source numbers) [N] 150 260 3,861 (N 998) 63,958 0.8
yyjson write 204 327 3,748 65,989 1.2
sonic-cpp Serialize 256 541 5,120 69,546 1.6
Glaze generic, generic (strict) 360 660 3,714 124,598 1.7
nlohmann/json json dump 471 644 4,344 114,096 1.9
simdjson to_string 359 637 4,999 154,364 1.9
sonic-rs (Rust) Value 434 927 3,987 109,176 2.8
simd-json (Rust) borrowed 582 800 5,147 122,623 2.8
Boost.JSON serialize 480 1,016 6,154 139,022 2.5
serde_json (Rust) Value 550 887 4,005 134,163 3.3
RapidJSON Writer 717 1,033 9,017 134,260 4.0
Times in µs on x86-64 with Clang 23

validate

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document accept 255 512 1,055 34,729 1.1
yyjson insitu (copy untimed), read (raw numbers) 334 811 2,580 83,153 1.5
sonic-rs (Rust) IgnoredAny 501 970 2,249 54,369 1.8
Boost.JSON SAX, SAX (numbers: none) 571 910 1,684 55,498 2.3
simdjson DOM 306 732 3,206 113,452 1.7
sonic-cpp Document [U] – (U 448) – (U 1,032) – (U 3,143) – (U 94,884) – (U 1.8)
Glaze validate_json 588 1,431 1,867 75,571 2.4
serde_json (Rust) IgnoredAny [U], from_utf8 + IgnoredAny 624 (U 484) 1,440 (U 1,370) 2,406 (U 2,314) 64,720 (U 58,247) 2.4 (U 1.9)
nlohmann/json json accept 658 1,390 4,265 71,982 3.5
simd-json (Rust) tape (copy) 567 1,430 4,359 207,551 2.9
RapidJSON SAX (default precision), SAX (default precision) (no encoding check) [U], SAX (string stream, default precision), SAX (string stream, default precision) (no encoding check) [U], insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] 1,756 (U 1,132) 1,971 (U 1,572) 4,118 198,915 (U 131,204) 8.2 (U 4.1)

sum

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document parse 374 923 3,846 85,286 1.8
yyjson insitu (copy untimed) 399 946 3,445 88,447 1.9
simdjson DOM, On-Demand 394 977 4,117 114,602 2.2
sonic-rs (Rust) visitor 501 1,123 4,397 59,583 2.5
sonic-cpp Document [U] – (U 502) – (U 1,175) – (U 3,754) – (U 104,583) – (U 2.1)
Boost.JSON SAX 580 928 5,809 56,774 2.4
nlohmann/json json sax 728 1,393 5,428 89,481 3.9
simd-json (Rust) visitor (copy) 681 1,650 5,083 210,185 3.5
serde_json (Rust) visitor 1,311 2,288 7,127 129,497 4.6
RapidJSON SAX, SAX (no encoding check) [U], SAX (string stream), SAX (string stream) (no encoding check) [U], insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] 1,816 (U 1,187) 2,224 (U 1,765) 10,719 206,811 (U 135,945) 8.5 (U 4.3)
Glaze generic (double, strict), lazy_json [P] 2,427 3,972 12,165 253,385 (P 196,582) 9.2 (P 5.6)

select

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document parse, structs (from_json) 320 759 1,390 88,083 1.6
yyjson insitu (copy untimed), read (raw numbers) 351 788 2,632 92,551 1.6
simdjson DOM, On-Demand (key selectors) [P], On-Demand [P], On-Demand structs [P] 316 (P 181) 731 (P 495) 3,262 (P 702) 120,808 (P 48,823) 1.9 (P 1.1)
sonic-rs (Rust) structs 504 1,033 2,328 90,486 2.0
Glaze structs (validate_skipped), structs [P] 544 (P 345) 1,368 (P 419) 1,924 (P 444) 85,745 (P 66,442) 2.2 (P 1.6)
sonic-cpp Document [U] – (U 460) – (U 1,011) – (U 3,204) – (U 108,245) – (U 1.9)
serde_json (Rust) from_utf8 + structs, structs [U] 701 1,532 2,529 (U 2,402) 107,316 3.0
Boost.JSON parse, parse_into structs 776 1,389 7,121 97,681 3.2
simd-json (Rust) structs (copy) 679 1,731 5,144 239,164 3.5
RapidJSON insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] 1,762 (U 1,144) 2,198 (U 1,817) 11,464 209,290 (U 142,852) 8.2 (U 4.1)
nlohmann/json json parse, structs (from_json) 3,602 5,124 11,831 444,210 20.2

edit

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document editable, editable (source numbers) [N] 526 1,127 5,674 (N 2,443) 196,370 2.9
sonic-cpp Document [U] – (U 711) – (U 1,472) – (U 7,996) – (U 183,852) – (U 3.2)
yyjson insitu (copy untimed) + mut copy, insitu (copy) + mut copy 918 1,548 7,155 270,654 3.9
sonic-rs (Rust) Value 1,197 2,389 7,744 390,952 6.9
Boost.JSON parse 1,855 2,596 12,765 350,286 8.3
simd-json (Rust) borrowed (copy), owned (copy) 1,862 4,194 14,896 391,369 9.0
RapidJSON insitu (copy untimed), insitu (copy untimed) (no encoding check) [U], insitu (copy) 2,635 (U 1,952) 3,103 (U 2,728) 21,139 333,913 (U 267,081) 11.8 (U 7.7)
nlohmann/json json ordered_json, parse 3,069 4,905 16,702 666,261 13.1
serde_json (Rust) Value 4,042 6,586 16,631 632,766 17.6
Glaze generic (strict), generic [P] 4,341 7,322 26,786 725,881 18.3 (P 17.7)

minify

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document parse + dump, parse + dump (source numbers) [N] 518 1,040 5,043 (N 2,456) 146,125 2.4
yyjson insitu (copy untimed) + write, read (raw numbers) + write [N] 620 1,189 6,283 (N 3,472) 154,016 3.0
sonic-cpp Document + Serialize [U] – (U 710) – (U 1,485) – (U 8,122) – (U 166,935) – (U 3.1)
simdjson DOM + to_string, minify [P] 783 (P 105) 1,395 (P 269) 8,303 (P 337) 275,007 (P 45,087) 4.0 (P 0.5)
sonic-rs (Rust) Value 1,000 2,097 7,795 215,280 5.4
Boost.JSON parse + serialize 1,924 2,569 12,622 329,090 8.4
simd-json (Rust) borrowed (copy) 1,889 4,196 15,716 414,377 9.2
RapidJSON Document + Writer, SAX to Writer, SAX to Writer (no encoding check) [U], SAX to Writer (raw numbers) (no encoding check) [U N], SAX to Writer (raw numbers) [N], SAX to Writer (string stream), SAX to Writer (string stream) (no encoding check) [U] 2,808 (U N 2,022) 2,975 (U 2,480) 22,589 (N 14,986) 358,807 (U 261,527) 12.7 (U 7.2)
serde_json (Rust) Value 4,012 6,583 17,306 509,702 16.5
nlohmann/json json parse + dump 4,445 5,713 16,711 543,033 21.4
Glaze generic (strict), minify_json [P] 4,132 (P 444) 7,465 (P 1,059) 26,962 (P 2,222) 528,811 (P 91,153) 17.2 (P 1.9)

write

library variant twitter.json citm_catalog.json canada.json jeopardy.json one status (2.5 KB)
nlohmann/json json_document dump, dump (source numbers) [N] 160 (N 154) 311 (N 279) 3,714 (N 989) 67,038 (N 64,301) 0.9
yyjson write 218 386 3,476 71,247 1.4
sonic-cpp Serialize 224 475 4,850 65,131 1.3
nlohmann/json json dump 451 628 4,506 116,529 2.0
Glaze generic 399 748 4,220 128,020 2.0
simdjson to_string 389 656 4,896 157,616 2.1
sonic-rs (Rust) Value 434 927 3,987 109,176 2.8
Boost.JSON serialize 478 916 5,615 141,846 2.5
simd-json (Rust) borrowed 582 800 5,147 122,623 2.8
serde_json (Rust) Value 550 887 4,005 134,163 3.3
RapidJSON Writer 627 1,008 9,596 123,764 3.7

F. Behavior changes

Visible changes that came with the speed:

  • Float output (#5634). Żmij writes the shortest decimal that reads back as the same double, and the closest one if there are several. Of one million random double bit patterns, 1,425 (0.14%) are now written differently: 810 with fewer digits (5.3165205877497296e+16 becomes 5.31652058774973e+16), 615 with the same number of digits but the closer ones. They all read back as the same double, and none of one million short decimals changes. In canada.json, 69 of the 111,126 numbers come out differently.
  • Error positions (#5283). The column reported after a number at the end of a line is now correct; the fast paths otherwise report the same byte, line, and column as the byte path, checked on 481,000 documents.
  • SAX float text (#5852). The built-in handlers behind parse() and accept() get an empty string as the text of a float token. A user-written SAX handler still gets the token's text.
  • FILE* locking (#5828). The lock is taken once and held for the whole parse instead of once per byte. The documentation mentions it, and an opt-out macro restores the old behavior.

G. How we measured

What is compared. "3.12.0" is the released single header and "3.13.0" the released one. Both are compiled from the same benchmark source, the Google Benchmark suite in tests/benchmarks, with the BON8 benchmarks removed because 3.12.0 has no BON8.

machine Apple M1 Max laptop on AC power, macOS 27.0.1
compiler Apple clang 21, libc++
flags -O3 -flto -DNDEBUG, the compiler's default standard, C++14
suite 3 rounds × 4 repetitions per version, random interleaving, each round of a version started only when the load average was below 4 and repeated when it rose above 5 while running; of 3.12.0 and 3.13.0, the round that was 4.2% and 1.8% slower overall than the other two was dropped; the median of the remaining 8
noise the coefficient of variation was 3% or less for 172 of the 182 rows with 3.12.0 and 174 with 3.13.0; the largest was 7.3%, not counting one 3.12.0 run of a 1.5 µs benchmark that a disturbance made 1.4× as slow
inputs the files of Appendix A, and number-heavy files
Intel check the suite again on a MacBook Pro (15-inch, 2016) with an Intel Core i7-6920HQ (Skylake, 4 cores), macOS 12.0.1, Apple clang 13, the same flags with C++11; started below a load of 3, repeated above 4; the first round of each version dropped (3.12.0's was 4.7% slower than the others); the coefficient of variation was 3% or less for 120 rows with 3.12.0 and 161 of 234 with 3.13.0, so single rows are less reliable than on the M1 Max; geometric mean 3.07×, and two rows below 1×: copying citm_catalog.json without destroying the copy (0.93×) and == of it and a copy (0.95×), both in each round

Setup of the Google Benchmark comparison of 3.12.0 and 3.13.0.

The other measurements. The timeline is tests/benchmarks/history: one program, compiled against single_include of 3.12.0, of each of the 351 commits on develop up to November 3 that changed json.hpp or json_view.hpp since, and of 3.13.0 (C++17, -O3, no LTO), 4 rounds in random order, median of the rounds (the mean of the two middle ones). Two commits that don't compile on their own are left out. The commits up to October 27 were run on October 27, the later ones and 3.13.0 on November 3, together with twelve of the earlier commits as a check: per workload, their median moved by 1.5% or less. The algorithmic fixes come from small extra programs (C++17, -O3, median of 3 interleaved runs). The comparison with other libraries is described in Appendix E.

Compile times. The two programs of The price, each compiled as one translation unit with -std=c++20 (modules need it) and -O0 or -O2, with Apple clang 21 and Homebrew GCC 16.2 on the M1 Max. The time is the CPU time (user plus system) of the compiler process, the median of 5 rounds in which all 40 compilations ran in a random order. The slowest of the 5 runs was within 13% of the fastest for every program and every build of the precompiled header or the module. Every variant was also linked and run once, with the same output from all of them.

build program 3.12.0 3.13.0 3.13.0, PCH 3.13.0, module
Apple clang -O0 count_numbers 955 1,320 689 918
server_config 1,113 1,496 863 1,136
Apple clang -O2 count_numbers 1,393 2,081 1,458 1,685
server_config 1,754 2,546 1,925 2,169
GCC 16 -O0 count_numbers 1,569 2,548 1,725 1,700
server_config 2,025 2,985 2,302 2,326
GCC 16 -O2 count_numbers 2,336 3,702 2,987 2,913
server_config 3,122 4,714 4,006 4,055

Compile time in milliseconds of the two programs in The price (CPU time of the compiler, median of 5 runs). Building the precompiled header took 973 and 987 ms with Apple clang (-O0, -O2) and 2,565 and 2,589 ms with GCC; building the module took 1,617 and 1,602 ms or 2,499 and 2,465 ms.

The programs and the build commands
// Reads a JSON file and adds up all of its numbers (the "sum" use case of the benchmark).
#include <cstddef>
#include <fstream>
#include <iostream>
#ifdef USE_MODULE
import nlohmann.json;
#else
#include <nlohmann/json.hpp>
#endif

using nlohmann::json;

static double sum_numbers(const json& j)
{
    switch (j.type())
    {
        case json::value_t::array:
        case json::value_t::object:
        {
            double sum = 0;
            for (const auto& element : j)
            {
                sum += sum_numbers(element);
            }
            return sum;
        }
        case json::value_t::number_integer:
        case json::value_t::number_unsigned:
        case json::value_t::number_float:
            return j.get<double>();
        default:
            return 0;
    }
}

int main(int argc, char* argv[])
{
    if (argc != 2)
    {
        return 1;
    }
    std::ifstream in(argv[1]);
    const json j = json::parse(in);
    std::cout << sum_numbers(j) << '\n';
}

count_numbers.cpp.

// Reads a configuration file into a struct, changes it, and writes it back indented.
#include <fstream>
#include <iostream>
#include <string>
#include <vector>
#ifdef USE_MODULE
import nlohmann.json;
#else
#include <nlohmann/json.hpp>
#endif

using nlohmann::json;

struct server_config
{
    std::string host;
    int port = 0;
    double timeout = 0;
    std::vector<std::string> tags;
};

void to_json(json& j, const server_config& c)
{
    j = json{{"host", c.host}, {"port", c.port}, {"timeout", c.timeout}, {"tags", c.tags}};
}

void from_json(const json& j, server_config& c)
{
    j.at("host").get_to(c.host);
    j.at("port").get_to(c.port);
    j.at("timeout").get_to(c.timeout);
    j.at("tags").get_to(c.tags);
}

int main(int argc, char* argv[])
{
    if (argc != 3)
    {
        return 1;
    }
    std::ifstream in(argv[1]);
    auto config = json::parse(in).get<server_config>();
    config.port += 1;
    config.tags.push_back("updated");
    std::ofstream(argv[2]) << json(config).dump(2) << '\n';
}

server_config.cpp.

# Apple clang: the precompiled header (pch.hpp contains only #include <nlohmann/json.hpp>) ...
clang++ -std=c++20 -O2 -I single_include -x c++-header pch.hpp -o pch.hpp.pch
clang++ -std=c++20 -O2 -I single_include -include-pch pch.hpp.pch -c count_numbers.cpp
# ... and the module
clang++ -std=c++20 -O2 -fcxx-modules -I single_include -c -x c++-module src/modules/json.cppm \
    -fmodule-output=nlohmann.json.pcm -o json_mod.o
clang++ -std=c++20 -O2 -fcxx-modules -fmodule-file=nlohmann.json=nlohmann.json.pcm -DUSE_MODULE \
    -c count_numbers.cpp

# GCC 16 (Homebrew on macOS, add --sysroot=$(xcrun --show-sdk-path))
g++-16 -std=c++20 -O2 -I single_include -x c++-header pch.hpp -o pch.hpp.gch
g++-16 -std=c++20 -O2 -I single_include -include pch.hpp -Winvalid-pch -c count_numbers.cpp
g++-16 -std=c++20 -O2 -fmodules -I single_include -c src/modules/json.cppm -o json_mod.o
g++-16 -std=c++20 -O2 -fmodules -DUSE_MODULE -c count_numbers.cpp

The commands for one program at -O2; the module variants are linked with json_mod.o.

What we did not measure. The timeline and the algorithmic fixes ran on one Apple silicon laptop with Clang, the suite also on an Intel laptop with Clang. The comparison with other libraries also ran on an x86-64 server with GCC 16.2, GCC 13, and Clang 23, and on the Mac with GCC 16.2 and Clang 23 as a check. That server is a virtual machine with 8 cores that present themselves as Haswell; its timings show the penalty of the microcode fix for Intel's JCC erratum, which Haswell doesn't have, so the real CPU is probably a later one. It shares its cores with other work, so its differences of a few percent are noise. The theme sections quote x86-64 numbers where a pull request measured them, mostly with GCC 13 and Clang 18. Expect smaller gains for some workloads on x86-64 with glibc: its FILE* locking is cheaper to begin with, and its allocator behaves differently. We did not measure on Windows, and we did not measure binary size or memory use.

H. Pull requests by theme

I. Details of the themes

Eisel-Lemire (theme 3). fast_float's compute_float, which 3.13.0 adapts, works with integers only. The value is w · 10^q = w · 5^q · 2^q, so it normalizes w to 64 significant bits, multiplies it with a 128-bit approximation of 5^q from a table of 651 entries, and takes the 53 bits of the mantissa plus one rounding bit from the top of the product. The binary exponent comes from a fixed-point log2(10). If the nine bits below the rounding bit are all ones, the truncated part of the table entry could still carry into the result; then a second multiplication with the next 64 bits of 5^q decides, which happens for about one input in 512. Mushtak and Lemire showed in 2023 that for up to 19 digits these 128 bits always suffice, so the algorithm needs no fallback. Exact ties between two doubles, the only place where rounding half to even matters, can only occur for small exponents, and are handled there. (Clinger's fast path, by contrast, uses the floating-point unit and assumes the default rounding mode.)

Numbers with more than 19 digits are rare in JSON. For them, Eisel-Lemire runs on the first 19 digits, w, and on w + 1: the true value lies between the two, so if both give the same double, that is the answer. Only if they differ does fast_float's digit comparison build the exact midpoint between the two candidates as a big integer and compare all digits with it.

Żmij's digits (theme 3). Żmij's digit conversion, by Xiang JunBo, is the same divide-by-multiplying idea as the parser's, run backwards on all lanes of a word at once:

// 12345678 -> the bytes 1, 2, 3, 4, 5, 6, 7, 8 (one digit per byte)
std::uint64_t eight_digit_bytes(std::uint64_t abcdefgh) noexcept
{
    // x / d as (x * (2^k / d + 1)) >> k
    const std::uint64_t abcd = (abcdefgh * (((1ull << 40) / 10000) + 1)) >> 40;
    const std::uint64_t abcd_efgh = abcdefgh + ((1ull << 32) - 10000) * abcd;
    const std::uint64_t ab_ef = ((abcd_efgh * (((1ull << 19) / 100) + 1)) >> 19) & 0x7F0000007Full;
    const std::uint64_t ab_cd_ef_gh = abcd_efgh + ((1ull << 16) - 100) * ab_ef;
    const std::uint64_t a_c_e_g =
        ((ab_cd_ef_gh * (((1ull << 10) / 10) + 1)) >> 10) & 0x000F000F000F000Full;
    return ab_cd_ef_gh + ((1ull << 8) - 10) * a_c_e_g;
}

Żmij's conversion of an eight-digit number to one digit per byte, in three steps of divide-by-multiplying.

(x · ⌈2^k / d⌉) >> k is x / d for the range that occurs, and x + (2^w − d) · (x / d) rewrites x as (x / d) · 2^w + x mod d: the quotient moves up into the next lane. Three steps split 8 digits into 4 + 4, 2 + 2 + 2 + 2, and single digits. A zero digit becomes a zero byte, so leading and trailing zeros are counted with a count of leading or trailing zero bits instead of divisions.

The destructor's walk (theme 7). While the destructor's walk empties a container, the slot of that container's last child holds the link back to the container's parent:

// descend into the last child of cur, reversing the link
basic_json& slot = last_child(cur);
take(tmp, slot);    // the child's contents
take(slot, prev);   // its slot now remembers cur's parent
take(prev, cur);    // cur becomes the parent
take(cur, tmp);     // the child becomes cur
// on the way up: read the parent back out of prev's last slot, pop that slot, free cur

One step down in the destructor's walk: the emptied slot remembers the way back.

take() moves the 16 bytes of type and value and leaves a null behind. The way back needs no memory: the slot that holds the link is the one being emptied, it stays the last child until the walk comes back up, and only then, after the link is taken out, is it removed. A child is removed only when it is a scalar or an empty container, so each removal destroys one level at most.

J. Validation

A faster path is only useful if it gives the same result as the slow one, including the same error at the same byte. These are the checks behind the themes:

  • Error positions (theme 1). The contiguous fast paths defer to the byte path on anything malformed. On 481,000 documents, 298,333 of them with errors, the contiguous and the byte path reported the same exception, message, byte, line and column, except for the one deliberate correction listed in Appendix F.
  • Stream windows (theme 1). A test streambuf hands out get areas of 1–4,096 bytes, so that every token crosses a window edge at every offset, and the result is compared with a streambuf without a get area, which takes the old path.
  • Differential fuzzing of the input kinds. Every fuzz input is also parsed from an std::istringstream, from std::list iterators (the byte path) and from a FILE*, and the value, the exception id, the message, and the byte offset must be identical (#5827).
  • Guard pages. The input is copied so that its last byte is the last readable byte of a page, and the next page is unmapped. AddressSanitizer misses an over-read into the slack of the same heap allocation, and fuzzers rarely end an input exactly at a page boundary; this test crashes on any over-read by one byte (#5826).
  • Number conversion (theme 3). 6.5 million float tokens were compared with strtod, among them 60,000 exact halfway cases, and 900,000 hard cases from other test suites: all bit-identical. json_document's batched float output is byte-identical to the general writer for 10⁹ generated tokens.
  • Unchanged output. For this post, we checked that 3.13.0 writes the same bytes as 3.12.0 in all five binary formats for 39 test files, and the same JSON text for twitter.json and citm_catalog.json. The changes of float output are listed in Appendix F.
  • json_document against json. A fuzzer parses every input with both and requires the same verdict, the same value and the same error message, from a borrowed string and from an exact-size buffer without a closing NUL (fuzzer-parse_json_view.cpp).
  • Comparison (theme 7). The single-pass operator< was compared with the old code on 68,121 pairs of values.
  • Mutation checks. Break a fast path on purpose (skip one byte too many, mark " as copyable) and make sure the tests notice. Marking the quote as copyable in the short-string table fails 4,551 assertions.
  • The comparison with other libraries. Before anything is timed, every way's result is checked: the same values, the same string bytes, the same sum of all numbers (to the last bit where they are added in the same order), and an output that reads back as the expected document. The expected values come from nlohmann/json; the Rust programs compute their own with serde_json, and the two agree (Appendix E).