to_cbor()/to_msgpack() allocate a temporary basic_json for every object key
#5,318 opened on Jul 27, 2026
Repository metrics
- Stars
- (50,278 stars)
- PR merge metrics
- (Avg merge 9d 4h) (16 merged PRs in 30d)
Description
Description
write_cbor() serializes object keys by calling itself on the key:
el.first is a string_t, but the parameter is const BasicJsonType&, so each key is implicitly converted to a temporary basic_json — which heap-allocates a string_t (plus its buffer for keys beyond SSO) — purely to reach the value_t::string case a few lines above. write_msgpack() has the identical pattern.
The UBJSON and BSON writers in the same file already do it the direct way, writing the key's characters straight to the output adapter without constructing a basic_json.
Measurement
Counting global operator new calls while serializing a 1000-entry object (-O2, g++ 13.3.0):
| case | allocations |
|---|---|
to_cbor, array of 1000 ints (baseline) |
13 |
to_cbor, object, 1000 short keys (SSO) |
1016 |
to_cbor, object, 1000 64-char keys |
2016 |
to_msgpack, object, 1000 short keys |
1016 |
to_ubjson, object, 1000 short keys |
17 |
to_bson, object, 1000 short keys |
14 |
So CBOR and MessagePack pay one allocation per key even when the key fits in the small-string buffer (the string_t object itself is heap-allocated by basic_json), and two per key otherwise. UBJSON and BSON pay none.
Minimal code example
static long allocs = 0;
void* operator new(std::size_t n) { ++allocs; void* p = std::malloc(n); if (!p) throw std::bad_alloc(); return p; }
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }
#include <nlohmann/json.hpp>
using json = nlohmann::json;
int main()
{
json j = json::object();
for (int i = 0; i < 1000; ++i) { char k[8]; std::snprintf(k, sizeof k, "k%04d", i); j[k] = i; }
allocs = 0; (void)json::to_cbor(j); std::printf("cbor %ld\n", allocs); // 1016
allocs = 0; (void)json::to_ubjson(j); std::printf("ubjson %ld\n", allocs); // 17
}
Suggested fix
Factor the string-writing body of the value_t::string case out of write_cbor into a helper taking const string_t& (say write_cbor_string), call it from both the value_t::string case and the key loop, and do the same for write_msgpack. This is the same shape as the BSON writer cleanup in #5313, which changed write_bson_unsigned to take the underlying value rather than the basic_json.
Compiler and operating system
g++ 13.3.0, Ubuntu 24.04, -std=c++14, -O2
Library version
develop @ 8ec98e2
Would the fix be breaking?
No. This is a purely internal refactor of two private member functions of detail::binary_writer:
- Byte-for-byte identical output. The extracted helper would run the same length-prefix and character-writing code the
value_t::stringcase runs today, soto_cbor/to_msgpackproduce exactly the same bytes. Existing round-trip tests should pass unchanged. - No API/ABI surface.
binary_writeris indetailand the affected functions are not part of the documented API. No signature inbasic_jsonchanges. - One thing to watch: object keys are
object_t::key_type, which is not necessarilystring_tfor a customObjectType. The helper should be written so key types that merely convert tostring_tstill compile — either by templating it or by keepingwrite_cbor(el.first)as a fallback for non-string_tkey types. Getting this wrong would break exoticbasic_jsonspecializations at compile time, so it deserves a test with a custom object type.
I'm happy to open a PR for this if it's wanted.