NAME

JSON::YY - Fast JSON encoder/decoder with document manipulation API, backed by yyjson

SYNOPSIS

# functional API (fastest for simple encode/decode)
use JSON::YY qw(encode_json decode_json);
my $json = encode_json { foo => 1, bar => [1, 2, 3] };
my $data = decode_json '{"foo":1}';

# OO API (configurable)
my $coder = JSON::YY->new(utf8 => 1, pretty => 1);
my $json  = $coder->encode($data);
my $data  = $coder->decode($json);

# zero-copy readonly decode (fastest for read-only access)
use JSON::YY qw(decode_json_ro);
my $data = decode_json_ro $json;  # readonly, zero-copy strings

# Doc API (manipulate JSON without full Perl materialization)
use JSON::YY ':doc';
my $doc = jdoc '{"users":[{"name":"Alice","age":30}]}';
jset $doc, "/users/0/age", 31;
my $name = jgetp $doc, "/users/0/name";   # "Alice"
print jencode $doc, "";                    # serialize

DESCRIPTION

JSON::YY is a JSON module backed by yyjson 0.12.0, a high-performance JSON library written in ANSI C. It provides three API layers:

Functional/Keyword API - encode_json/decode_json compiled as custom Perl ops via XS::Parse::Keyword, eliminating function call overhead.
OO API - JSON::XS-compatible interface with chaining setters.
Doc API - Operate directly on yyjson's mutable document tree using path-based keywords. Avoids full Perl materialization for surgical JSON edits.

FUNCTIONAL API

use JSON::YY qw(encode_json decode_json decode_json_ro);
encode_json $perl_value

Encode a Perl value to a UTF-8 JSON string. Equivalent to JSON::YY->new->utf8->encode($value) but faster (no object overhead).

decode_json $json_string

Decode a UTF-8 JSON string to a Perl value.

decode_json_ro $json_string

Decode to a deeply readonly structure with zero-copy strings. String SVs point directly into yyjson's parsed buffer. Faster than decode_json for medium/large documents. Modification attempts croak.

When imported via qw(), these compile to custom ops via XS::Parse::Keyword, bypassing normal function dispatch. Keywords are lexically scoped. The -flag import style installs pre-configured closures instead (not compiled as keywords).

OO API

my $coder = JSON::YY->new(utf8 => 1, pretty => 1);
my $coder = JSON::YY->new->utf8->pretty;  # chaining style
new(%options)

Create a new encoder/decoder. Options: utf8, pretty, canonical, allow_nonref, allow_unknown, allow_blessed, convert_blessed, max_depth. (canonical is accepted for JSON::XS compatibility but is currently a no-op; see "LIMITATIONS".)

By default allow_nonref is on and every other flag is off, so a fresh coder produces character-mode output; pass utf8 => 1 for UTF-8 byte strings (as the encode_json function always does).

encode($perl_value)

Encode to JSON string. With utf8 enabled the result is a UTF-8 byte string; otherwise it is a character string.

Strings without Perl's UTF8 flag are treated as Latin-1 and re-encoded to UTF-8 on output, as JSON::XS does, so "caf\xE9" and "caf\x{E9}" produce the same JSON.

decode($json_string)

Decode from JSON string.

decode_doc($json_string)

Decode to a JSON::YY::Doc handle (mutable document, no Perl materialization). Can then use Doc API keywords on the result.

utf8, pretty, canonical, allow_nonref, allow_unknown, allow_blessed, convert_blessed

Boolean setters, return $self for chaining.

convert_blessed calls a blessed object's TO_JSON method when the class has one; when it does not, encoding falls back to the allow_blessed behaviour (null, or a croak if that flag is off).

max_depth($n)

Set maximum nesting depth (default 512), applied when encoding and when decoding. JSON nested deeper than this is rejected rather than materialised.

The default is what keeps untrusted input from exhausting the C stack; raising it raises that ceiling too. A few thousand is safe, but values in the tens of thousands let input through that recurses deeply enough to crash rather than croak, so only raise it as far as your data actually needs.

DOC API

use JSON::YY ':doc';

The Doc API operates on yyjson's internal mutable document tree, using JSON Pointer (RFC 6901) paths for addressing. All keywords compile to custom ops for maximum performance.

Character strings, not bytes. jdoc and jraw read their JSON as characters, like JSON::YY->new->decode does with utf8 off. If you hand them UTF-8 bytes straight off a socket or file, non-ASCII text is double-encoded. Decode first, or use the OO entry point for bytes:

my $doc = jdoc $body;                          # WRONG for raw bytes
my $doc = jdoc Encode::decode_utf8($body);     # right
my $doc = JSON::YY->new(utf8=>1)->decode_doc($body);  # right, no copy

jread reads a file as bytes and needs no such care. The keywords never modify the scalars passed to them.

Unless documented otherwise, path keywords croak when the path is missing or the value has the wrong type for the operation. The exceptions return a soft value instead: jgetp, jtype, jdel, and jfind return undef; jhas and the jis_* predicates return false.

Document creation

jdoc $json_string

Parse JSON into a mutable document handle (JSON::YY::Doc).

jfrom $perl_value

Create a document from a Perl value (hash, array, scalar).

Value constructors

Create typed JSON values for use with jset:

jstr $value - JSON string (ensures string type, e.g. jstr "007")
jnum $value - JSON number (croaks if $value is not numeric)
jbool $value - JSON true/false
jnull - JSON null
jarr - empty JSON array
jobj - empty JSON object

Path operations

All path arguments use JSON Pointer syntax: /key/0/nested. Use "" for root. Use /arr/- to append to an array.

jget $doc, $path

Get a subtree reference (returns a Doc that shares the parent's tree). Croaks if path not found. Use jhas to check first, or jgetp for undef-on-missing behavior.

jgetp $doc, $path

Get value materialized to Perl (string, number, hashref, arrayref, etc.). Alias: jdecode.

jset $doc, $path, $value

Set value at path. $value can be a scalar (auto-typed), Perl ref (recursively converted), or another Doc (deep-copied). Returns $doc.

Missing intermediate levels are created as objects, even when the path component is a number, so jset $doc, "/users/0/name", "Bob" on an empty document yields {"users":{"0":{"name":"Bob"}}}, not an array. Create the array first (jset $doc, "/users", []) when you want one; appending to an existing array with /- works as expected.

A blessed object is converted with its TO_JSON method if it has one, and otherwise becomes null (the Doc API always behaves as if convert_blessed and allow_blessed were enabled). The same applies to jfrom.

jdel $doc, $path

Delete value at path. Returns the removed subtree as an independent Doc, or undef if path not found. Croaks on an empty path (the root cannot be deleted).

jhas $doc, $path

Check if path exists. Returns boolean.

jclone $doc, $path

Deep copy subtree into a new independent document.

Serialization

jencode $doc, $path

Serialize document or subtree to compact JSON bytes.

jpp $doc, $path

Serialize to pretty-printed JSON (indented with 4 spaces).

jraw $doc, $path, $json_fragment

Insert a raw JSON string at path without Perl roundtrip. The fragment is parsed by yyjson and inserted directly into the document tree.

Inspection

jtype $doc, $path

Returns type string: "object", "array", "string", "number", "boolean", "null".

jlen $doc, $path

Array length, object key count, or string byte length.

jkeys $doc, $path

Object keys as a list of strings.

jvals $doc, $path

Object values as a list of Doc handles.

Iteration

Pull-style iterators for arrays and objects:

my $it = jiter $doc, "/users";
while (defined(my $elem = jnext $it)) {
    my $name = jgetp $elem, "/name";
    my $key  = jkey $it;  # for objects: current key
}
jiter $doc, $path - create iterator
jnext $iter - advance, returns Doc or undef
jkey $iter - current key (objects only)

File I/O

jread $filename

Read a JSON file and return a Doc handle.

jwrite $doc, $filename

Write a Doc to a file (pretty-printed).

Path enumeration

jpaths $doc, $path

Enumerate all leaf paths under the given path. Returns a list of JSON Pointer strings. Keys containing ~ or / are escaped per RFC 6901. Empty objects and arrays contain no leaves and so contribute no paths.

jfind $doc, $array_path, $key_path, $match_value

Find the first element in an array where the value at $key_path equals $match_value. Returns the matching element as a Doc, or undef if no element matches (also if $array_path is missing or does not point to an array).

my $bob = jfind $doc, "/users", "/name", "Bob";

Integer fields are compared as 64-bit integers and real fields as doubles (so values above 2^53 do not collide). To match a JSON true, false, or null field, pass the corresponding string "true", "false", or "null" as $match_value.

Patching

jpatch $doc, $patch_doc

Apply RFC 6902 JSON Patch. $patch_doc must be a Doc containing a patch array. Modifies $doc in-place. $doc must be an owned document; croaks on a borrowed subtree (from jget) -- jclone it first.

jmerge $doc, $patch_doc

Apply RFC 7386 JSON Merge Patch. Modifies $doc in-place. As with jpatch, $doc must be an owned document, not a borrowed subtree.

Comparison

jeq $doc_a, $doc_b

Deep equality comparison. Returns boolean.

Type predicates

All return boolean. Return false for missing paths.

jis_obj $doc, $path
jis_arr $doc, $path
jis_str $doc, $path
jis_num $doc, $path
jis_int $doc, $path
jis_real $doc, $path
jis_bool $doc, $path
jis_null $doc, $path

Overloading

JSON::YY::Doc objects support:

"$doc"          # stringify to JSON
if ($doc)       # always true
$a eq $b        # deep equality; vs a plain string, compares the JSON
$a ne $b        # deep inequality
$a == $b        # identity -- true only for the same handle

IMPORT FLAGS

use JSON::YY -utf8, -pretty;

Imports encode_json/decode_json with the specified flags pre-configured. (decode_json_ro is only available via the qw() import, not the flag form.)

The two import styles do not combine: the flag form installs closures, so it turns off the keyword compilation for those two names in that scope. Import one way or the other.

JSON POINTER (RFC 6901)

Paths use JSON Pointer syntax:

""            root value
/key          object key
/0            array index 0
/a/b/0/c      nested path
/arr/-        append to array (jset/jraw only)
/k~0ey        key containing ~ (escaped as ~0)
/k~1ey        key containing / (escaped as ~1)

EXAMPLES

# surgical edit of large document
use JSON::YY ':doc';
my $doc = jdoc $large_json;
jset $doc, "/config/timeout", 30;
my $json = jencode $doc, "";

# extract fields without full decode
my $doc = jdoc $api_response;
my $status = jgetp $doc, "/status";
my $count  = jlen  $doc, "/data/items";

# type-safe value insertion
jset $doc, "/active", jbool 1;     # true, not 1
jset $doc, "/id",     jstr "007";  # "007", not 7

# iterate without materializing
my $it = jiter $doc, "/users";
while (defined(my $u = jnext $it)) {
    say jgetp $u, "/name" if jis_str $u, "/name";
}

# apply RFC 6902 patch
my $patch = jdoc '[{"op":"replace","path":"/v","value":2}]';
jpatch $doc, $patch;

# apply RFC 7386 merge patch
jmerge $doc, jdoc '{"debug":null,"version":"2.0"}';

# OO decode directly to Doc
my $coder = JSON::YY->new(utf8 => 1);
my $doc = $coder->decode_doc($json);

# insert raw JSON without Perl roundtrip
jraw $doc, "/blob", '[1,2,{"nested":true}]';

# deep compare
say "equal" if jeq $doc_a, $doc_b;
say "equal" if $doc_a eq $doc_b;   # overloaded

PERFORMANCE

Indicative figures from the bundled bench/bench.pl. Throughput depends heavily on payload shape, perl build and hardware, and the two libraries trade places across those axes -- run the benchmark on your own data rather than relying on the numbers below.

Encode (ops/sec, higher is better)

                JSON::XS    JSON::YY     delta
small  (38B)    6.4M        6.7M         +4%
medium (11KB)   26.8K       27.3K        +2%
large  (806KB)  153         234         +53%

Decode (ops/sec, higher is better)

                JSON::XS    JSON::YY     delta
small  (38B)    4.2M        3.5M        -17%
medium (11KB)   16.9K       14.1K       -16%
large  (806KB)  249         267          +8%

Encode is consistently faster, especially on large payloads where yyjson's optimized serializer dominates. Decode is slightly slower on small/medium payloads due to Perl SV allocation overhead.

Doc API vs decode-modify-encode cycle

                        Perl        Doc         speedup
read one value          3.0M/s      3.1M/s      ~equal
modify + serialize      1.6M/s      2.2M/s      +42%
read from large doc     14.6K/s     73.7K/s     +405%
modify large + encode   7.4K/s      47.3K/s     +536%
clone subtree           15.0K/s     75.2K/s     +400%
type/length check       14.4K/s     74.6K/s     +418%

The Doc API avoids full Perl materialization, providing 4-5x speedup for surgical operations on medium/large documents.

THREADS

Encoding and decoding are stateless, so encode_json, decode_json and decode_json_ro can be used freely from any thread.

Coder objects (JSON::YY->new) are copied into a new thread with their settings intact and remain usable on both sides.

JSON::YY::Doc handles and iterators are tied to the interpreter that created them: they may be alive when a thread starts, but the copy the child receives is inert and using it croaks with cannot be shared between threads. The original stays fully usable. Pass JSON text (or the result of jencode) between threads instead of a Doc.

Structures from decode_json_ro may be shared with a thread and read from either side; the underlying parse buffer is kept alive until the last thread holding part of it goes away.

LIMITATIONS

  • canonical mode is accepted but not yet implemented (yyjson has no sorted-key writer).

  • Duplicate keys in one object are resolved differently by the two decoders: decode_json/decode_json_ro keep the last occurrence (as JSON::XS does), while the Doc API resolves a pointer to the first. jdel on such a key removes every occurrence but returns only the first. JSON does not define duplicate-key semantics; avoid relying on either.

  • NaN and Infinity values cannot be encoded (croaks).

  • Nesting is bounded in both directions by max_depth (default 512): input nested deeper than that is rejected with maximum nesting depth exceeded rather than being materialised, and encoding croaks the same way. A JSON Pointer passed to jset/jraw is bounded too, since each component creates a nested parent. The functional and Doc APIs use the default; only the OO coder can change it -- see "max_depth($n)" before raising it far.

    A document can still be driven past the limit by repeated mutation, since each individual path is short (jset $cur, "/k", {}; $cur = jget $cur, "/k" in a loop). Serialising such a document is safe (jencode, jpp and stringification are iterative), and jgetp/jpaths croak rather than recurse. jclone, jeq, jdel, jwrite and jpatch however recurse inside yyjson and will exhaust the C stack somewhere around 100_000 levels. Reaching that takes a deliberate loop -- neither parsed JSON nor a JSON Pointer can build a document that deep -- but if you construct documents by unbounded repeated nesting, serialise rather than clone or compare them.

  • JSON true/false decode to the Perl scalars 1/0 (correct in boolean context), not to overloaded boolean objects. To encode a JSON boolean, pass a scalar ref (\1 for true, \0 for false) or use jbool in the Doc API. Consequently encode_json(decode_json('[true,false]')) yields [1,0], not [true,false].

COOKBOOK

Read config, modify, write back

use JSON::YY ':doc';
my $config = jread "config.json";
jset $config, "/database/host", "newhost";
jwrite $config, "config.json";

Extract fields from large API response

my $doc = jdoc $response_body;
my $status = jgetp $doc, "/status";
my $count  = jlen  $doc, "/data/items";
my $first  = jgetp $doc, "/data/items/0/name";

Find user by name in array

my $user = jfind $doc, "/users", "/name", "Alice";
say jgetp $user, "/email" if defined $user;

Build document from scratch

my $doc = jfrom {};
jset $doc, "/name", "My App";
jset $doc, "/version", jnum 1;
jset $doc, "/features", jarr;
jset $doc, "/features/-", "auth";
jset $doc, "/features/-", "logging";
jset $doc, "/debug", jbool 0;
jwrite $doc, "output.json";

Apply incremental updates (merge patch)

my $doc = jread "state.json";
jmerge $doc, jdoc $incoming_patch_json;
jwrite $doc, "state.json";

Debug: show all paths

my @paths = jpaths $doc, "";
say "$_ = ", jencode $doc, $_ for @paths;

Type-safe assertions

die "expected array" unless jis_arr $doc, "/items";
die "expected string" unless jis_str $doc, "/name";

Compare two documents

die "configs differ" if $prod ne $staging;  # overloaded
# or explicitly:
die "differ" unless jeq $prod, $staging;

CHEATSHEET

# --- Import ---
use JSON::YY qw(encode_json decode_json);    # functional
use JSON::YY ':doc';                          # Doc API keywords

# --- Encode/Decode ---
encode_json $data          decode_json $json
$coder->encode($data)      $coder->decode($json)
decode_json_ro $json       # zero-copy readonly

# --- Doc lifecycle ---
jdoc $json                 # parse JSON string -> Doc
jfrom $perl_data           # Perl data -> Doc
jread $file                # read JSON file -> Doc
jwrite $doc, $file         # Doc -> write JSON file
jencode $doc, $path        # Doc -> JSON string
jpp $doc, $path            # Doc -> pretty JSON string
jgetp $doc, $path          # Doc -> Perl value
$coder->decode_doc($json)  # OO: JSON -> Doc

# --- Read ---
jget $doc, $path           # -> Doc subtree ref (shared)
jgetp $doc, $path          # -> Perl value (materialized)
jdecode $doc, $path        # alias for jgetp
jhas $doc, $path           # -> bool
jfind $doc, $arr, $k, $v   # -> Doc (first match) or undef

# --- Write ---
jset $doc, $path, $val     # set (scalar/ref/Doc)
jdel $doc, $path           # delete -> Doc (removed)
jraw $doc, $path, $json    # insert raw JSON fragment

# --- Copy ---
jclone $doc, $path         # deep copy -> independent Doc

# --- Inspect ---
jtype $doc, $path          # "object"|"array"|"string"|...
jlen $doc, $path           # array/object/string length
jkeys $doc, $path          # object keys (list)
jvals $doc, $path          # object values (list of Doc)
jpaths $doc, $path         # all leaf paths (list)

# --- Type predicates ---
jis_obj jis_arr jis_str jis_num jis_int jis_real jis_bool jis_null

# --- Value constructors ---
jstr $v    jnum $v    jbool $v    jnull    jarr    jobj

# --- Iterate ---
my $it = jiter $doc, $path;
while (defined(my $v = jnext $it)) { jkey $it; ... }

# --- Patch ---
jpatch $doc, $patch        # RFC 6902
jmerge $doc, $patch        # RFC 7386

# --- Compare ---
jeq $a, $b                 # deep equality
$a eq $b                   # overloaded
"$doc"                     # overloaded stringify

# --- Path syntax (JSON Pointer RFC 6901) ---
""          root           /key        object key
/0          array[0]       /arr/-      append to array
/k~0ey      key with ~     /k~1ey      key with /

SEE ALSO

JSON::XS, Cpanel::JSON::XS, JSON::PP

yyjson: https://github.com/ibireme/yyjson

AUTHOR

vividsnow

LICENSE

This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself.

yyjson is included under the MIT License.