std::format: Type-Safe Text Formatting in C++20
Learn to use C++20 std::format and std::print for type-safe, readable string formatting — replacing printf, sprintf, and stream concatenation with a clean, Python-style syntax.
By the end of this page you will use std::format to build strings with positional arguments, control alignment, width, precision, and base, write formatted output directly to a stream with std::print, and add formatting support to your own types.
What and Why
Before C++20, you had three choices for formatted text:
| Approach | Problem |
|---|---|
printf("%d", x) | No type safety — wrong format specifier is undefined behaviour |
std::cout << x | Verbose; formatting state leaks between calls |
| Manual string concatenation | Cluttered, slow for many pieces |
std::format (C++20, <format>) combines Python's {} placeholder syntax with C++'s type system. The format string is checked at compile time — a mismatch between {} specifiers and argument types is a hard error, not a runtime crash.
#include <format>
#include <iostream>
int main() {
std::string s = std::format("Hello, {}! You are {} years old.", "Alice", 30);
std::cout << s << '\n';
// Hello, Alice! You are 30 years old.
}Step by Step
Basic substitution
Every {} in the format string is a replacement field that consumes the next argument:
#include <format>
#include <string>
std::string result = std::format("{} + {} = {}", 1, 2, 3);
// "1 + 2 = 3"You can also specify argument positions explicitly — useful when the same value appears more than once or when you need to reorder:
std::string s = std::format("{0} is {1} and {0} is also {2}", "Alice", "smart", "kind");
// "Alice is smart and Alice is also kind"
// ^0 ^1 ^0 ^2Width, fill, and alignment
The format spec goes after a colon inside {}. Width sets the minimum field size; fill and alignment control padding:
// {:<width>} — minimum width, default alignment
std::format("{:10}", 42) // " 42" (right-aligned for numbers)
std::format("{:10}", "hi") // "hi " (left-aligned for strings)
// Explicit alignment: < left, > right, ^ center
std::format("{:<10}", 42) // "42 "
std::format("{:>10}", "hi") // " hi"
std::format("{:^10}", "hi") // " hi "
// Fill character before the alignment option
std::format("{:*^10}", "hi") // "****hi****"
std::format("{:0>6}", 42) // "000042"Numeric formatting
// Integers
std::format("{:d}", 255) // "255" decimal (default)
std::format("{:b}", 255) // "11111111" binary
std::format("{:o}", 255) // "377" octal
std::format("{:x}", 255) // "ff" hex lowercase
std::format("{:X}", 255) // "FF" hex uppercase
std::format("{:#x}", 255) // "0xff" hex with prefix
std::format("{:+d}", 42) // "+42" always show sign
std::format("{:08x}", 255) // "000000ff" zero-padded to width 8
// Floating-point
std::format("{:.2f}", 3.14159) // "3.14" fixed, 2 decimal places
std::format("{:.4e}", 3.14159) // "3.1416e+00" scientific
std::format("{:g}", 3.14) // "3.14" shortest representation
std::format("{:10.3f}", 3.14) // " 3.140" width 10, precision 3Dynamic width and precision
Width and precision can themselves be arguments, passed as a nested {}:
int width = 10;
int prec = 3;
float pi = 3.14159f;
std::format("{:{}.{}f}", pi, width, prec) // " 3.142"
// ^ ^
// width precision from argumentsThis is the std::format equivalent of printf("%*.*f", width, prec, pi) but without the type hazard.
std::format_to — write to an output iterator
When you want to append formatted text to an existing buffer without building a temporary std::string:
#include <format>
#include <vector>
std::vector<char> buf;
std::format_to(std::back_inserter(buf), "x = {}, y = {}", 3, 4);
// buf now contains: 'x', ' ', '=', ' ', '3', ',', ' ', 'y', ' ', '=', ' ', '4'std::format_to_n adds a size cap — useful for fixed-size char arrays:
char buf[64];
auto result = std::format_to_n(buf, sizeof(buf) - 1, "value = {}", 42);
*result.out = '\0'; // null-terminatestd::print and std::println (C++23)
C++23 adds std::print and std::println to <print>, combining formatting and output in one call:
#include <print>
std::print("Hello, {}!\n", "world"); // no newline in format string
std::println("Hello, {}!", "world"); // appends '\n' automatically
std::println(stderr, "Error: {}", msg); // write to stderrstd::println is now the idiomatic replacement for std::cout << combined with std::format.
Common Patterns
Pattern 1 — table-like output with column alignment
#include <format>
#include <iostream>
#include <vector>
struct Row { std::string name; int score; double ratio; };
int main() {
std::vector<Row> rows = {{"Alice", 95, 0.95}, {"Bob", 72, 0.72}, {"Carol", 88, 0.88}};
std::cout << std::format("{:<10} {:>6} {:>8}\n", "Name", "Score", "Ratio");
std::cout << std::string(26, '-') << '\n';
for (const auto& r : rows)
std::cout << std::format("{:<10} {:>6} {:>8.2f}\n", r.name, r.score, r.ratio);
}
// Name Score Ratio
// --------------------------
// Alice 95 0.95
// Bob 72 0.72
// Carol 88 0.88Pattern 2 — hex dump
#include <format>
#include <span>
#include <string>
std::string hex_dump(std::span<const std::byte> data) {
std::string out;
for (std::size_t i = 0; i < data.size(); ++i) {
if (i && i % 16 == 0) out += '\n';
std::format_to(std::back_inserter(out),
"{:02x} ", static_cast<unsigned>(data[i]));
}
return out;
}Pattern 3 — formatting your own type
Specialize std::formatter<T> to make T formattable:
#include <format>
struct Point { double x, y; };
template <>
struct std::formatter<Point> {
// parse() receives the spec between ':' and '}' — we ignore it here
constexpr auto parse(std::format_parse_context& ctx) { return ctx.begin(); }
auto format(const Point& p, std::format_context& ctx) const {
return std::format_to(ctx.out(), "({:.2f}, {:.2f})", p.x, p.y);
}
};
int main() {
Point p{3.0, 4.0};
std::println("point = {}", p); // point = (3.00, 4.00)
}Once formatter<Point> exists, Point works inside any nested format string, including inside containers formatted with std::format.
What Can Go Wrong
Wrong number of arguments
std::format("{} {}", 42); // compile error: too few arguments
std::format("{}", 1, 2); // compile error: too many argumentsThe format string is validated at compile time via std::format_string<Args...>. These errors appear at the call site, not inside <format>.
Invalid specifier for the type
std::format("{:d}", 3.14); // compile error: 'd' is not valid for double
std::format("{:.2f}", "hi"); // compile error: precision not valid for stringsAgain caught at compile time.
Runtime format strings
When the format string is not a compile-time constant — for example, loaded from a config file — you cannot pass it directly. Use std::runtime_format (C++26) or std::vformat:
// C++20/23 — runtime format string
std::string fmt = load_fmt_from_config();
std::string result = std::vformat(fmt, std::make_format_args(42, "hello"));
// C++26 — cleaner wrapper
std::string result2 = std::format(std::runtime_format(fmt), 42, "hello");std::vformat cannot check the format string at compile time. A mismatch throws std::format_error at runtime.
std::format vs std::to_string
std::to_string converts a single number to string with no formatting control. Use std::format whenever you need width, precision, base, alignment, or you're combining multiple values:
// std::to_string — single value, no control
std::to_string(3.14) // "3.140000" (6 decimal places, always)
// std::format — explicit precision
std::format("{:.2f}", 3.14) // "3.14"Quick Reference
| Goal | Syntax |
|---|---|
| Basic substitution | std::format("{}", value) |
| Positional arg | std::format("{0} {1} {0}", a, b) |
| Minimum width | {:10} |
| Left / right / center align | {:<10} / {:>10} / {:^10} |
| Fill character | {:*^10} |
| Zero-pad | {:08} |
| Decimal / hex / binary / octal | {:d} / {:x} / {:b} / {:o} |
| Hex with prefix | {:#x} |
| Always show sign | {:+} |
| Fixed float, 2 dp | {:.2f} |
| Scientific | {:.3e} |
| Dynamic width/precision | {:{}.{}f} |
| Locale-specific separators | {:L} |
| Debug/escaped string (C++23) | {:?} |
| Write to buffer | std::format_to(out_iter, ...) |
| Write to stdout | std::print(...) (C++23) |
| Write with newline | std::println(...) (C++23) |
Header: <format> for std::format/format_to; <print> for std::print/println (C++23).
Custom Formatters — std::formatter<MyType, char>
Any user-defined type can be made formattable by specializing std::formatter. The specialization requires two member functions: parse, which reads the format spec between : and }, and format, which writes the formatted representation to the output iterator stored in the format context.
#include <format>
#include <string_view>
struct Color { uint8_t r, g, b; };
template <>
struct std::formatter<Color> {
// Presentation mode: 'h' for hex (#rrggbb), 'd' for decimal (default)
char mode = 'd';
constexpr auto parse(std::format_parse_context& ctx) {
auto it = ctx.begin();
if (it != ctx.end() && *it == 'h') { mode = 'h'; ++it; }
if (it != ctx.end() && *it != '}')
throw std::format_error("invalid Color format spec");
return it;
}
auto format(const Color& c, std::format_context& ctx) const {
if (mode == 'h')
return std::format_to(ctx.out(), "#{:02X}{:02X}{:02X}", c.r, c.g, c.b);
return std::format_to(ctx.out(), "rgb({},{},{})", c.r, c.g, c.b);
}
};
int main() {
Color red{255, 0, 0};
std::println("{}", red); // rgb(255,0,0)
std::println("{:h}", red); // #FF0000
}The parse function must advance the iterator past every character it consumes and return it pointing at the closing }. Throwing std::format_error from parse produces a compile-time error when the format string is a constant.
A common shortcut is to delegate to an existing formatter. If your type has a natural std::string representation, inherit from std::formatter<std::string> and call its format after converting:
template <>
struct std::formatter<Color> : std::formatter<std::string> {
auto format(const Color& c, std::format_context& ctx) const {
return std::formatter<std::string>::format(
std::format("rgb({},{},{})", c.r, c.g, c.b), ctx);
}
};This approach inherits the full string format spec (width, fill, alignment) for free.
Compile-Time Format String Validation
std::format validates its format string against the argument types at compile time — a mismatched specifier is a hard error, not a runtime crash. This guarantee comes from the std::format_string<Args...> wrapper type that the format string is implicitly converted to when calling std::format.
// All of these are compile errors, caught at the call site:
std::format("{:d}", 3.14); // 'd' not valid for double
std::format("{:.2f}", "text"); // precision not valid for strings
std::format("{} {}", 1); // too few arguments
std::format("{}", 1, 2, 3); // too many arguments
std::format("{:z}", 42); // unknown specifier 'z'The mechanism works because std::format_string<Args...> has a consteval constructor that evaluates the format string parsing at compile time. You get the error message at the std::format(...) call, not buried inside <format> internals.
When you need to store a format string in a variable and pass it at runtime, you must opt out of static checking:
// Runtime format string — no static check, throws std::format_error on mismatch
std::string fmt = "value = {:d}";
std::string result = std::vformat(fmt, std::make_format_args(42));
// C++26: std::runtime_format makes the intent explicit
std::string result2 = std::format(std::runtime_format(fmt), 42);std::vformat is the underlying variadic overload that accepts std::format_args instead of a typed pack. It is the right tool for logging frameworks and config-driven output where the format string is not known until runtime.
std::format_to and Output Iterators
std::format_to writes directly to any output iterator, avoiding the temporary std::string that std::format must allocate. This makes it the preferred choice when appending to an existing buffer, writing to a fixed-size array, or building output incrementally.
#include <format>
#include <string>
#include <vector>
// Append to std::string
std::string log;
log.reserve(256);
std::format_to(std::back_inserter(log), "[{}] {}\n", "INFO", "server started");
std::format_to(std::back_inserter(log), "[{}] {}\n", "WARN", "low memory");
// Append to std::vector<char>
std::vector<char> buf;
std::format_to(std::back_inserter(buf), "x={}, y={}", 3, 4);
// Write directly into a pre-allocated string (using string::iterator)
std::string fixed(64, '\0');
auto end = std::format_to(fixed.begin(), "pi = {:.5f}", 3.14159265);
fixed.resize(std::distance(fixed.begin(), end));std::format_to_n adds a size cap, which is essential for fixed char arrays and protocol buffers:
char packet[32];
auto [out, count] = std::format_to_n(packet, sizeof(packet) - 1,
"CMD:{} SEQ:{:04d}", "PING", 42);
*out = '\0'; // null-terminate
// count is the total characters that *would* have been written,
// even if truncated — use it to detect truncation
if (count >= sizeof(packet))
throw std::runtime_error("packet truncated");The return type of format_to_n is std::format_to_n_result<Out> with members out (iterator past last written character) and count (number of characters that would have been written without truncation).
Locale-Aware Formatting
The :L flag instructs std::format to use the thread's locale for number grouping, decimal separators, and boolean names. Without :L, formatting is always locale-independent (dot as decimal, no digit separators).
#include <format>
#include <locale>
std::locale::global(std::locale("en_US.UTF-8"));
std::format("{:L}", 1'234'567) // "1,234,567" — US locale groups with comma
std::format("{:.2Lf}", 3.14159) // "3.14" — decimal point (en_US)
std::format("{:Ld}", true) // "true" — localized boolean
std::locale::global(std::locale("de_DE.UTF-8"));
std::format("{:L}", 1'234'567) // "1.234.567" — German locale
std::format("{:.2Lf}", 3.14159) // "3,14" — comma as decimal separatorYou can also pass a locale object explicitly to std::format via the overload that accepts std::locale:
auto de = std::locale("de_DE.UTF-8");
std::string s = std::format(de, "{:L}", 9876543.21); // "9.876.543,21"The :L flag works for integers, floating-point, and booleans. Strings and characters are unaffected by locale in std::format.
Width, Fill, Align, and Sign — Full Reference
The full format spec grammar fits a consistent pattern: [[fill]align][sign][#][0][width][.precision][L][type]. Understanding each slot lets you build any numeric or string presentation without trial and error.
// Fill + align
std::format("{:*<10}", "hi") // "hi********" fill='*', align=left
std::format("{:*>10}", "hi") // "********hi" fill='*', align=right
std::format("{:*^10}", "hi") // "****hi****" fill='*', align=center
std::format("{:010}", 42) // "0000000042" zero-fill (numbers only)
// Sign
std::format("{:+}", 42) // "+42" always show sign
std::format("{:+}", -42) // "-42"
std::format("{: }", 42) // " 42" space for positive (aligns with negative)
std::format("{:-}", 42) // "42" only show minus (default)
// Alternate form (#)
std::format("{:#b}", 6) // "0b110" binary with prefix
std::format("{:#o}", 8) // "010" octal with prefix
std::format("{:#x}", 255) // "0xff" hex with prefix
std::format("{:#X}", 255) // "0XFF"
std::format("{:#.3f}", 1.0) // "1.000" always show decimal point and trailing zeros
// Precision (strings — max characters; floats — decimal digits)
std::format("{:.5}", "hello world") // "hello" truncate string to 5 chars
std::format("{:.3f}", 3.14159) // "3.142" 3 decimal places
std::format("{:.3g}", 0.000123) // "0.000123" → "0.000123" (g picks fixed/sci)
std::format("{:.3e}", 12345.6789) // "1.235e+04"The type code controls the presentation base. For integers: d (decimal), b (binary), o (octal), x/X (hex). For floats: f (fixed), e/E (scientific), g/G (shortest), a/A (hex float). For strings: s (default) or ? (escaped, C++23).
std::print and std::println (C++23)
std::print and std::println are C++23 additions that combine std::format with output in a single call, replacing the std::cout << std::format(...) idiom. They write directly to a FILE* (defaulting to stdout) using efficient buffered I/O that avoids the overhead of std::ostream.
#include <print> // C++23
// Basic usage
std::print("Hello, {}!\n", "world"); // stdout, no automatic newline
std::println("Hello, {}!", "world"); // stdout, appends '\n'
// Explicit stream
std::println(stderr, "Error: {} (code {})", msg, errno);
std::println(stdout, "Written {} bytes", n);
// Works with all format features
std::println("{:>20}", "right-aligned");
std::println("{:#010x}", 0xDEAD); // "0x0000dead"
std::println("{:.2f}", 3.14159); // "3.14"std::print is more efficient than std::cout << std::format(...) because it writes the formatted output directly to the underlying file descriptor in one shot, without going through the std::ostream synchronization machinery. When performance matters for logging or diagnostics, prefer std::print over the combination.
One important note: std::print and std::println flush the buffer on '\n' when writing to a terminal (the same way std::cout does in line-buffered mode), so there is no need to add explicit std::flush calls for interactive output.
Migrating from printf to std::format
std::format has a direct equivalent for every common printf conversion specifier — the main differences are the syntax ({} vs %) and the fact that std::format never relies on implicit type coercion.
printf specifier | std::format equivalent | Notes |
|---|---|---|
%d | {} or {:d} | int — {} deduces type automatically |
%u | {} | unsigned int |
%ld | {} | long — no length modifier needed |
%lld | {} | long long |
%f | {:f} | fixed-point float |
%e | {:e} | scientific notation |
%g | {:g} | shortest float representation |
%.2f | {:.2f} | 2 decimal places |
%10.3f | {:10.3f} | width 10, 3 decimal places |
%s | {} | string — null terminator not required |
%c | {} | char |
%x | {:x} | lowercase hex |
%X | {:X} | uppercase hex |
%#x | {:#x} | hex with 0x prefix |
%08x | {:08x} | zero-padded hex |
%-10s | {:<10} | left-aligned, width 10 |
%+d | {:+} | always show sign |
%*d | {:{}} | dynamic width from argument |
%p | no direct equivalent | cast pointer to uintptr_t and use {:#x} |
// Before: printf
printf("%-10s %5d %8.2f\n", name, score, ratio);
// After: std::format
std::println("{:<10} {:>5} {:>8.2f}", name, score, ratio);The key ergonomic improvements: argument order is enforced by the type system, there are no silent truncations from mismatched lengths (%d for a long), and the format string is checked at compile time. The only printf capability that has no std::format counterpart is %n (writing the character count into an integer pointer) — which is a security vulnerability anyway and disabled on most modern platforms.
What's Next
- std::string_view — zero-copy string references, commonly paired with format output.
- Strings — the
std::stringfundamentals thatstd::formatbuilds on. - format reference — the full format specification grammar and all type codes.