Skip to content

RapidJSON (C++)

The fast C++ JSON standard from before simdjson — a header-only, self-contained parser/generator offering a dual SAX (event) and DOM (tree) API, a template-specialized recursive-descent reader, destructive in-situ zero-copy parsing, and a pool allocator, with SIMD used only as a narrow optimization (whitespace skipping and string scanning), not as the parsing strategy.

FieldValue
LanguageC++ (header-only, no STL/BOOST dependency)
LicenseMIT (source); bin/jsonchecker/ alone is under the JSON license
RepositoryTencent/rapidjson
Documentationrapidjson.org · doc/sax.md · doc/dom.md · doc/internals.md
Key authorsMilo Yip (miloyip) et al.; © 2015 THL A29 Limited, a Tencent company, and Milo Yip
CategoryHigh-performance JSON (C++, partial SIMD)
Algorithm / grammar classRecursive-descent DOM builder + SAX event parser; strict RFC 7159 / ECMA-404 JSON (optional relaxed modes)
Lexing modelScannerless, character-at-a-time recursive descent; SIMD accelerates whitespace-skip and string-scan only
Latest releasev1.1.0 (2016-08-25)

NOTE

RapidJSON is a single-grammar parser, like simdjson — not a generator or combinator toolkit. Its place in this survey is as the reference point the SIMD generation measured itself against: simdjson's paper reports parsing "4x faster than RapidJSON" and using "a quarter or fewer instructions than a state-of-the-art reference parser like RapidJSON" (simdjson). Reading the two back to back is the sharpest available before/after picture of what whole-input SIMD changed. It shares the fast-JSON category with simd-json, sonic-rs, and yyjson.


Overview

What it solves

RapidJSON is "a fast JSON parser/generator for C++ with both SAX/DOM style API" (readme.md). It set the practical performance bar for C++ JSON in the mid-2010s by pairing a tightly template-specialized recursive-descent parser with aggressive memory discipline. The readme.md summarizes the four pillars:

"RapidJSON is small but complete. It supports both SAX and DOM style API. … RapidJSON is fast. Its performance can be comparable to strlen(). It also optionally supports SSE2/SSE4.2 for acceleration. … RapidJSON is self-contained and header-only. It does not depend on external libraries such as BOOST. It even does not depend on STL. … RapidJSON is memory-friendly. Each JSON value occupies exactly 16 bytes for most 32/64-bit machines (excluding text string)."readme.md

It targets strict compliance — "in full compliance with RFC7159/ECMA-404, with optional support of relaxed syntax" (readme.md) — with full Unicode: "Unicode-friendly. It supports UTF-8, UTF-16, UTF-32 (LE & BE), and their detection, validation and transcoding internally" (readme.md).

Design philosophy

Three ideas run through the codebase:

  1. Static binding over virtual dispatch. The SAX Reader and its user Handler are bound at compile time through templates, so event calls inline: "RapidJSON uses templates to statically bind the Reader type and the handler type, instead of using classes with virtual functions. This paradigm can improve performance by inlining functions" (doc/sax.md). Parse flags are non-type template parameters, so "C++ compiler can generate code which is optimized for specified combinations, improving speed, and reducing code size" (doc/dom.md).

  2. SAX is the substrate; DOM is built on it. The DOM Document is itself a SAX Handler: "The DOM style API (rapidjson::GenericDocument) is actually implemented with SAX style API (rapidjson::GenericReader). SAX is faster but sometimes DOM is easier" (doc/features.md).

  3. Never copy what you can point at. In-situ parsing decodes strings in place, short-string optimization inlines small strings into the Value, and the default MemoryPoolAllocator bump-allocates and never frees individually. The goal throughout is cache coherence and minimal allocation.

RapidJSON is deliberately lean: it compiles "Without C++ exception, RTTI" and includes only <cstdio>, <cstdlib>, <cstring>, <inttypes.h>, <new>, <stdint.h> (doc/features.md).


How it works

The SAX core — GenericReaderHandler

Reader (a typedef of GenericReader) "parses a JSON from a stream. While it reads characters from the stream, it analyzes the characters according to the syntax of JSON, and publishes events to a handler" (doc/sax.md). The handler is a concept — any type exposing the fourteen event methods:

cpp
concept Handler {
    bool Null();
    bool Bool(bool b);
    bool Int(int i);   bool Uint(unsigned i);
    bool Int64(int64_t i);  bool Uint64(uint64_t i);
    bool Double(double d);
    bool RawNumber(const Ch* str, SizeType length, bool copy);
    bool String(const Ch* str, SizeType length, bool copy);
    bool StartObject();  bool Key(const Ch* str, SizeType length, bool copy);
    bool EndObject(SizeType memberCount);
    bool StartArray();   bool EndArray(SizeType elementCount);
};

(include/rapidjson/reader.h) On a number the reader "chooses a suitable C++ type mapping" and calls exactly one of Int/Uint/Int64/Uint64/Double (doc/sax.md). Every event returns bool: "If the handler encounters an error, it can return false to notify the event publisher to stop further processing" — placing the reader in an error state with code kParseErrorTermination (doc/sax.md). Because Reader, Writer (the SAX generator), and Document all speak the same event vocabulary and none depend on the others, they chain freely: piping a Reader straight into a Writer removes whitespace (condense), into a PrettyWriter reformats (pretty), and an intermediate filter can transform events on the fly (capitalize) (doc/sax.md, doc/internals.md).

The DOM layer — GenericDocument and GenericValue

Value (= GenericValue<UTF8<>>) and Document (= GenericDocument<UTF8<>>) are typedefs of templates parameterized on Encoding and Allocator (doc/dom.md). Document is a Handler: "Document is a handler which receives events from a reader to build a DOM during parsing" (doc/sax.md). The inverse direction is Value::Accept(Handler&), which "is responsible for publishing SAX events about the value to the handler" (doc/dom.md) — this is why stringifying a DOM is written d.Accept(writer).

Value is a variant packed into 16 bytes. From doc/internals.md:

_"Value is a [variant type]. In RapidJSON's context, an instance of Value can contain 1 of 6 JSON value types. This is possible by using union. Each Value contains two members: union Data data_and aunsigned flags*. The flags* indicates the JSON type, and also additional information."_

The flags_ word carries both a sequential type tag and redundant capability bits (kIntFlag, kUintFlag, kInt64Flag, kDoubleFlag, …) so that IsNumber() is a single bit-test and integers auto-widen: "An Int is always an Int64, but the converse is not always true" (doc/internals.md).

Short-string optimization

Small strings live inside the 16-byte Value, avoiding a heap allocation and a pointer chase (doc/internals.md):

_"Excluding the flags_, a Valuehas 12 or 16 bytes (32-bit or 64-bit) for storing actual data. Instead of storing a pointer to a string, it is possible to store short strings in these space internally. For encoding with 1-byte character type (e.g.char), it can store maximum 11 or 15 characters string inside the Value type."_

A neat trick stores MaxChars - length as the in-band length byte "to store 11 characters with trailing \0" and improves cache coherence (doc/internals.md).

In-situ parsing — destructive zero-copy

The signature RapidJSON idea. ParseInsitu(Ch* str) "decodes those JSON string at the place where it is stored. It is possible in JSON because the length of decoded string is always shorter than or equal to the one in JSON" (doc/dom.md). Decoding an escape (\n, s) only ever shrinks the text, so the result fits over the original bytes; an escape-free string like "msg" is handled by simply overwriting its closing quote with '\0' (doc/dom.md). The DOM's string Values then point directly into the caller's mutated buffer — the String() event fires with copy = false (doc/sax.md). Formally it is an O(1) auxiliary space algorithm (doc/dom.md):

"In situ parsing minimizes allocation overheads and memory copying. Generally this improves cache coherence, which is an important factor of performance in modern computer."

The cost is a sharp usage contract: the API takes char* not const char*, the whole JSON must be in memory, source and target encodings must match, and "The buffer need to be retained until the document is no longer used" (doc/dom.md) — dangling pointers otherwise. It suits "short-term JSON that only need to be processed once, and then be released" — deserializing to C++ objects, handling web requests (doc/dom.md).

MemoryPoolAllocator — bump-allocate, never free

The default DOM allocator "allocate but do not free memory. This is suitable for building a DOM tree" (doc/internals.md). Internally it "allocates chunks of memory from the base allocator (by default CrtAllocator) and stores the chunks as a singly linked list," serving requests from (1) an optional user-supplied buffer, then (2) the current chunk, then (3) a freshly allocated chunk (doc/internals.md). A user buffer — stack array or static scratch — can make a parse allocation-free entirely: "If the total size of allocation is less than 4096+1024 bytes during parsing, this code does not invoke any heap allocation … at all" (doc/dom.md). The alternative CrtAllocator wraps malloc/realloc/free and is "far less efficient" but supports piecemeal deallocation (doc/dom.md).

The one place SIMD lives — SkipWhitespace_SIMD

This is the crux of RapidJSON's contrast with simdjson: SIMD is a peephole optimization, not the parser. It accelerates whitespace skipping (and, similarly, string-content scanning), while the parse itself stays scalar and character-at-a-time. From doc/internals.md:

"To accelerate this process, SIMD was applied to compare 16 characters with 4 white spaces for each iteration. Currently RapidJSON supports SSE2, SSE4.2 and ARM Neon instructions for this. And it is only activated for UTF-8 memory streams, including string stream or in situ parsing."

The SSE4.2 path uses one _mm_cmpistri (pcmpistrm) per 16 bytes against the 4-char whitespace set, returning the index of the first non-whitespace (include/rapidjson/reader.h):

cpp
static const char whitespace[16] = " \n\r\t";
const __m128i w = _mm_loadu_si128(reinterpret_cast<const __m128i *>(&whitespace[0]));
for (;; p += 16) {
    const __m128i s = _mm_load_si128(reinterpret_cast<const __m128i *>(p));
    const int r = _mm_cmpistri(w, s, _SIDD_UBYTE_OPS | _SIDD_CMP_EQUAL_ANY | _SIDD_LEAST_SIGNIFICANT | _SIDD_NEGATIVE_POLARITY);
    if (r != 16)    // some of characters is non-whitespace
        return p + r;
}

The SSE2 fallback does four _mm_cmpeq_epi8 compares OR'd together, then _mm_movemask_epi8 + a bit-scan for the first mismatch; a NEON variant mirrors it (include/rapidjson/reader.h). The same _mm_cmpeq_epi8/_mm_max_epu8 pattern scans string bodies for ", \, and control bytes (< 0x20) during ParseStringToStream (include/rapidjson/reader.h). All of this is compile-time gated by RAPIDJSON_SSE2 / RAPIDJSON_SSE42 / RAPIDJSON_NEON: "these are compile-time settings. Running the executable on a machine without such instruction set support will make it crash" (doc/internals.md) — there is no runtime CPU dispatch, unlike simdjson. A historical page-boundary bug (a _mm_loadu_si128 reading past '\0' across a protected page, crashing ~1 in 500,000) was fixed by first advancing to the next aligned address, then using aligned reads (doc/internals.md).

Number parsing

By default numbers use internal::StrtodNormalPrecision(), which "has maximum 3 ULP error" and is fast (doc/internals.md). kParseFullPrecisionFlag switches to internal::StrtodFullPrecision(), which tries three methods in order — a decimal fast-path, a DIY-FP implementation (as in Google's double-conversion), and Clinger's Big-Integer method — falling through on failure (doc/internals.md). Generation is symmetric: a header-only Grisu2 for double-to-string ("always accurate … in most of cases it produces the shortest (optimal) string representation") and branchlut for integer-to-string (doc/internals.md).

Encodings & transcoding

GenericReader<SourceEncoding, TargetEncoding, Allocator> decouples the stream encoding from the emitted-string encoding, so a UTF-8 stream can produce UTF-16 String() events (doc/sax.md). During transcoding "the source string is decoded into Unicode code points, and then the code points are encoded in the target format," validating the byte sequence and failing with kParseErrorStringInvalidEncoding on a bad sequence (doc/dom.md). When source and target encodings match, validation is skipped unless kParseValidateEncodingFlag is set (doc/dom.md).


Algorithm & grammar class

RapidJSON parses exactly one grammar — strict JSON (RFC 7159 / ECMA-404), with optional relaxed extensions (comments, trailing commas, NaN/Inf), and offers two engines for it:

  • Recursive-descent (default). Parse dispatches through ParseValueParseObject / ParseArray / ParseString / ParseNumber, recursing on the C++ call stack. "Recursive parser is faster but prone to stack overflow in extreme cases" (doc/features.md).
  • Iterative (kParseIterativeFlag). "The iterative parser is a recursive descent LL(1) parser implemented in a non-recursive manner" (doc/internals.md). Left-factoring the values/members productions makes the grammar LL(1); the FIRST/FOLLOW parsing table is then "encoded in a state machine" with extra states for array/object element counting, giving "constant complexity in terms of function call stack size" (include/rapidjson/reader.h, doc/internals.md).

Ambiguity does not arise — JSON is LL(1) with single-token lookahead, exactly the property the iterative parser's table exploits (see top-down / recursive descent and formal languages). This is the character-at-a-time recursive-descent end of the design space — the antithesis of simdjson's whole-input SIMD structural indexing.

Interface & composition model

There is no grammar DSL or combinator — the surface is JSON-in, events-or-tree-out — but two composition axes stand out:

APIShapeWhen
SAXGenericReader → user Handler; Writer/PrettyWriter consume the same eventsStreaming, filtering, custom in-memory structures, min RAM
DOMGenericDocument builds a GenericValue tree; Value::Accept re-emits eventsRandom access, mutation, re-serialization

The Handler concept is the composition primitive: because Reader, Writer, Document, and any user filter all implement it, they form pipelines (Reader → Filter → Writer) with no shared base class (doc/internals.md). The DOM tree is built fully (unlike simdjson's lazy On Demand), but a SAX handler can build a custom, smaller structure — the messagereader example populates a std::map directly, "eliminat[ing] building of DOM, thus reducing memory and improving performance" (doc/sax.md).

Performance

Performance is the reason RapidJSON existed; the concrete posture:

  • Throughput. README claims performance "comparable to strlen()" (readme.md); it long topped the nativejson-benchmark collection. Relative to the SIMD generation it is now the baseline: simdjson reports ~4× higher throughput and, per its paper, RapidJSON at 18.7 instructions/byte vs simdjson's 8.3 (simdjson).
  • In-situ zero-copy. No string allocation and no copy for escape-free strings; O(1) auxiliary memory (doc/dom.md).
  • Allocation. Bump-pointer MemoryPoolAllocator with optional user buffer → parses with zero heap allocation when the buffer suffices (doc/dom.md).
  • SIMD. Narrow — whitespace skip and string-body scan only, 16 bytes/iteration, compile-time-gated, no runtime dispatch (doc/internals.md). The parse loop itself is scalar.
  • Value footprint. Exactly 16 bytes per Value on most machines; short strings inlined (readme.md, doc/internals.md).
  • Backtracking / memoization. None — LL(1) recursive descent with single-token lookahead, one forward pass.

Error handling & recovery

RapidJSON is a strict, fail-fast validator — no recovery, no resynchronization. On the first violation it stops and records a ParseErrorCode plus a character offset: the DOM has HasParseError(), GetParseError(), and GetErrorOffset(), and "When there is an error, the original DOM is unchanged" (doc/dom.md). The error enum is granular — kParseErrorObjectMissColon, kParseErrorStringUnicodeSurrogateInvalid, kParseErrorNumberTooBig, kParseErrorTermination, and a dozen more (doc/dom.md) — and rapidjson/error/en.h maps codes to English messages, with localization left to the user. The offset is a raw character count: "Currently RapidJSON does not keep track of line number" (doc/dom.md). A handler returning false aborts the parse with kParseErrorTermination, which is how streaming validators reject early (doc/sax.md). There is no incremental reparse and no IDE-grade diagnostics — for that tolerant, error-recovering end of the space, see tree-sitter via the comparison. Multiple concatenated JSON documents in one stream are supported via kParseStopWhenDoneFlag (doc/dom.md).

Ecosystem & maturity

RapidJSON is a mature, widely-vendored Tencent open-source project (© 2015 THL A29 Limited, a Tencent company, and Milo Yip). Distribution is frictionless — header-only, "Just copy the include/rapidjson folder" (readme.md) — with vcpkg and CMake find_package(RapidJSON) integration. It ships a broad example set (DOM tutorial; SAX simplereader, condense, pretty, capitalize, messagereader, serialize, jsonx; schemavalidator; and advanced prettyauto, parsebyparts, filterkey) and a googletest-based unit + performance test suite (readme.md). Standards coverage beyond core JSON includes JSON Pointer (RFC 6901), JSON Schema Draft v4, Swagger v2 / OpenAPI v3.0 schema, and NPM compliance (doc/features.md). The v1.1.0 release (2016-08-25) added JSON Pointer, JSON Schema, relaxed syntax, C++11 range-based iteration, and shrank Value from 24 to 16 bytes on x86-64 (readme.md).

NOTE

The pinned tree carries a version discrepancy worth flagging: the readme.md claims "full compliance with RFC7159/ECMA-404," while doc/features.md says "fully RFC4627/ECMA-404 compliance." RFC 7159 obsoletes RFC 4627; the README is the newer, authoritative statement.


Strengths

  • Battle-tested, header-only, dependency-free — no STL/BOOST, no exceptions/RTTI, trivial to vendor.
  • Dual SAX + DOM on one event vocabulary — stream, filter, or build a tree; pipelines compose without a shared base class.
  • In-situ destructive parsing — O(1) auxiliary memory, no string copies, cache-friendly for parse-once workloads.
  • Aggressive memory discipline — 16-byte Value, short-string inlining, bump-pointer pool allocator, optional zero-heap parsing via a user buffer.
  • Full Unicode — UTF-8/16/32 (LE & BE), detection, validation, and transcoding between stream and DOM encodings.
  • Compile-time specialization — template-bound handler + non-type parse-flag template parameters inline the hot path.
  • Accurate numbers on demand — Grisu2 output, optional full-precision (correctly-rounded) input parsing.

Weaknesses

  • One grammar only — not a parsing toolkit; you cannot express another language. (By design.)
  • SIMD is narrow and static — only whitespace/string scanning is vectorized, and the ISA is chosen at compile time with no runtime dispatch; a binary built for SSE4.2 crashes on a CPU without it. This is precisely the ceiling simdjson broke by making SIMD the whole parse.
  • No error recovery, no incremental reparse — first-error-and-stop; offsets are character counts with no line numbers; wrong tool for an editor or LSP.
  • In-situ's contract is sharp — mutates the caller's buffer, needs char*, source/target encodings must match, and the buffer must outlive the DOM.
  • Recursive parser can stack-overflow on deeply nested input unless you opt into the iterative LL(1) engine.
  • MemoryPoolAllocator never frees individually — great for parse-then-discard, wasteful for long-lived, heavily-mutated DOMs (use CrtAllocator there).

Key design decisions and trade-offs

DecisionRationaleTrade-off
SAX as substrate, DOM as a Handler on topOne event model powers streaming, filtering, tree-building, and re-serializationDOM users pay for a generic event interface; SAX is faster but forces manual state-keeping
Template/static binding of reader + handlerEvents inline; parse flags as template params specialize the hot pathFlags must be compile-time constants; more instantiations / code bloat
In-situ destructive parsing (ParseInsitu)O(1) extra memory, no string copies, cache-friendlyMutates and pins the input buffer; char* API; encodings must match
16-byte Value variant + short-string inliningSmall footprint, fewer allocations and pointer chases, better cache behavior11/15-char inline cap; bit-packed flags_ is intricate
MemoryPoolAllocator (bump, no per-object free)Fastest possible allocation for build-once DOMs; optional user buffer → zero heapCannot reclaim individual nodes; unsuited to churn-heavy DOMs
SIMD only for whitespace/string scan, compile-time ISACheap, targeted win on a measured hot spot without rewriting the parserNo runtime dispatch (mis-targeted binary crashes); leaves the scalar parse as the bottleneck
Recursive descent by default, iterative LL(1) opt-inRecursion is fastest; iterative bounds stack for adversarial nestingDefault risks stack overflow; iterative table/state-machine is more complex
Strict validation, first-error-stop, char-offset diagnosticsKeeps the hot path simple and fast; rejects malformed input outrightNo recovery, no line numbers, no incremental reparse — useless for editors/LSPs
Normal-precision floats by default, full-precision opt-inFast common path (≤ 3 ULP); correctness on demandDefault is not correctly-rounded; full precision is slower

Sources