Skip to content

Zero-copy JSON views

<nlohmann/json_view.hpp> adds a read-only, non-owning way to look at a parsed JSON text, as an alternative to building a basic_json tree with parse().

The problem

basic_json::parse() builds a tree of basic_json values: one allocation for every array and object, and every string copied into its own std::string. That is the right trade-off when the program goes on to read and write the value freely, but it does more work than necessary when only a small part of a large JSON text is actually needed, or when the same text is parsed over and over (many small messages, for instance) and most of the resulting tree is thrown away almost immediately.

The idea

basic_json_document::parse() parses the same JSON grammar, with the same options, but instead of a tree it builds a flat index of the values it found: one 16-byte entry per value (and one per object key), in document order. Integers are converted while parsing and stored in their entry. Floating-point numbers and strings are referenced in place in the source text: a float is converted when it is read, and only strings that contain escape sequences are decoded, into one shared buffer owned by the document.

basic_json_view is a small, trivially copyable handle (two pointers) into that index. It gives you the read-only, type-inspection part of the basic_json interface — type() and the is_*() predicates, size()/empty() — as well as element access (operator[], at, front/back), lookup (find, contains, count), and iteration (begin/end, items) — without ever allocating a basic_json value. When you do need an actual basic_json value for a subtree, materialize() builds exactly the one parse() would have produced for it.

The same small text, parsed both ways:

The same J​SON text parsed by basic_json::parse() and by json_document::parse() The text {"port": 6379, "price": 19.90, "tags": ["eu", "a\"b"]}. Top: basic_json::parse() builds a tree: 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. Bottom: json_document::parse() 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. 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 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

Top: the tree of separate heap allocations that basic_json::parse() builds. Bottom: json_document::parse() keeps the source text and builds one flat index of 16-byte nodes that point back into it, plus a buffer of decoded strings.

How to use it

Include <nlohmann/json_view.hpp> in addition to (or instead of) <nlohmann/json.hpp>. Parse into a json_document, inspect its root(), and materialize() when you need a real value. materialize() works on any view, not just the root, so you can turn just the subtree you need into a value:

Example: parse a document, inspect its root, and materialize it
#include <iostream>
#include <nlohmann/json_view.hpp>

using nlohmann::json_document;

int main()
{
    std::cout << std::boolalpha;

    // json_document is basic_json_document<nlohmann::json>: same value types
    // and containers as the ordinary json specialization
    json_document doc = json_document::parse(R"({"pi": 3.14, "numbers": [1, 2, 3]})");

    std::cout << doc.root().is_object() << '\n';
    std::cout << doc.root().materialize().dump() << '\n';
}

Output:

true
{"numbers":[1,2,3],"pi":3.14}

ordered_json_document is the equivalent for ordered_json, just as ordered_json is to json.

Ownership and lifetime

