Skip to content

Support a float precision in std::format: "{:.3}" - #5608

Draft
maddanio wants to merge 3 commits into
nlohmann:developfrom
maddanio:feature/float-precision
Draft

maddanio wants to merge 3 commits into
nlohmann:developfrom
maddanio:feature/float-precision

Conversation

@maddanio

@maddanio maddanio commented Sep 28, 2026 •

Copy link
Copy Markdown

Support a float precision in std::format: "{:.3}"

Reduced to the scope agreed in the review: std::format("{:.N}", j) only, with printf-exact rounding. There is no
dump() options struct and no stream wrapper. Streams can use os << std::format("{:.3}", j).

Behaviour

{:.N} writes floating-point numbers with N significant digits. The digits are exactly those that std::format
(and printf("%.*g")) writes for the number itself, including how ties are rounded:

value std::format("{:.N}", json(v)) std::format("{:.N}", v)
π, N = 3 3.14 3.14
1.9999, N = 3 2.0 2
2.675, N = 3 2.67 2.67
12345.678, N = 3 1.23e+04 1.23e+04
0.1, N = 20 0.10000000000000000555 0.10000000000000000555
  • A number that would otherwise read back as an integer gets a .0, as in dump().
  • It combines with the existing specs: {:#.3}, {:2.3}, {:.>#1.2}.
  • Integers are written exactly. NaN and infinity are still null.
  • The docs say that the output no longer round-trips.
  • Dynamic precision ({:.{}}) and . without digits are still rejected.
  • The precision is capped at 1000, which covers every significant digit of a double (at most 767).

Implementation

Three commits:

  1. Serializer: a trailing float_precision_ constructor argument. A negative value, the default, keeps today's
    output.
    • With a precision, every floating-point type takes the snprintf("%.*g") path that non-IEEE types such as a
      wider long double already use. It's the same function with the same locale handling; only the digit count
      changes from max_digits10 to N. The existing code was moved into a helper that both call, and the
      long double default output is unchanged.
    • One addition on this path only: trailing zeros of the fraction are removed, as %g requires. macOS's printf
      keeps them on exact ties in exponent notation: %.3g of 5405000 gives 5.40e+06, where std::format gives
      5.4e+06.
    • The serializer is in detail, so this is not public API.
  2. std::format: parse .N in std::formatter<basic_json> and pass it to the serializer. Includes docs and the
    example, whose output is checked.
  3. Amalgamate: single_include regenerated in a commit of its own, so that the first two diffs contain only the
    real changes.

Default path: unchanged output, benchmark

dump() without a precision, -O2, M-series Mac:

  • Output: byte-for-byte identical to develop on 1M random doubles (18.7 MB), with both compilers.
  • 1M random doubles, median of 31 runs:
    • Apple clang 21: develop 67.1–67.7 ms, this branch 67.3–67.7 ms.
    • GCC 15: develop 65.0–65.2 ms, this branch 64.5–64.7 ms.
  • canada.json, median of 200 runs, Apple clang: develop 7043–7105 µs, this branch 7034–7124 µs.

The precision path is marked JSON_HEDLEY_NEVER_INLINE. Without it, the default path was about 0.7% slower, and a
control build without the precision check showed that the check caused it. The likely reason is the path's 1 KB stack
buffer: inlined, it enlarges the stack frame for every number. With the attribute, the difference is within noise.

Tests

  • unit-float-precision.cpp (new):
    • rounding, the carry, ties, notation;
    • precision 0 and large precisions;
    • integers, NaN/inf, nested and pretty-printed values;
    • float and long double;
    • 100,000 random values checked against std::setprecision.
  • unit-std-format.cpp:
    • the spec, combinations, and rejected forms;
    • 100,000 random values where std::format("{:.N}", json(v)) must equal std::format("{:.N}", v) (plus .0).
      Checked against both libc++ and libstdc++.
  • unit-locale-cpp.cpp: the precision path under de_DE, where the decimal point is ,.
  • Test runs (macOS):
    • The full suite with Apple clang (131 tests).
    • The new and changed tests with GCC 15, including the ci_test_gcc warning set that failed in the previous CI
      run (-Wformat-truncation=2 and more).

🤖 Generated with Claude Code

Comment thread include/nlohmann/json.hpp Outdated
string_t result;
detail::output_string_adapter<char, string_t> string_adapter(result);
const bool pretty_print = options.indent >= 0;
serializer s(string_adapter, options.indent_char,

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.

Since the dump_options class is already in serializer.hpp, would it make sense to update serializer to use it, rather than converting to the individual parameters?

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.

Thought so too, will look into it

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.

Is it public api? Do i have to keep the arg variant?

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.

I don't think so, because it's in the detail namespace. @nlohmann can you confirm?

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.

I am not a fan of the options struct, and we excluded it from the scope in #1170 (comment). The reason is that we have so many issues regarding dump options that I'm afraid of adding options we may want to revisit later which would mean breaking changes.

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.

@maddanio Fair enough, you deserve a clear answer, so here it is.

Precision: yes, through std::format only. I'm not the person who needs it, but #1170 has been open since 2018 and people keep asking for it. std::format("{:.3}", j) gives a meaning to a spec that is rejected today, and that meaning comes from std::format itself. There are no new names or types, and nothing to design. In a stream, os << std::format("{:.3}", j) does the job.

dump_options: not in this PR. This isn't about precision. It's about what a public struct commits us to:

Your arguments for the struct are good ones, and I still think we'll get one eventually. But that needs its own issue and its own design discussion, not a fourth commit on a precision PR.

The stream wrapper: also not in this PR. I have to take back what I asked for in #1170. The wrapper has the same problem as the struct, just smaller. It's a public name we can never change, and it only covers one option. Once we have an options struct, streams will want one wrapper that takes all the options, and with_float_precision would stay behind as a duplicate we have to keep forever. It also holds a reference to j, so auto w = with_float_precision(make_json(), 3); os << w; dangles.

The cost is that C++11–17 and fmt users don't get precision until the options design exists. The fmt::formatter recipe can only call the public dump(), so it has no way to pass a precision. I think that's the right order: the first non-std::format way to set a precision should come from that design.

@gregmarr On your second question: serializer is in detail, so its signature isn't public API. Without a public struct, though, there's nothing to switch it to, so the point goes away.

Rounding ties: because {:.N} is std::format syntax, people will compare std::format("{:.3}", j) with std::format("{:.3}", 2.675) and expect the same digits. Once this ships, we can't change which way ties round, so it has to be right on the first try as well. That makes me lean toward the printf-exact tie fallback you already tested.

Next steps: please reduce this PR to commits 1 and 2 (serializer + std::format), and use the printf-exact tie rounding.

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.

Thanks for explaining all that @nlohmann, that all makes sense.

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.

ok, but std format is not really the standard way of dumping a huge file. so can I put the other two to PRs? If its onloy format I mean, sure, grad I could help, but still did not address my use case. Also for the tie: that means using snprintf or to_char, because grisu simply cannot do it.

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.

So this with format only and to_char/snprintf and then I would try the stream wrapper again with an appropriately open precision config

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.

Ah you want a dangling safe wrapper with config struct? That sounds klunky, but the whole wrapper is. Manipulators would be better. But there you a static xalloc call.

Comment thread tests/src/unit-float-precision.cpp Fixed
maddanio and others added 3 commits September 29, 2026 09:50
The serializer takes a new trailing constructor argument, float_precision_.
A negative value (the default) keeps the current output.

With a precision of zero or more, every floating-point type takes the
snprintf("%.*g") path that types other than IEEE-754 float and double
already use, with that many significant digits instead of max_digits10.
The digits are therefore those of printf and std::format("{:.N}", x),
including how ties are rounded. The precision is capped at 1000, which
covers every significant digit of a double.

On this path, trailing zeros of the fraction are also removed, as "%g"
requires: macOS's printf keeps them on exact ties in exponent notation
("5.40e+06" for 5405000 at precision 3). The default output does not
change.

Integers and NaN/inf are unaffected. No public API uses the precision
yet; std::format follows in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Daniel Oberhoff <daniel@danieloberhoff.de>
"{:.N}" writes floating-point numbers with N significant digits, using
the serializer's float precision. The digits are those that std::format
writes for the number itself, including how ties are rounded, so
std::format("{:.3}", json(2.675)) and std::format("{:.3}", 2.675) both
give 2.67. It combines with the existing specs, e.g. "{:#.3}" or
"{:.>nlohmann#2.3}". Dynamic precision and a '.' without digits are still
rejected.

The docs state that the output no longer round-trips, and that streams
can use os << std::format("{:.3}", j).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Daniel Oberhoff <daniel@danieloberhoff.de>
Regenerate single_include with make amalgamate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Daniel Oberhoff <daniel@danieloberhoff.de>
@maddanio
maddanio force-pushed the feature/float-precision branch from 20d1724 to fd4bbe8 Compare September 29, 2026 07:50
@maddanio maddanio changed the title Add a float precision: std::format "{:.3}", with_float_precision, dump(dump_options) Support a float precision in std::format: "{:.3}" Sep 29, 2026
@maddanio

Copy link
Copy Markdown
Author

changed: only std::format supported and uses snprintf as was already the case for long double

nlohmann added a commit that referenced this pull request Sep 30, 2026
The serializer constructor snapshotted std::localeconv() into a
locale_chars member on every dump(), even though the only reader is
dump_float(number_float_t, std::false_type)'s snprintf path, taken
only for a number_float_t that is neither IEEE single nor double.
localeconv() is not required to be thread-safe with setlocale(), so
every dump() paid for and raced on a lookup that almost never mattered.

Remove locale_chars and the locale member. Right before the
thousands-separator/decimal-point fixups in the snprintf path, read
std::localeconv() into local thousands_sep/decimal_point variables
(null-checked, first byte only, as before) - the same way
lexer::get_decimal_point() already does since #5597. Output is
unchanged unless the locale changes during a single dump(); in that
case the fixups now match what snprintf just produced, instead of a
value snapshotted before the call.

Overlaps draft PR #5608, which touches the same constructor and
dump_float() lines to move this code into a new
dump_float_snprintf(); this lands the lookup change now as #5709 asks,
and #5608 can do the lookup inside dump_float_snprintf() when it
rebases.

Verification: the full json_test_data corpus (742 files, dump(),
dump(4) and dump(-1,' ',true)) is byte-identical to before the change
under the C locale. Added a test pinning the new per-conversion
lookup: it switches LC_NUMERIC mid-dump() (via a streambuf that
switches on its first write, after the serializer's write buffer has
been flushed once but before a later float is converted) and checks
the decimal point is still normalized using the locale active at
conversion time. On a platform where long double is IEEE-754 double
(e.g. 64-bit Arm), dump_float() takes the locale-independent
to_chars() path and the test is a no-op there; it is meaningful on a
platform where long double is extended precision (most x86 targets).

Part of #5709 item 3

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
nlohmann added a commit that referenced this pull request Oct 1, 2026
…5729)

* Share scalar serialization between dump_internal and dump_value

dump_value()'s cases for string, binary, boolean, number_integer,
number_unsigned, number_float, discarded and null were a byte-for-byte
copy of dump_internal()'s (added together in #5285 for the iterative
fallback path). Any future change to scalar output had to be made in
both places, or the recursive and depth-limited paths would silently
start producing different bytes.

Extract the shared cases into a private dump_scalar() and have both
dump_internal() and dump_value() call it. Output is unchanged: dump(),
dump(4), dump(-1,' ',true) and the replace/ignore error_handler_t
variants are byte-identical over the json_test_data corpus before and
after, and dump() throughput on a scalar-heavy document is unaffected.

Part of #5709

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

* Drop serializer.hpp's dependency on binary_writer.hpp

The only use of binary_writer in serializer.hpp was
binary_writer<BasicJsonType, char>::to_char_type() to write the
U+FFFD replacement character's three bytes. With CharType=char this
is an identity conversion, so the include of binary_writer.hpp (and
transitively binary_reader.hpp) pulled in a large, unrelated header
for a no-op call.

Write the three bytes directly instead. serializer.hpp compiles
standalone with -Wall -Wextra -Werror, with and without
-funsigned-char, and unit-serialization's error_handler_t::replace
cases (with and without ensure_ascii) still pass. Moving
binary_writer's to_char_type/to_msgpack_length to its private section
is left as an optional follow-up.

Part of #5709

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

* Fix stale #include lines in the output headers

output_adapters.hpp included <algorithm> and <iterator> for std::copy
and std::back_inserter, which have not been used there since #3569
(2022). serializer.hpp included <algorithm> for std::reverse (also
unused), <cmath> for labs/isnan/signbit (only std::isfinite is used)
and <utility> for std::move (nothing from <utility> is used there),
while using std::next without including <iterator> at all, relying on
getting it transitively through output_adapters.hpp's own stale
<iterator>.

Drop the unused includes, add <iterator> for std::next, and correct
the remaining include comments. Both headers still compile standalone
with -Wall -Wextra -Werror.

Part of #5709

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

* Remove JSON_HEDLEY_NON_NULL(2) from write_characters() overrides

output_vector_adapter, output_stream_adapter and output_string_adapter
declared their write_characters(const CharType*, std::size_t) override
JSON_HEDLEY_NON_NULL(2), but binary_writer legitimately calls it with
a null pointer and length 0 for an empty string or binary value; the
type-erased call path only stayed silent under UBSan because the
static callee at those call sites is the unattributed virtual base.
A nonnull attribute on a definition lets GCC and Clang assume the
parameter is non-null inside the function body even when the call is
virtual, so this was latent undefined behavior, not just style.

Drop the attribute from the three overrides and document the
(nullptr, 0) contract on output_adapter_protocol::write_characters.
unit-cbor, unit-msgpack, unit-bson and unit-bon8 (which all exercise
empty binary/string payloads through the stream and vector/string
adapters) pass under -fsanitize=address,undefined,nonnull-attribute.

Part of #5709

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

* Update stale serializer doc comments to match the current implementation

dump_internal()'s doc block still described the pre-#5285/#5449
implementation: an escape_string() function that does not exist
(the function is dump_escaped), integer conversion "implicitly via
operator<<" (dump_integer actually uses a digit-pair lookup table),
and floating-point conversion via "%g" (IEEE-754 types go through
to_chars, others through snprintf). dump_value()'s comment said
elements are pushed for dump_internal to walk, but it is
dump_iteratively() that walks the stack. dump_escaped(), dump_integer()
and dump_float() each said they write "to output stream @A o", which
has not been true since the writer moved to write_buffer.

Doc-only change; no behavior, API or ABI impact.

Part of #5709

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

* Merge duplicate byte-to-hex helper and drop stale '| 0' promotions

serializer::hex_bytes() and binary_writer::hex_byte() had identical
bodies. Keep one, detail::hex_byte() in string_utils.hpp, and use it
from both. Also drop the `| 0` at the two serializer call sites
(hex_bytes(byte | 0) and hex_bytes(s.back() | 0)): #3088 (7440786)
added it so that `ss << std::hex << (byte | 0)` printed a number
rather than a char with the old stringstream writer; the int result
just narrows back to uint8_t now, so it was a no-op.

Behavior is unchanged: unit-serialization, unit-bon8 (whose
type_error.316 messages exercise this code) and unit-diagnostics
pass, and both headers still compile standalone with -Werror.

Part of #5709

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

* Trim two stale lint suppressions in serializer.hpp

dump_integer()'s `auto buffer_ptr = number_buffer.begin();` carried
NOLINT entries for cppcoreguidelines-pro-type-vararg and hicpp-vararg,
left over from the snprintf-based implementation (#3088); there is no
variadic call on that line, so keep only the qualified-auto
suppressions it actually needs. remove_sign()'s assert checked
`x < 0 && x < (std::numeric_limits<number_integer_t>::max)()) `with a
NOLINT(misc-redundant-expression) to hide it; the second conjunct is
always true once x < 0, and has been since 6ce2f35 (2019), so
reduce the assert to `x < 0` and drop the suppression instead of
masking it.

Both are documentation-only changes to assertions/suppressions, not
behavior. The to_chars.hpp `#if 0` branch this item also flagged is
left alone, next to draft PR #5634's pending hunk.

Part of #5709

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

* Move the Hoehrmann SPDX copyright line to string_utils.hpp

serializer.hpp carried the SPDX-FileCopyrightText line for Björn
Hoehrmann's UTF-8 decoder, but the decoder (decode() and the utf8d
table) has lived in string_utils.hpp since #5185 (d19f7f5);
serializer.hpp now only calls decode(). Move the copyright line to
where the code it covers actually is.

Part of #5709

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

* Stop calling std::localeconv() on every dump()

The serializer constructor snapshotted std::localeconv() into a
locale_chars member on every dump(), even though the only reader is
dump_float(number_float_t, std::false_type)'s snprintf path, taken
only for a number_float_t that is neither IEEE single nor double.
localeconv() is not required to be thread-safe with setlocale(), so
every dump() paid for and raced on a lookup that almost never mattered.

Remove locale_chars and the locale member. Right before the
thousands-separator/decimal-point fixups in the snprintf path, read
std::localeconv() into local thousands_sep/decimal_point variables
(null-checked, first byte only, as before) - the same way
lexer::get_decimal_point() already does since #5597. Output is
unchanged unless the locale changes during a single dump(); in that
case the fixups now match what snprintf just produced, instead of a
value snapshotted before the call.

Overlaps draft PR #5608, which touches the same constructor and
dump_float() lines to move this code into a new
dump_float_snprintf(); this lands the lookup change now as #5709 asks,
and #5608 can do the lookup inside dump_float_snprintf() when it
rebases.

Verification: the full json_test_data corpus (742 files, dump(),
dump(4) and dump(-1,' ',true)) is byte-identical to before the change
under the C locale. Added a test pinning the new per-conversion
lookup: it switches LC_NUMERIC mid-dump() (via a streambuf that
switches on its first write, after the serializer's write buffer has
been flushed once but before a later float is converted) and checks
the decimal point is still normalized using the locale active at
conversion time. On a platform where long double is IEEE-754 double
(e.g. 64-bit Arm), dump_float() takes the locale-independent
to_chars() path and the test is a no-op there; it is meaningful on a
platform where long double is extended precision (most x86 targets).

Part of #5709 item 3

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

---------

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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants