Repository navigation
JSON Patch: an operation with a value of null cannot be read back (diff's own output fails to round trip) #2883
Description
Summary
A JSON Patch operation that carries null as its value cannot be read back. glz::patch_json rejects the whole patch document with missing_key, and a patch produced by glz::diff cannot be applied through the round trip the documentation describes (diff → write_json → read_json → patch). Setting any member of a document to null is therefore impossible through the JSON text of a patch, which is the one thing the documentation points users here for.
The cause is that patch_op_t::value is a std::optional<GenericType> (include/glaze/json/patch.hpp:40), and Glaze reads a JSON null into a std::optional as an empty optional. The value is gone before the operation is applied, and apply_operation reports the empty optional as a missing member (patch.hpp:573, :582, :600). The write side is correct: Glaze emits "value":null, i.e. it writes patch documents it cannot read.
Minimal reproduction
#include "glaze/json/patch.hpp"
#include "glaze/glaze.hpp"
#include <cstdio>
#include <string>
int main()
{
// (a) a patch document as any other RFC 6902 implementation would send it
auto result = glz::patch_json(R"({"foo":"bar"})", R"([{"op":"replace","path":"/foo","value":null}])");
std::printf("(a) replace /foo with null : %s\n",
result ? (std::string("OK, document is now ") + *result).c_str() : "ERROR missing_key");
// (b) the round trip the documentation describes: diff -> write_json -> read_json -> patch
auto source = glz::read_json<glz::generic>(R"({"a":1,"b":"keep"})");
auto target = glz::read_json<glz::generic>(R"({"a":null,"b":"keep"})");
auto patch = glz::diff(*source, *target);
std::string text;
glz::write_json(*patch, text);
std::printf("(b) diff output : %s\n", text.c_str());
auto ops = glz::read_json<glz::patch_document>(text);
std::printf(" value present after read: %d\n", (int)(*ops)[0].value.has_value());
auto doc = *source;
auto ec = glz::patch(doc, *ops);
std::string out;
glz::write_json(doc, out);
std::printf(" apply: ec=%d (missing_key), document is now %s\n", (int)ec.ec, out.c_str());
// (c) the same operation built in memory and written by glaze
glz::patch_document built{};
built.push_back({glz::patch_op_type::replace, "/foo", glz::generic(nullptr), std::nullopt});
std::string written;
glz::write_json(built, written);
std::printf("(c) written by glaze : %s\n", written.c_str());
auto back = glz::read_json<glz::patch_document>(written);
std::printf(" value present after read: %d\n", (int)(*back)[0].value.has_value());
return 0;
}$ g++ -std=c++23 -O1 -I include repro.cpp -o repro && ./repro
(a) replace /foo with null : ERROR missing_key
(b) diff output : [{"op":"replace","path":"/a","value":null}]
value present after read: 0
apply: ec=38 (missing_key), document is now {"a":1,"b":"keep"}
(c) written by glaze : [{"op":"replace","path":"/foo","value":null}]
value present after read: 0
Scale, measured rather than estimated. A diff → serialize → parse → patch oracle over 20 000 random document pairs:
== 20000 pairs ==
in-memory diff->patch mismatches : 0
diff/apply errors : 0
patch-as-text failures : 1925
of which missing_key (null value): 1925
missing_key without literal null: 0
other error kinds : 0
1925 of 20 000 pairs (9.6 %) fail, every one of them for this single reason; diff plus patch in memory is clean (0 of 20 000). The defect is only in the read of the value.
Why this is not intended
The official json-patch-tests suite exercises all three operations with a null value and expects them to succeed — these cases are not marked disabled:
{"doc": {"foo": 1}, "patch": [{"op": "add", "path": "/bar", "value": null}], "expected": {"foo": 1, "bar": null}}
{"doc": {"foo": null}, "patch": [{"op": "test", "path": "/foo", "value": null}], "expected": {"foo": null}}
{"comment": "null value should still be valid obj property replace other value",
"doc": {"foo": "bar"}, "patch": [{"op": "replace", "path": "/foo", "value": null}], "expected": {"foo": null}}RFC 6902 §4.6 defines value as the member conveying the value to be compared, and a JSON null is an ordinary JSON value; §4.1 add on an existing object member replaces that member's value.
The documentation recommends JSON Patch for exactly this case and documents the alternative as unable to do it: docs/json-merge-patch.md:18 has the table row "Need to set explicit null values | JSON Patch", and docs/json-merge-patch.md:340 explains that merge patch cannot set a value to null (quoting RFC 7386: "merge patch documents … do not make use of explicit null values"). Meanwhile docs/json-patch.md:217 ("Round-trip Example") documents the very diff → write_json → read_json → patch sequence that fails above.
Pre-existing
include/glaze/json/patch.hpp is byte identical between 1ce36b4f (2026-06-12, an ancestor of main) and current main 96cc2a39; git diff 1ce36b4f 96cc2a39 -- include/glaze/json/patch.hpp is empty. Nothing in the last 30 commits touching that file is related. Reproduced on GCC 15.2.0, -std=c++23, Linux x86-64.
Suggested direction
value has to distinguish "member absent" from "member present with value null". The cleanest shape is to read value as raw JSON and parse it into GenericType (a glz::custom reader through a glz::meta<patch_op_t> specialization, or a small wrapper type), so that null survives and missing_key is reserved for a genuinely absent member. That is an API change for patch_op_t, so the direction is yours to pick.
Found in an audit of main @ 96cc2a39 (2026-09-17), GCC 15.2.0, -std=c++23. Every claim above is compiled and run; the conformance suite used is json-patch-tests (95 cases) plus its spec_tests.json (17 cases).
Activity
stephenberry commentedon Sep 23, 2026
Fixed on main by #2894: a
nullvalueis accepted and diff output round trips.