A document either borrows the text it was parsed from, or owns its own copy of it; call owns_source() to find out which happened. parse() decides this from the value category and type of its argument (an lvalue std::string is borrowed; an rvalue std::string is moved in, owned without a copy; a stream is read into an owned buffer; and so on — see parse's Notes for the full table). parse_copy() always owns a copy, regardless of the input.

A borrowed document depends on your buffer

If a document borrows its text, that text must outlive the document (and every view taken from it) and must stay unmodified for as long as the document exists, since floating-point values and unescaped strings are read from it lazily. Reading or writing through a view after the underlying buffer is gone is undefined behavior, exactly as it would be for a dangling std::string_view.

A view is valid only while all of the following hold:

  • the document is alive,
  • the document has not been re-parsed since the view was taken (with read() or parse() into it),
  • the document has not had shrink_to_fit() called on it since, and
  • if the document borrows its source text, that text is still alive and unmodified.

Moving the basic_json_document<> itself is fine and does not invalidate its views: the index is a separate heap allocation that keeps its address across the move. Take a fresh view from root() whenever any of the other conditions above was not met.

When a json_view becomes invalid Four timelines. First: a document borrows a std::string; when the string is destroyed, the document and every view taken from it dangle. Second: doc.read() re-parses the document; views taken before are invalid, a fresh view from root() is valid. Third: doc.shrink_to_fit() has the same effect. Fourth: moving the document does not invalidate its views; destroying the moved-to document does. alive / valid dangling: using it is undefined behavior time → The borrowed text is destroyed std::string text json_document doc json_view v text destroyed every view, and the document itself The document is re-parsed json_document doc json_view v v = doc.root() doc.read(other) views taken before a fresh view is valid again The document is shrunk json_document doc json_view v v = doc.root() doc.shrink_to_fit() views taken before a fresh view is valid again The document is moved, then destroyed json_document doc moved = std::move(doc) json_view v std::move: views stay valid moved is destroyed

Four timelines of a document and its views, each bar showing when an object is alive or valid and when it dangles. Destroying the borrowed text, re-parsing, or shrinking the document invalidates the views taken before; moving the document does not.

A view can dangle, exactly like a std::string_view: nothing stops you from using it after its document is gone. This is deliberate. A view is two pointers and trivially copyable; making it safe (a std::shared_ptr in the document and a std::weak_ptr in each view) would make it larger, add reference counting to every copy, and a lock() to every access — exactly the costs the view exists to avoid. What is caught is the obvious case: because a view dies with its document, root() is not callable on a temporary document: auto v = json_document::parse(text).root(); does not compile. Give the document a name first.

Example: borrowed and owned documents, and when views become invalid
#include <iostream>
#include <string>
#include <utility>
#include <nlohmann/json_view.hpp>

using nlohmann::json_document;

int main()
{
    std::cout << std::boolalpha;

    // BORROWED: doc only points into `text`; `text` must outlive `doc`
    std::string text = R"({"a": 1})";
    json_document doc = json_document::parse(text);
    std::cout << doc.owns_source() << '\n'; // false

    // a view is valid as long as the document is alive, has not been
    // re-parsed (read()) or shrunk (shrink_to_fit()), and -- if borrowed --
    // the source text is alive
    nlohmann::json_view v = doc.root();
    std::cout << v.is_object() << '\n';

    // re-parsing the SAME document invalidates views taken before the call;
    // `v` above must not be used after this line
    doc.read(R"([1, 2, 3])");
    v = doc.root(); // take a fresh view instead
    std::cout << v.is_array() << '\n';

    // moving the document does not invalidate views: the node index is
    // heap-allocated and does not move with the document object
    json_document moved = std::move(doc);
    std::cout << v.is_array() << '\n';
}

Output:

false
true
true
true

What is the same as parse()

  • Accept/reject. accept() and parse() accept and reject exactly the same inputs as basic_json::accept()/basic_json::parse(), with the same ignore_comments and ignore_trailing_commas options.
  • Errors. A failing parse throws the same exception — the same id, message, and position — because on failure the library's own parser is run on the same bytes to produce the diagnostic. Valid input never pays for this, and the replay builds no values: it costs about a lexing pass up to the error. allow_exceptions == false skips the replay and gives a discarded document instead of throwing, just as it gives a discarded value for basic_json::parse().
  • Number classification. An integer literal that does not fit into the 64-bit integer type becomes a floating-point value, exactly as it does for basic_json::parse().
  • Macros. JSON_STRICT_NUL_HANDLING and JSON_NOEXCEPTION/JSON_THROW_USER behave the same way they do for <nlohmann/json.hpp>. These are the macros that change what the parser accepts or how it reports errors. The others either do not apply (JSON_DIAGNOSTICS needs parent pointers in basic_json values) or only affect the values materialize() produces, through BasicJsonType.

What is different

  • Only 64-bit integers. basic_json_document<BasicJsonType> requires BasicJsonType::number_integer_t and number_unsigned_t to both be 64 bits wide; this is a compile-time static_assert.
  • A 4 GiB input limit. An input of 4294967280 bytes (4 GiB minus 16 bytes) or more throws out_of_range.416, a limit basic_json::parse() does not have. It keeps an index entry at 16 bytes: offsets and counts are 32 bits wide. The limit is 16 bytes below 2^32, because the parser reads in 16-byte steps, and a position one step past the end must still fit.
  • A stream is always read to its end. There is no partial/streaming read of an std::istream: the stream is read to its end into a buffer the document owns, and parsing starts after that.
  • No source positions on materialize(). Even with JSON_DIAGNOSTIC_POSITIONS enabled, materialize() does not set them. The index has the start offset of every value, but not the end of arrays, objects, and escaped strings, which the positions also need.
  • Objects iterate in document order. begin()/ end() and items() visit an object's members in the order they appear in the source text. basic_json's default object_t is a std::map, which sorts by key, so iterating a materialize()d value can print members in a different order than iterating the view they came from.
  • Chained access is safe. operator[] with a missing key, an index out of range, or an unresolvable JSON pointer returns a discarded view, and operator[] on a discarded view returns a discarded view without throwing: v["a"]["b"][0] can be tested once at the end. Type errors on values that exist (a key on an array, an index on an object) still throw, and at throws for every missing value.
  • Duplicate keys: lookups find the first member, parse() keeps the last. See Duplicate keys below.
  • No JSON_DIAGNOSTICS path. Exceptions thrown by basic_json_view's own element access and lookup functions never carry the JSON Pointer path JSON_DIAGNOSTICS would otherwise add: the view has no basic_json value to point at, so the exception is created without one, regardless of how BasicJsonType was built.
  • Ordering comparisons are not provided by basic_json_view — there is no operator<. operator== and operator!= are provided, though: two views, or a view and a BasicJsonType value, compare equal exactly when materialize() or parse() would produce equal values for them, without ever building a tree to do it. For ordering, too, materialize() is the way to get a value you can compare.

Duplicate keys

A lookup in a view and in the parsed value can give different answers

If an object in the source text repeats a key, operator[], at, find, value, contains, and JSON pointer resolution all return the first member with that key. basic_json::parse() instead keeps the last value of a repeated key, so for {"a":1,"a":2}, view["a"] is 1 while parse(text)["a"] is 2. count counts every member with the key, so it is 2 here.

begin()/end() and items() visit every occurrence, in document order, and size() counts all of them, so view.size() can be larger than view.materialize().size().

Why the first? A lookup can stop as soon as it finds a match. Returning the last member would force every lookup to scan all members of the object, even when the key is found at the very first one: this made lookups in small objects 1.6 to 3.4 times slower. The hash index of the keys of objects with 128 members or more leads to the first member of a key as well. Other zero-copy parsers that index the source text, such as yyjson and simdjson, also return the first member. RFC 8259 only says that names within an object SHOULD be unique and that the behavior of a receiver that sees duplicates is unpredictable, so neither choice is wrong.

The same holds for get<T>() of a type whose from_json() reads the view, such as the types of the NLOHMANN_DEFINE_TYPE_* macros: their members are looked up, so get<point>() of {"x":1,"x":2} reads x == 1, while materialize().get<point>() reads 2.

What stays the same as parse()? materialize() and get<std::map<...>>() replay every member in order, so they keep the last value exactly like basic_json::parse() (at the position of the first occurrence of the key, for an ordered_json).

How do I get the value parse() would give? Either call view.materialize() and look the key up in the result, or iterate the members and keep the last match:

// the last member with the key "a", as parse() would keep it
json_view last;
for (const auto item : view.items())
{
    if (item.key() == "a")
    {
        last = item.value();
    }
}

If you cannot trust the source text to have unique keys, check size() against the number of distinct keys, or reject duplicates when iterating.

Getting values out without copying

get<T>() converts most T directly from the flat index, without ever building a basic_json value for the conversion: bool, arithmetic types, std::nullptr_t, std::string/other std::basic_strings (copied once), basic_json_view itself, the standard containers and other types the library converts (std::vector, std::map, std::list, std::array, std::pair, std::tuple, std::optional, ...), and the types of the NLOHMANN_DEFINE_TYPE_* and NLOHMANN_JSON_SERIALIZE_ENUM macros. Types whose from_json() is written for basic_json only go through materialize().get<T>() instead: the subtree is built into a real basic_json value first, exactly as parse() would, and converted from there. See Writing generic code for how to write a from_json() that reads views.

Two conversions never copy at all:

  • get_string() (equivalently, get<string_view_t>()) returns a string as a string_view_t pointing into the document's source() text — or, for a string that contains escape sequences, into the document's own buffer of decoded strings — instead of allocating a new std::string.
  • number_token() returns a number exactly as it was written in the source, e.g. "1.50", "1E2", or an integer with more digits than any number type holds, instead of rounding it into a double/int64_t the way get<T>() (and basic_json::parse()) would.

Both results are only valid as long as the view — and, for a string with no escapes, the borrowed source text — is.

Writing generic code

A view's type names its BasicJsonType and its view_mode: json_view is basic_json_view<json, view_mode::read_only>, and json_editable_view is basic_json_view<json, view_mode::editable>. These are different types. The only conversion between them turns a view of a read-only document into a view of an editable document of the same BasicJsonType, so a function written for one shortcut accepts few other views:

void print_names(json_view v);           // accepts neither an ordered_json_view nor a json_editable_view
void print_names(json_editable_view v);  // also accepts a json_view, but no ordered_json_view

This is the same pitfall as a to_json/from_json written for json instead of any BasicJsonType, which then does not work for ordered_json. The shortcuts are for declaring documents and views; a function that takes a view names the views it accepts with a template:

// views of editable documents only
template<typename BasicJsonType>
void f(basic_json_view<BasicJsonType, view_mode::editable> v);

// views of read-only documents only
template<typename BasicJsonType>
void f(basic_json_view<BasicJsonType, view_mode::read_only> v);

// views of either document type
template<typename BasicJsonType, view_mode Mode>
void f(basic_json_view<BasicJsonType, Mode> v);

Views are passed by value: they are two pointers, cheap to copy, like a std::string_view. A const& works as well, but a non-const reference does not bind to the views that root(), operator[], and at() return, so f(doc.root()) would not compile.

There is no conversion from an editable view to a read-only one, by design: the mode decides how a view walks the index. A read-only view reads a container in place, while a view of an editable document follows the links that edits leave behind, so a read-only view of an edited document would read stale values. The other direction is safe: a read-only document has no edits, so a view of it converts to an editable view, which reads it like an editable document before its first edit. A template deduces the mode without conversions, so the template forms above still accept only the modes they name. Views of both modes compare with operator==.

is_json_view<T> tells views from other types, for constraints that cannot be written as a basic_json_view<...> parameter.

A from_json() that reads views

get<T>() on a view calls T's from_json() with the view itself if that from_json() accepts views, and with materialize() otherwise. The from_json() functions of the NLOHMANN_DEFINE_TYPE_*, NLOHMANN_DEFINE_DERIVED_TYPE_*, and NLOHMANN_JSON_SERIALIZE_ENUM macros, and those of the library for the standard types, accept views, so these types need nothing else. For decoding the statuses of twitter.json into structs, reading the view is about 5 times faster than materialize().get<T>().

A hand-written from_json() reads views if it is constrained to json values and views:

template < typename J, typename std::enable_if <
               nlohmann::is_json_view<J>::value || std::is_same<J, nlohmann::json>::value, int >::type = 0 >
void from_json(const J& j, person& p)
{
    p.name = j.at("name").template get<std::string>();
    p.age = j.at("age").template get<int>();
}

Its body may only use what views and basic_json have in common: the type checks, at(), operator[], contains(), value(), size(), iteration and items(), and get<T>()/get_to(). An unconstrained template<typename BasicJsonType> void from_json(const BasicJsonType& j, person& p), as in arbitrary type conversions, is never called with a view, as it might use basic_json functions a view does not have; such a type is converted through materialize(). A from_json() for json only is converted through materialize() as well.

Two things differ when a from_json() reads the view:

  • Duplicate keys. Members looked up by name (at(), value(), ... and thus the macros) find the first member with a repeated key, see Duplicate keys; materialize().get<T>() would see the last one. Containers built from an object, such as std::map, keep the last value either way, as parse() does.
  • JSON_DIAGNOSTICS. Exceptions thrown by the view carry no path to the value.

to_json() is unaffected: it writes basic_json values, never views.

Writing a view back

dump() serializes a view directly from the flat index, without ever building a basic_json value. An object's members are written in document order, not sorted by key, and every occurrence of a repeated key is written, not only the last one — the same two ways iteration already differs from a materialize()d value, see Duplicate keys. materialize().dump() gives a different result in both respects for a json_view.

By default, numbers are written the way basic_json::dump() would. number_format::source instead copies every number exactly as it was written in the source text — a price like 19.90, a long order or account ID with more digits than any number type holds, or a high-precision coordinate — something basic_json cannot do at all, since parsing already reduces a number to its parsed double/int64_t value.

operator<< writes a view to a stream the way basic_json's does, using the stream's width/fill for indentation.

Editing a document

Everything above is read-only: a json_document/json_view lets you look at a parsed text without copying it, but not change it. basic_json_document<BasicJsonType, view_mode::editable> — more conveniently spelled json_editable_document or ordered_json_editable_document — also lets you set a value, push_back onto or insert into an array, and erase an object member or an array element, still without ever building a basic_json tree for parts you do not touch.

The mode defaults to view_mode::read_only, so json_document/ordered_json_document are unaffected — they carry none of the bookkeeping edits need, and calling set/push_back/insert/erase on one is a compile error, not a runtime one.

Why: editing without reformatting

The 19.90 price from above is exactly the kind of value that makes editing a json or ordered_json value in place lossy. Say you parse a configuration file, patch one field, and write it back:

  • json re-sorts every key on the way in (object_t is a std::map) and rewrites every number to its shortest round-trip form on the way out — a one-field patch turns into a diff that reorders the whole file and rewrites 19.90 to 19.9.
  • ordered_json keeps the key order, but still rewrites every number the same way: parsing has already reduced it to a double/int64_t, and there is no way back to how it was spelled in the source text.

The patch of the example below (set "host", add "retries") written back by each of them:

json

keys sorted, numbers rewritten

{"host":"db2",
 "name":"cache",
 "port":6379,
 "price":19.9,
 "retries":3}

ordered_json

key order kept, numbers rewritten

{"name":"cache",
 "host":"db2",
 "port":6379,
 "price":19.9,
 "retries":3}

json_editable_document

order and spelling kept

{"name":"cache",
 "host":"db2",
 "port":6379,
 "price":19.90,
 "retries":3}

An editable document keeps both. dump() of an edited document writes members in document order — a member set added goes at the end, exactly where it was inserted, and an erased member simply leaves a gap: everything around it keeps its place — and number_format::source keeps the exact spelling of every number an edit did not itself touch; a number an edit did touch is written the way BasicJsonType::dump() would write it, since there is no source spelling for a brand new value.

Example: patch a configuration, keeping member order and number spellings
#include <iostream>
#include <nlohmann/json_view.hpp>

using nlohmann::json;
using nlohmann::json_editable_document;
using nlohmann::json_editable_view;

int main()
{
    // a configuration file, as it might be read from disk
    const std::string text = R"({"name": "cache", "host": "db1", "port": 6379, "price": 19.90})";
    std::cout << text << "\n\n";

    // patch two fields -- "price" is never touched
    json_editable_document doc = json_editable_document::parse(text);
    doc.set(doc.root(), "host", "db2");
    doc.set(doc.root(), "retries", 3);

    // member order (the new member at the end) and the untouched number's
    // exact spelling survive
    std::cout << doc.root().dump(-1, ' ', false, json_editable_view::number_format::source) << '\n';

    // the same patch on a plain json value: keys are sorted (object_t is a
    // std::map), and "price" is rewritten even though the patch never
    // touched it
    json plain = json::parse(text);
    plain["host"] = "db2";
    plain["retries"] = 3;
    std::cout << plain.dump() << '\n';
}

Output:

{"name": "cache", "host": "db1", "port": 6379, "price": 19.90}

{"name":"cache","host":"db2","port":6379,"price":19.90,"retries":3}
{"host":"db2","name":"cache","port":6379,"price":19.9,"retries":3}

What stays valid, and what an edit costs

The source text itself is never written, and the parsed index never moves — a value keeps the node it was parsed into for as long as it is not itself replaced. So every view taken before an edit, including a previously obtained root(), stays valid and, if it still refers to the edited value, sees the edit; a view of a value a later edit drops or replaces just keeps showing what it last held. New values go to storage the document allocates and owns on demand. The one thing an edit does invalidate is the iterators taken over an edited array or object: the first time an element is added to it, inserted into it, erased from it, or replaced by a value that spans a different number of nodes, its elements move from the parsed, fixed layout to a growable block of links so that push_back can later grow it in amortized constant time — existing elements are not touched, but an iterator that was walking the old layout no longer matches. A string obtained with get_string() is unaffected either way and stays valid across further edits. See basic_json_document's Edits for the details, and set's Exception safety for what an edit guarantees if it throws (the basic guarantee, not the strong one dump() and the read-only functions provide). How edits are kept in the index is described in the architecture overview.

Step through the edits of a small document (the node fields follow the architecture overview):

Step 1 of 5: parsed After parse: nine nodes, nothing owned yet. The dump with number_format::source is {"port":6379,"price":19.90,"tags":["eu","a\"b"]}. parsed index: fixed, never moves the source text is never written 0 object { } 3 1 key port 2 uint 6379 3 key price 4 float 19.90 5 key tags 6 array [ ] 2 7 string "eu" 8 string a"b owned storage: allocated on demand, never moves empty: a read-only json_document has none of this view v = root()["tags"] (taken after parse): valid, sees every edit iterator over tags (taken after parse): valid dump(): {"port":6379,"price":19.90,"tags":["eu","a\"b"]}
Step 1 of 5. After parsing: nine nodes in the parsed index, no owned storage yet.
Step 2 of 5: doc.set(doc.root(), "port", 6380) set: the node of the integer is overwritten in place. The dump with number_format::source is {"port":6380,"price":19.90,"tags":["eu","a\"b"]}. parsed index: fixed, never moves the source text is never written 0 object { } 3 1 key port 2 uint 6380 3 key price 4 float 19.90 5 key tags 6 array [ ] 2 7 string "eu" 8 string a"b owned storage: allocated on demand, never moves empty: a read-only json_document has none of this view v = root()["tags"] (taken after parse): valid, sees every edit iterator over tags (taken after parse): valid dump(): {"port":6380,"price":19.90,"tags":["eu","a\"b"]}
Step 2 of 5. doc.set(doc.root(), "port", 6380) overwrites the node of the integer in place; no member is added or removed, so the object keeps its parsed layout.
Step 3 of 5: doc.erase(doc.root()["tags"], 0) erase: the array moves to a block of links in owned storage. The dump with number_format::source is {"port":6380,"price":19.90,"tags":["a\"b"]}. parsed index: fixed, never moves the source text is never written 0 object { } 3 1 key port 2 uint 6380 3 key price 4 float 19.90 5 key tags 6 array moved [ ] 2 7 string "eu" unreferenced 8 string a"b owned storage: allocated on demand, never moves 0' header [ ] 1 1' link → 8 free view v = root()["tags"] (taken after parse): valid, sees every edit iterator over tags (taken after parse): invalid: tags no longer uses the parsed layout dump(): {"port":6380,"price":19.90,"tags":["a\"b"]}
Step 3 of 5. doc.erase(doc.root()["tags"], 0): the array is edited for the first time, so its elements move to a block of links in owned storage. The node of "eu" stays in the index, but nothing refers to it any more; iterators over the array taken before are invalid.
Step 4 of 5: doc.push_back(doc.root()["tags"], "new") push_back: a new node, and a link appended to the block. The dump with number_format::source is {"port":6380,"price":19.90,"tags":["a\"b","new"]}. parsed index: fixed, never moves the source text is never written 0 object { } 3 1 key port 2 uint 6380 3 key price 4 float 19.90 5 key tags 6 array moved [ ] 2 7 string "eu" unreferenced 8 string a"b owned storage: allocated on demand, never moves 0' header [ ] 2 1' link → 8 2' link → 3' 3' string "new" edited text new view v = root()["tags"] (taken after parse): valid, sees every edit iterator over tags (taken after parse): invalid: tags no longer uses the parsed layout dump(): {"port":6380,"price":19.90,"tags":["a\"b","new"]}
Step 4 of 5. doc.push_back(doc.root()["tags"], "new"): the new string gets its own node, its text goes to the buffer of edited text, and a link to it is appended to the block of the array.
Step 5 of 5: doc.set(doc.root(), "retries", 3) set of a new member: the object moves to a block as well. The dump with number_format::source is {"port":6380,"price":19.90,"tags":["a\"b","new"],"retries":3}. parsed index: fixed, never moves the source text is never written 0 object moved { } 3 1 key port 2 uint 6380 3 key price 4 float 19.90 5 key tags 6 array moved [ ] 2 7 string "eu" unreferenced 8 string a"b owned storage: allocated on demand, never moves 0' header [ ] 2 1' link → 8 2' link → 3' 3' string "new" 4' uint 3 5' header { } 4 6' key port 7' link → 2 8' key price 9' link → 4 10' key tags 11' link → 6 12' key retries 13' link → 4' edited text newretries view v = root()["tags"] (taken after parse): valid, sees every edit iterator over tags (taken after parse): invalid: tags no longer uses the parsed layout dump(): {"port":6380,"price":19.90,"tags":["a\"b","new"],"retries":3}
Step 5 of 5. doc.set(doc.root(), "retries", 3): a new member, so the object moves to a block too: copies of its keys, links to the values it already had, then the new key and a link to the new integer.

The node index and the owned storage of the document after each of five edits. Use Back, Next, or the numbered buttons to switch between the steps; without JavaScript all steps are shown one below the other.

Images

save() writes a document as an image: a byte buffer that load() reads back into a document without parsing — no lexing, no building the node index, nothing but copying the nodes and pointing the text and the decoded strings at the image. Where parse_copy() still has to scan the whole input, load() turns that scan into a copy of the node index alone.

Why. A document that is parsed once and then read many times — a configuration loaded at startup, a template rendered on every request, a large reference dataset a worker process needs in memory — pays for parsing once but can amortize save()'s cost across every later load. That makes images useful for a cache: save a document the first time it is parsed (to a file, a shared-memory segment, an in-process cache), and load() it on every later use instead of parsing the source text again. They are just as useful for handing a parsed document to another process (or a forked worker) running the same build of the library, since load() turns the transfer into a copy of the node index plus pointers into the received bytes, not a re-parse.

Choosing a check. load() takes an image_check that trades validation against speed: image_check::full (the default) checks everything the parser itself guarantees, so a checked image is exactly as safe to read and serialize as a freshly parsed document — the right choice whenever the image did not come straight from this process's own save(), such as a file or a network peer. image_check::bounds only checks structure and bounds — cheaper, since it skips scanning the text and the decoded strings — and fits a cache the process trusts, one it wrote and reads back itself. image_check::none skips validation entirely, for an image trusted as much as the process's own memory. See load()'s Notes for exactly what each level does and does not guarantee.

