Skip to content

support JSON Merge Patch (RFC 7396) diff creation - #4965

Open
satelliteprogrammer wants to merge 4 commits into
nlohmann:developfrom
satelliteprogrammer:develop
Open

satelliteprogrammer wants to merge 4 commits into
nlohmann:developfrom
satelliteprogrammer:develop

Conversation

@satelliteprogrammer

@satelliteprogrammer satelliteprogrammer commented Oct 25, 2025 •

Copy link
Copy Markdown

The JSON merge patch document format describes the set of modifications to a resource's content, that more closely mimics the syntax of the resource being modified.

However, and in contrast to JSON Patch (RFC 6902), a JSON Merge Patch cannot express certain modifications, e.g., changing an array element at a specific index, or setting a specific object value to null. The null value in a JSON Merge Patch is used to remove the key from the object.

The diff algorithm is not part of the RFC 7396, but it was tested against all examples provided, plus additional cases on how null values are handled.

JSON Merge Patch PR: #876
PR discussing the diff: #2018

If the content is approved, please let me know if/what documentation needs to be updated.

[Describe your pull request here. Please read the text below the line and make sure you follow the checklist.]

  • The changes are described in detail, both the what and why.
  • If applicable, an existing issue is referenced.
  • The Code coverage remained at 100%. A test case for every new line of code.
  • If applicable, the documentation is updated.
  • The source code is amalgamated by running make amalgamate.

Read the Contribution Guidelines for detailed information.

@github-actions

Copy link
Copy Markdown

🔴 Amalgamation check failed! 🔴

The source code has not been amalgamated. @satelliteprogrammer
Please read and follow the Contribution Guidelines.

@coveralls

Copy link
Copy Markdown

Coverage Status

coverage: 99.194% (+0.003%) from 99.191%
when pulling d32edb3 on satelliteprogrammer:develop
into 29913ca on nlohmann:develop.

@nlohmann

Copy link
Copy Markdown
Owner

I am not sure if this feature would be widely used, so I'm opening a discussion.

@nlohmann nlohmann added the state: please discuss please discuss the issue or vote for your favorite option label Oct 25, 2025
@satelliteprogrammer

satelliteprogrammer commented Oct 25, 2025 •

Copy link
Copy Markdown
Author

We had a (somewhat niche) need for this at work, that's why I've contributed here.

It's essentially a combination of 2 things:

  1. we log all our data structures in JSON, this makes it easier to parse the logs and pretty-format large data structures;
  2. we have a container that only notifies listeners on data changes.

For very large data structures, reading through a log line to find the one variable that did change is a pain. Not only that, but on some interfaces only a small amount of member variables on the entire structure are actually changing.
Given that we are already serializing in JSON, on those containers that track differences, we thought why not log only those differences using one of the available JSON patch methods. So here we are.

@github-actions

Copy link
Copy Markdown

This pull request has been marked as stale because it has had no activity for 30 days. While we won’t close it automatically, we encourage you to update or comment if it is still relevant. Keeping pull requests active and up-to-date helps us review and merge changes more efficiently. Thank you for your contributions!

@github-actions github-actions Bot added the state: stale the issue has not been updated in a while and will be closed automatically soon unless it is updated label Nov 25, 2025
@cschreib-ibex

Copy link
Copy Markdown

[..] we thought why not log only those differences using one of the available JSON patch methods. So here we are.

We had this exact need as well: logging changes in simple JSON structures without all the noise of the formal JSON Patch syntax. Would have been convenient to have this capability built in.

Comment thread include/nlohmann/json.hpp Outdated
Comment on lines +5278 to +5281
if (diff.is_null())
{
JSON_THROW(other_error::create(503, detail::concat("cannot set \"", itf.key(), "\" to null"), &target));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be overly constraining. An alternative would be to interpret "field set to null" as "field removed".

The RFC isn't very clear on this. While it says:

This design means that merge patch documents are suitable for describing modifications to JSON documents that primarily use objects for their structure and do not make use of explicit null values.

It also lists examples in appendix where some fields are set to null in the source JSON. So they may have meant "do not make use of explicit null values" as "do not attribute specific meaning to explicit null values".

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've changed the if to explicitly check if we have a transition from !null -> null. That one is impossible to generate, as it would collide with the actual meaning of null in the patch file.

Null values in the merge patch are given special meaning to indicate the removal of existing values in the target.

However, the end result is the same. We would reach here if the source != target AND target == null. There's no other way for the diff to be null.

Comment thread include/nlohmann/json.hpp Outdated
{
JSON_THROW(other_error::create(503, detail::concat("cannot set \"", itf.key(), "\" to null"), &target));
}
result[it.key()] = merge_diff(it.value(), itf.value());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inefficient; merge_diff was already called on the same inputs above and diff could be reused here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I had figured this one out already, but since the PR wasn't going anywhere I didn't fix it here.

Comment thread include/nlohmann/json.hpp Outdated
auto itf = target.find(it.key());
if (itf != target.end())
{
if (it.value() != itf.value())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inefficient: this will traverse the whole depth of the JSON structure to check for equality of all sub fields, and we'll do that again in merge_diff if going inside the if. This check could be removed, calling merge_diff unconditionally, then checking the output isn't an empty object.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though, this will need checking for value types, and only call merge_diff if both source and target are objects.

if (it.value().is_object() && itf.value().is_object()) 
{
    auto diff = merge_diff(it.value(), itf.value());
    if (!diff.empty()) 
    {
        result[it.key()] = std::move(diff);
    }
} 
else if (it.value() != itf.value()) 
{
    result[it.key()] = itf.value();
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will traverse the whole depth of the JSON structure to check for equality of all sub fields

I realised it, but since it would break on the first inequality, I conceded. What nudged me in this direction was that we were already checking if the target is not an object at the start, so it didn't feel right to check again before calling the recursive function.

I've now realised where I was wrong.
For two non-object identical values, it they are present at the top-level of the JSON, then the patch must contain them, as otherwise it will not apply them.
However, if the identical non-objects are nested within an object, then, even though it would still work, they aren't necessary in the patch file, as the target object will retain its previous values.

I think this difference in behaviour is what requires the extra work within the for-loop. And then you're correct, we need to check the type to make sure we're applying the most efficient comparison.

@github-actions github-actions Bot removed the state: stale the issue has not been updated in a while and will be closed automatically soon unless it is updated label Mar 14, 2026
@benpeck-eepics

benpeck-eepics commented Mar 16, 2026 •

Copy link
Copy Markdown

This is a feature that would be very useful to me. Our usecase is we would like to create a hierarchical data storage system that allows users to override values set in a base layer of json, storing user "overrides" as a merge patch. So being able to easily apply and generate merge patch documents would be essential to such a use case.

I find the merge patch formatting to be more human-readable than the json patch "action list" format. They are more intuitive to interpret for my usecase, where you are essentially looking at a sparse overlay document. You have the context of the changed element's path in the document providing self-documentation as to the intent of the change. A flat list of change actions lacks some of this context when it isn't structured like a typical document.

Thank you for your time, have a nice day.

@satelliteprogrammer

Copy link
Copy Markdown
Author

@cschreib-ibex just in case it's useful for you. You can represent null values iff the source and target represent the same container. In that scenario you don't need null to represent removing existing values.

@github-actions

Copy link
Copy Markdown

This pull request has been marked as stale because it has had no activity for 30 days. While we won’t close it automatically, we encourage you to update or comment if it is still relevant. Keeping pull requests active and up-to-date helps us review and merge changes more efficiently. Thank you for your contributions!

@github-actions github-actions Bot added the state: stale the issue has not been updated in a while and will be closed automatically soon unless it is updated label Apr 19, 2026

@nlohmann nlohmann left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution, and for iterating on the earlier feedback. The feature is well-motivated: several users have asked for it, and Jakarta JSON-P (Json.createMergeDiff) and Python's json-merge-patch (create_patch) offer the same thing. It's also a purely additive API.

However, I found two correctness issues where merge_patch(a, merge_diff(a, b)) != b, and the PR needs some housekeeping before it can be merged. I tested against the PR head (376be93) with this harness:

json a = json::parse(src), b = json::parse(dst);
json c = a;
c.merge_patch(json::merge_diff(a, b));
// expect c == b
source target patch produced result after merge_patch
{"a":{"x":1}} {"a":[]} {} {"a":{"x":1}} ❌
{"a":{}} {"a":[]} {} {"a":{}} ❌
{} {"a":{"b":null}} {"a":{"b":null}} {"a":{}} ❌
{"a":1} {"a":{"b":null}} {"a":{"b":null}} {"a":{}} ❌
1 {"a":{"b":null}} {"a":{"b":null}} {"a":{}} ❌
{"a":{"b":1}} {"a":{"b":null}} throws other_error.503 –

Details are in the inline comments. To summarize:

Must fix

  1. Only recurse when both values are objects (the empty-array case above).
  2. Handle nulls in the target the same way at every depth. Right now a top-level null throws, but a null nested inside an added or replaced value is silently lost.
  3. Rebase onto current develop. The branch is ~350 commits behind and conflicts in include/nlohmann/json.hpp.
  4. Run make amalgamate (the amalgamation check failed).

Documentation

  • Add docs/mkdocs/docs/api/basic_json/merge_diff.md, modeled on merge_patch.md and diff.md: signature, parameters, return value, exceptions, complexity, an example in docs/mkdocs/docs/examples/, and a "Version history" entry. Also add it to mkdocs.yml, the basic_json index page, and the "See also" sections of merge_patch.md and diff.md.
  • If the exception stays, document other_error.503 in docs/mkdocs/docs/home/exceptions.md.
  • Expand the one-line @brief to the usual @sa link to the docs page.

Tests

  • Add cases for the rows in the table above.
  • Assert the shape of the produced patch in at least some cases, not only the round-trip. That would have caught the empty {} patch.
  • Also run the tests with nlohmann::ordered_json.

This comment was written by Claude Code on behalf of @nlohmann.

Comment thread include/nlohmann/json.hpp Outdated
Comment thread include/nlohmann/json.hpp Outdated
Comment thread include/nlohmann/json.hpp
Comment thread include/nlohmann/json.hpp
Comment thread tests/src/unit-merge_diff.cpp
@nlohmann

Copy link
Copy Markdown
Owner

@satelliteprogrammer Are you willing to continue working on this?

@satelliteprogrammer

Copy link
Copy Markdown
Author

@satelliteprogrammer Are you willing to continue working on this?

Hi @nlohmann thanks for the interest and taking the time to review the PR.
Absolutely, I'll take a look at your examples and see what I missed.
I'll do it over the week.

@github-actions github-actions Bot removed the state: stale the issue has not been updated in a while and will be closed automatically soon unless it is updated label Sep 28, 2026

@nlohmann nlohmann left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few optional nits below. None of them block merging.

This comment was written by Claude Code on behalf of @nlohmann.

Comment thread include/nlohmann/json.hpp
auto itf = source.find(it.key());
if (itf == source.end())
{
result[it.key()] = it.value();

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (non-blocking): when a key is missing from source and its value in target is null, this copies "a": null into the patch. Applying it removes a member that isn't there, so it has no effect. Since the docs say a null member of target is treated as absent, it would be a bit cleaner to leave it out:

if (itf == source.end() && !it.value().is_null())

(The test "empty object to object with null value" would then expect {}.)

This comment was written by Claude Code on behalf of @nlohmann.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get the point, but wouldn't this also fit the idea of inconsistent null handling?
{} -> {"a":{"b":null}} results in {"a":{"b":null}}, not {"a":{}}, unless I recursively search for nulls.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point, you're right. Skipping only the top-level null would just move the inconsistency one level down, and stripping nulls recursively isn't worth the extra cost for a patch entry that does nothing anyway. The documented guarantee already excludes targets with null members. Let's keep it as it is, so consider this nit withdrawn.

This comment was written by Claude Code on behalf of @nlohmann.

Comment thread docs/mkdocs/docs/api/basic_json/merge_diff.md Outdated
Comment thread docs/mkdocs/docs/api/basic_json/merge_diff.md Outdated

## Complexity

Linear in the sizes of `source` and `target`, times the cost of looking up a key in an object: logarithmic in the size

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (non-blocking): this leaves out the cost of comparing changed non-object values: two arrays (or nested arrays inside them) are compared element by element with !=. Something like "plus the cost of comparing non-object values" would make it complete.

This comment was written by Claude Code on behalf of @nlohmann.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, isn't the cost of comparing non-object values included in the "linear in the sizes of ..."?
For the following 2 JSONs: {"a": [1,2,3]} and {"a": {"1": "one", "2": "two", "3": "three"}, what would you consider to be the size of each?

@nlohmann nlohmann left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great progress! I added some small nits, but otherwise good to go.

Please fix the DCO requirements.

@satelliteprogrammer satelliteprogrammer left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know what you thing.
I'll sign the last commit when I push again.

Comment thread docs/mkdocs/docs/api/basic_json/merge_diff.md Outdated
Comment thread docs/mkdocs/docs/api/basic_json/merge_diff.md Outdated

## Complexity

Linear in the sizes of `source` and `target`, times the cost of looking up a key in an object: logarithmic in the size

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point.

Comment thread include/nlohmann/json.hpp
auto itf = source.find(it.key());
if (itf == source.end())
{
result[it.key()] = it.value();

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get the point, but wouldn't this also fit the idea of inconsistent null handling?
{} -> {"a":{"b":null}} results in {"a":{"b":null}}, not {"a":{}}, unless I recursively search for nulls.

@nlohmann

nlohmann commented Oct 4, 2026

Copy link
Copy Markdown
Owner

Thanks, this looks good to merge from my side. Two things left before I can approve:

  1. DCO: all three commits need a Signed-off-by: line, not only the last one. For example, run git rebase --signoff HEAD~3 and then force-push.
  2. Merge develop: the current CI failures (MSVC C4127, the -Wexperimental-fmv-target gcc flag, cpplint in ordered_map.hpp) come from develop and were fixed in Fix CI on develop after merging the ready-to-merge PRs #5754. Merging develop into your branch should make CI green.

This comment was written by Claude Code on behalf of @nlohmann.

The JSON merge patch document format describes the set of modifications
to a resource's content, that more closely mimics the syntax of the
resource being modified.

However, and in contrast to JSON Patch (RFC 6902), a JSON Merge Patch
cannot express certain modifications, e.g., changing an array element
at a specific index, or setting a specific object value to null.
The null value in a JSON Merge Patch is used to remove the key from the
object.

The diff algorithm is not part of the RFC 7396, but it was tested
against all examples provided, plus additional cases on how null values
are handled.

Signed-off-by: Luís Murta <luis@murta.dev>
Signed-off-by: Luís Murta <luis@murta.dev>
Also add CHECKs for the patch, documentation and run `make amalgamate`.

Signed-off-by: Luís Murta <luis@murta.dev>
Signed-off-by: Luís Murta <luis@murta.dev>
Comment thread include/nlohmann/json.hpp
{
if (it.value().is_object() && itf.value().is_object())
{
auto diff = merge_diff(it.value(), itf.value());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a new recursive function and doesn't have the depth limit fallback to an iterative function that is being added to all other recursive functions.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll take a look at what changed in the other recursive functions.

Comment thread include/nlohmann/json.hpp
result[it.key()] = std::move(diff);
}
}
else if (it.value() != itf.value())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should there be handling for arrays here? Otherwise, a one element difference in an array will result in the diff having a full replacement of the array.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the specification.

Also, it is not possible to patch part of a target that is not an object, such as to replace just some of the values in an array.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, that's unfortunate, but I guess there's nothing to do about it.

This branch has not been deployed

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

Labels

documentation L state: please discuss please discuss the issue or vote for your favorite option tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants