When transitioning to Modern C++ (C++20 and beyond), developers often expect std::format to replace older formatting mechanisms completely. However, you might run into an unexpected surprise when printing floating-point numbers in fixed-point mode:

#include <format>
#include <iostream>

int main() {
    constexpr double num = 2.000000007;
    std::cout << std::format("{:f}", num) << '\n'; // Outputs: 2.000000
}

While std::to_chars with std::chars_format::fixed automatically outputs the exact number of decimal places needed to represent the value (2.000000007), std::format("{:f}", num) truncates it to 6 decimal places. Why does this happen, and how can you achieve automatic precision in fixed mode?

Why Does std::format Default to 6 Decimal Places?

The design of std::format specification borrows heavily from Python's str.format() and C's printf. In the C++ standard (specifically [format.string.std]), floating-point presentation types follow these rules:

  • f or F (Fixed): If no precision is specified, it defaults to 6.
  • e or E (Scientific): Default precision is 6.
  • Empty specifier {}: Produces the shortest decimal representation that accurately round-trips back to the original value using the shortest representation algorithm (similar to Ryu or Dragonbox).

Because {:f} explicitly requests fixed notation, the standard mandates a default precision of 6. Unfortunately, the format string syntax does not have a flag that means "fixed notation with automatic shortest precision."

Solution 1: Use the Default Format Specifier {} (General Representation)

If you omit the format specifier or use {}, std::format automatically prints the exact precision required:

#include <format>
#include <iostream>

int main() {
    constexpr double num = 2.000000007;
    std::cout << std::format("{}", num) << '\n'; // Outputs: 2.000000007
}

Caveat: The default specifier behaves like std::chars_format::general. For numbers that are very small (e.g., < 1e-4) or very large (e.g., ≥ 1e16), it automatically flips to scientific notation (e.g., 1e-05 instead of 0.00001). If you must enforce fixed notation across all magnitudes, this solution alone is not enough.

Solution 2: Create a Lightweight Fixed Wrapper Using std::to_chars

Since std::to_chars already supports the exact behavior you need without dynamic allocations, the cleanest and most performant solution is to create a small wrapper type and provide a custom std::formatter specialization.

#include <charconv>
#include <format>
#include <iostream>
#include <string_view>
#include <system_error>

struct auto_fixed {
    double value;
};

template <>
struct std::formatter<auto_fixed> : std::formatter<std::string_view> {
    auto format(auto_fixed af, std::format_context& ctx) const {
        char buffer[128];
        auto [ptr, ec] = std::to_chars(buffer, buffer + sizeof(buffer), af.value, std::chars_format::fixed);
        
        if (ec != std::errc()) {
            return std::formatter<std::string_view>::format("error", ctx);
        }
        
        return std::formatter<std::string_view>::format(std::string_view(buffer, ptr), ctx);
    }
};

int main() {
    constexpr double num1 = 2.000000007;
    constexpr double num2 = 0.000012345;

    std::cout << std::format("{}", auto_fixed{num1}) << '\n'; // Outputs: 2.000000007
    std::cout << std::format("{}", auto_fixed{num2}) << '\n'; // Outputs: 0.000012345
}

Advantages of this approach:

  • Zero heap allocation: Uses a small stack buffer.
  • Composable: Works seamlessly within larger format strings like std::format("Coordinates: ({}, {})", auto_fixed{x}, auto_fixed{y}).
  • Consistent: Guarantees fixed-point output without dropping digits or switching to exponent notation.

Solution 3: Dynamic Precision with std::format

If you prefer to stay purely within the standard formatting library without wrapping types, you can dynamically determine the required precision using std::to_chars and pass it as an argument using the nested {:.{}f} syntax:

#include <charconv>
#include <format>
#include <iostream>
#include <string_view>

int get_fixed_precision(double val) {
    char buffer[128];
    auto [ptr, ec] = std::to_chars(buffer, buffer + sizeof(buffer), val, std::chars_format::fixed);
    std::string_view sv(buffer, ptr);
    auto dot_pos = sv.find('.');
    return (dot_pos == std::string_view::npos) ? 0 : static_cast<int>(sv.size() - dot_pos - 1);
}

int main() {
    double num = 2.000000007;
    int precision = get_fixed_precision(num);
    
    std::cout << std::format("{:.{}f}", num, precision) << '\n'; // Outputs: 2.000000007
}

While this works, Solution 2 is generally preferred because calling to_chars just to count decimal positions and then running std::format's floating-point routine introduces redundant computations.

Summary

Unlike std::to_chars, std::format("{:f}", val) adheres to standard C-style precision rules where omitted precision defaults to 6. If your values stay within normal ranges, the default format std::format("{}", val) provides shortest round-trip output. For guaranteed fixed-point formatting with exact precision, wrapping std::to_chars inside a custom std::formatter is the most robust and idiomatic C++ solution.