How to Format Custom Data Types with fmtlib: A Complete Guide to formatter Specialization and format_as

To format custom data types with fmtlib, specialize fmt::formatter<T> with parse() and format() methods, or provide a format_as() free function that returns a fmt-compatible type.

The fmt library (also known as {fmt}) provides a type-safe, high-performance formatting API modeled after Python's str.format. While built-in types and standard library containers work out of the box, extending support to user-defined types requires implementing either a formatter specialization or a format_as hook. This guide demonstrates both approaches with complete examples drawn from the fmtlib/fmt source code.

Understanding fmt's Type Resolution Mechanism

When you call fmt::print("{}", value), the library searches for a valid formatter in the following order, as implemented in [include/fmt/format.h](https://github.com/fmtlib/fmt/blob/main/include/fmt/format.h):

  1. Check for a format_as(const T&) function that returns a formattable type
  2. Look for a specialized fmt::formatter<T, Char> template
  3. Fall back to ostream_formatter if <fmt/ostream.h> is included and operator<< exists

Understanding this resolution chain helps you choose the right extension mechanism for your use case.

Method 1: Specializing fmt::formatter

The most flexible approach requires specializing the primary formatter template with two member functions.

Required Interface

template <>
struct fmt::formatter<YourType, CharType> {
  // Parse format specifiers (e.g., {:>10})
  constexpr auto parse(format_parse_context& ctx) -> decltype(ctx.begin());
  
  // Perform the actual formatting
  template <typename FormatContext>
  auto format(const YourType& value, FormatContext& ctx) 
    -> decltype(ctx.out());
};

Basic Formatter Example

This example formats a 2D point as (x, y) with no custom specifiers:

#include <fmt/core.h>

struct Point {
  int x, y;
};

template <>
struct fmt::formatter<Point> {
  // No format specifiers supported—just validate the closing brace
  constexpr auto parse(format_parse_context& ctx) {
    return ctx.begin();  // Empty parse accepts any spec (or none)
  }

  template <typename FormatContext>
  auto format(const Point& p, FormatContext& ctx) const {
    // Reuse built-in formatters via format_to
    return fmt::format_to(ctx.out(), "({}, {})", p.x, p.y);
  }
};

int main() {
  Point pt{3, 7};
  fmt::print("Point = {}\n", pt);   // Output: Point = (3, 7)
}

The format_to function writes directly to the output iterator from ctx.out(), avoiding intermediate string construction for maximum performance.

Supporting Format Specifiers

To handle width, precision, and alignment, parse the specification in parse():

#include <fmt/core.h>

struct HexByte {
  unsigned char value;
};

template <>
struct fmt::formatter<HexByte> {
  int width = 0;
  bool upper = true;

  constexpr auto parse(format_parse_context& ctx) {
    auto it = ctx.begin();
    auto end = ctx.end();
    
    // Parse width if present (simplified—production code handles full spec)
    if (it != end && *it != '}') {
      // Skip format spec characters, parse width
      while (it != end && *it != '}') {
        if (*it >= '0' && *it <= '9') {
          width = width * 10 + (*it - '0');
        } else if (*it == 'x') {
          upper = false;
        } else if (*it == 'X') {
          upper = true;
        }
        ++it;
      }
    }
    
    // Return iterator pointing past '}' (caller expects this)
    return it;
  }

  template <typename FormatContext>
  auto format(const HexByte& hb, FormatContext& ctx) const {
    const char* fmt = upper ? "{:0{}X}" : "{:0{}x}";
    return fmt::format_to(ctx.out(), fmt, 
                          static_cast<unsigned>(hb.value), width);
  }
};

int main() {
  HexByte b{0xAB};
  fmt::print("{:04x}\n", b);   // Output: 00ab
  fmt::print("{:4}\n", b);     // Output:   AB (default upper, width 4)
}

For production parsing, use fmt::detail::parse_nonnegative_int() and fmt::detail::parse_format_specs() utilities found in [include/fmt/format.h](https://github.com/fmtlib/fmt/blob/main/include/fmt/format.h).

Method 2: Using format_as for Simple Conversions

When your type maps naturally to an existing formattable type, the format_as hook eliminates boilerplate. The library detects this function via SFINAE and uses the returned type's formatter.

format_as Example

#include <fmt/core.h>
#include <string>

struct Name {
  std::string first;
  std::string last;
};

// fmt automatically finds this via ADL or namespace lookup
inline std::string format_as(const Name& n) {
  return n.first + " " + n.last;
}

// Alternative: return a struct with built-in formatter
struct Date {
  int year, month, day;
};

inline auto format_as(const Date& d) {
  // Return a type that fmt already knows how to format
  return fmt::join(std::array{d.year, d.month, d.day}, "-");
}

int main() {
  Name author{"Ada", "Lovelace"};
  fmt::print("Author: {}\n", author);   // Output: Author: Ada Lovelace
  
  Date birth{1815, 12, 10};
  fmt::print("Born: {}\n", birth);      // Output: Born: 1815-12-10
}

The format_as approach is ideal for:

  • Wrapper types that contain a single formattable value
  • Type aliases that should format like their underlying type
  • Legacy code where modifying the struct definition is impractical

Choosing Between formatter Specialization and format_as

Approach Use When Overhead
formatter specialization Custom format syntax, multiple formatting modes, complex output logic Zero runtime overhead
format_as Simple 1:1 mapping to existing type, no custom specifiers needed One extra function call

Both mechanisms are resolved at compile time in [include/fmt/format.h](https://github.com/fmtlib/fmt/blob/main/include/fmt/format.h) via template metaprogramming.

Working with Container Types

The fmt::join utility combines with custom formatters to format ranges:

#include <fmt/core.h>
#include <fmt/ranges.h>
#include <vector>

struct Line {
  std::vector<Point> points;  // Point from earlier example
};

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

  template <typename FormatContext>
  auto format(const Line& line, FormatContext& ctx) const {
    // Use fmt::join with our custom Point formatter
    auto out = fmt::format_to(ctx.out(), "Line[");
    out = fmt::format_to(out, "{}", fmt::join(line.points, " → "));
    return fmt::format_to(out, "]");
  }
};

The [include/fmt/ranges.h](https://github.com/fmtlib/fmt/blob/main/include/fmt/ranges.h) header demonstrates how range formatters delegate to element formatters—study this file for advanced container formatting patterns.

Integration with std::ostream

For types that already implement operator<<, include [include/fmt/ostream.h](https://github.com/fmtlib/fmt/blob/main/include/fmt/ostream.h):

#include <fmt/ostream.h>
#include <iostream>

struct LegacyPoint {
  int x, y;
  friend std::ostream& operator<<(std::ostream& os, const LegacyPoint& p) {
    return os << '(' << p.x << ", " << p.y << ')';
  }
};

// No formatter needed—ostream_formatter provides fallback
int main() {
  LegacyPoint p{1, 2};
  fmt::print("{}\n", p);   // Uses existing operator<<
}

This bypasses custom formatter development but incurs ostream overhead. For performance-critical code, prefer direct formatter specialization.

Testing and Validation

The fmtlib test suite in test/format-test.cc provides extensive examples of custom formatter behavior. Key patterns to verify:

  1. Empty format string handling
  2. Invalid format specifier detection
  3. Move-only and non-default-constructible types
  4. Constexpr evaluation where applicable
// Compile-time test (C++20)
constexpr auto test_format() {
  Point p{1, 2};
  return fmt::format("{}", p);
}
static_assert(test_format() == "(1, 2)");

Summary

  • Specialize fmt::formatter<T> for full control over format syntax and output, implementing parse() for specifiers and format() for generation
  • Provide format_as() for simple type-to-type conversions without custom syntax
  • Include fmt/ostream.h when reusing existing operator<< implementations
  • Reference fmt/ranges.h for container formatting patterns that compose with your formatters
  • Study test/format-test.cc for validated implementation patterns

Both approaches integrate seamlessly with fmt's compile-time format string checking and minimize runtime overhead through direct iterator writes.

Frequently Asked Questions

How does fmtlib find my custom formatter?

fmtlib discovers custom formatters through template argument lookup for fmt::formatter<T> specializations and argument-dependent lookup (ADL) for format_as functions. Both mechanisms occur at compile time; no runtime registration is required.

Can I use custom format specifiers like {:08x} with my type?

Yes—parse the specification in your parse() method using ctx.begin() and ctx.end(). Advance the iterator past consumed characters and return the iterator pointing to the closing }. The fmt::detail namespace contains parsing helpers used by built-in formatters.

What is the performance cost of format_as versus full specialization?

format_as incurs one additional function call to obtain the convertible value, then uses that type's formatter. For simple conversions this is negligible. Full specialization eliminates this indirection and allows direct output iterator access, preferred in hot paths.

Why does my formatter work with fmt::format but fail at compile time with consteval?

Ensure your parse() and format() methods are marked constexpr (C++17) or consteval (C++20). The formatter must be usable in constant evaluation contexts for compile-time format string validation to succeed.

Can I format a type I don't own, like a third-party library struct?

Yes—specialize fmt::formatter<T> in the fmt namespace or provide format_as in a namespace associated with T. Do not specialize templates in std or modify third-party headers. For types with operator<< but no formatter, include fmt/ostream.h.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →