std::hash<basic_json> is inconsistent with operator== for equal cross-type numbers (breaks unordered containers)
Nobody has claimed this yet.
- Dominant language
- C++
- Stars
- 50.6k
- Forks
- 7.5k
- Avg merge
- 4d 17h
- Merged PRs (30d)
- 58
Description
Description
std::hash<basic_json> can produce different hash values for two json values that compare equal under operator==. The C++ standard requires of a Hash used with std::unordered_map/unordered_set that a == b implies hash(a) == hash(b); this invariant is violated for numbers of different internal types.
operator== converts between number_integer, number_unsigned, and number_float before comparing (include/nlohmann/json.hpp, JSON_IMPLEMENT_OPERATOR), so json(0) == json(0.0) == json(0u) are all true. But hash() folds the value_t type tag into the result via combine(type, h) (include/nlohmann/detail/hash.hpp), giving each of the three a distinct hash.
Consequently a std::unordered_set<json> can hold json(0), json(0.0), and json(0u) as three separate elements even though they are all equal — silent duplicate keys / lookup misses.
The documentation (std_hash.md) currently describes this as intended ("different hash values for null, 0, 0U, and false"). But 0, 0U, and 0.0 are equal under operator==, so distinguishing them by hash is precisely the contract violation. Only false vs 0 is legitimately distinguishable (those are not equal — different value_t and no cross-conversion). This looks like a case where the documented intent itself encodes the bug.
Reproduction steps
Hash json(0), json(0.0), json(0u); observe they are == but hash differently and coexist in an unordered_set.
Expected vs. actual results
- Expected: equal values hash equally;
unordered_setof{0, 0.0, 0u}has size 1. - Actual: three distinct hashes;
unordered_setsize is 3.
Minimal code example
#include <nlohmann/json.hpp>
#include <unordered_set>
#include <cstdio>
using json = nlohmann::json;
int main()
{
json a = 0, b = 0.0, c = 0u;
std::hash<json> H;
std::printf("a==b:%d a==c:%d hashes: %zu %zu %zu\n",
(int)(a==b), (int)(a==c), H(a), H(b), H(c));
std::unordered_set<json> s; s.insert(a); s.insert(b); s.insert(c);
std::printf("unordered_set size (all == 0): %zu (expected 1)\n", s.size());
}
Error messages
a==b:1 a==c:1 hashes: 2654436095 2654436221 2654436156
unordered_set size (all == 0): 3 (expected 1)
Discussion / possible fix
Make hash() consistent with operator== for numbers, e.g. normalize numeric values to a canonical representation before hashing (so that any two numerically-equal values hash the same), or fold a single "number" category into the hash instead of the specific value_t. This is a semantics decision for the maintainer; the current behavior breaks the standard Hash requirement whenever numeric json values are used as keys in unordered containers.
Compiler and operating system
g++ 13.3.0 (Ubuntu 24.04, x86-64)
Library version
develop @ 01853ed6bcf9ebe88ec2e248ea757b86417b1487
Validation
- The bug also occurs if the latest version from the
developbranch is used. - I can successfully compile and run the unit tests.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with include/nlohmann/detail/hash.hpp and compare its numeric handling with operator== in include/nlohmann/json.hpp. Reproduce the issue using the unordered_set example, then review docs/mkdocs/docs/api/basic_json/std_hash.md. Done means equal numeric values produce equal hashes, the set contains one element, and the documented behavior is updated.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100