json is a JSON library built on the same principles as styml, and aims at:
- simplicity, a small API surface and a strict RFC 8259 subset of behaviors
- efficiency, fast parsing and lean memory usage
- portability, copying a single header is enough (C++17, no external dependencies)
- user friendliness, with member order keeping and O(1) object access
The implementation deliberately trades generality for speed and simplicity:
- String views into the loaded buffer: the input text is copied once into an internal arena, and every escape-free string or number token is referenced in place as a view (offset + size). No per-value allocation, no per-string copy. Only strings containing escapes are unescaped (once, at parse time) into the arena.
- Arena allocator for modifications: setters append the new content at the end of the arena. The arena only grows, nothing is ever deallocated before the document itself. Heavily-rewritten documents thus consume more memory over time; the counterpart is very fast writes and a trivial memory lifecycle.
- Numbers are kept as text: a parsed number keeps its original token; the text-to-
doubleconversion happens in theasNumber()getter, and only there. Exact round-trip of untouched numbers is guaranteed, and unread numbers cost no conversion time. - No SIMD: the parser is a simple scalar one-pass scanner, as a trade-off with simplicity.
- No recursion: parsing, emission and cloning are fully iterative, so arbitrarily deep documents cannot overflow the call stack.
- Simple templates only: the API relies on plain overloads wherever possible, to keep compilation times low (see the compilation time measures).
- Strictness: duplicated object keys are rejected at parse time (the RFC leaves the behavior undefined; silent acceptance hides bugs).
- Copy
lib/json.hinto your project. No external dependencies. - Add a few lines of code, as in the example below
#include "json.h"
...
// Parse the input text string
json::Document root;
try {
root = json::parse(inputStringText);
} catch (json::ParseException& e) {
// e.what() contains the message and quotes the faulty line with its line number
}
// Read items. Getters succeed only on the matching JSON type, else an AccessException is thrown.
double size = root["build"]["font size"].asNumber(); // Number (integer or float) -> double
std::string_view name = root["build"]["font name"].asString(); // View into the document storage
bool bold = root["build"]["bold"].asBool(false); // With a default value if the key is absent
bool none = root["build"]["extra"].isNull();
// Write items
root["build"]["font size"] = size + 1.;
// Emit (strings are easily saved on disk)
std::string compact = root.asJson(); // Compact JSON
std::string indented = root.asJson(true); // Indented JSONThe library namespace is json by default. As this short name may conflict with other components,
it can be overridden by defining JSON_NAMESPACE before including the header:
#define JSON_NAMESPACE myjson
#include "json.h"
...
myjson::Document root = myjson::parse(text);A Document is simply a (root) Node with 2 additional features:
- it owns the JSON tree
- its destruction releases the document. All
Nodeobjects related to it are invalidated and shall no more be used. - as it owns its tree, a
Documentcannot be copied, only moved. To duplicate one, useclone()(available on anyNode, not just aDocument's root): it deep-copies the subtree into a brand new, fully independentDocument, without going through aasJson()+parse()round-trip.
- its destruction releases the document. All
- it owns the emission API
std::string asJson(bool withIndent = false) const
A Document can be created from scratch:
// {"preferences":{"font size":4,"font name":"Helvetica","names":["toto",14,true,null]}}
json::Document doc; // A default document is the JSON value 'null'
doc = json::OBJECT; // Choice is between OBJECT, ARRAY or any scalar value
doc["preferences"] = json::OBJECT;
doc["preferences"]["font size"] = 4;
doc["preferences"]["font name"] = "Helvetica";
doc["preferences"]["names"] = json::ARRAY;
doc["preferences"]["names"].push_back("toto");
doc["preferences"]["names"].push_back(14);
doc["preferences"]["names"].push_back(true);
doc["preferences"]["names"].push_back(nullptr);It can also be created from a JSON string in memory with one of the following functions:
// Canonical form
json::Document parse(const std::string& text);
// Variant with const char* input. It does not need to be zero terminated
json::Document parse(const char* text, uint32_t textSize);
// Variant with const char* input. It must be zero terminated
json::Document parse(const char* text);The main object is Node, which represents a typed item in the JSON tree.
It maps directly on the JSON types, plus the internal KEY type:
| Node type | Description | Example |
|---|---|---|
NodeType::NULLVALUE |
The JSON null value |
null |
NodeType::BOOLEAN |
The JSON true / false values, read with asBool() |
true |
NodeType::NUMBER |
A JSON number, converted to double by asNumber() |
-2.5e3 |
NodeType::STRING |
A JSON string, read as a view with asString() |
"text" |
NodeType::ARRAY |
An ordered list of children of any value type | [1, "a"] |
NodeType::OBJECT |
A list of children of type KEY, in insertion order, with O(1) access by name |
{"age": 25} |
NodeType::KEY |
An object member: a name and its value (keyName() / value()) |
"age": 25 |
The Node API is restricted depending on its type ("X" means accessible):
| Method | Null/Bool/Num/Str | Array | Object | Key |
|---|---|---|---|---|
NodeType type(), is<Type>() |
X | X | X | X |
int getOriginLineNbr() |
X | X | X | X |
Document clone() const |
X | X | X | X |
Node& operator=(value / NodeType) |
X | X | X | |
std::string_view keyName() |
X | |||
Node value() |
X | |||
double asNumber() / asNumber(dflt) |
X (Number) | |||
std::string_view asString() / (dflt) |
X (String) | |||
bool asBool() / asBool(dflt) |
X (Boolean) | |||
bool isNull() |
X | X | X | X |
iterator begin() / end() |
X | X | ||
size_t size() |
X | X | ||
Node operator[](uint32_t) |
X | |||
Node back() |
X | |||
Node push_back(value / NodeType) |
X | |||
Node insert(uint32_t, value / NodeType) |
X | |||
void remove(uint32_t) |
X | |||
void pop_back() |
X | |||
bool hasKey(std::string_view) |
X | |||
Node operator[](std::string_view) |
X | |||
Node insert(std::string_view, value / NodeType) |
X | |||
bool remove(std::string_view) |
X |
Notes:
asNumber()succeeds only if the value is a JSON number (integer or float) and returns adouble. The text conversion is done inside this getter.asString()andkeyName()returnstd::string_views pointing inside the document storage: they are valid until the document is destroyed or modified (a modification may relocate the arena). Copy them into astd::stringif they must outlive that.- Assigning to a non-existing object key auto-creates the member:
root["new key"] = 3.14; explicit operator bool()tells whether a node exists:if (root["maybe"]) {...}- Getters with a default value (e.g.
asNumber(3.14)) return the default when the key is absent, but still throw on a type mismatch. getOriginLineNbr()returns the 1-based line, in the originally parsed text, where a key, a scalar value, or a container's opening{/[was found. It returns0when there is no such line to report: for nodes created or overwritten through the mutation API, and forclone()d nodes. A document modified after parsing is not guaranteed to keep meaningful line numbers for the parts that changed.
Error handling is based on C++ exceptions rather than carrying an error context in each API:
- it enables bloat-free tree manipulation API like the
operator[]which is a natural access for containers - it allows a global handling of error for a whole section of JSON tree manipulation
They just contain a message (queried with standard what()) and can be of 2 kinds:
ParseExceptionraised only during parsing. The message highlights the line of the issue, with its line number and a copy of the line content.AccessExceptionraised when manipulating the tree (wrong type, out of bounds, missing key, non-representable value...).
Example of a ParseException message:
Parse error: ':' is expected after an object member key, got '4'
In line 2: " "key with issue" 42"
All measures below are done on a laptop (Intel Core Ultra 5 228V on Linux, "performance" power profile), single-threaded,
compiled with gcc in Release mode. Reproduce them with ./bin/json_formatter -n data/<file>.json
for the parsing/emission speeds and memory factor, and ./bin/json_unittest benchmark for the
access speeds (add -l for longer runs on larger working sets).
The measures are done on the 3 well-known JSON files (in data/) popularized by
nativejson-benchmark and used by many JSON
libraries for benchmarking. They are more representative than a synthetic document and cover very
different content profiles:
canada.json(2.2 MB): the GeoJSON contour of Canada, dominated by floating point numbers (arrays of coordinate pairs), with almost no whitespace (compact form is 1.0x the input)citm_catalog.json(1.7 MB): an indented event catalog, dominated by structure (many small objects, numeric ids, repeated keys) with some accented strings (compact form is 0.29x the input)twitter.json(0.6 MB): a Twitter API search result, dominated by strings (with non-ASCII content and escapes) in nested objects (compact form is 0.74x the input)
Speeds are expressed relative to the input file size, and are the median of 11 runs of
json_formatter -n; the memory factor is the resident memory used by the parsed document divided
by the input file size:
| File | Parse | Emit compact | Emit indented | Memory factor |
|---|---|---|---|---|
canada.json |
~380 MB/s | ~1030 MB/s | ~390 MB/s | 3.4x |
citm_catalog.json |
~445 MB/s | ~1650 MB/s | ~1130 MB/s | 2.4x |
twitter.json |
~335 MB/s | ~610 MB/s | ~530 MB/s | 2.7x |
Reading the profiles: twitter.json parses slowest per byte because its many escaped strings must
be unescaped into the arena; canada.json emits slowest in indented mode because its coordinate
arrays expand to 2.4x the input size (one number per line); citm_catalog.json shows the highest
compact-emission and lowest memory figures because compacting its indented input shrinks it to
0.29x. Untouched numbers are emitted as their stored text (no float formatting involved).
An additional benchmark on a synthetic document is available with json_unittest benchmark, for
internal regression testing (its composition is described in lib/ut/test_main.h).
Compiling a small program (parse + read + write + emit) including the header, with gcc 13.3, and compared with the heavily templated nlohmann/json 3.11.3 single header compiled on the same program (an empty program compiles in 0.03 s, as a baseline):
| Header | -O0 |
-O2 |
|---|---|---|
json.h |
~0.4 s | ~0.7 s |
nlohmann/json.hpp |
~1.0 s | ~1.6 s |
Both figures grow with the diversity of usage in the translation unit, but much faster for a templated API (each new value type instantiates new template code) than for this library, whose API is made of plain non-template overloads.
Programmatic access through the API, measured on 100 000 object keys and 1 000 000 array items:
| Operation | Speed |
|---|---|
| Object build (insert key+value) | ~8.8 M keys/s |
| Object read (by key) | ~21 M keys/s |
| Array build (push_back) | ~25 M items/s |
| Array read (by index) | ~52 M items/s |
Object access is O(1) thanks to an accelerating hashtable. Array
reads include the text-to-double conversion performed inside asNumber() (the "conversion in the
getter" trade-off).
The doctest-based test suite offers three kinds of tests:
./bin/json_unittest # sanity tests (default, fast)
./bin/json_unittest benchmark # performance measures (see above)
./bin/json_unittest stress # deterministic fuzzing-like torture tests
./bin/json_unittest stress -l # same, longerThe stress tests cover: random generated documents (parse + emission round-trip stability),
random byte mutations and truncations of valid documents (the parser shall either succeed or throw
a json::Exception, never crash), and long random sequences of API operations checked against a
shadow state.
For deeper exploration, a libFuzzer harness is provided in test/fuzz_parse.cpp, with seed inputs
in test/seeds; see test/FUZZING.md.
mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
make -j # builds json_unittest and json_formatter
make test # runs the sanity test suite
./bin/json_formatter file.json # pretty-prints a JSON file ('-c' for compact, '-n' for statistics)- The whole document (input text + modifications) is limited to 4 GB, and element count to 2^32.
- Object member removal swaps with the last member: removal perturbs the member order (a trade-off for O(1) removal with the index-based accelerating hashtable).
- The empty string is a valid JSON object key and parses fine, but it cannot be accessed through
operator[]/hasKey()/insert()/remove(). - Input text is expected to be UTF-8 (bytes are passed through unmodified;
\uXXXXescapes are decoded to UTF-8).
json source code is available under the MIT license.
Associated components:
- Hash function:
Wyhash- Selected for its good non-cryptographic properties, speed and small code size
- Released in the public domain
- Test framework:
doctest- Released under the MIT license