Formatting looks like a solved problem until a codebase becomes large, latency-sensitive, localized, or simply old. Then output code starts to reveal its hidden cost: unsafe format strings, unreadable stream chains, inconsistent timestamps, allocations in hot paths, and formatting logic scattered across business code.
That is why {fmt} is interesting again. The library appeared in GitHub Trending today, but its real story is not novelty. It is a mature answer to a stubborn engineering problem, and much of its design influenced std::format and std::print in modern C++.
The three styles C++ teams usually inherit
Most production repositories contain more than one formatting style:
| Style | Good at | Typical problem |
|---|---|---|
printf |
compact output, familiar syntax | type mismatches and manual buffer management |
| iostreams | type-aware composition | noisy syntax, stateful manipulators, slower compile times |
{fmt} / std::format
|
readable templates and type safety | another dependency when the standard library is enough |
The difference is obvious in ordinary code:
#include <iomanip>
#include <iostream>
void print_order(int id, double total) {
std::cout << "order=" << id
<< " total=" << std::fixed
<< std::setprecision(2) << total << '\n';
}
Formatting state can leak through a stream: a later call may inherit std::fixed or changed precision. The equivalent {fmt} call makes the output contract visible in one place:
#include <fmt/base.h>
void print_order(int id, double total) {
fmt::print("order={} total={:.2f}\n", id, total);
}
It is easier to review because the text and arguments stay together.
Compile-time checks change the failure mode
Modern formatting moves many mistakes from runtime to compilation. Consider this invalid request:
auto message = fmt::format("value={:d}", "not a number");
The d specifier expects an integer, but the argument is a string. With a compile-time format string, the build fails before the code reaches a user.
Runtime-created templates are still possible, but they should be explicit:
std::string pattern = load_pattern_from_config();
auto result = fmt::format(fmt::runtime(pattern), 42);
That distinction documents trust boundaries. A literal owned by the program can be checked during compilation; a template loaded from configuration must be validated and tested as runtime data.
Formatting domain types without polluting business code
The extensibility model is where {fmt} becomes more than a prettier printf. Suppose a service has a small domain type:
struct Money {
long long cents;
std::string currency;
};
Instead of teaching every call site how to divide cents and position the currency, define a formatter once:
#include <fmt/format.h>
template <>
struct fmt::formatter<Money> {
constexpr auto parse(format_parse_context& ctx) {
return ctx.begin();
}
auto format(const Money& value, format_context& ctx) const {
return fmt::format_to(
ctx.out(), "{} {:.2f}", value.currency, value.cents / 100.0);
}
};
Money price{1299, "USD"};
fmt::print("Price: {}\n", price);
The formatter becomes a tiny presentation boundary. Tests can target it directly, while application code remains focused on orders, invoices, or transactions.
Ranges, time, and structured diagnostics
Three small features remove surprising amounts of glue code.
First, ranges:
#include <fmt/ranges.h>
#include <vector>
std::vector<int> ports{8080, 8081, 9090};
fmt::print("ports={}\n", ports);
Second, time values:
#include <chrono>
#include <fmt/chrono.h>
auto now = std::chrono::system_clock::now();
fmt::print("started at {:%Y-%m-%d %H:%M:%S}\n", now);
Third, building into an existing buffer:
fmt::memory_buffer buffer;
fmt::format_to(std::back_inserter(buffer),
"request_id={} status={}", request_id, status);
send_log({buffer.data(), buffer.size()});
This last pattern matters in hot paths because it lets the application control buffer reuse and the final write. A formatting library cannot make logging architecture efficient by itself, but it can avoid forcing an intermediate std::string at every step.
A practical CMake setup
For a normal compiled dependency:
include(FetchContent)
FetchContent_Declare(
fmt
GIT_REPOSITORY https://github.com/fmtlib/fmt.git
GIT_TAG 12.1.0
)
FetchContent_MakeAvailable(fmt)
add_executable(app main.cpp)
target_link_libraries(app PRIVATE fmt::fmt)
Pin a release tag rather than tracking the default branch. Reproducibility is more valuable than receiving an unreviewed dependency update during a clean build.
Header-only mode is available, but it is not automatically the best choice. It simplifies linking, yet can increase repeated compilation across translation units. For medium and large projects, measure both clean-build time and binary size before standardizing on it.
Migration without a flag day
A formatting migration should be boring. A useful sequence is:
- Add
{fmt}as a pinned dependency and enable warnings in CI. - Use it in new code and in files already being changed.
- Replace risky
sprintfbuffers first. - Add tests around user-visible output and machine-parsed logs.
- Benchmark only known hot paths; do not guess from microbenchmarks alone.
Avoid a repository-wide formatting-only rewrite. Large diffs create merge conflicts and hide behavioral changes; migrate incrementally during real maintenance work.
{fmt} or the standard library?
If every supported compiler has a complete, performant implementation of std::format and std::print, the standard library may be the simplest answer. {fmt} remains attractive when a project needs older compiler support, features not yet available everywhere, a predictable cross-platform implementation, or a faster release cadence.
The strategic point is to hide neither choice behind a homemade formatting framework. Prefer direct, well-known APIs. If portability requires an abstraction, keep it extremely thin so the code can move from {fmt} to the standard library later.
What the trend actually says
The popularity of {fmt} is a reminder that developer experience is often performance work in disguise. Clear format strings reduce review time. Compile-time checks reduce debugging. Domain formatters remove duplicated presentation logic. Controlled buffers help hot paths. None of these changes is dramatic alone, but together they make output code predictable.
Good infrastructure does not ask developers to think about it on every line. It turns a recurring source of tiny mistakes into a small, explicit contract — and then gets out of the way.
Top comments (0)