std::format landed in C++20 and it’s a genuine improvement over the printf and stringstream approaches most C++ codebases still carry around. Type-safe, fast, and readable. The catch is that it only knows how to format built-in types out of the box. If you have a custom class, you need to teach it.
Spencer Collyer wrote a thorough walkthrough on isocpp.org covering exactly that. This is a retrospective look at a piece originally published in November 2025, but the mechanics haven’t changed and it’s still one of the cleaner explanations I’ve come across.
The Mechanism
To make a type formattable, you specialize std::formatter<T>. The specialization needs two member functions: parse and format.
template <>
struct std::formatter<Point> {
constexpr auto parse(std::format_parse_context& ctx) {
return ctx.begin();
}
auto format(const Point& p, std::format_context& ctx) const {
return std::format_to(ctx.out(), "({}, {})", p.x, p.y);
}
};
parse handles the format spec, which is whatever appears after the colon inside the braces in a format string, like the :.2f part of {:.2f}. If your type doesn’t need any custom spec, returning ctx.begin() is enough. format writes the output to the context’s output iterator using std::format_to.
Once that specialization exists, you can use your type anywhere std::format is accepted:
Point p{3, 7};
std::string s = std::format("Position: {}", p);
// Position: (3, 7)
Where It Gets More Interesting
The format spec parsing is where most of the real work lives for non-trivial types. If you want to support things like width, fill characters, or custom flags, you need to actually walk through ctx and interpret the characters yourself. The standard formatters for things like integers and floats do this, and you can delegate to them if your type wraps a primitive.
There’s a useful pattern here: inherit from an existing formatter for a member field, call its parse, and then use it inside your own format. This avoids reimplementing spec parsing from scratch for common cases.
template <>
struct std::formatter<Celsius> : std::formatter<double> {
auto format(Celsius c, std::format_context& ctx) const {
return std::formatter<double>::format(c.value, ctx);
}
};
This is the kind of composability that makes the design feel well thought out, compared to rolling your own formatting infrastructure.
Why This Matters
The Discord bots I work on involve a lot of ad-hoc string building: command output, embed descriptions, log lines. Historically that meant either raw concatenation or a pile of helper functions. Having a clean, standard extension point means you can define formatting once per type and then use it consistently everywhere, including in logging, testing output, and user-facing strings.
It also plays well with the rest of the standard library. std::print, introduced in C++23, uses the same formatting machinery, so types you make formattable today work there too.
Collyer’s article is worth reading for the full treatment, particularly the section on handling format specs with actual flags. The fundamentals are not complicated, but the details of walking the parse context correctly are easy to get subtly wrong.