Example: cache a parsed configuration as an image
#include <cstdint>
#include <iostream>
#include <string>
#include <vector>
#include <nlohmann/json_view.hpp>

using nlohmann::json_document;

int main()
{
    std::cout << std::boolalpha;

    // a configuration a service parses once and then caches as an image, so
    // that later requests can load() it instead of parsing the text again
    const std::string text = R"({"name": "cache", "host": "db1", "port": 6379, "replicas": ["db2", "db3"]})";
    const json_document config = json_document::parse(text);

    // save() turns the parsed document into a byte buffer: a 64-byte header,
    // the node index, the source text, and the decoded strings
    const std::vector<std::uint8_t> image = config.save();
    std::cout << image.size() << '\n';

    // the same document always saves to the same bytes
    std::cout << (image == json_document::parse(text).save()) << '\n';

    // loading the image back needs no parsing, yet dumps exactly what
    // parsing the text produced
    const json_document reloaded = json_document::load(image);
    std::cout << (reloaded.root().dump() == config.root().dump()) << '\n';
}

Output:

316
true
true

Experimental

The image format is versioned but not yet stable, and may change in an incompatible way before it is declared stable. It is little-endian only (on big-endian targets, save() and load() do not exist), and tied to the library build that wrote it — use it to cache a document or to hand one to another process running the same build, not as a long-term storage format; keep the original JSON text if a saved document needs to be readable by a future library version.

The idea of a document you can read without parsing comes from zero-copy formats such as FlatBuffers and YaFF; the check load() runs follows the idea of FlatBuffers' Verifier. No code is taken from either.

Choosing between json, ordered_json, the SAX interface, and json_view

json / ordered_json SAX interface json_document / json_view json_editable_document / json_editable_view
Ownership owns every value owns nothing; you decide what to keep, in your handler borrows or owns the text; the index is always owned by the document same as json_document; edits go to storage the document owns
Mutability freely mutable not applicable (a one-shot event stream) read-only set/push_back/insert/erase edit in place; the source text is never rewritten
What you get a full tree you can read, write, and keep as long as you like a sequence of callbacks; whatever your handler builds from them a flat index plus, on demand, materialize()d json/ordered_json values for the parts you actually use the same, plus dump() of an edited document that keeps the member order and, with number_format::source, the spelling of every untouched number
Typical use general-purpose JSON handling: config, request/response bodies you build or modify, anything you hold onto validating or projecting a text into your own data structure without ever holding the whole thing as JSON large or high-volume input where you only need part of it, or need it repeatedly, and can keep the source text (or a copy) alive for as long as the document lives a document you read, patch a few fields of, and write back — a configuration file, for instance — where the rest of it should come back exactly as it was
Caching/reload not applicable — re-parse, or roll your own serialization not applicable save()/load(): cache the parsed index as an image and reload it without parsing same, saving the document's current — possibly edited — state

Each column is one way to work with JSON in this library, and each row compares one property of them.

See also

Version history

  • Added in version 3.13.0 unreleased.