printf is fast and terse but not type-safe: pass a long to %d and the
behaviour is undefined. iostreams are type-safe and use formatting state
(std::hex sticks to the stream). Their performance depends on workload,
buffering and configuration; measure it rather than assuming a universal
ranking. std::format (C++20) and
std::print (C++23) give you both: a compact, Python-like mini-language, checked
against the argument types at compile time, extensible to your own types.
1. The basics
#include <cstdio>
#include <format>
#include <print>
#include <string>
int main() {
std::string name = "ada";
int score = 42;
std::println("{} scored {}", name, score); // ada scored 42
std::print("no newline, "); // print: no '\n'
std::println("then one");
std::string s = std::format("{}-{}", 1, 2); // format: returns a std::string
std::println("{}", s);
std::println(stderr, "to stderr"); // any FILE*
}std::println formats a line and appends a newline without requiring you to
construct a result string yourself. A sequence of output calls is not a
cross-thread record protocol; choose synchronization for a complete shared
record. When using iostreams, std::endl also flushes the stream. Prefer a
newline without that flush when a flush is not required (Core Guidelines
SL.io.50); a deliberate prompt or protocol boundary may need an explicit flush.
2. The format string is checked at compile time
The first argument isn't a plain string: it's a std::format_string<Args...>,
whose constructor is consteval. It parses the string while compiling and
checks each replacement field against the type of its argument. A mistake is a
compile error, not a runtime surprise:
#include <format>
int main() {
auto s = std::format("{:d}", "text"); // 'd' (integer) spec on a string
}#include <format>
int main() {
auto s = std::format("{} {}", 1); // two fields, one argument
}Too many arguments is fine (extra ones are ignored). Mixing automatic {}
and manual {0} numbering in one string is an error.
3. The replacement-field grammar
std::format("{0:*^+10.3f}", 3.14159) gives **+3.142**: the value +3.142 (6 characters), centred in a width of 10 with *. Hover a piece for its meaning.| piece | meaning | example → output |
|---|---|---|
| arg-id | which argument (all-manual or all-automatic) | {1} {0} with (a, b) → b a |
| fill + align | < left, > right, ^ centre; fill is any char |
{:*^7} "hi" → **hi*** |
| sign | + always, - only negative (default), space |
{:+} 5 → +5 |
# |
alternate form: 0x, 0b, 0 prefixes |
{:#x} 255 → 0xff |
0 |
pad with zeros after the sign | {:08.2f} -3.14159 → -0003.14 |
| width | minimum width (can be {} — taken from an argument) |
{:>{}} (7, 4) → 7 |
| precision | digits after the point / max chars for strings | {:.3} "abcdef" → abc |
L |
locale-specific (grouping, decimal point) | {:L} |
| type | d x X b o c for integers, f e g a for floats, s ? for strings |
{:b} 5 → 101 |
#include <print>
int main() {
std::println("[{:<6}] [{:>6}] [{:^6}]", "ab", "ab", "ab");
std::println("[{:*^9}] [{:+}] [{:#x}] [{:08.2f}]", "hi", 5, 255, -3.14159);
std::println("[{:>{}}]", 7, 4); // width from an argument
std::println("[{1}, {0}]", "first", "second");
std::println("[{:?}]", "tab\there"); // debug: quoted and escaped
}{:<6}, {:>6} and {:^6} with "ab". Strings default to left, numbers to right; an odd amount of centring padding puts the extra character on the right.Defaults worth remembering: numbers align right, strings align left, and
{} on a double prints the shortest representation that round-trips
(0.1 prints as 0.1, not 0.100000).
4. Ranges and tuples (C++23)
Containers, ranges, pairs and tuples are formattable out of the box. The spec
after the first : applies to the range; a second : passes a spec down
to each element. n drops the brackets.
#include <map>
#include <print>
#include <tuple>
#include <vector>
int main() {
std::vector<int> v{10, 255};
std::println("{}", v); // [10, 255]
std::println("{::x}", v); // [a, ff] element spec "x"
std::println("{:n:>4}", v); // " 10, 255" no brackets, width 4 each
std::println("{}", std::tuple{1, "a", 2.5});
std::map<int, std::string> m{{1, "one"}, {2, "two"}};
std::println("{}", m); // {1: "one", 2: "two"}
}Strings inside ranges are printed in debug form (quoted) — that's why the map
above shows "one".
5. Formatting without allocating
std::format returns a std::string (an allocation for anything beyond the
small-string buffer). When you already have a buffer, write into it:
| function | writes to | returns |
|---|---|---|
std::format_to(out, fmt, args...) |
any output iterator (e.g. std::back_inserter(str)) |
the iterator past the end |
std::format_to_n(out, n, fmt, args...) |
at most n chars |
{out, size} — size is the full length it would have written |
std::formatted_size(fmt, args...) |
nothing | the length |
format_to_n is the type-safe snprintf: it never overruns and tells you
how much space you needed.
#include <format>
#include <print>
#include <string_view>
int main() {
char buf[8];
auto r = std::format_to_n(buf, sizeof buf, "{}", 123456789);
std::println("wrote {} of {} chars: {}",
r.out - buf, r.size, std::string_view(buf, r.out));
std::println("needs {} chars", std::formatted_size("{:08.3f}", 3.14159));
}buf — but r.size is the full length it wanted: 9. Compare r.out - buf with r.size to detect truncation. No '\0' is added.Note r.out - buf (what was written) versus r.size (what was needed) — and
that format_to_n does not add a '\0'.
6. Runtime format strings
Because the format string must be a compile-time constant, this doesn't compile:
#include <format>
#include <string>
std::string apply(const std::string& pattern, int v) {
return std::format(pattern, v); // pattern isn't a constant expression
}
int main() {}When the pattern genuinely comes from outside (config, translation files,
user input), opt out of the compile-time check explicitly. Errors then surface
at run time as std::format_error:
#include <format>
#include <print>
#include <string>
int main() {
std::string pattern = "[{:>6}]";
std::println("{}", std::format(std::dynamic_format(pattern), 42));
std::string bad = "[{:q}]";
try {
std::println("{}", std::format(std::dynamic_format(bad), 42));
} catch (const std::format_error& e) {
std::println("bad pattern: {}", e.what());
}
// The C++20 spelling: std::vformat with lvalue arguments.
int n = 7;
std::println("{}", std::vformat(pattern, std::make_format_args(n)));
}std::dynamic_format is C++26 (it was called std::runtime_format in drafts;
the final standard renamed it). std::vformat(pattern, std::make_format_args(...))
is the C++20 equivalent — note make_format_args takes lvalues, which is
why it's clumsy with temporaries.
7. Formatting your own types
Specialise std::formatter<T>. It has two jobs: parse reads the spec
between : and } and returns an iterator to the }; format writes the
value. The easiest correct formatter delegates to an existing one by
inheriting from it — you get its whole spec grammar for free:
#include <format>
#include <print>
#include <vector>
struct Celsius {
double value;
};
// Accepts every double spec ({:.1f}, {:>8}, …) and appends the unit.
template <>
struct std::formatter<Celsius> : std::formatter<double> {
template <class FormatContext>
auto format(Celsius c, FormatContext& ctx) const {
auto out = std::formatter<double>::format(c.value, ctx);
return std::format_to(out, "°C");
}
};
int main() {
std::println("{}", Celsius{21.5});
std::println("[{:>8.1f}]", Celsius{-3.25});
std::println("{::.0f}", std::vector<Celsius>{{1.4}, {2.6}}); // works in ranges too
}Why is format a template over the context? std::formattable<T> — which
range, tuple and optional formatting require of their elements — checks that
your formatter works with any format context, not just
std::format_context. A formatter declared with std::format_context&
works for std::format("{}", x) but makes std::vector<T> unformattable.
Worked example: a custom spec
To add your own flags, write parse yourself. It's constexpr, so when the
format string is a literal your validation runs at compile time — throwing
std::format_error from parse becomes a compile error at the call site.
#include <format>
#include <print>
struct Point {
int x, y;
};
// {:p} → "(x, y)", {:c} → "x,y". Default is p.
template <>
struct std::formatter<Point> {
char style = 'p';
constexpr auto parse(std::format_parse_context& ctx) {
auto it = ctx.begin();
if (it != ctx.end() && (*it == 'p' || *it == 'c')) style = *it++;
if (it != ctx.end() && *it != '}') throw std::format_error("Point: expected p or c");
return it;
}
template <class FormatContext>
auto format(Point p, FormatContext& ctx) const {
return style == 'p' ? std::format_to(ctx.out(), "({}, {})", p.x, p.y)
: std::format_to(ctx.out(), "{},{}", p.x, p.y);
}
};
int main() {
std::println("{} {:c}", Point{1, 2}, Point{3, 4});
// std::println("{:z}", Point{}); // compile error: thrown from parse
}Points to notice:
parsestarts at the first character after the:and must stop at the closing}without consuming it.formatisconstand a template over the context. State decided inparse(herestyle) lives in members.- Write through
ctx.out()and return the iterator you end with — never build a temporary string just to copy it.
Faded example — your turn
Ratio{num, den} should print as 3/4, and the spec should apply to both
numbers (so {:03} gives 003/004). Inherit from the right formatter and
call its format twice:
template <>
struct std::formatter<Ratio> : std::formatter<int> {
template <class FormatContext>
auto format(Ratio r, FormatContext& ctx) const {
auto out = /* format r.num with the inherited formatter */;
out = /* write '/' */;
ctx.advance_to(out);
return /* format r.den */;
}
};Answer
std::formatter<int>::format(r.num, ctx), then std::format_to(out, "/"),
then ctx.advance_to(out) so the second call continues where you left off,
then std::formatter<int>::format(r.den, ctx).
8. Locale
By default std::format is locale-independent — {} always uses . and
no digit grouping, which is what you want for data files and logs. Add L to
opt in to the current (or an explicitly passed) locale:
#include <format>
#include <locale>
#include <print>
int main() {
std::println("{} {:L}", 1234567, 1234567); // "C" locale: no grouping either way
// std::format(std::locale("de_DE.UTF-8"), "{:L}", 1234.5) → "1.234,5"
}9. Compared with printf and iostreams
| printf | iostreams | std::format / print | |
|---|---|---|---|
| type-checked | no (UB on mismatch) | yes | yes, at compile time |
| user types | no | operator<< |
std::formatter<T> |
| sticky state | no | yes (std::hex persists!) |
no |
| allocation-free into a buffer | snprintf |
no | format_to_n |
| positional args (translations) | no (POSIX %1$) |
no | {1} {0} |
Summary
std::format/std::printlncheck the format string against the arguments at compile time.- Spec grammar:
{id:fill align sign # 0 width .precision L type}. - Ranges/tuples format out of the box;
{::spec}reaches the elements,ndrops brackets. format_to_n+formatted_size= allocation-free, overflow-proof output.- Runtime patterns:
std::dynamic_format(C++26) orstd::vformat; catchstd::format_error. - Custom types: specialise
std::formatter<T>, delegate by inheriting, or write aconstexpr parsethat rejects bad specs at compile time; makeformataconsttemplate over the context so the type staysformattable.