The Trap of std::format_context in C++ Custom Formatters

When implementing a custom formatter in C++20 or C++23 using the <format> library, it is easy to fall into a common trap: using std::format_context directly in your format member function. While it works perfectly fine when formatting single objects, it suddenly breaks and throws cryptic compilation errors when you try to format a container (like a std::vector) of your custom type.

In this article, we will explore why this happens, why std::format_context exists in the first place, and the correct way to write robust custom formatters.

The Problem: Code that Compiles... Until It Doesn't

Consider the following custom struct and its corresponding std::formatter specialization:

#include <format>
#include <print>
#include <vector>

struct Point {
    int x = 42;
};

template <>
struct std::formatter<Point> {
    constexpr auto parse(std::format_parse_context& ctx) {
        return ctx.begin();
    }

    // Hardcoding std::format_context here is the trap!
    auto format(const Point& p, std::format_context& ctx) const {
        return std::format_to(ctx.out(), "{}", p.x);
    }
};

If you run this code to print a single Point, it works flawlessly:

std::println("{}", Point{}); // Outputs: 42

However, if you attempt to format a range of Point objects (a feature introduced in C++23), the compiler will reject the code with a wall of errors:

std::println("{}", std::vector{Point{}, Point{}}); // Compilation Error!

Why Does This Error Occur?

To understand the compilation failure, we need to look at how std::format_context is defined. It is actually a type alias:

using std::format_context = std::basic_format_context</* unspecified output iterator */, char>;

The standard library hardcodes a specific, default output iterator type into std::format_context (typically an iterator that writes directly to a string buffer).

When you format a single object, the formatting engine uses this default output iterator. However, when formatting a range (like std::vector<Point>), the standard range-formatter needs to handle delimiters, brackets, and nested elements. To do this efficiently, the range-formatting machinery wraps or replaces the original output iterator with a specialized, internal iterator.

As a result, the formatting context passed to your Point formatter is not std::format_context, but rather a std::basic_format_context instantiated with a completely different iterator type. Because your format function explicitly expects std::format_context, template argument deduction fails, leading to a compilation error.

The Solution: Template Your format Method

To make your custom formatter compatible with ranges and other custom formatting contexts, you must template your format member function on the context type. This allows the compiler to deduce the correct iterator type automatically.

Here are the two standard ways to write it:

Option 1: Templating the entire Context (Recommended)

template <>
struct std::formatter<Point> {
    constexpr auto parse(std::format_parse_context& ctx) {
        return ctx.begin();
    }

    template <typename FormatContext>
    auto format(const Point& p, FormatContext& ctx) const {
        return std::format_to(ctx.out(), "{}", p.x);
    }
};

Option 2: Templating just the Output Iterator

template <>
struct std::formatter<Point> {
    constexpr auto parse(std::format_parse_context& ctx) {
        return ctx.begin();
    }

    template <typename Out>
    auto format(const Point& p, std::basic_format_context<Out, char>& ctx) const {
        return std::format_to(ctx.out(), "{}", p.x);
    }
};

Both approaches ensure that your formatter is fully compliant with the Formatter named requirements and will work seamlessly inside containers, ranges, and custom formatting pipelines.

Why Does std::format_context Exist Then?

If std::format_context shouldn't be used in custom formatters, why does it exist in the standard library? Is it just a "honeypot" for unsuspecting developers?

Not quite. std::format_context serves a critical purpose in the type-erased core of the <format> library.

  • Type Erasure and Binary Size: To prevent template bloat and keep compile times reasonable, functions like std::vformat are not templates. They take a type-erased std::format_args object.
  • Standard Interface: std::format_args is a type alias for std::basic_format_args<std::format_context>. Having a fixed, non-templated context type allows the library to implement the heavy lifting of formatting inside a pre-compiled library binary, rather than instantiating everything in header files.

In short, std::format_context is meant for the library's internal type-erasure boundary, while custom std::formatter implementations are meant to be fully templated to handle any output iterator they are handed.