From 7ba4db88b16d0de5c127f69e8c49f7e03eb938c4 Mon Sep 17 00:00:00 2001 From: Mateusz Pusz Date: Tue, 20 Oct 2026 13:26:04 +0200 Subject: [PATCH 1/2] Add ' format option for ISO 80000-1 digit grouping Prototype of a ' option in the standard format spec, placed after width and before precision: [[fill]align][sign][#][0][width]['][.precision][L][type] Digits are grouped in threes counting from the decimal marker towards the left and towards the right, as specified by ISO 80000-1 and the SI Brochure. The separator is U+202F NARROW NO-BREAK SPACE (one code unit for wide character types, UTF-8 for char when the literal encoding is UTF-8, U+0020 otherwise) and counts as one column of width. - Integers: presentation none or d. - Floating point: none, e, E, f, F, g, G. In exponent form only the significand fraction is grouped. - With 0, padding zeros are grouped too, never starting with a separator. - With L, the locale supplies only the decimal point. - Errors for other presentation types, bool, char, strings and pointers, diagnosed at compile time with checked format strings. - FMT_ISO_GROUPING_MIN2=1 enables the four-digit exception, applied to the integer and fraction parts independently. Co-Authored-By: Claude Opus 5.5 --- doc/syntax.md | 31 +++- include/fmt/core.h | 41 ++++- include/fmt/format.h | 148 +++++++++++++++ test/CMakeLists.txt | 3 + test/compile-error-test/CMakeLists.txt | 70 +++++++ test/format-test.cc | 246 ++++++++++++++++++++++++- test/iso-grouping-min2-test.cc | 59 ++++++ test/xchar-test.cc | 13 ++ 8 files changed, 605 insertions(+), 6 deletions(-) create mode 100644 test/iso-grouping-min2-test.cc diff --git a/doc/syntax.md b/doc/syntax.md index c0b3193c..19d56ab8 100644 --- a/doc/syntax.md +++ b/doc/syntax.md @@ -63,7 +63,7 @@ fmt's parser.
format_spec ::= [[fill]align][sign]["#"]["0"][width]["." precision]["L"][type]
+>format_spec ::= [[fill]align][sign]["#"]["0"][width]["'"]["." precision]["L"][type]
 fill        ::= <a character other than '{' or '}'>
 align       ::= "<" | ">" | "^"
 sign        ::= "+" | "-" | " "
@@ -221,6 +221,35 @@ fmt::format(loc, "{:L}", 1234567890);     // Result: "1,234,567,890"
 fmt::format(loc, "{:.2Lf}", 1234567.89);  // Result: "1,234,567.89"
 ```
 
+### Digit grouping (`'`)
+
+The `'` option groups the digits of integers and of floating-point values in
+decimal notation in threes, counting from the decimal marker towards the left
+and towards the right, as specified by ISO 80000-1 and the SI Brochure. The
+separator is U+202F NARROW NO-BREAK SPACE: a single code unit for wide
+character types and its UTF-8 encoding for `char` when the literal encoding is
+UTF-8, otherwise U+0020 SPACE. Each separator counts as one column of width.
+
+In exponential notation only the fraction digits of the significand are
+grouped, never the exponent. Infinity and NaN are not affected. With `0`, the
+padding zeros are grouped like other digits; the result never starts with a
+separator, so it can be one column wider than *width*. With `L`, the locale
+supplies only the decimal point; grouping always follows `'`.
+
+The option is accepted for integers (other than `bool` and characters) with
+presentation type `d` or none, and for floating-point values with presentation
+types `e`, `E`, `f`, `F`, `g`, `G` or none.
+
+```c++
+fmt::format("{:'}", 1234567);            // Result: "1 234 567"
+fmt::format("{:'.6f}", 1234567.891234);  // Result: "1 234 567.891 234"
+fmt::format("{:'.6e}", 123456.789);      // Result: "1.234 568e+05"
+fmt::format("{:*>12'}", 1234567);        // Result: "***1 234 567"
+fmt::format("{:010'}", 1234567);         // Result: "01 234 567"
+```
+
+(The spaces in the results above are U+202F.)
+
 ### Presentation type
 
 The *type* field chooses the representation for the value. Specifiers are
diff --git a/include/fmt/core.h b/include/fmt/core.h
index 04983ee5..bfd5f75b 100644
--- a/include/fmt/core.h
+++ b/include/fmt/core.h
@@ -687,8 +687,8 @@ class basic_specs {
   //  0                   1                   2                   3
   //  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
-  // |type |align| w | p | s |u|#|L|  f  |          unused           |
-  // +-----+-----+---+---+---+-+-+-+-----+---------------------------+
+  // |type |align| w | p | s |u|#|L|  f  |'|         unused          |
+  // +-----+-----+---+---+---+-+-+-+-----+-+-------------------------+
   //
   //   w - dynamic width info
   //   p - dynamic precision info
@@ -697,6 +697,7 @@ class basic_specs {
   //   # - alternate form ('#')
   //   L - localized
   //   f - fill size
+  //   ' - ISO 80000-1 digit grouping
   //
   // Bitfields are not used because of compiler bugs such as gcc bug 61414.
   enum : unsigned {
@@ -709,6 +710,7 @@ class basic_specs {
     alternate_mask = 0x02000,
     localized_mask = 0x04000,
     fill_size_mask = 0x38000,
+    iso_grouping_mask = 0x40000,
 
     align_shift = 3,
     width_shift = 6,
@@ -720,7 +722,7 @@ class basic_specs {
   };
 
   unsigned data_ = 1 << fill_size_shift;
-  static_assert(sizeof(basic_specs::data_) * CHAR_BIT >= 18, "");
+  static_assert(sizeof(basic_specs::data_) * CHAR_BIT >= 19, "");
 
   // Character (code unit) type is erased to prevent template bloat.
   char fill_data_[max_fill_size] = {' '};
@@ -781,6 +783,15 @@ class basic_specs {
     return (data_ & localized_mask) != 0;
   }
   FMT_CONSTEXPR void set_localized() { data_ |= localized_mask; }
+  FMT_CONSTEXPR void clear_localized() { data_ &= ~localized_mask; }
+
+  // Digit grouping in threes on both sides of the decimal marker as specified
+  // by ISO 80000-1 and the SI Brochure (the ' option).
+  constexpr auto iso_grouping() const -> bool {
+    return (data_ & iso_grouping_mask) != 0;
+  }
+  FMT_CONSTEXPR void set_iso_grouping() { data_ |= iso_grouping_mask; }
+  FMT_CONSTEXPR void clear_iso_grouping() { data_ &= ~iso_grouping_mask; }
 
   constexpr auto fill_size() const -> size_t {
     return (data_ & fill_size_mask) >> fill_size_shift;
@@ -1451,7 +1462,17 @@ FMT_CONSTEXPR auto parse_precision(const Char* begin, const Char* end,
   return result.end;
 }
 
-enum class state { start, align, sign, hash, zero, width, precision, locale };
+enum class state {
+  start,
+  align,
+  sign,
+  hash,
+  zero,
+  width,
+  iso_grouping,
+  precision,
+  locale
+};
 
 // Parses standard format specifiers.
 template 
@@ -1486,6 +1507,12 @@ FMT_CONSTEXPR auto parse_format_specs(const Char* begin, const Char* end,
 
     FMT_CONSTEXPR auto operator()(pres pres_type, int set) -> const Char* {
       if (!in(arg_type, set)) report_error("invalid format specifier");
+      // Digit grouping is only defined for decimal presentations.
+      if (specs.iso_grouping() &&
+          (in(arg_type, float_set) ? pres_type == pres::hexfloat
+                                   : pres_type != pres::dec)) {
+        report_error("invalid presentation type for digit grouping");
+      }
       specs.set_type(pres_type);
       return begin + 1;
     }
@@ -1531,6 +1558,12 @@ FMT_CONSTEXPR auto parse_format_specs(const Char* begin, const Char* end,
       enter_state(state::width);
       begin = parse_width(begin, end, specs, specs.width_ref, ctx);
       break;
+    case '\'':
+      enter_state(state::iso_grouping,
+                  in(arg_type, sint_set | uint_set | float_set));
+      specs.set_iso_grouping();
+      ++begin;
+      break;
     case '.':
       enter_state(state::precision,
                   in(arg_type, float_set | string_set | cstring_set));
diff --git a/include/fmt/format.h b/include/fmt/format.h
index ebaac1a1..3625fe80 100644
--- a/include/fmt/format.h
+++ b/include/fmt/format.h
@@ -2402,6 +2402,133 @@ FMT_CONSTEXPR FMT_NOINLINE auto write_int_noinline(OutputIt out,
   return write_int(out, arg, specs);
 }
 
+// ISO 80000-1 / SI Brochure digit grouping (the ' format option).
+//
+// Digits are grouped in threes counting from the decimal marker to the left
+// (integer part) and to the right (fraction part). If FMT_ISO_GROUPING_MIN2 is
+// nonzero, a part that has exactly four digits is not split (the "four-digit
+// exception"), applied to each part independently.
+#ifndef FMT_ISO_GROUPING_MIN2
+#  define FMT_ISO_GROUPING_MIN2 0
+#endif
+
+template  struct iso_separator {
+  Char data[3];
+  size_t size;  // In code units; the display width is always 1.
+};
+
+// Returns the digit group separator: U+202F NARROW NO-BREAK SPACE encoded as
+// a single code unit for wide character types, as UTF-8 for char if the
+// literal encoding is UTF-8, and U+0020 SPACE otherwise.
+template 
+FMT_CONSTEXPR auto iso_digit_separator() -> iso_separator {
+  return {{static_cast(0x202F), Char(), Char()}, 1};
+}
+template <>
+FMT_CONSTEXPR inline auto iso_digit_separator() -> iso_separator {
+  if (FMT_UNICODE != 0 && is_utf8_enabled != 0)
+    return {{'\xE2', '\x80', '\xAF'}, 3};
+  return {{' ', '\0', '\0'}, 1};
+}
+
+FMT_CONSTEXPR inline auto is_iso_digit(char c) -> bool {
+  return '0' <= c && c <= '9';
+}
+
+// Returns the number of separators to insert into a run of num_digits digits.
+FMT_CONSTEXPR inline auto iso_separator_count(int num_digits) -> int {
+  if (num_digits <= 3 || (FMT_ISO_GROUPING_MIN2 && num_digits == 4)) return 0;
+  return (num_digits - 1) / 3;
+}
+
+// Writes an already formatted (unpadded) decimal number of the form
+//   [sign] digits ['.' digits] [exponent]
+// inserting ISO digit group separators, replacing '.' with point and applying
+// padding from specs. With zero padding (numeric alignment) the padding zeros
+// are digits of the integer part and are grouped as well: the smallest number
+// of zeros is used such that the display width is at least specs.width, so the
+// result can be one column wider than specs.width but never starts with a
+// separator.
+template 
+FMT_CONSTEXPR auto write_iso_grouped(OutputIt out, const char* begin,
+                                     const char* end, const format_specs& specs,
+                                     Char point = Char('.')) -> OutputIt {
+  const char* int_begin = begin;
+  if (int_begin != end &&
+      (*int_begin == '-' || *int_begin == '+' || *int_begin == ' ')) {
+    ++int_begin;
+  }
+  const char* int_end = int_begin;
+  while (int_end != end && is_iso_digit(*int_end)) ++int_end;
+  const char* frac_begin = int_end;
+  const char* frac_end = int_end;
+  bool has_point = frac_begin != end && *frac_begin == '.';
+  if (has_point) {
+    frac_end = ++frac_begin;
+    while (frac_end != end && is_iso_digit(*frac_end)) ++frac_end;
+  }
+  int digits_size = static_cast(int_end - int_begin);
+  int frac_size = static_cast(frac_end - frac_begin);
+  int frac_seps = iso_separator_count(frac_size);
+  auto num_chars = to_unsigned(end - begin);
+
+  auto padded_specs = specs;
+  int int_size = digits_size;  // Including padding zeros.
+  if (specs.align() == align::numeric) {
+    if (digits_size == 0) {
+      // Replace '0'-padding with space for non-finite values.
+      padded_specs.set_fill(' ');
+    } else {
+      // Display width of everything but the integer part.
+      auto other_width =
+          num_chars - to_unsigned(digits_size) + to_unsigned(frac_seps);
+      auto spec_width = to_unsigned(specs.width);
+      while (other_width +
+                 to_unsigned(int_size + iso_separator_count(int_size)) <
+             spec_width) {
+        ++int_size;
+      }
+    }
+  }
+  int num_zeros = int_size - digits_size;
+  int int_seps = iso_separator_count(int_size);
+  auto sep = iso_digit_separator();
+  auto num_seps = to_unsigned(int_seps + frac_seps);
+  // Each separator is one code point of display width 1 but may take several
+  // code units, so size and width are passed to write_padded separately.
+  size_t size = num_chars + to_unsigned(num_zeros) + num_seps * sep.size;
+  size_t width = num_chars + to_unsigned(num_zeros) + num_seps;
+  return write_padded(
+      out, padded_specs, size, width, [&](reserve_iterator it) {
+        it = copy(begin, int_begin, it);
+        for (int i = 0; i < int_size; ++i) {
+          if (int_seps != 0 && i != 0 && (int_size - i) % 3 == 0)
+            it = copy(sep.data, sep.data + sep.size, it);
+          *it++ = i < num_zeros ? Char('0')
+                                : static_cast(int_begin[i - num_zeros]);
+        }
+        if (has_point) *it++ = point;
+        for (int i = 0; i < frac_size; ++i) {
+          if (frac_seps != 0 && i != 0 && i % 3 == 0)
+            it = copy(sep.data, sep.data + sep.size, it);
+          *it++ = static_cast(frac_begin[i]);
+        }
+        return copy(frac_end, end, it);
+      });
+}
+
+template 
+FMT_CONSTEXPR20 auto write_iso_grouped_int(OutputIt out, write_int_arg arg,
+                                           const format_specs& specs)
+    -> OutputIt {
+  constexpr int buffer_size = num_bits();
+  char buffer[buffer_size + 1];
+  if (is_constant_evaluated()) fill_n(buffer, buffer_size + 1, '\0');
+  char* begin = do_format_decimal(buffer + 1, arg.abs_value, buffer_size);
+  if (arg.prefix != 0) *--begin = static_cast(arg.prefix & 0xff);
+  return write_iso_grouped(out, begin, buffer + buffer_size + 1, specs);
+}
+
 template ::value &&
                         !std::is_same::value &&
@@ -2409,6 +2536,11 @@ template  OutputIt {
+  // ' takes precedence over L: grouping never comes from the locale.
+  if (specs.iso_grouping()) {
+    return write_iso_grouped_int(
+        out, make_write_int_arg(value, specs.sign()), specs);
+  }
   if (specs.localized() && write_loc(out, value, specs, loc)) return out;
   auto arg = make_write_int_arg(value, specs.sign());
   // Out of line for appenders to avoid bloat; inlined for compiled formatting.
@@ -3718,6 +3850,22 @@ template ::value)>
 FMT_CONSTEXPR20 auto write(OutputIt out, T value, format_specs specs,
                            locale_ref loc = {}) -> OutputIt {
+  if (specs.iso_grouping()) {
+    // Format without padding, grouping and localization, then insert
+    // separators and pad. ' takes precedence over L for grouping; L only
+    // supplies the decimal point.
+    auto plain_specs = specs;
+    plain_specs.clear_iso_grouping();
+    plain_specs.clear_localized();
+    plain_specs.set_align(align::none);
+    plain_specs.width = 0;
+    auto buffer = memory_buffer();
+    write(appender(buffer), value, plain_specs, loc);
+    bool localized = FMT_USE_LOCALE && specs.localized();
+    Char point = localized ? detail::decimal_point(loc) : Char('.');
+    return write_iso_grouped(out, buffer.data(),
+                                   buffer.data() + buffer.size(), specs, point);
+  }
   if (specs.localized() && write_loc(out, value, specs, loc)) return out;
 
   // Use signbit because value < 0 is false for NaN.
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index faa4705d..ea3aa0ab 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -70,6 +70,9 @@ if (STDLIBFS)
   target_link_libraries(std-test ${STDLIBFS})
 endif ()
 add_fmt_test(unicode-test HEADER_ONLY)
+add_fmt_test(iso-grouping-min2-test HEADER_ONLY)
+target_compile_definitions(iso-grouping-min2-test PRIVATE
+                           FMT_ISO_GROUPING_MIN2=1)
 if (MSVC)
   target_compile_options(unicode-test PRIVATE /utf-8)
 endif ()
diff --git a/test/compile-error-test/CMakeLists.txt b/test/compile-error-test/CMakeLists.txt
index 5b57a2cf..36f0671b 100644
--- a/test/compile-error-test/CMakeLists.txt
+++ b/test/compile-error-test/CMakeLists.txt
@@ -250,6 +250,76 @@ if (CMAKE_CXX_STANDARD GREATER_EQUAL 20)
     "
     ERROR)
 
+  # Compile-time check of the ' (ISO digit grouping) option. Note that the
+  # tests above use FMT_HAS_CONSTEVAL which is no longer defined.
+  expect_compile(
+    format-iso-grouping
+    "
+    #if FMT_USE_CONSTEVAL
+      fmt::format(\"{:>12'.3f} {:'} {:010'} {:'L}\", 1.5, 42, 42, 42);
+    #endif
+    ")
+  expect_compile(
+    format-iso-grouping-int-hex-error
+    "
+    #if FMT_USE_CONSTEVAL
+      fmt::format(\"{:'x}\", 42);
+    #else
+      #error
+    #endif
+    "
+    ERROR)
+  expect_compile(
+    format-iso-grouping-float-hexfloat-error
+    "
+    #if FMT_USE_CONSTEVAL
+      fmt::format(\"{:'a}\", 4.2);
+    #else
+      #error
+    #endif
+    "
+    ERROR)
+  expect_compile(
+    format-iso-grouping-bool-error
+    "
+    #if FMT_USE_CONSTEVAL
+      fmt::format(\"{:'}\", true);
+    #else
+      #error
+    #endif
+    "
+    ERROR)
+  expect_compile(
+    format-iso-grouping-char-error
+    "
+    #if FMT_USE_CONSTEVAL
+      fmt::format(\"{:'}\", 'x');
+    #else
+      #error
+    #endif
+    "
+    ERROR)
+  expect_compile(
+    format-iso-grouping-string-error
+    "
+    #if FMT_USE_CONSTEVAL
+      fmt::format(\"{:'}\", \"abc\");
+    #else
+      #error
+    #endif
+    "
+    ERROR)
+  expect_compile(
+    format-iso-grouping-pointer-error
+    "
+    #if FMT_USE_CONSTEVAL
+      fmt::format(\"{:'}\", static_cast(nullptr));
+    #else
+      #error
+    #endif
+    "
+    ERROR)
+
   # Compile-time argument name check
   expect_compile(
     format-string-name
diff --git a/test/format-test.cc b/test/format-test.cc
index e86b8adb..1d1d248c 100644
--- a/test/format-test.cc
+++ b/test/format-test.cc
@@ -1149,6 +1149,213 @@ TEST(format_test, exponent_range) {
   for (int e = -1074; e <= 1023; ++e) (void)fmt::format("{}", std::ldexp(1, e));
 }
 
+// Replaces '_' with the ISO 80000-1 digit group separator: U+202F NARROW
+// NO-BREAK SPACE if the literal encoding is UTF-8 and U+0020 SPACE otherwise.
+static auto iso(const char* s) -> std::string {
+  const char* sep = fmt::detail::is_utf8_enabled != 0 && FMT_UNICODE != 0
+                        ? "\xE2\x80\xAF"
+                        : " ";
+  auto result = std::string();
+  for (; *s; ++s) {
+    if (*s == '_')
+      result += sep;
+    else
+      result += *s;
+  }
+  return result;
+}
+
+TEST(format_test, iso_digit_grouping) {
+  // Integers.
+  EXPECT_EQ(fmt::format("{:'}", 0), "0");
+  EXPECT_EQ(fmt::format("{:'}", 1), "1");
+  EXPECT_EQ(fmt::format("{:'}", 12), "12");
+  EXPECT_EQ(fmt::format("{:'}", 123), "123");
+  EXPECT_EQ(fmt::format("{:'}", 1234), iso("1_234"));
+  EXPECT_EQ(fmt::format("{:'}", 12345), iso("12_345"));
+  EXPECT_EQ(fmt::format("{:'}", 123456), iso("123_456"));
+  EXPECT_EQ(fmt::format("{:'}", 1234567), iso("1_234_567"));
+  EXPECT_EQ(fmt::format("{:'}", -1234567), iso("-1_234_567"));
+  EXPECT_EQ(fmt::format("{:'}", -123), "-123");
+  EXPECT_EQ(fmt::format("{:'d}", 1234567), iso("1_234_567"));
+  EXPECT_EQ(fmt::format("{:'}", 1234567u), iso("1_234_567"));
+  EXPECT_EQ(fmt::format("{:'}", static_cast(-1234)), iso("-1_234"));
+  EXPECT_EQ(fmt::format("{:'}", static_cast(-123)), "-123");
+  EXPECT_EQ(fmt::format("{:'}", 1234567L), iso("1_234_567"));
+  EXPECT_EQ(fmt::format("{:'}", INT64_MIN), iso("-9_223_372_036_854_775_808"));
+  EXPECT_EQ(fmt::format("{:'}", INT64_MAX), iso("9_223_372_036_854_775_807"));
+  EXPECT_EQ(fmt::format("{:'}", UINT64_MAX), iso("18_446_744_073_709_551_615"));
+#if FMT_USE_INT128
+  EXPECT_EQ(
+      fmt::format("{:'}",
+                  static_cast(INT64_MIN) * 1000),
+      iso("-9_223_372_036_854_775_808_000"));
+#endif
+  EXPECT_EQ(fmt::format(runtime("{:'}"), 1234567), iso("1_234_567"));
+
+  // Sign.
+  EXPECT_EQ(fmt::format("{:+'}", 1234), iso("+1_234"));
+  EXPECT_EQ(fmt::format("{:+'d}", -1234), iso("-1_234"));
+  EXPECT_EQ(fmt::format("{: '}", 1234), iso(" 1_234"));
+  EXPECT_EQ(fmt::format("{:-'}", 1234), iso("1_234"));
+  EXPECT_EQ(fmt::format("{:+'}", 0), "+0");
+
+  // Width, alignment and fill: each separator has display width 1.
+  EXPECT_EQ(fmt::format("{:>12'}", 1234567), iso("   1_234_567"));
+  EXPECT_EQ(fmt::format("{:12'}", 1234567), iso("   1_234_567"));
+  EXPECT_EQ(fmt::format("{:<12'}", 1234567), iso("1_234_567   "));
+  EXPECT_EQ(fmt::format("{:*^15'}", 1234567), iso("***1_234_567***"));
+  EXPECT_EQ(fmt::format("{:*>15'}", -1234567), iso("*****-1_234_567"));
+  EXPECT_EQ(fmt::format("{:*>+15'}", 1234567), iso("*****+1_234_567"));
+  EXPECT_EQ(fmt::format("{:5'}", 1234567), iso("1_234_567"));
+  EXPECT_EQ(fmt::format("{:{}'}", 1234567, 12), iso("   1_234_567"));
+  EXPECT_EQ(fmt::format("{:>{}'}", 1234567.0, 12), iso("   1_234_567"));
+  EXPECT_EQ(fmt::format(runtime("{:>12'}"), 1234567), iso("   1_234_567"));
+  EXPECT_EQ(fmt::format("{:—>8'}", 1234), iso("———1_234"));
+  // ' is still a valid fill character.
+  EXPECT_EQ(fmt::format("{:'>5}", 42), "'''42");
+  EXPECT_EQ(fmt::format("{:'>8'}", 1234), iso("'''1_234"));
+
+  // Zero padding: padding zeros are digits and are grouped too. The smallest
+  // number of zeros giving at least the requested width is used, so the
+  // result never starts with a separator and can be one column wider.
+  EXPECT_EQ(fmt::format("{:0'}", 1234), iso("1_234"));
+  EXPECT_EQ(fmt::format("{:03'}", 1), "001");
+  EXPECT_EQ(fmt::format("{:04'}", 1), iso("0_001"));
+  EXPECT_EQ(fmt::format("{:05'}", 1), iso("0_001"));
+  EXPECT_EQ(fmt::format("{:06'}", 1), iso("00_001"));
+  EXPECT_EQ(fmt::format("{:07'}", 1), iso("000_001"));
+  EXPECT_EQ(fmt::format("{:08'}", 1), iso("0_000_001"));
+  EXPECT_EQ(fmt::format("{:09'}", 1), iso("0_000_001"));
+  EXPECT_EQ(fmt::format("{:010'}", 1234567), iso("01_234_567"));
+  EXPECT_EQ(fmt::format("{:09'}", 1234567), iso("1_234_567"));
+  EXPECT_EQ(fmt::format("{:010'}", -1234), iso("-0_001_234"));
+  EXPECT_EQ(fmt::format("{:+08'}", 12), iso("+000_012"));
+  EXPECT_EQ(fmt::format("{:0{}'}", 1, 4), iso("0_001"));
+  EXPECT_EQ(fmt::format(runtime("{:010'}"), 1234567), iso("01_234_567"));
+  // An explicit alignment disables zero padding as usual.
+  EXPECT_EQ(fmt::format("{:>08'}", 1234), iso("   1_234"));
+  EXPECT_EQ(fmt::format("{:012'.2f}", 1234.5), iso("0_001_234.50"));
+  EXPECT_EQ(fmt::format("{:015'.3f}", -0.5), iso("-00_000_000.500"));
+  EXPECT_EQ(fmt::format("{:011'.4f}", 1.23456), iso("0_001.234_6"));
+  EXPECT_EQ(fmt::format("{:013'.3e}", 123456.0), iso("0_001.235e+05"));
+  EXPECT_EQ(fmt::format("{:08'}", std::numeric_limits::infinity()),
+            "     inf");
+
+  // Fixed.
+  EXPECT_EQ(fmt::format("{:'.6f}", 1234567.891234), iso("1_234_567.891_234"));
+  EXPECT_EQ(fmt::format("{:'.4f}", 1234.5678), iso("1_234.567_8"));
+  EXPECT_EQ(fmt::format("{:'.0f}", 1234.0), iso("1_234"));
+  EXPECT_EQ(fmt::format("{:#'.0f}", 1234.0), iso("1_234."));
+  EXPECT_EQ(fmt::format("{:'f}", 1.0), iso("1.000_000"));
+  EXPECT_EQ(fmt::format("{:'F}", 1234.5), iso("1_234.500_000"));
+  EXPECT_EQ(fmt::format("{:'.1f}", 0.1234567), "0.1");
+  EXPECT_EQ(fmt::format("{:'.2f}", 0.1234567), "0.12");
+  EXPECT_EQ(fmt::format("{:'.3f}", 0.1234567), "0.123");
+  EXPECT_EQ(fmt::format("{:'.4f}", 0.1234567), iso("0.123_5"));
+  EXPECT_EQ(fmt::format("{:'.5f}", 0.1234567), iso("0.123_46"));
+  EXPECT_EQ(fmt::format("{:'.6f}", 0.1234567), iso("0.123_457"));
+  EXPECT_EQ(fmt::format("{:'.7f}", 0.1234567), iso("0.123_456_7"));
+  EXPECT_EQ(fmt::format("{:'.4f}", 0.1234), iso("0.123_4"));
+  EXPECT_EQ(fmt::format("{:'.6f}", 0.000123), iso("0.000_123"));
+  EXPECT_EQ(fmt::format("{:'.3f}", -1234.5), iso("-1_234.500"));
+  EXPECT_EQ(fmt::format("{:+'.3f}", 1234.5), iso("+1_234.500"));
+  EXPECT_EQ(fmt::format("{:'.0f}", 1e20), iso("100_000_000_000_000_000_000"));
+  {
+    // 1e300 has 301 integer digits, hence 100 separators. The trailing
+    // digits are those of the exact value of the nearest double.
+    auto s = fmt::format("{:'.0f}", 1e300);
+    auto sep_size = iso("_").size();
+    EXPECT_EQ(s.size(), 301 + 100 * sep_size);
+    EXPECT_EQ(s.substr(0, 2 + sep_size), iso("1_0"));
+    EXPECT_EQ(s.substr(s.size() - 3 * (sep_size + 3)), iso("_400_540_160"));
+  }
+  EXPECT_EQ(fmt::format("{:>20'.3f}", 1234567.5), iso("       1_234_567.500"));
+
+  // Exponent: only the fraction of the significand is grouped.
+  EXPECT_EQ(fmt::format("{:'e}", 123456.7), iso("1.234_567e+05"));
+  EXPECT_EQ(fmt::format("{:'.6e}", 123456.789), iso("1.234_568e+05"));
+  EXPECT_EQ(fmt::format("{:'.3e}", 123456.789), "1.235e+05");
+  EXPECT_EQ(fmt::format("{:'.4E}", 123456.789), iso("1.234_6E+05"));
+  EXPECT_EQ(fmt::format("{:'.0e}", 123456.789), "1e+05");
+  EXPECT_EQ(fmt::format("{:'.10e}", 1e300), iso("1.000_000_000_0e+300"));
+  EXPECT_EQ(fmt::format("{:'.6e}", -1.5e-300), iso("-1.500_000e-300"));
+  EXPECT_EQ(fmt::format("{:>16'e}", 123456.7), iso("   1.234_567e+05"));
+
+  // General.
+  EXPECT_EQ(fmt::format("{:'g}", 1234567.0), iso("1.234_57e+06"));
+  EXPECT_EQ(fmt::format("{:'g}", 123456.0), iso("123_456"));
+  EXPECT_EQ(fmt::format("{:'g}", 1234.5), iso("1_234.5"));
+  EXPECT_EQ(fmt::format("{:'.10g}", 1234567.891), iso("1_234_567.891"));
+  EXPECT_EQ(fmt::format("{:'.10G}", 1.234567891e-10), iso("1.234_567_891E-10"));
+  EXPECT_EQ(fmt::format("{:#'g}", 1234.0), iso("1_234.00"));
+
+  // Default (shortest round trip).
+  EXPECT_EQ(fmt::format("{:'}", 0.0), "0");
+  EXPECT_EQ(fmt::format("{:'}", 1234.0), iso("1_234"));
+  EXPECT_EQ(fmt::format("{:'}", 1234567.0), iso("1_234_567"));
+  EXPECT_EQ(fmt::format("{:'}", 1234567.891234), iso("1_234_567.891_234"));
+  EXPECT_EQ(fmt::format("{:'}", 0.000123456), iso("0.000_123_456"));
+  EXPECT_EQ(fmt::format("{:'}", 0.1234), iso("0.123_4"));
+  EXPECT_EQ(fmt::format("{:'}", 1.5e300), "1.5e+300");
+  EXPECT_EQ(fmt::format("{:'}", 1.234567e300), iso("1.234_567e+300"));
+  EXPECT_EQ(fmt::format("{:'}", 1.234567e-10), iso("1.234_567e-10"));
+  EXPECT_EQ(fmt::format("{:'}", 1234.5f), iso("1_234.5"));
+  EXPECT_EQ(fmt::format("{:'}", 1234567.5L), iso("1_234_567.5"));
+  EXPECT_EQ(fmt::format("{:'.3f}", 1234.5L), iso("1_234.500"));
+
+  // Non-finite values are not affected.
+  EXPECT_EQ(fmt::format("{:'}", std::numeric_limits::infinity()),
+            "inf");
+  EXPECT_EQ(fmt::format("{:>6'}", -std::numeric_limits::infinity()),
+            "  -inf");
+  EXPECT_EQ(fmt::format("{:'F}", std::numeric_limits::quiet_NaN()),
+            "NAN");
+
+  // Errors.
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'x}"), 42), format_error,
+                   "invalid presentation type for digit grouping");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'X}"), 42), format_error,
+                   "invalid presentation type for digit grouping");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'o}"), 42), format_error,
+                   "invalid presentation type for digit grouping");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'b}"), 42), format_error,
+                   "invalid presentation type for digit grouping");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'B}"), 42), format_error,
+                   "invalid presentation type for digit grouping");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'c}"), 42), format_error,
+                   "invalid presentation type for digit grouping");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'a}"), 4.2), format_error,
+                   "invalid presentation type for digit grouping");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'A}"), 4.2), format_error,
+                   "invalid presentation type for digit grouping");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'d}"), 4.2), format_error,
+                   "invalid format specifier");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'}"), true), format_error,
+                   "invalid format specifier");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'d}"), true), format_error,
+                   "invalid format specifier");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'}"), 'x'), format_error,
+                   "invalid format specifier");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'d}"), 'x'), format_error,
+                   "invalid format specifier");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'}"), "abc"), format_error,
+                   "invalid format specifier");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'}"), std::string("abc")),
+                   format_error, "invalid format specifier");
+  EXPECT_THROW_MSG(
+      (void)fmt::format(runtime("{:'}"), static_cast(nullptr)),
+      format_error, "invalid format specifier");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:L'}"), 42), format_error,
+                   "invalid format specifier");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'010}"), 42), format_error,
+                   "invalid format specifier");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:''}"), 42), format_error,
+                   "invalid format specifier");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:.2'f}"), 4.2), format_error,
+                   "invalid format specifier");
+}
+
 TEST(format_test, precision) {
   char format_str[buffer_size];
   safe_sprintf(format_str, "{0:.%u", UINT_MAX);
@@ -1496,7 +1703,7 @@ TEST(format_test, format_short) {
 template 
 void check_unknown_types(const T& value, const char* types, const char*) {
   char format_str[buffer_size];
-  const char* special = ".0123456789L?}";
+  const char* special = ".0123456789L?}'";
   for (int i = CHAR_MIN; i <= CHAR_MAX; ++i) {
     char c = static_cast(i);
     if (std::strchr(types, c) || std::strchr(special, c) || !c) continue;
@@ -2665,6 +2872,43 @@ TEST(format_test, format_facet_grouping) {
   EXPECT_EQ(fmt::format(loc, "{:L}", 1234567890), "1,234,567,89,0");
 }
 
+namespace {
+struct comma_numpunct : std::numpunct {
+ protected:
+  auto do_decimal_point() const -> char override { return ','; }
+  auto do_thousands_sep() const -> char override { return '.'; }
+  auto do_grouping() const -> std::string override { return "\3"; }
+};
+}  // namespace
+
+TEST(format_test, iso_digit_grouping_locale) {
+  // With ' the grouping and separator never come from the locale; L only
+  // supplies the decimal point of floating-point numbers.
+  auto loc = std::locale(std::locale::classic(), new comma_numpunct());
+  EXPECT_EQ(fmt::format(loc, "{:.4Lf}", 1234.5678), "1.234,5678");
+  EXPECT_EQ(fmt::format(loc, "{:'.4Lf}", 1234.5678), iso("1_234,567_8"));
+  EXPECT_EQ(fmt::format(loc, "{:'.4f}", 1234.5678), iso("1_234.567_8"));
+  EXPECT_EQ(fmt::format(loc, "{:'L}", 1234567.125), iso("1_234_567,125"));
+  EXPECT_EQ(fmt::format(loc, "{:'.3Le}", 123456.0), iso("1,235e+05"));
+  EXPECT_EQ(fmt::format(loc, "{:'.4Le}", 123456.0), iso("1,234_6e+05"));
+  EXPECT_EQ(fmt::format(loc, "{:>14'.4Lf}", -1234.5678), iso("  -1_234,567_8"));
+  EXPECT_EQ(fmt::format(loc, "{:012'.2Lf}", 1234.5), iso("0_001_234,50"));
+  EXPECT_EQ(fmt::format(loc, "{:'L}", 1234567), iso("1_234_567"));
+  EXPECT_EQ(fmt::format(loc, "{:'Ld}", -1234567), iso("-1_234_567"));
+  EXPECT_EQ(fmt::format(loc, "{:L}", 1234567), "1.234.567");
+  EXPECT_EQ(fmt::format(loc, runtime("{:'.4Lf}"), 1234.5678),
+            iso("1_234,567_8"));
+  for (auto s : {fmt::format(loc, "{:'L}", 1234567),
+                 fmt::format(loc, "{:'.6Lf}", 1234567.891234),
+                 fmt::format(loc, "{:'Lg}", 1234567.0)}) {
+    EXPECT_EQ(s.find('.'), std::string::npos) << s;
+  }
+  // The format_facet thousands separator is not used either.
+  auto facet_loc =
+      std::locale({}, new fmt::format_facet(",", {1, 2, 3}));
+  EXPECT_EQ(fmt::format(facet_loc, "{:'L}", 1234567890), iso("1_234_567_890"));
+}
+
 TEST(format_test, format_named_arg_with_locale) {
   EXPECT_EQ(fmt::format(std::locale(), "{answer}", fmt::arg("answer", 42)),
             "42");
diff --git a/test/iso-grouping-min2-test.cc b/test/iso-grouping-min2-test.cc
new file mode 100644
index 00000000..76155e9b
--- /dev/null
+++ b/test/iso-grouping-min2-test.cc
@@ -0,0 +1,59 @@
+// Formatting library for C++ - ISO digit grouping with the four-digit
+// exception (FMT_ISO_GROUPING_MIN2) tests
+//
+// Copyright (c) 2012 - present, Victor Zverovich and {fmt} contributors
+// All rights reserved.
+//
+// For the license information refer to format.h.
+
+// This test is built header-only with FMT_ISO_GROUPING_MIN2=1 so that the
+// formatting functions are instantiated with the macro set.
+#include "fmt/format.h"
+#include "gtest-extra.h"
+
+using fmt::format_error;
+using fmt::runtime;
+
+static_assert(FMT_ISO_GROUPING_MIN2, "");
+
+static auto iso(const char* s) -> std::string {
+  const char* sep = fmt::detail::is_utf8_enabled != 0 && FMT_UNICODE != 0
+                        ? "\xE2\x80\xAF"
+                        : " ";
+  auto result = std::string();
+  for (; *s; ++s) {
+    if (*s == '_')
+      result += sep;
+    else
+      result += *s;
+  }
+  return result;
+}
+
+TEST(iso_grouping_min2_test, four_digit_exception) {
+  // A part of exactly four digits is not split; each part independently.
+  EXPECT_EQ(fmt::format("{:'}", 123), "123");
+  EXPECT_EQ(fmt::format("{:'}", 1234), "1234");
+  EXPECT_EQ(fmt::format("{:'}", -1234), "-1234");
+  EXPECT_EQ(fmt::format("{:'}", 12345), iso("12_345"));
+  EXPECT_EQ(fmt::format("{:'}", 1234567), iso("1_234_567"));
+  EXPECT_EQ(fmt::format("{:>6'}", 1234), "  1234");
+  EXPECT_EQ(fmt::format("{:>8'}", 12345), iso("  12_345"));
+  EXPECT_EQ(fmt::format("{:'.4f}", 1234.5678), "1234.5678");
+  EXPECT_EQ(fmt::format("{:'.4f}", 0.1234), "0.1234");
+  EXPECT_EQ(fmt::format("{:'.5f}", 0.12345), iso("0.123_45"));
+  EXPECT_EQ(fmt::format("{:'.4f}", 12345.6789), iso("12_345.6789"));
+  EXPECT_EQ(fmt::format("{:'.5f}", 1234.56789), iso("1234.567_89"));
+  EXPECT_EQ(fmt::format("{:'.6f}", 1234567.891234), iso("1_234_567.891_234"));
+  EXPECT_EQ(fmt::format("{:'.4e}", 123456.789), "1.2346e+05");
+  EXPECT_EQ(fmt::format("{:'.5e}", 123456.789), iso("1.234_57e+05"));
+  EXPECT_EQ(fmt::format("{:'}", 1234.0), "1234");
+  EXPECT_EQ(fmt::format("{:'}", 0.1234), "0.1234");
+  EXPECT_EQ(fmt::format(runtime("{:'}"), 1234), "1234");
+  // Zero padding: the four-digit exception applies to the padded digits.
+  EXPECT_EQ(fmt::format("{:04'}", 1), "0001");
+  EXPECT_EQ(fmt::format("{:05'}", 1), iso("00_001"));
+  EXPECT_EQ(fmt::format("{:010'}", 1234567), iso("01_234_567"));
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'x}"), 42), format_error,
+                   "invalid presentation type for digit grouping");
+}
diff --git a/test/xchar-test.cc b/test/xchar-test.cc
index 6fc174ef..a437239b 100644
--- a/test/xchar-test.cc
+++ b/test/xchar-test.cc
@@ -85,6 +85,19 @@ TEST(xchar_test, format) {
   EXPECT_EQ(std::wstring(600, L'\u03a9'), fmt::format(L"{:\u03a9>600}", L""));
 }
 
+TEST(xchar_test, iso_digit_grouping) {
+  // Wide character types use the single code unit U+202F.
+  EXPECT_EQ(fmt::format(L"{:'}", 1234567), L"1 234 567");
+  EXPECT_EQ(fmt::format(L"{:'.6f}", 1234567.891234), L"1 234 567.891 234");
+  EXPECT_EQ(fmt::format(L"{:'.6e}", 123456.789), L"1.234 568e+05");
+  EXPECT_EQ(fmt::format(L"{:*>12'}", -1234567), L"**-1 234 567");
+  EXPECT_EQ(fmt::format(fmt::runtime(L"{:>12'}"), 1234567), L"   1 234 567");
+  EXPECT_THROW((void)fmt::format(fmt::runtime(L"{:'x}"), 42),
+               fmt::format_error);
+  EXPECT_EQ(fmt::format(u"{:'}", 1234567), u"1 234 567");
+  EXPECT_EQ(fmt::format(U"{:>10'.1f}", 1234.5), U"   1 234.5");
+}
+
 TEST(xchar_test, is_formattable) {
   static_assert(!fmt::is_formattable::value, "");
 }

From f8abbeffc07e8c81454eb0a36c8a342c2dd9714a Mon Sep 17 00:00:00 2001
From: Mateusz Pusz 
Date: Wed, 21 Oct 2026 18:56:49 +0200
Subject: [PATCH 2/2] Add separator choice to the ' digit grouping option

Extend the grammar to grouping ::= "'" [" " | "," | "_" | "'"]:

- '  : U+202F (U+0020 for char if the literal encoding is not UTF-8),
       groups the integer and fraction parts, may be combined with L
- ' (apostrophe and space): U+0020, otherwise the same as '
- ', '_ '' : the given character, groups only the integer part;
       combining them with L is an error

All forms share the presentation type restrictions, the grouping of
zero padding and the four-digit exception macro. The separator kind is
stored in a 3-bit field of basic_specs (fmt::group_separator).

Co-Authored-By: Claude Opus 5.5 
---
 doc/syntax.md                          |  48 +++++++----
 include/fmt/core.h                     |  59 ++++++++++---
 include/fmt/format.h                   |  42 ++++++---
 test/compile-error-test/CMakeLists.txt |  33 ++++++-
 test/format-test.cc                    | 114 ++++++++++++++++++++++++-
 test/iso-grouping-min2-test.cc         |   8 ++
 test/xchar-test.cc                     |  24 ++++--
 7 files changed, 276 insertions(+), 52 deletions(-)

diff --git a/doc/syntax.md b/doc/syntax.md
index 19d56ab8..26be406e 100644
--- a/doc/syntax.md
+++ b/doc/syntax.md
@@ -63,10 +63,11 @@ fmt's parser.
 
 
 
format_spec ::= [[fill]align][sign]["#"]["0"][width]["'"]["." precision]["L"][type]
+>format_spec ::= [[fill]align][sign]["#"]["0"][width][grouping]["." precision]["L"][type]
 fill        ::= <a character other than '{' or '}'>
 align       ::= "<" | ">" | "^"
 sign        ::= "+" | "-" | " "
+grouping    ::= "'" [" " | "," | "_" | "'"]
 width       ::= integer | "{" [arg_id] "}"
 precision   ::= integer | "{" [12'}", 1234567);        // Result: "***1 234 567"
-fmt::format("{:010'}", 1234567);         // Result: "01 234 567"
+fmt::format("{:'}", 1234567);             // Result: "1 234 567" (U+202F)
+fmt::format("{:'.6f}", 1234567.891234);   // Result: "1 234 567.891 234" (U+202F)
+fmt::format("{:' .6e}", 123456.789);      // Result: "1.234 568e+05"
+fmt::format("{:',.6f}", 1234567.891234);  // Result: "1,234,567.891234"
+fmt::format("{:'_}", 1234567);            // Result: "1_234_567"
+fmt::format("{:*>12''}", 1234567);        // Result: "***1'234'567"
+fmt::format("{:010',}", 1234567);         // Result: "01,234,567"
 ```
 
-(The spaces in the results above are U+202F.)
-
 ### Presentation type
 
 The *type* field chooses the representation for the value. Specifiers are
diff --git a/include/fmt/core.h b/include/fmt/core.h
index bfd5f75b..aa76b691 100644
--- a/include/fmt/core.h
+++ b/include/fmt/core.h
@@ -677,6 +677,12 @@ enum class presentation_type : unsigned char {
 
 enum class align { none, left, right, center, numeric };
 enum class sign { none, minus, plus, space };
+// Digit group separator selected by the ' option. iso (') is U+202F, or
+// U+0020 for char if the literal encoding is not UTF-8; space (' followed by
+// a space) is U+0020; comma (',), underscore ('_) and apostrophe ('') are the
+// corresponding characters. iso and space group both the integer and the
+// fraction part, the others group only the integer part.
+enum class group_separator { none, iso, space, comma, underscore, apostrophe };
 enum class arg_id_kind { none, index, name };
 
 // Basic format specifiers for built-in and string types.
@@ -687,8 +693,8 @@ class basic_specs {
   //  0                   1                   2                   3
   //  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
-  // |type |align| w | p | s |u|#|L|  f  |'|         unused          |
-  // +-----+-----+---+---+---+-+-+-+-----+-+-------------------------+
+  // |type |align| w | p | s |u|#|L|  f  |  '  |       unused        |
+  // +-----+-----+---+---+---+-+-+-+-----+-----+---------------------+
   //
   //   w - dynamic width info
   //   p - dynamic precision info
@@ -697,7 +703,7 @@ class basic_specs {
   //   # - alternate form ('#')
   //   L - localized
   //   f - fill size
-  //   ' - ISO 80000-1 digit grouping
+  //   ' - digit grouping (fmt::group_separator)
   //
   // Bitfields are not used because of compiler bugs such as gcc bug 61414.
   enum : unsigned {
@@ -710,19 +716,20 @@ class basic_specs {
     alternate_mask = 0x02000,
     localized_mask = 0x04000,
     fill_size_mask = 0x38000,
-    iso_grouping_mask = 0x40000,
+    grouping_mask = 0x1C0000,
 
     align_shift = 3,
     width_shift = 6,
     precision_shift = 8,
     sign_shift = 10,
     fill_size_shift = 15,
+    grouping_shift = 18,
 
     max_fill_size = 4
   };
 
   unsigned data_ = 1 << fill_size_shift;
-  static_assert(sizeof(basic_specs::data_) * CHAR_BIT >= 19, "");
+  static_assert(sizeof(basic_specs::data_) * CHAR_BIT >= 21, "");
 
   // Character (code unit) type is erased to prevent template bloat.
   char fill_data_[max_fill_size] = {' '};
@@ -785,13 +792,17 @@ class basic_specs {
   FMT_CONSTEXPR void set_localized() { data_ |= localized_mask; }
   FMT_CONSTEXPR void clear_localized() { data_ &= ~localized_mask; }
 
-  // Digit grouping in threes on both sides of the decimal marker as specified
-  // by ISO 80000-1 and the SI Brochure (the ' option).
-  constexpr auto iso_grouping() const -> bool {
-    return (data_ & iso_grouping_mask) != 0;
+  // Digit grouping in threes counted from the decimal marker (the ' option).
+  constexpr auto grouping() const -> fmt::group_separator {
+    return static_cast((data_ & grouping_mask) >>
+                                             grouping_shift);
+  }
+  FMT_CONSTEXPR void set_grouping(fmt::group_separator g) {
+    data_ = (data_ & ~grouping_mask) | (unsigned(g) << grouping_shift);
+  }
+  constexpr auto iso_grouping() const -> bool {
+    return (data_ & grouping_mask) != 0;
   }
-  FMT_CONSTEXPR void set_iso_grouping() { data_ |= iso_grouping_mask; }
-  FMT_CONSTEXPR void clear_iso_grouping() { data_ &= ~iso_grouping_mask; }
 
   constexpr auto fill_size() const -> size_t {
     return (data_ & fill_size_mask) >> fill_size_shift;
@@ -1561,8 +1572,27 @@ FMT_CONSTEXPR auto parse_format_specs(const Char* begin, const Char* end,
     case '\'':
       enter_state(state::iso_grouping,
                   in(arg_type, sint_set | uint_set | float_set));
-      specs.set_iso_grouping();
-      ++begin;
+      specs.set_grouping(group_separator::iso);
+      if (++begin != end) {
+        switch (to_ascii(*begin)) {
+        case ' ':
+          specs.set_grouping(group_separator::space);
+          ++begin;
+          break;
+        case ',':
+          specs.set_grouping(group_separator::comma);
+          ++begin;
+          break;
+        case '_':
+          specs.set_grouping(group_separator::underscore);
+          ++begin;
+          break;
+        case '\'':
+          specs.set_grouping(group_separator::apostrophe);
+          ++begin;
+          break;
+        }
+      }
       break;
     case '.':
       enter_state(state::precision,
@@ -1571,6 +1601,9 @@ FMT_CONSTEXPR auto parse_format_specs(const Char* begin, const Char* end,
       break;
     case 'L':
       enter_state(state::locale, is_arithmetic_type(arg_type));
+      // L may only supply the decimal point for the ISO forms (' and ' ).
+      if (specs.grouping() > group_separator::space)
+        report_error("invalid digit group separator with 'L'");
       specs.set_localized();
       ++begin;
       break;
diff --git a/include/fmt/format.h b/include/fmt/format.h
index 3625fe80..6594b075 100644
--- a/include/fmt/format.h
+++ b/include/fmt/format.h
@@ -2417,18 +2417,36 @@ template  struct iso_separator {
   size_t size;  // In code units; the display width is always 1.
 };
 
-// Returns the digit group separator: U+202F NARROW NO-BREAK SPACE encoded as
-// a single code unit for wide character types, as UTF-8 for char if the
-// literal encoding is UTF-8, and U+0020 SPACE otherwise.
+// Returns the digit group separator for g: U+202F NARROW NO-BREAK SPACE for
+// group_separator::iso, encoded as a single code unit for wide character types,
+// as UTF-8 for char if the literal encoding is UTF-8, and U+0020 SPACE
+// otherwise. The other forms use the corresponding ASCII character.
 template 
-FMT_CONSTEXPR auto iso_digit_separator() -> iso_separator {
+FMT_CONSTEXPR auto iso_digit_separator(group_separator g)
+    -> iso_separator {
+  switch (g) {
+  case group_separator::space:      return {{Char(' '), Char(), Char()}, 1};
+  case group_separator::comma:      return {{Char(','), Char(), Char()}, 1};
+  case group_separator::underscore: return {{Char('_'), Char(), Char()}, 1};
+  case group_separator::apostrophe: return {{Char('\''), Char(), Char()}, 1};
+  default:                          break;
+  }
   return {{static_cast(0x202F), Char(), Char()}, 1};
 }
 template <>
-FMT_CONSTEXPR inline auto iso_digit_separator() -> iso_separator {
-  if (FMT_UNICODE != 0 && is_utf8_enabled != 0)
-    return {{'\xE2', '\x80', '\xAF'}, 3};
-  return {{' ', '\0', '\0'}, 1};
+FMT_CONSTEXPR inline auto iso_digit_separator(group_separator g)
+    -> iso_separator {
+  if (g == group_separator::iso) {
+    if (FMT_UNICODE != 0 && is_utf8_enabled != 0)
+      return {{'\xE2', '\x80', '\xAF'}, 3};
+    g = group_separator::space;
+  }
+  switch (g) {
+  case group_separator::comma:      return {{',', '\0', '\0'}, 1};
+  case group_separator::underscore: return {{'_', '\0', '\0'}, 1};
+  case group_separator::apostrophe: return {{'\'', '\0', '\0'}, 1};
+  default:                          return {{' ', '\0', '\0'}, 1};
+  }
 }
 
 FMT_CONSTEXPR inline auto is_iso_digit(char c) -> bool {
@@ -2469,7 +2487,9 @@ FMT_CONSTEXPR auto write_iso_grouped(OutputIt out, const char* begin,
   }
   int digits_size = static_cast(int_end - int_begin);
   int frac_size = static_cast(frac_end - frac_begin);
-  int frac_seps = iso_separator_count(frac_size);
+  // Only the ISO forms (' and ' ) group the fraction digits.
+  bool group_frac = specs.grouping() <= group_separator::space;
+  int frac_seps = group_frac ? iso_separator_count(frac_size) : 0;
   auto num_chars = to_unsigned(end - begin);
 
   auto padded_specs = specs;
@@ -2492,7 +2512,7 @@ FMT_CONSTEXPR auto write_iso_grouped(OutputIt out, const char* begin,
   }
   int num_zeros = int_size - digits_size;
   int int_seps = iso_separator_count(int_size);
-  auto sep = iso_digit_separator();
+  auto sep = iso_digit_separator(specs.grouping());
   auto num_seps = to_unsigned(int_seps + frac_seps);
   // Each separator is one code point of display width 1 but may take several
   // code units, so size and width are passed to write_padded separately.
@@ -3855,7 +3875,7 @@ FMT_CONSTEXPR20 auto write(OutputIt out, T value, format_specs specs,
     // separators and pad. ' takes precedence over L for grouping; L only
     // supplies the decimal point.
     auto plain_specs = specs;
-    plain_specs.clear_iso_grouping();
+    plain_specs.set_grouping(group_separator::none);
     plain_specs.clear_localized();
     plain_specs.set_align(align::none);
     plain_specs.width = 0;
diff --git a/test/compile-error-test/CMakeLists.txt b/test/compile-error-test/CMakeLists.txt
index 36f0671b..ee1a1d8b 100644
--- a/test/compile-error-test/CMakeLists.txt
+++ b/test/compile-error-test/CMakeLists.txt
@@ -256,7 +256,7 @@ if (CMAKE_CXX_STANDARD GREATER_EQUAL 20)
     format-iso-grouping
     "
     #if FMT_USE_CONSTEVAL
-      fmt::format(\"{:>12'.3f} {:'} {:010'} {:'L}\", 1.5, 42, 42, 42);
+      fmt::format(\"{:>12'.3f} {:'} {:010'} {:'L} {:' L} {:',} {:'_.2f} {:''} {:'>8''}\", 1.5, 42, 42, 42, 42, 42, 1.5, 42, 42);
     #endif
     ")
   expect_compile(
@@ -320,6 +320,37 @@ if (CMAKE_CXX_STANDARD GREATER_EQUAL 20)
     "
     ERROR)
 
+  expect_compile(
+    format-iso-grouping-comma-locale-error
+    "
+    #if FMT_USE_CONSTEVAL
+      fmt::format(\"{:',L}\", 42);
+    #else
+      #error
+    #endif
+    "
+    ERROR)
+  expect_compile(
+    format-iso-grouping-underscore-hex-error
+    "
+    #if FMT_USE_CONSTEVAL
+      fmt::format(\"{:'_x}\", 42);
+    #else
+      #error
+    #endif
+    "
+    ERROR)
+  expect_compile(
+    format-iso-grouping-apostrophe-locale-float-error
+    "
+    #if FMT_USE_CONSTEVAL
+      fmt::format(\"{:''.2Lf}\", 4.2);
+    #else
+      #error
+    #endif
+    "
+    ERROR)
+
   # Compile-time argument name check
   expect_compile(
     format-string-name
diff --git a/test/format-test.cc b/test/format-test.cc
index 1d1d248c..8649d290 100644
--- a/test/format-test.cc
+++ b/test/format-test.cc
@@ -1350,12 +1350,114 @@ TEST(format_test, iso_digit_grouping) {
                    "invalid format specifier");
   EXPECT_THROW_MSG((void)fmt::format(runtime("{:'010}"), 42), format_error,
                    "invalid format specifier");
-  EXPECT_THROW_MSG((void)fmt::format(runtime("{:''}"), 42), format_error,
-                   "invalid format specifier");
   EXPECT_THROW_MSG((void)fmt::format(runtime("{:.2'f}"), 4.2), format_error,
                    "invalid format specifier");
 }
 
+TEST(format_test, digit_group_separators) {
+  // ' followed by space always uses U+0020 and groups both parts like '.
+  EXPECT_EQ(fmt::format("{:' }", 1234567), "1 234 567");
+  EXPECT_EQ(fmt::format("{:' .6f}", 1234567.891234), "1 234 567.891 234");
+  EXPECT_EQ(fmt::format("{:' .6e}", 123456.789), "1.234 568e+05");
+  EXPECT_EQ(fmt::format("{:' }", 0.000123456), "0.000 123 456");
+  EXPECT_EQ(fmt::format("{:>12' }", -1234567), "  -1 234 567");
+  EXPECT_EQ(fmt::format("{:010' }", 1234567), "01 234 567");
+  // In a UTF-8 build ' and ' differ only in the separator.
+  if (fmt::detail::is_utf8_enabled != 0 && FMT_UNICODE != 0) {
+    EXPECT_EQ(fmt::format("{:'}", 1234567),
+              "1\xE2\x80\xAF"
+              "234\xE2\x80\xAF"
+              "567");
+    EXPECT_NE(fmt::format("{:'}", 1234567), fmt::format("{:' }", 1234567));
+    EXPECT_EQ(fmt::format("{:>12'}", 1234567).size(),
+              fmt::format("{:>12' }", 1234567).size() + 2 * 2);
+  }
+
+  // ',  '_ and '' group only the integer part.
+  EXPECT_EQ(fmt::format("{:',}", 1234567), "1,234,567");
+  EXPECT_EQ(fmt::format("{:'_}", 1234567), "1_234_567");
+  EXPECT_EQ(fmt::format("{:''}", 1234567), "1'234'567");
+  EXPECT_EQ(fmt::format("{:',}", 0), "0");
+  EXPECT_EQ(fmt::format("{:',}", 123), "123");
+  EXPECT_EQ(fmt::format("{:',}", 1234), "1,234");
+  EXPECT_EQ(fmt::format("{:',}", -1234567), "-1,234,567");
+  EXPECT_EQ(fmt::format("{:+'_d}", 1234567), "+1_234_567");
+  EXPECT_EQ(fmt::format("{:',}", INT64_MIN), "-9,223,372,036,854,775,808");
+  EXPECT_EQ(fmt::format("{:''}", UINT64_MAX), "18'446'744'073'709'551'615");
+  EXPECT_EQ(fmt::format("{:',.6f}", 1234567.891234), "1,234,567.891234");
+  EXPECT_EQ(fmt::format("{:'_.6f}", 1234567.891234), "1_234_567.891234");
+  EXPECT_EQ(fmt::format("{:''.6f}", 1234567.891234), "1'234'567.891234");
+  EXPECT_EQ(fmt::format("{:',.4f}", 0.1234567), "0.1235");
+  EXPECT_EQ(fmt::format("{:'_.3e}", 123456.789), "1.235e+05");
+  EXPECT_EQ(fmt::format("{:',.6e}", 123456.789), "1.234568e+05");
+  EXPECT_EQ(fmt::format("{:',}", 1234567.891234), "1,234,567.891234");
+  EXPECT_EQ(fmt::format("{:',}", 1.234567e300), "1.234567e+300");
+  EXPECT_EQ(fmt::format("{:'_g}", 1234.5), "1_234.5");
+  EXPECT_EQ(fmt::format("{:',.10G}", 1234567.891), "1,234,567.891");
+  EXPECT_EQ(fmt::format("{:#',.0f}", 1234.0), "1,234.");
+  EXPECT_EQ(fmt::format("{:',.0f}", 1e20), "100,000,000,000,000,000,000");
+  EXPECT_EQ(fmt::format("{:',}", std::numeric_limits::infinity()),
+            "inf");
+
+  // Width and fill.
+  EXPECT_EQ(fmt::format("{:*>12',}", 1234567), "***1,234,567");
+  EXPECT_EQ(fmt::format("{:<12'_}", 1234567), "1_234_567   ");
+  EXPECT_EQ(fmt::format("{:^13''}", 1234567), "  1'234'567  ");
+  // ' as fill, then '' as grouping.
+  EXPECT_EQ(fmt::format("{:'>8''}", 1234), "'''1'234");
+  EXPECT_EQ(fmt::format("{:'>12'}", 1234), iso("'''''''1_234"));
+
+  // Zero padding groups the zeros with the same rule as '.
+  EXPECT_EQ(fmt::format("{:010',}", 1234567), "01,234,567");
+  EXPECT_EQ(fmt::format("{:04',}", 1), "0,001");
+  EXPECT_EQ(fmt::format("{:03',}", 1), "001");
+  EXPECT_EQ(fmt::format("{:010',}", -1234), "-0,001,234");
+  EXPECT_EQ(fmt::format("{:08'_}", 1), "0_000_001");
+  EXPECT_EQ(fmt::format("{:012',.2f}", 1234.5), "0,001,234.50");
+  EXPECT_EQ(fmt::format("{:014',.6f}", 1.234567), "000,001.234567");
+  EXPECT_EQ(fmt::format("{:012',.6f}", 1.234567), "0,001.234567");
+  EXPECT_EQ(fmt::format("{:012''.3e}", 123456.0), "0'001.235e+05");
+  EXPECT_EQ(fmt::format(runtime("{:010',}"), 1234567), "01,234,567");
+
+  // Invalid presentation types for every form.
+  for (auto spec :
+       {"{:'x}", "{:' x}", "{:',x}", "{:'_x}", "{:''x}", "{:'X}", "{:',X}",
+        "{:'o}", "{:'_o}", "{:'b}", "{:''b}", "{:',B}", "{:'c}", "{:',c}"}) {
+    EXPECT_THROW_MSG((void)fmt::format(runtime(spec), 42), format_error,
+                     "invalid presentation type for digit grouping")
+        << spec;
+  }
+  for (auto spec : {"{:'a}", "{:' a}", "{:',a}", "{:'_A}", "{:''A}"}) {
+    EXPECT_THROW_MSG((void)fmt::format(runtime(spec), 4.2), format_error,
+                     "invalid presentation type for digit grouping")
+        << spec;
+  }
+  // L only with the ISO forms.
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:',L}"), 42), format_error,
+                   "invalid digit group separator with 'L'");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'_L}"), 42), format_error,
+                   "invalid digit group separator with 'L'");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:''.2Lf}"), 4.2), format_error,
+                   "invalid digit group separator with 'L'");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:',.4Lf}"), 4.2), format_error,
+                   "invalid digit group separator with 'L'");
+  // Other characters after ' are not separators.
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'.}"), 4.2), format_error,
+                   "invalid format string");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'-}"), 42), format_error,
+                   "invalid format specifier");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'''}"), 42), format_error,
+                   "invalid format specifier");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:',,}"), 42), format_error,
+                   "invalid format specifier");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:','}"), 42), format_error,
+                   "invalid format specifier");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:',}"), "abc"), format_error,
+                   "invalid format specifier");
+  EXPECT_THROW_MSG((void)fmt::format(runtime("{:'_}"), true), format_error,
+                   "invalid format specifier");
+}
+
 TEST(format_test, precision) {
   char format_str[buffer_size];
   safe_sprintf(format_str, "{0:.%u", UINT_MAX);
@@ -2903,6 +3005,14 @@ TEST(format_test, iso_digit_grouping_locale) {
                  fmt::format(loc, "{:'Lg}", 1234567.0)}) {
     EXPECT_EQ(s.find('.'), std::string::npos) << s;
   }
+  // ' followed by space: U+0020 separator, L supplies the decimal point.
+  EXPECT_EQ(fmt::format(loc, "{:' .4Lf}", 1234.5678), "1 234,567 8");
+  EXPECT_EQ(fmt::format(loc, "{:' L}", 1234567), "1 234 567");
+  // ',  '_ and '' cannot be combined with L.
+  EXPECT_THROW_MSG((void)fmt::format(loc, runtime("{:',.4Lf}"), 1234.5678),
+                   format_error, "invalid digit group separator with 'L'");
+  // Without L the locale is not consulted.
+  EXPECT_EQ(fmt::format(loc, "{:',.4f}", 1234.5678), "1,234.5678");
   // The format_facet thousands separator is not used either.
   auto facet_loc =
       std::locale({}, new fmt::format_facet(",", {1, 2, 3}));
diff --git a/test/iso-grouping-min2-test.cc b/test/iso-grouping-min2-test.cc
index 76155e9b..aacd4dfa 100644
--- a/test/iso-grouping-min2-test.cc
+++ b/test/iso-grouping-min2-test.cc
@@ -54,6 +54,14 @@ TEST(iso_grouping_min2_test, four_digit_exception) {
   EXPECT_EQ(fmt::format("{:04'}", 1), "0001");
   EXPECT_EQ(fmt::format("{:05'}", 1), iso("00_001"));
   EXPECT_EQ(fmt::format("{:010'}", 1234567), iso("01_234_567"));
+  // All forms; the comma forms only group the integer part.
+  EXPECT_EQ(fmt::format("{:',}", 1234), "1234");
+  EXPECT_EQ(fmt::format("{:',}", 12345), "12,345");
+  EXPECT_EQ(fmt::format("{:' .4f}", 1234.5678), "1234.5678");
+  EXPECT_EQ(fmt::format("{:' .5f}", 1234.56789), "1234.567 89");
+  EXPECT_EQ(fmt::format("{:'_.5f}", 1234.56789), "1234.56789");
+  EXPECT_EQ(fmt::format("{:04',}", 1), "0001");
+  EXPECT_EQ(fmt::format("{:05''}", 1), "00'001");
   EXPECT_THROW_MSG((void)fmt::format(runtime("{:'x}"), 42), format_error,
                    "invalid presentation type for digit grouping");
 }
diff --git a/test/xchar-test.cc b/test/xchar-test.cc
index a437239b..55c981cb 100644
--- a/test/xchar-test.cc
+++ b/test/xchar-test.cc
@@ -87,15 +87,25 @@ TEST(xchar_test, format) {
 
 TEST(xchar_test, iso_digit_grouping) {
   // Wide character types use the single code unit U+202F.
-  EXPECT_EQ(fmt::format(L"{:'}", 1234567), L"1 234 567");
-  EXPECT_EQ(fmt::format(L"{:'.6f}", 1234567.891234), L"1 234 567.891 234");
-  EXPECT_EQ(fmt::format(L"{:'.6e}", 123456.789), L"1.234 568e+05");
-  EXPECT_EQ(fmt::format(L"{:*>12'}", -1234567), L"**-1 234 567");
-  EXPECT_EQ(fmt::format(fmt::runtime(L"{:>12'}"), 1234567), L"   1 234 567");
+  EXPECT_EQ(fmt::format(L"{:'}", 1234567), L"1\u202F234\u202F567");
+  EXPECT_EQ(fmt::format(L"{:'.6f}", 1234567.891234),
+            L"1\u202F234\u202F567.891\u202F234");
+  EXPECT_EQ(fmt::format(L"{:'.6e}", 123456.789), L"1.234\u202F568e+05");
+  EXPECT_EQ(fmt::format(L"{:*>12'}", -1234567), L"**-1\u202F234\u202F567");
+  EXPECT_EQ(fmt::format(fmt::runtime(L"{:>12'}"), 1234567),
+            L"   1\u202F234\u202F567");
   EXPECT_THROW((void)fmt::format(fmt::runtime(L"{:'x}"), 42),
                fmt::format_error);
-  EXPECT_EQ(fmt::format(u"{:'}", 1234567), u"1 234 567");
-  EXPECT_EQ(fmt::format(U"{:>10'.1f}", 1234.5), U"   1 234.5");
+  EXPECT_EQ(fmt::format(u"{:'}", 1234567), u"1\u202F234\u202F567");
+  EXPECT_EQ(fmt::format(U"{:>10'.1f}", 1234.5), U"   1\u202F234.5");
+  // The other forms emit a single code unit too.
+  EXPECT_EQ(fmt::format(L"{:' }", 1234567), L"1 234 567");
+  EXPECT_EQ(fmt::format(L"{:',}", 1234567), L"1,234,567");
+  EXPECT_EQ(fmt::format(L"{:'_.3f}", 1234.5), L"1_234.500");
+  EXPECT_EQ(fmt::format(L"{:''}", -1234567), L"-1'234'567");
+  EXPECT_EQ(fmt::format(L"{:010',}", 1234567), L"01,234,567");
+  EXPECT_EQ(fmt::format(u"{:*>10',}", 1234567), u"*1,234,567");
+  EXPECT_EQ(fmt::format(U"{:' .4f}", 1234.5678), U"1 234.567 8");
 }
 
 TEST(xchar_test, is_formattable) {