How std::format Works in Modern C++

Introduced in C++20, std::format provides a fast, type-safe, and extensible alternative to legacy formatting mechanisms like printf and std::stringstream. By adopting a syntax inspired by Python's str.format, it allows developers to build formatted strings using replacement fields enclosed in curly braces. This article explains the underlying mechanics of format strings, including positional indexing, standard format specifications for alignment and numbers, compile-time validation, and custom type formatting.

Basic Syntax and Replacement Fields

At its simplest, a format string contains ordinary text mixed with replacement fields denoted by {}. During execution, std::format replaces each field with the string representation of the corresponding argument passed to the function.

#include <format>
#include <iostream>
#include <string>

int main() {
    std::string message = std::format("Hello, {}! You have {} new notifications.", "Alice", 5);
    std::cout << message; // Output: Hello, Alice! You have 5 new notifications.
}

Arguments are matched automatically from left to right. However, explicit positional indices can also be used to change the order of arguments or reuse them multiple times:

// Positional indexing
std::string repeated = std::format("{0} can be {1}, but {0} is always {0}.", "Code", "complex");
// Output: Code can be complex, but Code is always Code.

Note: You cannot mix automatic and manual indexing within the same format string.

Format Specifiers

A replacement field can include an optional colon (:) followed by a format specification: {index:specifier}. The standard specification grammar supports alignment, width, precision, and type presentation.

The general syntax follows this pattern: [[fill]align][sign]['#']['0'][width]['.' precision]['L'][type]

Alignment and Width

You can control field width and text alignment using <, >, or ^:

  • <: Left-aligns the result (default for non-numbers).
  • >: Right-aligns the result (default for numbers).
  • ^: Centers the result.

An optional fill character can precede the alignment operator:

std::format("{:<10}", "left");      // "left      "
std::format("{:>10}", "right");     // "     right"
std::format("{:^10}", "center");    // "  center  "
std::format("{:*^10}", "fill");     // "***fill***"

Numeric Formatting and Signs

Numbers can be formatted in different radices and notation styles:

  • Integers: d (decimal), x/X (hexadecimal), o (octal), b/B (binary).
  • Alternative Form (#): Adds prefixes like 0x, 0b, or forces decimal points.
  • Signs: + (always display sign), - (display only for negative, default), (space for positive).
std::format("{:#x}", 255);          // "0xff"
std::format("{:#010b}", 5);         // "0b00000101" (padded with zeros)
std::format("{:+d}", 42);           // "+42"

Floating-Point Precision

Floating-point presentation types include f/F (fixed-point), e/E (scientific), g/G (general), and a/A (hex-float). Precision is specified with a period (.) followed by an integer:

double pi = 3.1415926535;

std::format("{:.2f}", pi);          // "3.14"
std::format("{:10.4f}", pi);        // "    3.1416"
std::format("{:.2e}", pi);          // "3.14e+00"

Compile-Time Format String Checking

Unlike C's printf, which performs type checking via compiler warnings that can be bypassed or missed, std::format verifies format strings at compile time using consteval constructors.

If a format specifier does not match the provided argument type (for example, attempting to format a string with {:d}), the code fails to compile:

// This causes a compilation error:
// std::string bad = std::format("{:d}", "not a number");

For dynamic format strings constructed at runtime, std::vformat or std::runtime_format (introduced in C++26) must be used explicitly.

Formatting User-Defined Types

The format string engine is extensible. To support custom classes or structs, you specialize the std::formatter template for your type:

struct Point {
    int x;
    int y;
};

template <>
struct std::formatter<Point> {
    constexpr auto parse(std::format_parse_context& ctx) {
        return ctx.begin(); // Simple parser: accepts default "{}"
    }

    auto format(const Point& p, std::format_context& ctx) const {
        return std::format_to(ctx.out(), "({}, {})", p.x, p.y);
    }
};

// Usage:
Point pt{10, 20};
std::string result = std::format("Point coordinates: {}", pt); // "Point coordinates: (10, 20)"

By customizing parse() and format(), custom types can process their own specifiers, integrating seamlessly into standard format strings.