pjson (short for Praveen's JSON) is an ultra-simple JSON library for C++.
- Its intent is to make creating and accessing JSON data in C++ as simple as possible.
- A single class,
ByteDance::pjson, represents any JSON value (null, bool, number, string, array, or object) and provides an ergonomicobj["key"][i] = valuestyle API. - Licensed under Apache-2.0;
- Current source version: 2.0.0 (
pjson::getVersion()/ thePJSON_VERSIONmacro).
- Building / Installation
- Quick start
- Creating & building JSON
- Serializing (
toString) - Parsing (
parse) - Streaming large documents
- Reading values
- Reading arrays
- Reading objects / maps
- Safe vs. vivifying access
- Editing an existing document
- Inspecting, comparing & modifying
- JSON Pointer, Patch & Merge Patch
- Numbers
- Schema validation
- Error handling & allocator ownership
- API reference (cheat sheet)
- Testing & fuzzing
- Benchmarking
- Documentation & project resources
- Limitations
From the repo root, run the bundled script. It configures CMake and builds the
library, tests, and examples in both Release and Debug, collecting the
artifacts into an out/ folder. Works on Linux, macOS, and Windows (Git Bash /
MSYS2):
./build.sh # full verification sweep (same as --all)
./build.sh --all # same as above, explicitly
./build.sh --test # just build both configs, then run the unit tests
./build.sh --bench # run dependency-free Release benchmarks
./build.sh --bench-compare --auto # compare with three pinned libraries
./build.sh --fuzz --auto # bounded libFuzzer corpus smoke
./build.sh --docs --auto # generate and validate API reference
./build.sh --package --auto # relocatable static/shared install + pkg-config checks
./build.sh --license --auto # validate SPDX/REUSE licensing metadata
./build.sh --clean # remove out/ first, then build
./build.sh --release-only # or --debug-only, to build just one configResulting layout:
out/
include/pjson.h DOM public header
include/pjson_parser.h parser public header
include/pjson_schema.h schema-validator public header
release/lib/libpjson.a Release library
release/bin/pjsontest Release test runner
release/bin/pjsonbench Release benchmark runner
release/bin/examples/ Release example programs
debug/... the same, built as Debug
Missing tools (cmake, clang-format, clang-tidy, Doxygen) are detected and, with
your confirmation, installed via the system package manager; pass --auto to
install without prompting. Run ./clean.sh to remove all generated files,
including both pinned test corpora and benchmark dependencies under
.test-corpora/ and .benchmark-deps/.
Direct source integration must include every translation unit used by the selected features and the private Ryu include path for serialization. The single CMake target below is preferred for the full library. Public headers are:
pjson.hfor the DOMpjson_parser.hfor parsingpjson_schema.hfor schema validation
cmake -S . -B build
cmake --build buildadd_subdirectory(pjson) in your project and link the exported target:
add_subdirectory(pjson)
target_link_libraries(myapp PRIVATE pjson::pjson)Or install a relocatable package:
cmake -S . -B build -DCMAKE_INSTALL_PREFIX=/desired/prefix \
-DPJSON_BUILD_TESTS=OFF \
-DPJSON_BUILD_EXAMPLES=OFF \
-DPJSON_BUILD_BENCHMARKS=OFF
cmake --build build --config Release
cmake --install build --config ReleaseConsumers use the same target after pointing CMake at that prefix:
find_package(pjson 2.0 CONFIG REQUIRED)
target_link_libraries(myapp PRIVATE pjson::pjson)The install also provides relocatable pkg-config metadata (pkg-config --cflags --libs pjson). Repository-local Conan 2 and
vcpkg overlay recipes are available, but their
presence here does not imply publication in a public package registry:
conan create . --build=missing
vcpkg install pjson --overlay-ports=packaging/vcpkg/portsFor a standalone first program and its exact compile command, see the hello-world tutorial and its canonical source file.
#include "pjson.h"
#include "pjson_parser.h"
#include <cstdint>
#include <iostream>
using namespace ByteDance;
int main() {
// Build a document
pjson person;
person["name"] = "Ada";
person["age"] = int64_t(36);
person["active"] = true;
person["scores"][0] = int64_t(90);
person["scores"][1] = int64_t(82);
person["scores"][2] = int64_t(77);
person["address"]["city"] = "London";
const pjson::SerializeOptions pretty =
pjson::SerializeOptions::prettyPrinted();
std::cout << person.toString(pretty) << "\n";
// Every DOM parse returns a pjson value; pass a pJsonParser::Error to detect failure.
pJsonParser::Error error;
pjson parsed = pJsonParser().parse(person.toString(), error);
if (error.ok) {
std::string name;
int64_t age = 0;
if (parsed.tryGet("name", name) && parsed.tryGet("age", age)) {
std::cout << "name = " << name << "\n";
std::cout << "age = " << age << "\n";
}
}
}Assigning to operator[] builds the tree as you go — intermediate maps and
arrays are created automatically.
pjson j;
// Object of key/value
j["myKey1"] = "Value1"; // const char* -> string
j["myKey2"] = std::string("v2"); // std::string -> string
// Nested object
j["myKey3"]["myInteger"] = int64_t(1); // signed 64-bit integer
j["myKey3"]["myFloat"] = double(1.0); // double
// Build an array element by element
for (int64_t i = 0; i != 7; ++i)
j["myKey4"][static_cast<int>(i)] = i;
// Direct array indexing (extends the array as needed)
j["myKey4"][7] = int64_t(7);
j["myKey4"][8] = "Eight"; // arrays may hold mixed types
// Deep nesting: map -> array -> map -> value
j["myKey4"][9]["ninth"] = double(9.0);
// Take a reference to a sub-node and keep building through it
pjson& doubles = j["myKey3"]["myFloatArray"];
doubles[0] = double(0.0);
doubles[1] = double(1.1);
doubles[3] = double(4.4); // index 2 is auto-filled with nullOne indexed access may create at most 1,000,000 children. An access that would
cross that growth limit throws std::length_error before mutating the value.
Append to an array with += (it promotes the node to an array if needed):
pjson list;
list += int64_t(1); // [1]
list += "two"; // [1,"two"]
list += int64_t(3); // [1,"two",3]
list += int64_t(4); // [1,"two",3,4]The = and += operators accept strings, bool, all standard non-character integer types,
and float, double, or long double, as well as vectors of the supported
non-character numeric types, bool, or std::string. Concrete overloads keep
ordinary expressions unambiguous without putting templates in the public
header. Values are stored as signed or unsigned 64-bit integers, or as double.
The document built above serializes to:
{
"myKey1": "Value1",
"myKey2": "v2",
"myKey3": {
"myFloat": 1.0,
"myFloatArray": [
0.0,
1.1,
null,
4.4
],
"myInteger": 1
},
"myKey4": [
0,
1,
2,
3,
4,
5,
6,
7,
"Eight",
{
"ninth": 9.0
}
]
}The skipped array position [2] is auto-filled with null by
doubles[3] = 4.4. Object member order is unspecified.
std::string compact = person.toString(); // single line, no extra spaces
pjson::SerializeOptions prettyOptions =
pjson::SerializeOptions::prettyPrinted();
std::string pretty = person.toString(prettyOptions);Use SerializeOptions when formatting choices must be explicit:
pjson::SerializeOptions output = pjson::SerializeOptions::prettyPrinted();
output.indentWidth = 4;
output.indentCharacter = ' '; // only space or tab; other values fall back to space
output.escapeNonAscii = true;
output.maxOutputBytes = size_t(64) * 1024 * 1024;
std::string text = person.toString(output);
person.write(std::cout, output);Defaults are compact output, two-space indentation when pretty is enabled,
raw UTF-8, and a 64 MiB output limit. Set
maxOutputBytes = 0 only when explicitly requesting unlimited output. Use
SerializeOptions::prettyPrinted() for two-space pretty output. Objects use
private process-seeded hash storage, so insertion, traversal, keys(), and
serialization order are unspecified.
For the document built in Quick start:
Compact — one possible member order is:
{"active":true,"address":{"city":"London"},"age":36,"name":"Ada","scores":[90,82,77]}Pretty (toString(SerializeOptions::prettyPrinted())):
{
"active": true,
"address": {
"city": "London"
},
"age": 36,
"name": "Ada",
"scores": [
90,
82,
77
]
}Strings are automatically escaped (", \, control characters, etc.). Valid
UTF-8 input therefore produces valid JSON. If a programmatically stored string
contains invalid UTF-8, toString() throws std::invalid_argument and write()
sets failbit; escapeNonAscii does not make invalid byte sequences valid. If
the configured output limit or indentation arithmetic would overflow,
toString() throws std::length_error before returning output and write()
sets failbit. These logical preflight failures emit no bytes; only a physical
stream/I/O failure can leave a partial prefix.
Every DOM-parsing overload returns a pjson by value — no smart pointer, no
manual delete. The value owns its subtree and frees it on destruction. The
terse overloads return a JSON null value on failure; pass a pJsonParser::Error when
you need to tell failure apart from a successfully parsed literal null.
pJsonParser::Error err;
pjson p = pJsonParser().parse(R"({ "a": 1, "b": [true, null, "x"] })", err);
if (err.ok) {
int64_t a = 0;
if (p.tryGet("a", a))
std::cout << a << "\n"; // 1
} // freed automaticallyA (const char*, size_t) overload handles buffers that are not
NUL-terminated or that contain embedded NUL bytes:
pjson p = pJsonParser().parse(buffer, length, err);Parse options — parse() accepts an optional pJsonParser::Options:
pJsonParser::Options opt;
opt.maxDepth = 64; // reject nesting deeper than this (default 512, hard cap 1024)
opt.maxNodes = 100000; // cap materialized values (default 1,000,000)
opt.maxInputBytes = 8 * 1024 * 1024; // cap input (default 64 MiB)
opt.duplicateKeys = pJsonParser::Options::RejectDuplicateKeys; // default
opt.numberPolicy = pJsonParser::Options::RejectUnrepresentableNumbers; // default
pjson p = pJsonParser(opt).parse(text);Error reporting — pass a pJsonParser::Error to learn why/where parsing failed
(no exceptions):
pJsonParser::Error err;
pjson p = pJsonParser().parse("[1, 2, ]", err);
if (!err.ok) {
std::cerr << "parse failed at " << err.line << ':' << err.column
<< " (byte " << err.offset << "): " << err.message
<< " [code " << err.code << "]\n";
}The parser resets the supplied pJsonParser::Error at the start of every call. Success
leaves ok == true, code == None, offset 0, line 1, column 1, and an
empty message; failure sets ok == false, a stable code, and records the
first failure. Because a failed parse returns a null value, prefer the
pJsonParser::Error overload whenever the input might legitimately be the literal
null.
The parser always enforces RFC 8259. It rejects:
- unescaped control characters inside strings,
- invalid UTF-8 byte sequences,
- unknown escapes (e.g.
\q), - unpaired
\usurrogates, - non-lowercase keywords (
NULL,True), - out-of-range numbers.
Duplicate object keys are rejected by default. Set duplicateKeys to
KeepFirstDuplicate or KeepLastDuplicate only when interoperability requires
it. Resource budgets, duplicate-key handling, and the number policy are the only
parse options; none relaxes the JSON grammar or UTF-8 validation.
std::ifstream file("data.json");
pJsonParser::Error err;
pjson doc = pJsonParser().parseStream(file, err);
if (err.ok) { /* ... */ }parseStream() builds a normal DOM. For very large documents, derive from
pJsonParser::SaxHandler and use parseSaxStream() to receive values incrementally
without buffering the full file or allocating a DOM:
struct Counter : pJsonParser::SaxHandler {
size_t numbers = 0;
bool onInt(int64_t) override { ++numbers; return true; }
bool onUInt(uint64_t) override { ++numbers; return true; }
bool onDouble(double) override { ++numbers; return true; }
};
Counter counter;
pJsonParser::Error err;
std::ifstream input("huge.json", std::ios::binary);
if (!pJsonParser().parseSaxStream(input, counter, err)) {
std::cerr << err.line << ':' << err.column << ": " << err.message << '\n';
}Every SAX callback returns bool; return false to stop early. Callback
exceptions are caught and returned as a parse failure. write(std::ostream&)
also emits directly and incrementally rather than allocating a full serialized
copy first.
For the complete SAX callback list, cancellation/error semantics, resource limits, and direct streaming output, see Chapter 11 — Streaming large JSON documents.
Use tryGet() for typed reads. It returns false and leaves the destination
unchanged when a node is absent or has the wrong type. The only numeric widening
it permits is a stored int64_t read into a double.
Given this document:
{
"active": true,
"age": 36,
"name": "Ada",
"ratio": 0.5
}pjson j = pJsonParser().parse(
R"({ "name": "Ada", "age": 36, "ratio": 0.5, "active": true })");
std::string name;
int64_t age = 0;
double ratio = 0.0;
bool active = false;
if (j.tryGet("name", name) && j.tryGet("age", age) &&
j.tryGet("ratio", ratio) && j.tryGet("active", active)) {
// name == "Ada", age == 36, ratio == 0.5, active == true
}
const pjson* nameNode = j.find("name");
pjson::jsonType t = nameNode ? nameNode->getType() : pjson::jsonNull;The type tags are: jsonNull, jsonString, jsonNumberInt,
jsonNumberUInt, jsonNumberDouble, jsonBoolean, jsonArray, jsonObject.
Given this document (shown formatted so you can see exactly what is being read):
{
"friends": [
{
"name": "Bob"
},
{
"name": "Cid"
}
],
"scores": [
90,
82,
77
],
"tags": [
"a",
"b",
"c"
]
}pjson j = pJsonParser().parse(
R"({ "scores": [90, 82, 77], "tags": ["a", "b", "c"],
"friends": [ {"name":"Bob"}, {"name":"Cid"} ] })");Read arrays through size() and findIndex(size_t). Use find(int) only when
you need negative indexes from the end. These operations do not resize or
otherwise modify the array:
if (const pjson* scores = j.find("scores")) {
std::cout << "count = " << scores->size() << "\n"; // 3
for (size_t i = 0; i < scores->size(); ++i) {
int64_t value = 0;
const pjson* element = scores->findIndex(i);
if (element && element->tryGet(value))
std::cout << value << " "; // 90 82 77
}
}Array of objects — combine iteration with per-element access:
if (const pjson* friends = j.find("friends")) {
for (size_t i = 0; i < friends->size(); ++i) {
const pjson* entry = friends->findIndex(i);
std::string name;
if (entry && entry->tryGet("name", name))
std::cout << name << " "; // Bob Cid
}
}Filter by element type — arrays can be heterogeneous, so check getType()
when you only want some elements. Given:
{
"mixed": [
1,
"two",
3,
true,
4
]
}// Sum only the integer elements -> 1 + 3 + 4 = 8
pjson mixed = pJsonParser().parse(R"({ "mixed": [1, "two", 3, true, 4] })");
if (const pjson* node = mixed.find("mixed")) {
for (size_t i = 0; i < node->size(); ++i) {
int64_t value = 0;
const pjson* element = node->findIndex(i);
if (element && element->tryGet(value))
std::cout << value << " ";
}
}Use keys() to obtain object keys, then find(key) to read each member without
creating it. The key order is unspecified.
Given this document:
{
"address": {
"city": "London",
"zip": "N1"
},
"name": "Ada"
}pjson j = pJsonParser().parse(
R"({ "name": "Ada", "address": { "city": "London", "zip": "N1" } })");
// Iterate top-level keys in unspecified order.
for (const std::string& key : j.keys()) {
const pjson* value = j.find(key);
if (value) {
pjson::SerializeOptions compact;
compact.maxOutputBytes = size_t(64) * 1024 * 1024;
std::cout << key << " : " << value->toString(compact) << "\n";
}
}To look up a single key without creating it, use find() (returns a pointer or
nullptr) or hasKey():
if (const pjson* addr = j.find("address")) {
if (const pjson* city = addr->find("city")) {
std::string value;
if (city->tryGet(value))
std::cout << value << "\n"; // "London"
}
}This is the one sharp edge worth understanding.
operator[] is a builder API: accessing a key or index that does not exist
creates it, and access can change a node's type. That makes building concise,
but operator[] is not a safe read. A single index access that would create
more than 1,000,000 children throws std::length_error before mutation:
Valid negative int indexes address existing elements from the end; a negative
index before the beginning throws std::out_of_range without mutation. A
size_t overload is available for ordinary non-negative builder indexes.
pjson building;
building["scores"][0] = int64_t(90);
building["scores"][1] = int64_t(82);
building["scores"][2] = int64_t(77);
building["scores"][10]; // OOPS: grows the array to 11 elements (nulls)
building["missing"]; // OOPS: creates an empty "missing" keyFor non-mutating reads, use these instead. Given:
{
"age": 36,
"name": "Ada",
"scores": [
90,
82,
77
]
}pjson j = pJsonParser().parse(
R"({ "age": 36, "name": "Ada", "scores": [90, 82, 77] })");
// hasKey / find never create anything
if (j.hasKey("scores")) { /* ... */ }
if (const pjson* p = j.find("scores")) {
// p is a read-only pointer, or nullptr if absent
if (p->hasIndex(-1)) {
const pjson* last = p->find(-1); // negative indexes count from the end
int64_t value = 0;
if (last && last->tryGet(value)) { /* value == 77 */ }
}
}
int64_t age = 0;
if (j.tryGet("age", age)) { /* age == 36 */ }For strict scalar reads, tryGet works on a node or directly by key/index. It
returns false and leaves the output unchanged for a missing child or type
mismatch; only integer-to-double widening is accepted:
int64_t score = -1;
if (const pjson* scores = j.find("scores")) {
if (scores->tryGet(0, score)) { /* score == 90 */ }
}
pjson::StringView view;
if (j.tryGet("name", view)) {
consumeBytes(view.data(), view.size()); // supports embedded NUL bytes
}StringView avoids a string copy but borrows the node's bytes. Mutation,
erasure, reset, move, swap, ancestor replacement, or destruction can invalidate
it; make a std::string copy when the value must outlive that state.
Because operator[] returns a mutable reference, editing a parsed tree is the
same as building one. Starting from:
{
"status": "active",
"user": {
"scores": [
10,
20,
30
]
}
}pjson p = pJsonParser().parse(
R"({ "user": { "scores": [10, 20, 30] }, "status": "active" })");
// Change values in place
p["user"]["scores"][0] = int64_t(99); // change a value
p["user"]["scores"][1] = "twenty"; // change an element's type
// Replace a whole node (the "status" string becomes an array here)
p["status"][0] = int64_t(1);
p["status"][1] = int64_t(2);
std::cout << p.toString(pjson::SerializeOptions::prettyPrinted());produces:
{
"status": [
1,
2
],
"user": {
"scores": [
99,
"twenty",
30
]
}
}Type predicates and container queries answer common questions directly:
pjson j = pJsonParser().parse(R"({ "scores": [90, 82, 77] })");
j.isObject(); // true
const pjson* scores = j.find("scores");
scores && scores->isArray(); // true
scores ? scores->size() : 0; // 3
scores && !scores->empty(); // true
j.getType(); // pjson::jsonObjectPredicates: isNull, isString, isNumber, isInt, isUInt, isInteger,
isDouble, isBool, isArray, isObject. size() returns the element count
for arrays/objects (0 for scalars); empty() is size() == 0.
Read with an application default — initialize the destination, then replace
it only if tryGet succeeds:
int64_t count = 0;
j.tryGet("count", count); // count remains 0 if missing or mistyped
std::string name = "anon";
j.tryGet("name", name); // name remains "anon" on failureIterate object keys (unspecified order):
for (const std::string& key : j.keys()) {
const pjson* value = j.find(key);
if (value) {
pjson::SerializeOptions compact;
compact.maxOutputBytes = size_t(64) * 1024 * 1024;
std::cout << key << " = " << value->toString(compact) << "\n";
}
}Modify — clear() empties a container (keeping its type), erase() removes
a key or index:
j.erase("scores"); // remove a map key -> true if present
j["list"].erase(2); // remove array element #2 -> true if in range
j.clear(); // empty the object (stays an object)Compare — deep, structural equality. Numbers compare across integer/double
(1 == 1.0), objects compare regardless of key order, arrays compare in order:
auto a = pJsonParser().parse(R"({"x":1,"y":[2,3]})");
auto b = pJsonParser().parse(R"({"y":[2,3],"x":1.0})");
bool same = (a == b); // truefindPointer() performs non-vivifying RFC 6901
lookup. The empty pointer selects the current value; non-empty pointers begin
with /, and - is not a lookup index. Escape dynamic object-key tokens with
escapePointerToken():
pjson::PointerError pointerError;
if (pjson* city = document.findPointer("/users/0/address/city", pointerError)) {
*city = "London";
}
std::string key = "a/b~c";
const pjson* value = document.findPointer("/" + pjson::escapePointerToken(key));applyPatch() supports the RFC 6902 add, remove, replace, move,
copy, and test operations. applyMergePatch() implements RFC 7396. Both
modify the receiver only after the complete operation succeeds, so failure is
atomic, and the PatchError overload reports the failing operation/path. A
successful RFC 6902 remove at the empty root path leaves the target as JSON
null:
pjson patch = pJsonParser().parse(R"([
{"op":"replace","path":"/status","value":"ready"},
{"op":"add","path":"/tags/-","value":"new"}
])");
pjson::PatchError patchError;
pjson::PatchOptions patchLimits;
if (!document.applyPatch(patch, patchError, patchLimits)) {
std::cerr << patchError.opIndex << ": " << patchError.message << '\n';
}
pjson merge = pJsonParser().parse(R"({"obsolete":null,"enabled":true})");
document.applyMergePatch(merge, patchError, patchLimits);PatchOptions defaults to 10,000 operations, 1,000,000 cloned nodes,
64 MiB of cloned node/string/key bytes, and 1,000,000 work units. Its fields are
maxOperations, maxClonedNodes, maxClonedBytes, and maxWork. Zero retains
the corresponding hard ceiling rather than disabling it. Exceeding a budget
returns false, reports PatchError::ResourceLimit when diagnostics are
requested, and leaves the target unchanged. maxOperations applies to RFC
6902 operation entries and to members processed by Merge Patch.
Moving the document root beneath itself is rejected as
PatchError::MoveRootNotAllowed; removing the root remains valid and produces
JSON null.
- Signed integers are stored and assigned as 64-bit (
int64_t); read them withtryGet(int64_t&). - Unsigned integers above
INT64_MAXare stored as 64-bit (uint64_t) in a distinctjsonNumberUIntkind, so the fulluint64_trange round-trips exactly. Read them withtryGet(uint64_t&), test withisUInt(), and test either integer kind withisInteger(). An explicituint64_tassignment keeps unsigned identity even for small values. A signed read of an unsigned value succeeds only when it fits inint64_t; an unsigned read of a signed value succeeds only when it is non-negative. - Non-integers are stored and assigned as
double; read them withtryGet(double&). Any stored integer may widen todouble; other conversions are rejected. Cross-kind comparison is exact (1 == 1u == 1.0), including above 2^53. - Integer tokens outside
[INT64_MIN, UINT64_MAX], and floating tokens outside finitedoublerange, are rejected by default (pJsonParser::Options::RejectUnrepresentableNumbers). SetpJsonParser::Options::AllowLossyNumbersto store the nearest finitedoubleinstead. - Nonzero floating tokens that round all the way to zero are also rejected by
default and require
AllowLossyNumbers. Other finite decimal tokens are converted by the platform's classic-locale C++ iostream implementation; on the supported libc++, libstdc++, and MSVC standard libraries this is the nearest representabledoubleunder the active floating-point rounding mode. Applications that change that mode must restore round-to-nearest before parsing when reproducibility across environments is required. - A stored non-finite
double(NaN/±infinity) fails serialization by default (SerializeOptions::RejectNonFinite):toString()throws andwrite()setsfailbit. UseNonFiniteToNullto emitnull(the pre-2.0 behavior) orNonFiniteToStringto emit"NaN"/"Infinity"/"-Infinity". - Double serialization is locale-independent and uses the Ryu
shortest-round-trip algorithm, providing bit-exact serialize/parse recovery
for every finite binary64
double. Integral-looking doubles retain a decimal marker (for example,1.0) so reparsing preserves the double storage kind; the spelling is not promised to be the shortest possible.
The type tags are: jsonNull, jsonString, jsonNumberInt, jsonNumberUInt,
jsonNumberDouble, jsonBoolean, jsonArray, jsonObject.
pjson makes no positive shared-object guarantee beyond the C++ default: distinct
values may be used concurrently, but a single value must not be mutated
concurrently with any other access to it (or its subtree). A custom Allocator
must provide its own synchronization if shared across threads. The default
allocator and getVersion() are initialization-safe.
pJsonSchemaValidator owns immutable schema/resource copies and may be read
concurrently when callers use separate error vectors. Resolver callbacks run
only during construction and are not retained.
Fractional and exponent-form JSON numbers are converted through a
classic-locale std::istream extraction. On the supported libc++, libstdc++,
and MSVC standard libraries this follows the implementation's correctly rounded
decimal-to-binary conversion under the active floating-point rounding mode.
pjson does not change that process/thread rounding mode. The CI matrix verifies
halfway cases and binary64 extremes on GCC/libstdc++, Clang/libstdc++,
AppleClang/libc++, and MSVC. Serialization uses pinned Ryu
shortest-round-trip conversion, then applies pjson's documented
fixed/scientific spelling policy. The bit-pattern property suite is enabled
when the platform reports IEEE binary64.
A document can be checked against a schema that is itself a pjson object,
so schemas load and round-trip through parse()/toString() like any other
JSON. Validation is performed by a standalone helper class,
ByteDance::pJsonSchemaValidator (declared in <pjson_schema.h>), that is a
pure consumer of pjson's public API — the core pjson class carries no schema
or regex machinery, while the schema implementation remains isolated in focused
private translation units within the current library target. Compile a schema
into a validator once, then reuse it for many instances. Default options retain
pjson's deliberately limited subset dialect; Options::draft2020() selects
the implemented required vocabularies of
JSON Schema Draft 2020-12.
validate() is noexcept and normally collects every applicable failure (a
resource-budget failure stops traversal), each reported as a
pJsonSchemaValidator::Error with a stable code, separate
instanceLocation and schemaLocation, the triggering keyword, a
human-readable message, and optional nested causes. category distinguishes
InstanceValidation from SchemaCompilation.
#include "pjson_schema.h"
pjson schema = pJsonParser().parse(R"({
"type": "object",
"required": ["name", "age"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"age": { "type": "integer", "minimum": 0 },
"tags": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": false
})");
pjson data = pJsonParser().parse(R"({ "name": "Ada", "age": 36, "tags": ["x","y"] })");
// Compile the schema once, then reuse the validator.
pJsonSchemaValidator validator(schema);
if (!validator.isSchemaValid()) {
for (const auto& e : validator.schemaErrors())
std::cerr << "invalid schema at " << e.schemaLocation << ": " << e.message << "\n";
}
// Simple pass/fail:
if (validator.validate(data)) {
/* conforms */
}
// Or collect all the reasons it failed:
std::vector<pJsonSchemaValidator::Error> errors;
if (!validator.validate(data, errors)) {
for (const auto& e : errors) {
std::cerr << (e.instanceLocation.empty() ? "(root)" : e.instanceLocation)
<< ": " << e.message << "\n";
}
}Default construction implements pjson's explicitly named subset dialect. Use
pJsonSchemaValidator::Options::draft2020() to opt into the official Draft
2020-12 URI, bundled standard meta-schema validation, modern $ref behavior,
and per-resource $vocabulary activation. Custom meta-schema URIs are loaded
only through the supplied resolver during construction; pjson never performs
network I/O. Unknown optional vocabularies are annotations, while unknown
required vocabularies fail compilation. Optional big-number and historic
cross-draft behavior remain outside pjson's numeric and dialect contracts.
References are compiled during construction; resolver callbacks and their context are not retained, and validation performs no resolver I/O or cache mutation. A compiled validator may therefore be read concurrently when callers use separate error vectors.
Example failure output for { "age": "old" } against the schema above:
(root): missing required property "name"
/age: expected type integer, got string
Schemas can equally be built with the normal API instead of parsed from text:
pjson schema;
schema["type"] = "object";
schema["required"][0] = "name";
schema["required"][1] = "age";
schema["properties"]["name"]["type"] = "string";
schema["properties"]["age"]["type"] = "integer";
schema["properties"]["age"]["minimum"] = int64_t(0);
bool ok = pJsonSchemaValidator(schema).validate(data);Warning: By default, unknown or unsupported schema keywords are ignored and therefore impose no constraint. A typo can silently weaken validation. Treat the table below as an allowlist, audit schemas before use, and test both accepted and rejected instances for every intended rule. For a fail-closed boundary, use
pJsonSchemaValidator::Options::strict(), which rejects unsupported standard keywords instead of ignoring them.
Supported keywords:
| Applies to | Keywords |
|---|---|
| any / references | type (name or array of names), enum, const, $id, $anchor, $dynamicAnchor, $ref, $dynamicRef |
| objects | properties, patternProperties, propertyNames, required, dependentRequired, dependencies, additionalProperties, unevaluatedProperties, minProperties, maxProperties |
| arrays | single-schema or tuple-array items, prefixItems, contains, minContains, maxContains, unevaluatedItems, minItems, maxItems, uniqueItems |
| numbers | minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf |
| strings | minLength, maxLength, pattern (ECMAScript regex), format |
| combinators | allOf, anyOf, oneOf, not |
Notes:
type: "integer"matches whole numbers (including2.0);type: "number"matches every numeric storage kind (int64_t,uint64_t, ordouble).enum/constuse pjson's deep equality, so they work for arrays and objects too.- A boolean schema is allowed:
trueaccepts every value,falserejects all. $refresolves URI resources, JSON Pointer fragments, and anchors using$idbases.$dynamicRefand$dynamicAnchorfollow dynamic scope. External documents require an explicit function-pointer resolver; pjson never performs network I/O.Options::modernSubset()enables modern$refsibling semantics and makesformatannotation-only unless explicitly re-enabled.- Known formats are
date,time,date-time,ipv4,ipv6,uuid, andregex; normal options assert them, whilemodernSubset()follows the Draft 2020-12 annotation-only default. Unknown format names are ignored. minLengthandmaxLengthcount Unicode code points, not UTF-8 bytes.patternuses ECMAScript syntax and search semantics. The default policy limits pattern and subject sizes and rejects unsafe expressions.pJsonSchemaValidator::OptionsdefaultsmaxRegexPatternBytesto 256,maxRegexSubjectBytesto 4096,allowUnsafeRegextofalse,maxValidationDepthto 32,maxRefResolutionsto 1024,maxValidationWorkto 1,000,000,maxErrorsto 100,maxResolvedDocumentsto 32,maxResolvedBytesto 16 MiB, andvalidateFormatstotrue. Zero removes only a regex byte limit; zero for a validation, reference, work, or error budget retains its documented hard ceiling. Validation depth has an absolute hard ceiling of 32, so larger configured values are clamped to 32.trustedRegex()removes only the regex limits/safety screen; reserve it for trusted schemas and data.
The default is pjson's documented subset dialect. Draft 2020-12 mode covers its required vocabularies, but not the complete optional format-assertion vocabulary, arbitrary-precision numbers, or other drafts. See the schema tutorial and the generated API reference for details.
- Every
parse()/parseStream()overload returns apjsonby value that owns its subtree and frees it on destruction — no smart pointer, no manualdelete. The terse overloads return a JSONnullvalue on failure; pass apJsonParser::Errorto distinguish failure from a successfully parsed literalnull. An exception-enabled input stream can still throw whileparseStream()buffers input. - A supplied
pJsonParser::Erroris reset for each attempt. Success leaves its success state (ok,code == None, offset 0, line 1, column 1, empty message); failure records the first error with a stablecode, a byteoffset, one-basedlineand bytecolumn, and a human-readablemessage. - The parser rejects trailing garbage, trailing/leading/doubled commas,
unterminated strings/containers, malformed numbers (
1.,.5,1e,+1), out-of-range numbers (1e400), and input nested deeper thanpJsonParser::Options::maxDepth(itself clamped to a stack-safe hard ceiling). - Strings are correctly escaped on output and unescaped on input, including
\uXXXX(decoded to UTF-8) and surrogate pairs. - Invalid UTF-8 in a programmatically stored string makes
toString()throwstd::invalid_argument;write()instead sets the destination stream'sfailbitbefore emitting bytes. The defaultSerializeOptions::maxOutputBytesis 64 MiB; exceeding it producesstd::length_errorfromtoString()or preflightfailbitfromwrite(). - Non-throwing overloads accept
SerializeErrorand report stable categories for invalid UTF-8, rejected non-finite numbers, output limits, allocation failure, stream failure, and internal errors. String output remains unchanged on failure; only a physical stream failure may leave a partial prefix. tryGetreturnsfalsewithout changing its output on a missing value or type mismatch. Operations that allocate, such as copying a string or moving across allocators, can still report allocation failure through the normal C++ mechanism unless their signature is explicitlynoexcept.
The default pjson and pJsonParser constructors use pjson's default allocator.
Applications that need to route persistent DOM storage can derive from
pjson::Allocator, bind a root during construction, or pass it to an
allocator-configured parser:
class Arena : public pjson::Allocator {
public:
void* allocate(size_t bytes, size_t alignment, AllocationKind kind) override;
void deallocate(void* ptr, size_t bytes, size_t alignment,
AllocationKind kind) noexcept override;
};
Arena arena;
pjson value(arena);
pJsonParser::Error error;
pjson parsed = pJsonParser(arena).parse(text, error); // bound to arenaallocate must return non-null storage satisfying bytes and alignment or
throw; deallocate receives matching metadata and must not throw. A directly
constructed root such as value is caller-owned, while its wrapper objects and
dynamic descendants use its bound allocator. A parsed value is likewise bound to
the allocator passed to parse() and releases its storage through that
allocator on destruction.
Allocator is borrowed and must outlive every bound root and descendant. It
covers persistent nodes and string/array/object wrapper objects; backing
allocations inside the standard containers and transient algorithm/parser
scratch space still use the standard allocator.
Ordinary copy construction inherits the source allocator;
pjson(source, allocator) explicitly deep-copies into another one. Copy and
move assignment preserve the destination allocator. Same-allocator moves
transfer storage after an ancestry-safety check, while cross-allocator moves
deep-transfer and may allocate. swap() performs the same safety check before
its constant-time exchange; cross-allocator and ancestor/descendant swaps are
safe no-ops. Rvalue insertion snapshots an ancestor when the destination lies
inside it. SAX parsing has no allocator overload because it creates no persistent
DOM.
| Category | Members |
|---|---|
| Parse | pJsonParser([allocator,] options).parse(str | ptr,size [, Error&]), parseStream(std::istream&[, Error&]) → pjson by value |
| Streaming parse | pJsonParser::parseSax(...), parseSaxStream(...), pJsonParser::SaxHandler callbacks |
| Parse options | pJsonParser::Options{ maxDepth, maxNodes, maxInputBytes, duplicateKeys, numberPolicy }, pJsonParser::Error{ ok, code, offset, line, column, message } |
| Serialize | toString([SerializeOptions]), write(std::ostream&[, SerializeOptions]); options include maxOutputBytes, nonFinite |
| Type | getType(), isNull/isString/isNumber/isInt/isUInt/isInteger/isDouble/isBool/isArray/isObject() |
| Typed read | node/key/index tryGet(out&) for int64_t, uint64_t, double, bool, std::string, or StringView; untouched on failure |
| Inspect containers | size(), empty(), keys(), hasKey(key), contains(key), hasIndex(index), find(key|index) |
| Traverse | forEachMember(fn, ctx) in unspecified object-storage order; forEachElement(fn, ctx) in array order — non-allocating callback visitors |
| Checked read | at(key), at(index) — throw std::out_of_range, never vivify |
| JSON Pointer | findPointer(pointer[, PointerError]), escapePointerToken(token) |
| JSON Patch | applyPatch(patch[, PatchError][, PatchOptions]), applyMergePatch(patch[, PatchError][, PatchOptions]) |
| Container ops | size(), empty(), clear(), erase(key), erase(index) |
| Compare | operator==, operator!= (deep, structural) |
| Validate | pJsonSchemaValidator v(schema[, Options]); v.validate(value[, errors]) — standalone validator (<pjson_schema.h>); subset by default, required Draft 2020-12 vocabularies via Options::draft2020() |
| Build | operator[](key|index) — vivifying |
| Factories | null(), object(), array(); operator=(nullptr) |
| Insert | pushBack(pjson[&&]), insertOrAssign(key, pjson[&&]), reserve(n) |
| Assign | operator= for strings, bool, native and 64-bit integers, float, double, and matching std::vector types |
| Append | operator+= for those same scalar and vector types; promotes the node to an array |
| Lifetime / allocator | allocator-aware constructors, getAllocator(), canSwap(), copyFrom(), swap() |
| Reset | reset() (→ null), resetTo(jsonType), resetIfNeeded(jsonType) |
pjson copies deeply. Same-allocator moves transfer storage and null the source;
cross-allocator moves deep-transfer into the destination allocator.
The unit test suite is the single pjsontest target under pjsontest/. It is
assertion based (via a tiny header-only harness in test_harness.h), prints a
PASS/FAIL line per test, and exits non-zero if any check fails. Topic files
under pjsontest/src/ link into one executable; CTest discovers and registers
each case individually, so the count remains derived from the source. See the
testing guide for the current suite inventory and corpus
setup.
Easiest — build and run through the script:
./build.sh --testContributors: before sending a change, run the full sweep — clean build, sanitizers, tests, formatting and static-analysis checks:
./build.sh # or, equivalently, ./build.sh --allThe current sweep covers formatting, Release plus sanitized Debug builds, all
tests, comparison benchmarks, bounded fuzz-corpus replay when supported, the
Doxygen reference, relocatable static and shared install consumers, pkg-config
checks, and clang-tidy. It offers to fetch both pinned JSON and JSON Schema
conformance corpora; --all --auto installs or downloads missing prerequisites
without prompting. Run ./build.sh --help for the exact current expansion and
./build.sh --format to apply source formatting.
Or with CMake/CTest directly:
cmake -S . -B build \
-DPJSON_BUILD_TESTS=ON \
-DPJSON_BUILD_EXAMPLES=OFF \
-DPJSON_BUILD_BENCHMARKS=OFF
cmake --build build
ctest --test-dir build --output-on-failureOr run the collected binary directly to see every case:
./out/debug/bin/pjsontest # if built via ./build.sh
./build/pjsontest/pjsontest # if built via plain cmakeDeterministic generated cases live in tests_fuzz.cpp; seven standalone
coverage-guided targets exercise DOM parsing/round trips, stream and SAX
agreement, serialization, schema validation, JSON Pointer, and the atomicity of
JSON Patch and Merge Patch.
With a full Clang/libFuzzer toolchain on Linux or macOS, replay the checked-in
seeds with the CI-sized budget:
./build.sh --fuzz --autoSeeds and the shared dictionary live under fuzz/; generated corpus
entries and crash artifacts go under ignored out/ directories. A focused
direct CMake build uses:
CXX=clang++ cmake -S . -B out/build-fuzz \
-DPJSON_BUILD_TESTS=OFF \
-DPJSON_BUILD_EXAMPLES=OFF \
-DPJSON_BUILD_BENCHMARKS=OFF \
-DPJSON_BUILD_FUZZERS=ON
cmake --build out/build-fuzz --parallelWithout PJSON_FUZZING_ENGINE, Clang must provide libFuzzer. An external engine
can instead be supplied through that cache string. Repository-local OSS-Fuzz
integration is provided in oss-fuzz/, without implying active
upstream service enrollment.
The Release benchmark suite measures parsing, compact serialization, read-only traversal, and deep copying on generated small, medium, large, wide-object, large-array, string-heavy, escape-heavy, integer-heavy, and floating-heavy JSON documents. Run pjson by itself or compare the same cases with pinned versions of nlohmann/json, RapidJSON, and simdjson:
./build.sh --bench --release-only
./build.sh --bench-compare --release-only
./build.sh --bench-compare --release-only --auto # download pinned dependencies without prompting
./build.sh --bench --release-only --bench-json out/benchmark.jsonAdd real documents with repeatable --bench-input arguments:
./build.sh --bench-compare --release-only \
--bench-input /path/to/small.json \
--bench-input /path/to/production-shaped.jsonComparison mode groups rows by workload and operation. For example, all four
small / parse rows appear together, followed by all four small / serialize
rows. Parse, serialize, and traverse cover every library. Copy covers pjson,
nlohmann/json, and RapidJSON; simdjson has no equivalent owned mutable-DOM
deep-copy operation.
The following pre-matrix-expansion snapshot covers only the original three mixed
workloads. It is retained as a directional example, not a current release result.
Generate a current, provenance-bearing report with --bench-json rather than
copying these values into a performance claim. The snapshot was produced on this
development machine with:
./build.sh --bench-compare --release-only --auto| Machine detail | Value |
|---|---|
| Computer | MacBook Pro (Mac15,6) |
| Processor | Apple M3 Pro, 12 cores (6 performance + 6 efficiency) |
| Memory | 36 GB |
| Architecture | ARM64 |
| Operating system | macOS 26.3.1 (25D771280a) |
| Compiler | Apple Clang 21.0.0 (clang-2100.0.123.102) |
| CMake / build | CMake 4.2.3, Release configuration |
| Compared versions | nlohmann/json 3.11.3, RapidJSON 1.1.0, simdjson 3.12.2 |
| Inputs | Deterministic generated workloads; no additional corpus files |
| Sampling | One warm-up, then six timed samples; iterations calibrated per case |
Each result cell is median microseconds per operation / MiB/s. Lower microseconds are better; higher MiB/s is better. Workload sizes are the original compact JSON inputs.
| Workload | Operation | pjson | nlohmann/json | RapidJSON | simdjson |
|---|---|---|---|---|---|
| small (341 B) | parse | 4.06 us / 80.1 MiB/s | 4.18 us / 77.8 MiB/s | 1.68 us / 193.6 MiB/s | 0.80 us / 405.4 MiB/s |
| small (341 B) | serialize | 2.74 us / 118.7 MiB/s | 1.61 us / 201.5 MiB/s | 1.17 us / 277.6 MiB/s | 0.98 us / 331.5 MiB/s |
| small (341 B) | traverse | 0.96 us / 339.7 MiB/s | 0.35 us / 922.6 MiB/s | 0.27 us / 1185.6 MiB/s | 0.37 us / 877.4 MiB/s |
| small (341 B) | copy | 3.13 us / 104.0 MiB/s | 1.61 us / 201.4 MiB/s | 1.17 us / 277.9 MiB/s | N/A |
| medium (117,854 B) | parse | 1108.31 us / 101.4 MiB/s | 1025.26 us / 109.6 MiB/s | 292.90 us / 383.7 MiB/s | 175.75 us / 639.5 MiB/s |
| medium (117,854 B) | serialize | 896.97 us / 125.3 MiB/s | 464.90 us / 241.8 MiB/s | 350.65 us / 320.5 MiB/s | 259.48 us / 433.2 MiB/s |
| medium (117,854 B) | traverse | 271.95 us / 413.3 MiB/s | 94.70 us / 1186.9 MiB/s | 83.00 us / 1354.1 MiB/s | 102.73 us / 1094.0 MiB/s |
| medium (117,854 B) | copy | 987.08 us / 113.9 MiB/s | 406.88 us / 276.2 MiB/s | 159.80 us / 703.3 MiB/s | N/A |
| large (805,216 B) | parse | 14026.68 us / 54.7 MiB/s | 8019.35 us / 95.8 MiB/s | 2173.38 us / 353.3 MiB/s | 1429.88 us / 537.0 MiB/s |
| large (805,216 B) | serialize | 21643.63 us / 35.5 MiB/s | 3756.54 us / 204.4 MiB/s | 2529.02 us / 303.6 MiB/s | 2379.25 us / 322.8 MiB/s |
| large (805,216 B) | traverse | 2464.98 us / 311.5 MiB/s | 777.77 us / 987.3 MiB/s | 605.36 us / 1268.5 MiB/s | 740.37 us / 1037.2 MiB/s |
| large (805,216 B) | copy | 8602.30 us / 89.3 MiB/s | 3447.04 us / 222.8 MiB/s | 1225.48 us / 626.6 MiB/s | N/A |
These numbers are a reproducible local snapshot, not a universal ranking. CPU scaling, background load, compiler versions, allocator behavior, and workload shape can materially change results. Re-run the command above on the target machine before making performance-sensitive decisions.
| Measurement | Interpretation | Better result |
|---|---|---|
best us |
Fastest microseconds per operation among six samples. | Lower |
median us |
Median microseconds per operation; usually the best primary comparison. | Lower |
avg us |
Mean microseconds per operation. | Lower |
MiB/s |
Original input size divided by median time. | Higher |
bytes |
Original input JSON size. | Context only |
iters |
Operations in each calibrated timed sample. | Context only |
MiB/s is an input-size-normalized comparison, not actual serialized, visited,
or copied bytes. The final sink= value is only an anti-optimization checksum.
Benchmark results have no pass/fail threshold; compare Release runs made on the
same machine under similar load. CI retains machine-readable reports as advisory
artifacts but does not gate on noisy shared-runner timings. See the
benchmark guide for
the exact timed work, dependency versions, methodology, and sample output.
- Tutorials and streaming guide
- pjson 2.0 behavioral and ABI contract
- Browsable API reference and its source landing page
- Migration guides for nlohmann/json and RapidJSON
- Custom allocator tutorial
- Contributing, security reporting, and changelog
- Versioning, release process, licensing/SPDX policy, and authors
Build and validate the generated reference with Doxygen and Python 3:
cmake -S . -B out/build-docs \
-DPJSON_BUILD_TESTS=OFF \
-DPJSON_BUILD_EXAMPLES=OFF \
-DPJSON_BUILD_BENCHMARKS=OFF \
-DPJSON_BUILD_DOCS=ON
cmake --build out/build-docs --target pjson-docs-checkOpen out/build-docs/docs/reference/html/index.html. Warnings and missing
public API families fail validation.
- pjson requires C++11 and owns a mutable DOM; it is not a zero-copy parser.
parseSaxStream()avoids buffering the whole document, although its handler, current tokens, nesting state, and duplicate-key tracking still use memory. - Object insertion and direct traversal order are not preserved. Private
process-seeded hash storage accelerates lookup;
keys()and serialization also use unspecified native storage order. - Duplicate object keys are rejected by default;
pJsonParser::Optionscan explicitly keep the first or last value. - Signed integers use
int64_t; unsigned integers aboveINT64_MAXuse a distinctuint64_tkind, so the full 64-bit range round-trips exactly. Integer tokens outside[INT64_MIN, UINT64_MAX], floating overflow, and nonzero floating tokens that underflow to zero are rejected by default; opt in withpJsonParser::Options::AllowLossyNumbersto store the nearest finitedouble. A stored non-finitedoublefails serialization by default; chooseNonFiniteToNullorNonFiniteToStringto emit it. - Parsing always enforces RFC 8259, including valid UTF-8 and the standard lowercase literals and escape syntax.
- Hostile-input limits default to 512 nesting levels, 1,000,000 materialized
values, and 64 MiB of input; tune
maxDepth,maxNodes, andmaxInputBytes. A configuredmaxDepthis clamped to a stack-safe hard ceiling (1024) that cannot be raised. - Schema validation uses a documented subset by default and implements the
required Draft 2020-12 vocabularies through
Options::draft2020(). It is provided by the standalonepJsonSchemaValidator(in<pjson_schema.h>), which consumes only pjson's public API. Permissive subset mode ignores unknown keywords (Options::strict()fails closed on unsupported standard keywords), while Draft 2020-12 applies vocabulary policy per schema resource and compiles schemas against bundled or explicitly resolved meta-schemas. It supports URI resources, anchors, dynamic references,unevaluated*, conditionals,prefixItems,containsbounds, anddependentSchemas. External references and custom meta-schemas require an application resolver and never perform implicit I/O. It does not validate during SAX parsing. Tuple-formitems/prefixItemsvalidates corresponding positions. String lengths count Unicode code points. Regex matching uses the policy-limited default unless trusted mode is requested. - A custom
Allocatorroutes persistent DOM nodes and wrapper objects, not transient scratch storage or the backing allocations inside standard-library containers.