Skip to content

Diff deeply nested values without recursing per nesting level - #5548

Merged
nlohmann merged 16 commits into
developfrom
claude/iterative-diff
Oct 1, 2026
Merged

nlohmann merged 16 commits into
developfrom
claude/iterative-diff

Conversation

@nlohmann

@nlohmann nlohmann commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

What & why

diff() descends into both values once per nesting level, and compares them with operator== on every level on the way, which recurses as well. Values nested deeply enough terminate the process with SIGSEGV: 25,000 levels on an 8 MiB stack. parse() accepts such values without complaint. On such a chain the per-level comparisons and per-level path strings also made diff() quadratic in time and memory. Reported in #5393.

Part of the same series as #5546, see the table there.

The change

Like merge_patch() and dump(), diff() now bounds its descent with a depth count:

  • The first detail::recursion_depth_limit() (128, shared with the other operations) levels: the recursive algorithm diffs them exactly as before, now named diff_recursively() and given a depth parameter. It keeps its per-level operator== shortcut, which is safe since Fix stack overflow and exponential runtime when comparing nested values #5390 made comparisons non-recursive.
  • Deeper: diff_iteratively() walks the rest on an explicit stack, emitting the same operations in the same order. It doesn't compare arrays and objects with operator== up front, since doing that on every level would be quadratic in the depth and equal ones yield no operations anyway. It keeps the path in one buffer instead of a new string per level.

An earlier version of this PR scanned the source first (nesting_exceeds) because operator== still recursed; that scan and its cost are gone.

Cost

None measurable. Median of 15 runs, clang -O2:

workload develop this PR
twitter.json, equal 0.19 ms 0.18 ms
twitter.json, every 97th leaf changed 4.87 ms 5.13 ms
citm_catalog.json, equal 0.29 ms 0.29 ms
citm_catalog.json, every 97th leaf changed 28.2 ms 28.2 ms
canada.json, equal 0.52 ms 0.53 ms
canada.json, every 97th leaf changed 7.83 ms 7.93 ms

(The scan-based version took 0.24 / 0.38 / 0.86 ms on the equal documents.)

Verification

  • Identical patches: a differential against develop on 12,042 diffs (8,524 non-empty) is identical, in C++11 and C++20. It covers random json and ordered_json pairs with NaN, discarded values, 1 vs 1.0, binary values, reordered members, arrays of different lengths, and a custom root path with escaping, plus chains crossing the bound up to 400 levels deep.
  • After the rework: another differential against develop on 800 random json and ordered_json pairs with chains 100–180 levels deep, 110 of whose operations lie deeper than 128 levels: byte-identical patches, and every patch reproduces its target.
  • Deep values: 1,000,000 levels of nested arrays diff fine on a 1 MiB stack (re-checked after the rework); develop crashes at 100,000.
  • Tests:
    • At every depth up to 300, the patch must reproduce the target (for json and ordered_json, including reordered members). A difference deep inside must be exactly one replace operation, with the right path.
    • Values nested 100,000 levels deep are diffed.
    • Three mutants of the iterative walk fail 623–1,775 assertions: dropping the added members, dropping the removed elements, and not restoring the path.
  • The JSON Patch conformance tests from the test data pass unchanged.
  • make amalgamate + make check-amalgamation; no new findings from the clang-tidy checks CI runs, or -Weverything.

Public API impact

No breaking changes. diff()'s signature and results are unchanged; the helpers are private.


🤖 Generated with Claude Code

@nlohmann
nlohmann added this pull request to stack #5550 September 23, 2026 20:31
@nlohmann nlohmann added the review needed It would be great if someone could review the proposed changes. label Sep 23, 2026
@nlohmann
nlohmann force-pushed the claude/iterative-diff branch from f85ff73 to f9247cd Compare September 23, 2026 20:42
Comment thread include/nlohmann/json.hpp Outdated
Comment thread include/nlohmann/json.hpp Outdated
@nlohmann
nlohmann force-pushed the claude/iterative-diff branch from f9247cd to ced82e8 Compare September 23, 2026 21:16
@nlohmann
nlohmann removed this pull request from stack #5550 September 23, 2026 21:17
@nlohmann
nlohmann force-pushed the claude/iterative-diff branch from ced82e8 to f4d0822 Compare September 24, 2026 04:54
@nlohmann
nlohmann marked this pull request as draft September 24, 2026 04:55
@nlohmann
nlohmann force-pushed the claude/iterative-diff branch from f4d0822 to c4d7239 Compare September 24, 2026 07:39
@nlohmann
nlohmann added this pull request to stack #5568 September 24, 2026 15:09
@nlohmann
nlohmann force-pushed the claude/iterative-diff branch from c4d7239 to fd39377 Compare September 24, 2026 15:11
Base automatically changed from claude/iterative-merge-patch-update to develop September 24, 2026 15:12
diff() descended into both values once per nesting level, and compared
them with operator== on every level on the way, which recurses as well.
Values nested deeply enough - 25,000 levels on an 8 MiB stack - exhausted
the call stack and terminated the process, although parse() accepts
them without complaint. On such a chain the per-level comparisons and
path strings also made diff() quadratic in time and memory.

Both the recursion and operator== only descend as far as the source is
nested. So diff() first checks, recursing at most diff_depth_limit()
(128) levels, whether the source is nested more deeply than that. If not
- all but a vanishing minority of values - the recursive algorithm
diffs it exactly as before, now as diff_recursively(). Otherwise
diff_iteratively() walks the two values on an explicit stack, emitting
the same operations in the same order. It does not compare arrays and
objects with operator== up front (equal ones yield no operations
anyway), keeps the path in one buffer instead of a new string per
level, and hands every subtree that is not nested too deeply back to
diff_recursively(), so equal parts are still skipped quickly.

The check costs one pass over the source. On a 3,000-object document
that is about 30% of diffing two equal values (which is just an
operator== call), about 10% of diffing values that differ in a few
places, and noise when arrays change length. Once operator== no longer
recurses (#5390), the check can go.

Tests check that the patch reproduces the target at every depth up to
300, for json and ordered_json, including reordered members. They also
check the exact operation for a difference deep inside, and diff values
nested 100,000 levels deep.

Fixes #5393 for diff().

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
GCC's -Weffc++ (an error in CI) asks a class with pointer members, a
user constructor and a non-trivial destructor to declare its copy
constructor and copy assignment; diff_frame's vector and basic_json
members make its destructor non-trivial. Declare all five as defaulted,
which also satisfies clang-tidy's special-member-functions check. Leave
their exception specifications implicit: GCC 4.8 rejects an explicit
one that differs from the implicit one, as it does for flatten_task in
#5517.

The converting constructor cannot throw, and is now declared noexcept
for GCC's -Wnoexcept, which flags the emplace_back() under C++26
otherwise. The struct also moves from diff_iteratively() into the class,
like dump_frame in the serializer.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
diff_depth_limit() is gone in favor of detail::recursion_depth_limit().

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann
nlohmann force-pushed the claude/iterative-diff branch from fd39377 to 08e30ec Compare September 24, 2026 15:12
Checking every depth up to 300 made test-json_patch exceed the 1500 s ctest
timeout in ci_test_valgrind. Check the depths up to 16, those around the
recursion limit of 128, and 300 instead.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
The braces are kept for GCC's -Weffc++, as in json_sax.hpp.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Now that operator== no longer recurses (#5390), diff() can keep its per-level
equality shortcut all the way down. It diffs recursively for the first
detail::recursion_depth_limit() levels, as merge_patch() does, and hands
anything deeper to diff_iteratively(). The nesting_exceeds() scan, which
cost about 30% on equal documents, is gone, and diff() is on par with
develop again.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann
nlohmann marked this pull request as ready for review September 25, 2026 20:24
Comment thread include/nlohmann/json.hpp Outdated
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
diff_recursively built every patch operation in place from initializer
lists. Unoptimized builds give each of those temporaries its own stack
slot, so every level of the bounded descent cost kilobytes of stack
(about 6 KB with clang -O0), and the 128 recursive levels overflowed the
1 MB stack of MSVC Debug in the "deeply nested values" test. The
operations and the key comparison of two objects are now built by
separate functions, which diff_iteratively shares, and both diff
functions append to one result instead of returning a patch per level
that the caller copies. With clang -O0, diffing values nested 300 levels
deep now peaks at about 190 KB of stack instead of 880 KB.

Since diff() now owns the only returned value, clang's -Wnrvo no longer
reports the returns of diff_recursively, which alternated between the
local patch and diff_iteratively's result.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
The loop in diff_iteratively held a reference to the top frame, which
enter() invalidates when it pushes and the end of the loop invalidates
when it pops. Nothing used it afterwards, but a later change could. As in
the other iterative walks, the members the loop reads are now copied out
as constants and the ones it advances are changed through stack.back().
The frame as a whole is not copied: it holds the common keys and the
"add" operations of an object.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann

Copy link
Copy Markdown
Owner Author

#5639 found that diff()'s fast/slow-path check (the code this PR moves into diff_object_keys()) sends every object type except an insertion-ordered one (e.g. ordered_json) through the slow (remove-everything, re-add-everything) path whenever an added key sorts before an existing common key, even though a plain std::map never needs its members reordered. Fixed for develop in #5691 by requiring the order check only when detail::is_ordered_map<object_t>::value is true.

This branch has the same condition (currently at diff_object_keys(), "if (common_keys_source_order == common_keys_target_order && new_keys_form_suffix)") and needs the equivalent change so the bug isn't reintroduced when this PR merges:

-        if (common_keys_source_order == common_keys_target_order && new_keys_form_suffix)
+        // Only an object type that keeps its members in insertion order,
+        // such as nlohmann::ordered_map, can need reordering: patch() appends
+        // a new member at the end of such an object. Any other object type
+        // places its members itself - std::map in key order, a hash map in an
+        // order its operator== ignores - so a member-by-member diff always
+        // reproduces target there.
+        if (!detail::is_ordered_map<object_t>::value
+                || (common_keys_source_order == common_keys_target_order && new_keys_form_suffix))
         {
             // fast path: order of common keys already matches (or the
             // object_t's iteration order does not depend on

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

Conflicts:
- include/nlohmann/json.hpp: kept the PR's diff_recursively()/
  diff_object_keys() split and re-applied develop's #5691 (fast path for
  every object type except an insertion-ordered one; target key order
  only tracked for such types) inside diff_object_keys(), so both the
  recursive and the iterative walk get it
- single_include/nlohmann/json.hpp: regenerated with make amalgamate
- tests/src/unit-json_patch.cpp: kept both new test cases (deeply
  nested diff from the PR, #5639 regression from develop)

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann

Copy link
Copy Markdown
Owner Author

Merged develop (795ce4e). Conflict in diff()'s object handling: develop's #5691 (take the fast path for every object type except an insertion-ordered one, and only track target key order for such types) edited the inline code this PR moved into diff_object_keys(). The merge keeps the PR's split and re-applies #5691 inside diff_object_keys(), so the recursive and the iterative walk both get it (checked: the #5639 example gives 1 op for json and 5 for ordered_json at depths 0 to 1000). Both new test cases in unit-json_patch.cpp kept. single_include regenerated.

— posted by Claude Code on behalf of @nlohmann

@nlohmann nlohmann removed the review needed It would be great if someone could review the proposed changes. label Oct 1, 2026
@nlohmann nlohmann added this to the Release 3.13.0 milestone Oct 1, 2026
@nlohmann
nlohmann merged commit 18dd566 into develop Oct 1, 2026
4 of 160 checks passed
@nlohmann
nlohmann deleted the claude/iterative-diff branch October 1, 2026 05:29
This was referenced Oct 1, 2026
nlohmann added a commit that referenced this pull request Oct 9, 2026
* Flatten deeply nested values without recursing per nesting level

json_pointer::flatten() called itself once per nesting level, so
flatten() on a value nested deeply enough exhausted the call stack.
#5547 and #5548 fixed merge_patch() and diff() from #5393, but flatten()
was left out.

flatten() now walks the value with an explicit stack and keeps the path
in one buffer that grows and shrinks with it. It has a single code path
and no depth limit: the old version built a new path string per child,
so the iterative one is no slower on shallow values and much faster on
deep ones. The output, including the order of an ordered_json result,
is unchanged.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Construct flatten frames in place

Give the frame a constructor so both call sites can use emplace_back, as
suggested in the review; index starts at 0 for every frame.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

---------

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants