Skip to content
C++

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); // Error

Composing 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.

← std::format
std::print (C++23) coming soon
Sign in to track progress