Skip to content

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 with error_code::invalid_utf8 on malformed input
  • get<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

  1. Know your JSON structure: If you know the key order, access them in that order:

    // JSON: {"id":1,"name":"...","email":"...","created_at":"..."}
    // Access in document order:
    auto id = doc["id"].get<int64_t>();
    auto name = doc["name"].get<std::string_view>();
    auto email = doc["email"].get<std::string_view>();
    auto created = doc["created_at"].get<std::string_view>();
    

  2. Use iterators for unknown order: If you need all keys but don't know the order:

    for (auto item : doc.root()) {
        auto key = item.key();
        // Process each key-value pair in document order
    }
    

  3. Single field access is always fast: Accessing just one field is O(k) where k is the position of that field - no penalty.

  4. Nested access is independent: Each nested object has its own position tracking:

    // Each level scans its own object independently
    doc["user"]["profile"]["email"]  // Fast - 3 separate scans
    

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:

doc.reset_parse_pos();  // Next access starts from 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 progressive parse_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_reached rather 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:

  1. Lazy navigation: Skip large sections of JSON you don't need
  2. Fast deserialization: Use Glaze's optimized struct parsing for the parts you do need
  3. 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():

User user{};
(*result)["user"].read_into(user);  // Equivalent to glz::read_json(user, view)

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 of glz::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

  1. Access keys in document order: This is the most important optimization. Sequential access gives O(n) total complexity:

    // If JSON is: {"a":1,"b":2,"c":3}
    doc["a"];  // Good: starts scanning
    doc["b"];  // Good: continues from "a"
    doc["c"];  // Good: continues from "b"
    // Total: one scan of the object
    

  2. Store the document reference: To benefit from progressive scanning, use the same document object:

    auto& doc = *result;  // Store reference
    doc["a"];  // Position tracked in doc
    doc["b"];  // Continues from where "a" left off
    

  3. Use iterators when order is unknown: If you don't know the key order or need all keys:

    for (auto item : doc.root()) {
        // Always efficient - iterates in document order
    }
    

  4. Use .index() for multiple random accesses: If you need to access many elements by index or iterate multiple times:

    auto items = doc["items"].index();  // Build index once
    auto first = items[0];              // O(1) access
    auto last = items[items.size()-1];  // O(1) access
    

  5. Keep JSON buffer alive: The lazy parser stores pointers into the original buffer - it must remain valid for the lifetime of the document.

  6. Prefer std::string_view for strings: When you don't need escape processing, get<std::string_view>() is faster than get<std::string>().

  7. 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.

  8. Use glz::read_json(value, view) for struct deserialization: Glaze provides an overload of read_json that accepts lazy_json_view directly. Use glz::read_json(obj, view) instead of glz::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