Skip to content

JSON Patch: an operation with a value of null cannot be read back (diff's own output fails to round trip) #2883

Closed

Description

@wilkolbrzym

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

  1. stephenberry commented on Sep 23, 2026

    @stephenberry
    Owner

    Fixed on main by #2894: a null value is accepted and diff output round trips.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions