Lazy JSON Parsing¶
Glaze provides a truly lazy JSON parser (glz::lazy_json) that offers on-demand parsing without any upfront processing. This approach is ideal when you need to extract a few separate fields from large JSON documents.
When to Use Lazy JSON¶
| Use Case | Recommended Approach |
|---|---|
| Extract 1-3 fields from large JSON | glz::lazy_json |
| Access fields near the beginning | glz::lazy_json or partial_read |
| Full deserialization into structs | glz::read_json |
| Iterate all elements (single pass) | glz::lazy_json |
| Multiple random accesses to array | glz::lazy_json with .index() |
| Unknown/dynamic JSON structure with persistent memory | glz::generic |
Basic Usage¶
#include "glaze/json.hpp"
std::string json = R"({"name":"John","age":30,"active":true,"balance":12345.67})";
auto result = glz::lazy_json(json);
if (result) {
auto& doc = *result;
// Access fields lazily - only parses what you access
auto name = doc["name"].get<std::string_view>();
auto age = doc["age"].get<int64_t>();
auto active = doc["active"].get<bool>();
auto balance = doc["balance"].get<double>();
if (name && age && active && balance) {
std::cout << *name << " is " << *age << " years old\n";
}
}
Why Lazy?¶
glz::lazy_json does zero upfront work:
lazy_json()just stores a pointer and validates the first byte - O(1)- Field access scans only the bytes needed to find that field
UTF-8 Validation¶
To maximize performance, lazy_json does no validation during initial parsing or field scanning. Validation happens when you extract a string value:
get<std::string>(): Processes escape sequences (\n,\uXXXX, etc.) and validates UTF-8 encoding, failing witherror_code::invalid_utf8on malformed inputget<std::string_view>(): Returns a raw view into the JSON buffer with no validation or processing
If you need validated UTF-8 strings and unescaping, use get<std::string>(). Otherwise get<std::string_view>() is faster, but the bytes it returns are unchecked. To validate a whole document up front, run glz::validate_json over the buffer. See Reading.
get<std::string>() honors the document's validate_utf8 option, so a lazy_json opened with that option set to false returns the raw bytes rather than an error.
glz::lazy_json will ensure that any instantiated C++ values are valid JSON (except for std::string_view), but it doesn't validate the entire document, because this is often not a requirement for lazy parsing. If you want high performance full validation it is best to use C++ structs. Or, use glz::validate_json for pure validation passes.
Nested Object Access¶
Access deeply nested fields efficiently:
std::string json = R"({
"user": {
"profile": {
"name": "Alice",
"email": "alice@example.com"
},
"settings": {
"theme": "dark"
}
}
})";
auto result = glz::lazy_json(json);
if (result) {
auto& doc = *result;
// Chain field access - each level is lazy
auto email = doc["user"]["profile"]["email"].get<std::string_view>();
if (email) {
std::cout << "Email: " << *email << "\n";
}
}
Array Access¶
Access array elements by index:
std::string json = R"({
"items": [
{"id": 1, "value": 100},
{"id": 2, "value": 200},
{"id": 3, "value": 300}
]
})";
auto result = glz::lazy_json(json);
if (result) {
auto& doc = *result;
// Access specific array element
auto first_value = doc["items"][0]["value"].get<int64_t>();
auto third_id = doc["items"][2]["id"].get<int64_t>();
if (first_value && third_id) {
std::cout << "First value: " << *first_value << "\n";
std::cout << "Third id: " << *third_id << "\n";
}
}
Iteration¶
Iterate over arrays and objects efficiently:
std::string json = R"({"items": [{"id": 1}, {"id": 2}, {"id": 3}]})";
auto result = glz::lazy_json(json);
if (result) {
auto& doc = *result;
// Iterate array elements
int64_t sum = 0;
for (auto item : doc["items"]) {
auto id = item["id"].get<int64_t>();
if (id) sum += *id;
}
std::cout << "Sum of ids: " << sum << "\n";
}
For objects, you can access both keys and values:
std::string json = R"({"a": 1, "b": 2, "c": 3})";
auto result = glz::lazy_json(json);
if (result) {
for (auto item : result->root()) {
std::cout << item.key() << ": ";
auto val = item.get<int64_t>();
if (val) std::cout << *val;
std::cout << "\n";
}
}
Indexed Views for O(1) Access¶
For scenarios requiring multiple random accesses or repeated iteration, you can build an index for O(1) element access:
std::string json = R"({"users": [{"id": 0}, {"id": 1}, ..., {"id": 999}]})";
auto result = glz::lazy_json(json);
if (result) {
// Build index once - O(n) scan
auto users = (*result)["users"].index();
// Now enjoy O(1) operations:
size_t count = users.size(); // O(1) - no scanning
auto user500 = users[500]; // O(1) - direct access
auto user999 = users[999]; // O(1) - no matter the position
// O(1) iteration advancement
for (auto& user : users) {
auto id = user["id"].get<int64_t>(); // Nested access still lazy
}
}
When to Use .index()¶
| Scenario | Without Index | With Index | Recommendation |
|---|---|---|---|
| Single random access | O(k) | O(n) build + O(1) | Don't index |
| 5+ random accesses | O(5k) | O(n) build + O(5) | Use index |
| Multiple iterations | O(n) each | O(n) build + O(n) each | Use index |
| Need size before iterating | O(n) | O(1) after build | Use index |
| Single sequential iteration | O(n) | O(n) build + O(n) | Don't index |
Indexed View API¶
auto indexed = doc["items"].index();
// O(1) size query
size_t count = indexed.size();
// O(1) empty check
if (!indexed.empty()) { /* ... */ }
// O(1) random access by position
auto third = indexed[2];
// For indexed objects: O(n) key lookup (linear search)
auto value = indexed["key"];
// Check if object contains key
if (indexed.contains("key")) { /* ... */ }
// Full random-access iterator support
auto it = indexed.begin();
it += 50; // Jump forward 50 elements
auto elem = it[10]; // Access 10 elements ahead
auto dist = indexed.end() - it; // Distance to end
Nested Access Remains Lazy¶
Elements returned from an indexed view are still lazy_json_view objects. Nested field access remains lazy:
auto users = doc["users"].index();
// O(1) to get to user 500
auto user = users[500];
// Nested access is still lazy - scans only "email" field
auto email = user["profile"]["email"].get<std::string_view>();
Performance Example¶
For 10 random accesses to a 1000-element array:
| Approach | Throughput | Notes |
|---|---|---|
lazy_json (no index) |
232 MB/s | Each access scans from start |
lazy_json (indexed) |
993 MB/s | Index built once, O(1) accesses |
The indexed approach is 327% faster than non-indexed for this use case.
Optimizing Performance: Sequential Access¶
The key to getting maximum performance from lazy_json is accessing keys in document order. The parser maintains a position pointer and continues scanning from where it left off.
How Progressive Scanning Works¶
std::string json = R"({"a":1,"b":2,"c":3,"d":4,"e":5})";
auto result = glz::lazy_json(json);
if (result) {
auto& doc = *result;
// FAST: Sequential access - O(n) total
doc["a"].get<int64_t>(); // Scans from start, finds "a"
doc["b"].get<int64_t>(); // Continues from after "a", finds "b"
doc["c"].get<int64_t>(); // Continues from after "b", finds "c"
doc["d"].get<int64_t>(); // Continues from after "c", finds "d"
doc["e"].get<int64_t>(); // Continues from after "d", finds "e"
// Total: scanned the object once
}
Performance Comparison¶
| Access Pattern | Complexity | Example |
|---|---|---|
| Sequential (in document order) | O(n) total | a, b, c, d, e |
| Reverse order | O(n) per access | e, d, c, b, a |
| Random order | O(n) per access | c, a, e, b, d |
Why Order Matters¶
Consider a JSON object with 1000 keys. Accessing 5 keys:
Sequential access (keys appear in order):
doc["key_001"] → scan 1 key
doc["key_002"] → scan 1 more key (continues from key_001)
doc["key_003"] → scan 1 more key
doc["key_004"] → scan 1 more key
doc["key_005"] → scan 1 more key
Total: ~5 keys scanned
Reverse order access:
doc["key_005"] → scan 5 keys from start
doc["key_004"] → wrap around, scan 1004 keys
doc["key_003"] → wrap around, scan 1003 keys
doc["key_002"] → wrap around, scan 1002 keys
doc["key_001"] → wrap around, scan 1001 keys
Total: ~5014 keys scanned (1000x slower!)
Practical Guidelines¶
-
Know your JSON structure: If you know the key order, access them in that order:
-
Use iterators for unknown order: If you need all keys but don't know the order:
-
Single field access is always fast: Accessing just one field is O(k) where k is the position of that field - no penalty.
-
Nested access is independent: Each nested object has its own position tracking:
Wrap-Around Behavior¶
If you access a key that appears earlier in the document, the parser wraps around:
doc["c"].get<int64_t>(); // Position now after "c"
doc["a"].get<int64_t>(); // Wraps: scans from "c" to end, then start to "a"
This still works correctly but is slower than sequential access.
Reset Parse Position¶
If you need to re-scan from the beginning:
Streaming Cursor (opt-in)¶
When you iterate a large container and fully consume each element, the iterator normally has to re-scan the element it just handed you in order to find where the next one begins. read_into already walked those same bytes, so the scan is pure repeated work.
The lazy_streaming_cursor option removes it. When a value is consumed end to end, its byte extent is recorded on the document, and the next advance jumps straight to the recorded end:
struct stream_opts : glz::opts
{
bool lazy_streaming_cursor = true;
};
inline constexpr stream_opts stream{};
auto doc = glz::lazy_json<stream>(buffer);
for (auto& row : (*doc)["rows"]) {
Row parsed{};
if (not row.read_into(parsed)) {
use(parsed); // the next ++ skips the re-scan of this row
}
}
The optimization composes through nesting: an inner iterator that runs to its closing bracket records the whole inner container, so the outer advance skips it too.
An extent is only used when it starts exactly at the element the iterator is positioned on. A byte offset identifies exactly one value in the buffer, so this check is what keeps the single shared slot safe when iterators are interleaved or abandoned partway. Anything that does not match falls back to a normal scan.
What it costs¶
Two size_t on lazy_document when enabled, and nothing when disabled, so a document that does not opt in is byte-for-byte what it was before. Views and iterators are unchanged in either case.
Thread safety¶
Recording an extent writes to the document, from a const method. Two threads calling read_into on disjoint subviews of the same lazy_document do not race with the option off, but do with it on. Give each thread its own lazy_document (copying one is cheap — it holds a pointer and a length, not the buffer) or leave the option off.
Note that a lazy_document is already not safe to share for keyed access either way, because operator[] advances the cached root view's scan position.
When it does not help¶
- Scalar elements. Only containers are recorded (see below); numbers, strings, booleans and nulls are skipped by the cheap paths anyway.
- Elements consumed key by key via
operator[]. Those are already served by the progressiveparse_pos_scan described above; no extent is produced. - Elements that are skipped rather than read. Nothing was consumed, so there is nothing to record.
partial_read. That option deliberately stops the parser as soon as the target's known keys are filled, which leaves it short of the element's true end.- The final element of a bounded (non-null-terminated) buffer, which ends on
end_reachedrather than on a close bracket.
What the cursor trusts, and what it verifies¶
read_into hands the iterator to parse<JSON>::op, and where that leaves it is up to the from<JSON, T> specialization. Glaze's own readers stop at the value's end, but that is a convention, not something the cursor can check for free — glz::text deliberately swallows the rest of the buffer, and a custom reader may consume only a prefix. Trusting the stopping point blindly is how a cursor turns a well-formed document into a short or mis-valued element stream with no error reported anywhere.
So an extent is recorded only when the element is a container and the parse finished exactly on that container's own closing bracket. That covers the case worth accelerating (large elements, where re-scanning is expensive) and rejects readers that stopped somewhere else.
One residual limitation: a custom reader that consumes a complete inner container but stops before the element's own end (for example reading [[1],2] and stopping after [1]) produces an extent that passes this check and is still wrong. Such a reader is already incompatible with ordinary glz::read_json on nested structures, since it would misparse whatever follows. If you write custom from<JSON, T> specializations, leave the iterator at the value's end.
Measured effect¶
A 9 MB array of three-field objects, read_into per row, Apple M1, clang -O3:
| throughput | |
|---|---|
| cursor off | 575 MB/s |
| cursor on | 955 MB/s (+66%) |
The gain scales with how much of each element read_into consumes; containers whose elements are large benefit most, since those are the scans being elided.
Wide Number Skip (opt-in)¶
Skipping over a container walks its bytes looking for the structural characters (" [ ] { }) that carry depth. Everything in between — numbers, literals, commas, whitespace — is stepped over one byte at a time with a table lookup.
That is the right default. Typical JSON numbers are a few bytes long, and a wide SIMD-style scan cannot amortize its setup over three digits. But documents built from long numeric cells — telemetry dumps, exported matrices, scientific data — spend most of their bytes inside numeric runs, and there the byte-at-a-time walk dominates.
lazy_wide_number_skip targets exactly that case. It keeps the cheap table walk for short numbers and escalates to an 8-byte SWAR scan only once a run is still going after 8 bytes:
struct wide_opts : glz::opts
{
bool null_terminated = false; // required: the option applies to bounded buffers
bool lazy_wide_number_skip = true;
};
When to enable it¶
Measure on your own data. This is a real trade, not a free win, and how it lands depends on more than just how long your numbers are. Full traversal, Apple M1, clang -O3, bounded buffers, interleaved best-of-5:
| document shape | option off | option on | |
|---|---|---|---|
| long numeric runs, array cells | 904 MB/s | 1231 MB/s | +36% |
| long numeric, object-keyed | 521 MB/s | 479 MB/s | −8% |
| short numeric cells | 380 MB/s | 380 MB/s | ±0% |
tiny objects ({"k":"v","w":1}) |
314 MB/s | 310 MB/s | −1% |
| long strings | 1877 MB/s | 1884 MB/s | ±0% |
| telemetry records | 485 MB/s | 478 MB/s | −1% |
| nested mixed (typical API payload) | 356 MB/s | 340 MB/s | −4% |
The two rows that matter most are the numeric ones, and they point in opposite directions:
- Long numbers as array cells win big. The escalated scan runs past the commas and the following cells in one sweep, so it skips much more than a single number.
- Long numbers as object values can lose. Each run ends a few bytes later at the next key's
", so the scan never amortizes its setup. An independent measurement using a different traversal put this case as bad as −19%, so treat roughly −20% as the worst case rather than the −8% above.
Enable it for array-shaped numeric data. Leave it off otherwise. With the option off the generated code is byte-for-byte what it was before the option existed, so the default costs nothing at all.
Restrictions¶
- Non-null-terminated buffers only. A null-terminated buffer stops on its own sentinel, and a detached view may have no end pointer at all, so the option is ignored there.
- Correctness is independent of the option. Inside a container every non-structural byte is ignorable, so escalating early, late, or never reaches the same position. The threshold is a performance knob, not a semantic one.
Type Checking¶
Check the type of a value before extracting:
auto& doc = *result;
auto value = doc["field"];
if (value.is_object()) { /* ... */ }
if (value.is_array()) { /* ... */ }
if (value.is_string()) { /* ... */ }
if (value.is_number()) { /* ... */ }
if (value.is_boolean()) { /* ... */ }
if (value.is_null()) { /* ... */ }
// Explicit bool conversion - true if not null/error
if (value) {
// Value exists and is not null
}
Supported Types for get()¶
| Type | Description |
|---|---|
bool |
Boolean values |
int32_t, int64_t |
Signed integers |
uint32_t, uint64_t |
Unsigned integers |
float, double |
Floating-point numbers |
std::string |
String with escape processing |
std::string_view |
Raw string view (no escape processing) |
std::nullptr_t |
Null values |
Error Handling¶
All operations return values that can be checked for errors:
auto result = glz::lazy_json(json);
if (!result) {
// Parse error
auto error = result.error();
std::cout << "Error: " << glz::format_error(error, json) << "\n";
return;
}
auto& doc = *result;
auto value = doc["missing_key"];
if (value.has_error()) {
// Key not found or type error
auto ec = value.error();
// Handle error...
}
auto num = doc["field"].get<int64_t>();
if (!num) {
// Extraction failed (wrong type, parse error, etc.)
auto error = num.error();
// Handle error...
}
Container Methods¶
auto& doc = *result;
auto arr = doc["items"];
// Check if container is empty
if (arr.empty()) { /* ... */ }
// Get number of elements (requires scanning)
size_t count = arr.size();
// Check if object contains a key
if (doc.root().contains("name")) { /* ... */ }
Deserializing into Structs¶
Use glz::read_json() to deserialize a lazy view directly into a typed struct:
struct User {
std::string name;
int age;
bool active;
};
std::string json = R"({
"user": {"name": "Alice", "age": 30, "active": true},
"metadata": {"version": 1, "large_data": "..."}
})";
auto result = glz::lazy_json(json);
if (result) {
// Navigate lazily to "user", then deserialize into struct
User user{};
auto ec = glz::read_json(user, (*result)["user"]);
// user.name == "Alice", user.age == 30, user.active == true
}
This works because Glaze provides a read_json overload that accepts lazy_json_view directly. The lazy navigation skips "metadata" entirely, and deserialization is single-pass (no double scanning).
Why Use This Pattern?¶
This hybrid approach gives you the best of both worlds:
- Lazy navigation: Skip large sections of JSON you don't need
- Fast deserialization: Use Glaze's optimized struct parsing for the parts you do need
- Type safety: Get compile-time checked structs instead of runtime field access
Deserializing Array Elements¶
Combine with indexed views for efficient random access deserialization:
struct Person {
std::string name;
Address address;
};
std::string json = R"({"people": [{"name": "Alice", ...}, {"name": "Bob", ...}, ...]})";
auto result = glz::lazy_json(json);
if (result) {
// Build index for O(1) random access
auto people = (*result)["people"].index();
// Deserialize only the 500th person
Person person{};
glz::read_json(person, people[500]);
}
Alternative: read_into() Member Function¶
If you prefer member function syntax, use read_into():
Performance Note¶
Both glz::read_json(value, view) and view.read_into(value) are ~49% faster than the older pattern of glz::read_json(value, view.raw_json()). The raw_json() approach requires scanning the value twice: once to find its extent, and once to parse it.
The raw_json() Method¶
Returns a std::string_view of the raw JSON bytes for any lazy view. Use this when you need the JSON text itself (for logging, forwarding, or storage):
auto result = glz::lazy_json(R"({"user": {"name": "Alice"}, "count": 5})");
// Get raw JSON for different value types
(*result).raw_json(); // {"user": {"name": "Alice"}, "count": 5}
(*result)["user"].raw_json(); // {"name": "Alice"}
(*result)["user"]["name"].raw_json(); // "Alice"
(*result)["count"].raw_json(); // 5
Note: For deserialization, use
glz::read_json(value, view)instead ofglz::read_json(value, view.raw_json())for better performance.
Writing Lazy Views¶
Lazy views can be written back to JSON:
auto& doc = *result;
auto user = doc["user"];
std::string output;
auto ec = glz::write_json(user, output);
// output contains the JSON for just the "user" field
The bytes written are the same bytes raw_json() returns, so writing a view is a copy of the original text rather than a re-serialization: formatting, key order, and number spelling are all preserved exactly.
The writer determines the value's extent under the document's options, which means a view over a buffer opened with null_terminated = false stays inside that buffer. It reports an error rather than writing anything if the value is truncated: a container that never reaches its closing bracket, or a string that never reaches its closing quote. A scalar that runs to the last byte of the document is complete, not truncated, and is written normally.
Like the rest of the lazy API, the writer does not validate scalars. A malformed literal is copied through as-is, exactly as raw_json() would return it.
Options¶
Use compile-time options for non-null-terminated buffers:
// For null-terminated strings (default, fastest)
auto result = glz::lazy_json(json);
// For non-null-terminated buffers
constexpr auto opts = glz::opts{.null_terminated = false};
auto result = glz::lazy_json<opts>(buffer);
Memory Layout¶
The lazy parser is designed for minimal memory overhead. A lazy_json_view is 48 bytes on 64-bit systems and 24 bytes on 32-bit systems.
Best Practices¶
-
Access keys in document order: This is the most important optimization. Sequential access gives O(n) total complexity:
-
Store the document reference: To benefit from progressive scanning, use the same document object:
-
Use iterators when order is unknown: If you don't know the key order or need all keys:
-
Use
.index()for multiple random accesses: If you need to access many elements by index or iterate multiple times: -
Keep JSON buffer alive: The lazy parser stores pointers into the original buffer - it must remain valid for the lifetime of the document.
-
Prefer
std::string_viewfor strings: When you don't need escape processing,get<std::string_view>()is faster thanget<std::string>(). -
Access few fields for best speedup: Lazy JSON shines when you access 1-5 fields from a large document. For full deserialization, use
glz::read_json. -
Use
glz::read_json(value, view)for struct deserialization: Glaze provides an overload ofread_jsonthat acceptslazy_json_viewdirectly. Useglz::read_json(obj, view)instead ofglz::read_json(obj, view.raw_json())- it's ~49% faster because it avoids scanning the value twice.
Partial Read vs Lazy JSON¶
Glaze offers two approaches for reading a subset of JSON data. Choose based on whether you know the fields at compile time:
Use partial_read When:¶
- Fields are known at compile time: You can define a struct with just the fields you need
- Type safety matters: You want compile-time type checking
- Fields appear early in the document: Partial read short-circuits after finding all struct fields
- Hash-based lookup: Uses Glaze's optimized key matching
// Define a struct with only the fields you need
struct Header {
std::string id{};
std::string type{};
};
std::string json = R"({"id":"abc123","type":"request","payload":{...large data...}})";
Header h{};
auto ec = glz::read<glz::opts{.partial_read = true}>(h, json);
// Parsing stops after "id" and "type" are found - "payload" is never parsed
Use lazy_json When:¶
- Fields determined at runtime: You don't know which fields to access until execution
- Conditional access: You need to check one field before deciding to read others
- Path-based access: You want to access nested fields by path (e.g.,
doc["user"]["email"]) - Iteration: You need to iterate over array/object elements
auto result = glz::lazy_json(json);
if (result) {
auto& doc = *result;
// Decide at runtime which fields to access
auto type = doc["type"].get<std::string_view>();
if (type && *type == "user_event") {
auto user_id = doc["user"]["id"].get<int64_t>(); // Only accessed conditionally
}
}
Performance Comparison¶
| Scenario | partial_read |
lazy_json |
Winner |
|---|---|---|---|
| Known fields, near start | Very fast | Fast | partial_read |
| Known fields, scattered | Moderate | Fast (sequential) | Depends on order |
| Conditional field access | N/A | Fast | lazy_json |
| Dynamic field names | N/A | Supported | lazy_json |
| Type-safe structs | Yes | No | partial_read |
See Partial Read for detailed documentation.
Comparison with All Approaches¶
| Feature | glz::read_json |
partial_read |
glz::lazy_json |
lazy_json + .index() |
glz::generic |
|---|---|---|---|---|---|
| Parse time | O(n) | O(n) worst | O(1) | O(1) + O(n) on index | O(n) |
| Field access | O(1) | Hash-based | O(k)* | O(1) after index | O(1) |
| Random array access | O(1) | N/A | O(k)* | O(1) after index | O(1) |
| Memory usage | Struct size | Struct size | ~48 bytes | ~48 + 8n bytes | Dynamic |
| Type safety | Compile-time | Compile-time | Runtime | Runtime | Runtime |
| Short-circuit | No | Yes | Yes | Yes | No |
| Best for | Full deser. | Known subset | Few accesses | Many accesses | Unknown structure |
*k = bytes to skip to reach field
See Also¶
- Partial Read - Compile-time partial reading with structs
- Generic JSON - Dynamic JSON with
glz::generic - Reading - Standard JSON reading with
glz::read_json - JSON Pointer Syntax - Alternative path-based access