CodeRigor

FREE LESSON

std::format and std::print

Type-safe, compile-time-checked formatting: the replacement-field grammar, ranges, allocation-free output, runtime patterns, and custom formatters.

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

C++
#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:

C++
#include <format>

int main() {
    auto s = std::format("{:d}", "text");   // 'd' (integer) spec on a string
}
C++
#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

Anatomy of a replacement field
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.
Anatomy of a replacement field
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
C++
#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
}
Fill and align in a width of 6
{:<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.
Fill and align in a width of 6
{:<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.

C++
#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.

C++
#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));
}
format_to_n(buf, 8, "{}", 123456789)
It stops after 8 characters and never writes past 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.
format_to_n(buf, 8, "{}", 123456789)
It stops after 8 characters and never writes past 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:

C++
#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:

C++
#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:

C++
#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.

C++
#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:

  • parse starts at the first character after the : and must stop at the closing } without consuming it.
  • format is const and a template over the context. State decided in parse (here style) 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:

C++
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:

C++
#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::println check 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, n drops brackets.
  • format_to_n + formatted_size = allocation-free, overflow-proof output.
  • Runtime patterns: std::dynamic_format (C++26) or std::vformat; catch std::format_error.
  • Custom types: specialise std::formatter<T>, delegate by inheriting, or write a constexpr parse that rejects bad specs at compile time; make format a const template over the context so the type stays formattable.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

The fmt design that became std::format: format-string syntax, safety and performance.

The surprising corners of std::format from an STL implementer: compile-time checking, locales, width.

std::print / println in C++23.

Writing a std::formatter for your own type.

C++23 formatting of whole ranges (vectors, maps, tuples) with std::format / std::print.