Custom Formatters for User-Defined Types
Any type can be formatted with std::format and std::println once you provide a specialisation of std::formatter<T>. The specialisation implements two member functions: parse(), which reads the format specifier from the format string (the part after the colon in {:spec}), and format(), which writes the formatted representation to the output. Together they give you full, type-safe control over how objects of your type appear wherever format strings are used.
The extension point: std::formatter<T>
The format library dispatches formatting of a value of type T to std::formatter<T>. To make your own type formattable, write a full specialisation of this class template with two member functions. parse() must be constexpr — it runs at compile time when the format string is a compile-time constant (C++20). format() runs at runtime and writes to an output iterator exposed through the format context.
// Minimum required structure
template <>
struct std::formatter<YourType>
{
// Must be constexpr
constexpr auto parse(std::format_parse_context& ctx)
{
// ctx is the range [first-char-of-specifier, '}')
// Read any custom specifier characters here.
// Return an iterator to the '}' that ends the replacement field.
return ctx.begin(); // trivial: no custom specifiers
}
// Must be const-qualified (called on a const formatter)
auto format(const YourType& value, std::format_context& ctx) const
{
// Write the formatted representation to ctx.out().
// Return the iterator past the last character written.
return std::format_to(ctx.out(), "...", /* parts of value */);
}
};A minimal formatter — no custom specifiers
When your type has a natural string representation and you do not need custom specifiers, the formatter is just a wrapper around std::format_to. The parse() function simply returns ctx.begin(), meaning "I consumed no specifier characters; the caller should find the closing } immediately." The format() function delegates to std::format_to, which is like std::format but writes directly to an output iterator — exactly what the format context provides.
struct Point { double x, y; };
template <>
struct std::formatter<Point>
{
constexpr auto parse(std::format_parse_context& ctx)
{
return ctx.begin(); // no specifiers; point at '}'
}
auto format(const Point& p, std::format_context& ctx) const
{
return std::format_to(ctx.out(), "({}, {})", p.x, p.y);
}
};Point p { 1.5, 2.7 };
std::println("Point: {}", p); // Point: (1.5, 2.7)
std::println("Scaled: {:>20}", ???); // NOTE: width/fill on YOUR type won't
// work yet — see nested formatters below
// It composes with std::format the same way any built-in type does:
std::string s = std::format("p = {}", p); // "p = (1.5, 2.7)"
std::vector<Point> pts = { {0,0}, {1,1} };
// Ranges print each element using your formatter:
std::println("{}", pts); // [(0, 0), (1, 1)]How parse() works
When the format library encounters a replacement field like {:k}, it calls parse() with a context whose character range points to the specifier characters after the colon — in this case just the single character k. The range ends before the closing }. Your parse() implementation iterates over that range, records what it finds (typically in data members of the formatter), and returns an iterator that points to the closing } — or throws std::format_error if the specifier is invalid.
// Formatter for a Status enum with custom specifiers:
// {:s} → short form ("OK", "ERR")
// {:l} or {} → long form ("Success", "Error") (default)
enum class Status { Ok, Error };
template <>
struct std::formatter<Status>
{
bool m_short { false };
constexpr auto parse(std::format_parse_context& ctx)
{
auto iter { ctx.begin() };
if (iter == ctx.end() || *iter == '}') {
return iter; // no specifier: use default (long form)
}
switch (*iter) {
case 's': m_short = true; break;
case 'l': m_short = false; break;
default: throw std::format_error { "Invalid Status format specifier." };
}
++iter;
if (iter != ctx.end() && *iter != '}') {
throw std::format_error { "Invalid Status format specifier." };
}
return iter; // point at '}'
}
auto format(Status s, std::format_context& ctx) const
{
if (m_short) {
return std::format_to(ctx.out(), "{}", s == Status::Ok ? "OK" : "ERR");
}
return std::format_to(ctx.out(), "{}", s == Status::Ok ? "Success" : "Error");
}
};
// Usage:
std::println("{}", Status::Ok); // Success
std::println("{:s}", Status::Ok); // OK
std::println("{:l}", Status::Error); // ErrorComposing with sub-formatters and nested specifiers
A common pattern is to store a std::formatter<MemberType> as a data member and delegate to it. This lets your custom formatter inherit all the built-in format specifiers (width, fill, precision, …) for the underlying member types. The trick is to split the outer format specifier string by a separator (conventionally a colon) and pass each sub-string to the corresponding sub-formatter's own parse() via a std::format_parse_context you construct manually. In format(), call ctx.advance_to(sub_formatter.format(member, ctx)) to forward the output iterator after each sub-format step.
// KeyValue: a class holding a string key and an integer value.
// Supported format specifiers:
// {} or {:b} → "Key 1 - 255" (both, default)
// {:k} → "Key 1" (key only)
// {:v} → "255" (value only)
// {:b:KeyFmt:ValueFmt} → nested specifiers for each part
// e.g. {::*^11:#06X} → "***Key 1*** - 0X00FF"
class KeyValue
{
public:
KeyValue(std::string_view key, int value) : m_key{key}, m_value{value} {}
const std::string& getKey() const { return m_key; }
int getValue() const { return m_value; }
private:
std::string m_key;
int m_value { 0 };
};template <>
class std::formatter<KeyValue>
{
public:
constexpr auto parse(auto& context)
{
std::string keyFormat, valueFormat;
std::size_t colons { 0 };
auto iter { begin(context) };
for (; iter != end(context); ++iter) {
if (*iter == '}') break;
if (colons == 0) { // parsing output type flag
switch (*iter) {
case 'k': case 'K': m_output = Output::KeyOnly; break;
case 'v': case 'V': m_output = Output::ValueOnly; break;
case 'b': case 'B': m_output = Output::Both; break;
case ':': ++colons; break;
default: throw format_error { "Invalid KeyValue format." };
}
} else if (colons == 1) { // parsing key sub-specifier
if (*iter == ':') ++colons;
else keyFormat += *iter;
} else { // parsing value sub-specifier
valueFormat += *iter;
}
}
if (!keyFormat.empty()) {
format_parse_context kctx { keyFormat };
m_keyFmt.parse(kctx);
}
if (!valueFormat.empty()) {
format_parse_context vctx { valueFormat };
m_valFmt.parse(vctx);
}
if (iter != end(context) && *iter != '}') {
throw format_error { "Invalid KeyValue format." };
}
return iter;
}
auto format(const KeyValue& kv, auto& ctx) const
{
switch (m_output) {
using enum Output;
case KeyOnly:
ctx.advance_to(m_keyFmt.format(kv.getKey(), ctx));
break;
case ValueOnly:
ctx.advance_to(m_valFmt.format(kv.getValue(), ctx));
break;
default: // Both
ctx.advance_to(m_keyFmt.format(kv.getKey(), ctx));
ctx.advance_to(format_to(ctx.out(), " - "));
ctx.advance_to(m_valFmt.format(kv.getValue(), ctx));
break;
}
return ctx.out();
}
private:
enum class Output { KeyOnly, ValueOnly, Both };
Output m_output { Output::Both };
std::formatter<std::string> m_keyFmt;
std::formatter<int> m_valFmt;
};KeyValue kv { "Key 1", 255 };
std::println("{}", kv); // Key 1 - 255
std::println("{:k}", kv); // Key 1
std::println("{:v}", kv); // 255
std::println("{:b}", kv); // Key 1 - 255
std::println("{:k:*^11}", kv); // ***Key 1***
std::println("{:v::#06X}", kv); // 0X00FF
std::println("{::*^11:#06X}", kv); // ***Key 1*** - 0X00FF
// Error cases — caught at compile time if the format string is a constant:
try {
auto s = std::vformat("{:cd}", std::make_format_args(kv));
} catch (const std::format_error& e) {
std::println("{}", e.what()); // Invalid KeyValue format.
}Practical rules and pitfalls
parse() must be constexpr
The format library calls parse() at compile time for compile-time constant format strings (C++20). Non-constexpr parse() breaks that guarantee and produces a runtime-only formatter.
format() must be const-qualified
The library holds a const formatter and calls format() on it. A non-const format() will fail to compile.
Return the iterator to '}', not past it
parse() must return an iterator pointing at the closing '}' of the replacement field, not past it. The format library advances past the '}' itself.
Throw std::format_error for bad specifiers
Invalid specifiers should throw std::format_error. At compile time this becomes a compile error; at runtime it propagates as an exception.
Use ctx.advance_to() when chaining sub-formatters
Each sub-formatter's format() returns a new output iterator. Calling ctx.advance_to() updates the context so the next sub-formatter writes to the right position.