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):
- Check for a
format_as(const T&)function that returns a formattable type - Look for a specialized
fmt::formatter<T, Char>template - Fall back to
ostream_formatterif<fmt/ostream.h>is included andoperator<<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:
- Empty format string handling
- Invalid format specifier detection
- Move-only and non-default-constructible types
- 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, implementingparse()for specifiers andformat()for generation - Provide
format_as()for simple type-to-type conversions without custom syntax - Include
fmt/ostream.hwhen reusing existingoperator<<implementations - Reference
fmt/ranges.hfor container formatting patterns that compose with your formatters - Study
test/format-test.ccfor 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →