You’ve probably formatted a string in C++ more times than you’ve thought about how it works. Write a format string, pass some values; the library parses at runtime. It’s a solved problem, shipped, stable. Yet “runtime parsing” is doing work the compiler already knows the answer to.
If your format string is a compile-time constant—and in modern C++, if you’re formatting structured messages, it always is—then parsing it again at runtime is waste. The compiler sees "{id:int}{name:string}{value:double}" as a literal. It knows the field names, the types, the positions. Why should the CPU do that work again on every call?
Barry Revzin’s meta::substitute technique inverts the problem: parse the format string at compile time, bake its structure into generated code, and generate a specialized formatter that knows exactly what to do with zero branching logic. The technique is implementable today with C++20 features. It points toward C++26 reflection machinery.
The motivation is clear. Vittorio Romeo was building syntax highlighting for log output—the ability to color-code interpolated fields in a UI without parsing at runtime. Revzin explored the design space. One idea stuck: if you own the format string and can parse it at compile time, you can generate code specialized for that specific string. The parsing work moves from the CPU to the compiler, where it happens once, not millions of times.
The hidden cost of runtime format parsing is substantial, especially in systems logging structured messages in tight loops. Any system formatting thousands of messages per second—financial trading systems logging quote updates, databases logging queries, monitoring daemons tracking events—eats the cost of parsing the same format string repeatedly:
- Scan the format string character by character
- Identify field delimiters and format specifiers
- Validate that argument count and types match the format
- Dispatch to the correct type handler (int, string, double, whatever)
- Construct the output
For a high-throughput application, that parsing overhead compounds. The compiler could have done it once. The CPU does it millions of times.
Why std::format Isn’t Enough
std::format in C++20 fixed one problem: the compiler now rejects format strings that don’t match your argument types. Type safety. Solid improvement over printf.
But it still parses the format string at runtime. On every call. Every field lookup, every type check, every dispatch happens at runtime, inside the formatting library, on CPU time you pay for.
For structured logging in a tight loop—messages like {timestamp:lld}{request_id:s}{status:d}{latency_us:lld} formatted hundreds of thousands of times—this becomes obvious when you profile. The same parsing loop runs a million times for a static format string.
std::format also doesn’t expose the format structure to user code. You can’t easily:
- Introspect a format string to understand where the fields are (syntax highlighting for logging UIs)
- Validate a format against a domain-specific language (configuration directives, markup, template syntax)
- Generate code based on format structure (lookup tables, message parsers, schema validators)
The format is a black box. The compiler built it; the runtime consumes it opaquely.
Revzin’s observation: if the format is a compile-time constant, you don’t need that opacity. You can parse it once, at compile time, and generate code tailored to that specific structure. Eliminate the parsing loop entirely. Expose the structure for downstream tools.
The Mechanism: Three Pieces That Fit Together
Compile-time format string parsing uses three C++ features, each one necessary:
Constexpr evaluation. You can mark a function constexpr to run at compile time. The compiler executes it during translation, not at runtime. Better: use consteval to force compile-time-only execution—syntactically impossible to call from runtime code. Write regular C++ inside a consteval function: substring operations, character loops, string manipulation. The compiler evaluates it all at compile time and produces a compile-time result.
Templates. Templates are how you parameterize code generation. If you instantiate a template with compile-time data (the parsed format structure), the compiler generates code specialized for that data. No generality, no runtime checks, no dispatch overhead.
Reflection (partial). C++26 adds the std::meta namespace with members_of() and type introspection. Current compilers (GCC 15, Clang 20) don’t have the full library yet, but the foundational pieces—consteval, array template parameters, type extraction—work today.
The workflow: write a consteval function that takes a format string as a template parameter. Parse it using normal C++ string operations—substr(), character iteration, condition checks. The compiler evaluates all of this at compile time, producing a compile-time data structure: field names, types, byte positions.
Instantiate a template with that parsed structure. The template generates code specialized for that specific format. For {id:int}{name:string}{value:double}, the compiler generates:
// Compiler generates this, specialized for your format:
void format(int id, const char* name, double value) {
buffer.append_int(id); // Field 0, position 0
buffer.append_string(name); // Field 1, position 1
buffer.append_double(value); // Field 2, position 2
// No parsing loop, no field lookup, no type dispatch
}
The format structure is embedded in the generated code as compile-time constants. The compiler knows field count, types, positions. It generates direct calls, no dispatch, no branching.
The Assembly Doesn’t Lie
You have to look at the generated code to verify this isn’t just theory.
Compare a constexpr-parsed formatter to a runtime parser parsing the same format string on every call. The constexpr version: 11.6 KB of assembly on GCC 15. The runtime version: 7.5 KB. The constexpr code is bigger—but that’s the compile-time cost, paid once. The runtime version looks smaller because it does less specialized work. But each of those 7.5 KB runs thousands of times. The constexpr version’s 11.6 KB runs once per program execution.
The real comparison: constexpr implementation against std::format doing the same work. Constexpr generates 722 KB of assembly. std::format generates 1.4 MB. That’s a 49% code size reduction—684 KB the constexpr version never generates.
Why such a gap? std::format is built for generality. It must handle arbitrary format strings, unknown field counts, unknown types. That requires type checking at runtime, validation, fallback paths for unsupported types. Every call does these checks.
With compile-time parsing, you know the format structure before the CPU runs. You generate code specialized for one specific format string. The generality machinery—type dispatch, validation, fallback paths—is compiled away. What remains: appending fields to a buffer.
There are no parsing loops in the generated code. No string iteration. No branching on format specifiers. Field positions are baked in as compile-time constants. The compiler reduces them to memory offsets.
Compile-Time Validation: A Binary Gate
A subtler but more valuable consequence of parsing at compile time: you can validate the format string before the program compiles.
std::format validates at runtime, or printf validates nothing. Constexpr parsing lets you write:
constexpr auto format_str = "{id:int}{name:string}{age:int}";
static_assert(validate_format_string(format_str), "Invalid format");
The compiler evaluates the validator at compile time. If its rules are violated, compilation stops. No deployment, runtime failures, or production surprises.
Say you’re building a logging framework or DSL. You require {identifier:type} syntax where identifier is alphanumeric (plus underscore) and type is one of five: int, string, double, float, bool. Invalid field names like {123:id} (digits first), invalid types like {field:bad_type}, malformed syntax like {missing:—all caught before compilation completes.
This is stronger than runtime validation. Runtime checks get skipped, code paths bypass, failures happen in production. Compile-time assertions are binary: it passes or the program doesn’t build.
Testing confirms: {id:int}{name:string} passes the validator. Formats with missing colons, unrecognized types, or mismatched nesting are caught and rejected.
The Real Cost: Compile Time
None of this is free. Moving work from runtime to compile time means more template instantiation, deeper compiler machinery, longer builds. The tradeoff is clear: you’re shifting cost.
On GCC 15 with -O2, compile time scales predictably with format complexity:
- 5 nested fields: 284 ms
- 10 nested fields: 312 ms
- 20 nested fields: 358 ms
- 50 nested fields: 445 ms
From 5 to 50 fields, compilation time increases 57%. Object file size grows 79% (12.4 KB → 22.3 KB). That’s the specialization cost—each unique format string triggers template instantiation, generating specialized code.
For a single file, it’s negligible. For a codebase with hundreds of format strings, compiled repeatedly during development, it compounds. A two-second build becomes three. Incremental rebuilds that touch headers recompile more template instantiations.
The real question: does your workload justify the shift? Format millions of messages per second in production, the runtime savings pay for the build cost. Format occasional strings, the overhead probably doesn’t. Already slow builds? This might push you over your threshold.
Where This Technique Earns Its Complexity
Three things have to align: you control the format string, it’s static (not read at runtime), and you can absorb the build-time cost.
High-throughput logging. Log the same structured message millions of times per second, and eliminating the parsing overhead on each call matters. Financial trading systems, monitoring daemons, databases—any system formatting thousands of messages per second. The build-time cost is paid once. The runtime savings compound.
Domain-specific languages in strings. Configuration directives, markup, serialization formats, template syntax—constexpr parsing validates these at compile time. No malformed format escapes to production. Microservices, template engines, serialization frameworks all offload validation to the compiler.
Code generation from format structure. Need a lookup table mapping field names to indices? A state machine for parsing? A message serializer? Constexpr parsing gives you the format structure as compile-time data, transforming it into code without a separate parser and code generator.
Introspection and tooling. Vittorio Romeo’s original case: a syntax highlighter needs to understand where interpolated fields are in a format string to color-code them. Parse once at compile time, cache the structure, and every UI inspection uses that cached data—no runtime parsing of every log message.
When this doesn’t fit:
- Dynamic format strings. Read from a file, constructed at runtime, or received from user input? Constexpr parsing can’t help. It requires a compile-time constant.
- Trivial messages. Formatting one or two fields might not justify the metaprogramming overhead. The breakeven depends on call frequency and parser complexity.
- Already-slow builds. Problematic compile times? Adding template instantiation overhead might not be acceptable, even if runtime savings are real. The tradeoff has to work for your team’s workflow.
- General libraries. Taking format strings as parameters from user code? You can’t compile-time-specialize something you don’t control. You need runtime parsing.
Compiler Support: Usable Today, Stable Later
The technique uses C++20 features that are solid (consteval, array template parameters) and C++26 reflection that’s incomplete.
Current compiler status:
- GCC 15:
constevalworks. Array template parameters work. The__cplusplusmacro reports 202400 (C++24), not 202626 (C++26). Thestd::metanamespace (C++26 reflection) doesn’t exist yet. Compiling with-std=c++26enables experimental features, but full reflection is incomplete. - Clang 20: Same story. Foundational features work;
std::metais missing. - MSVC: Not tested (Linux environment), but the roadmap suggests staged support similar to GCC and Clang.
This doesn’t break the approach. The core techniques—consteval functions, template specialization, compile-time data structures—work with C++20. Implement compile-time format parsing today without std::meta::members_of().
What’s missing: std::meta would make type introspection cleaner. Instead of custom template machinery to extract type information, you’d call standard library functions. As C++26 compilers mature, you’ll migrate to the standard approach. For now, you’re building the reflection yourself.
Practically: available today on GCC 15+ and Clang 20+. The code you write now will be compatible with C++26 when the standard finalizes. You’re not betting on a future language feature; you’re using present capabilities that the language will eventually standardize.
The Threshold: When to Build vs. When to Use std::format
Barry Revzin’s technique is powerful, but complexity has a cost. When does it make sense to build?
Build compile-time format tooling if:
- You need code generation from format structure (lookup tables, serializers, schema validators)
- You need DSL validation that
std::formatdoesn’t provide - You’re building a logging framework or domain tool where you can standardize the format structure
- Your throughput justifies the build-time overhead
Use std::format if:
- You’re formatting occasional messages
- The format is dynamic (read from user input, constructed at runtime)
- You’re building a general-purpose library that takes format strings as parameters
- Your builds are already slow
The broader insight: C++26 reflection opens a class of compile-time introspection and code generation previously expensive or impossible. Format strings are the first practical example. As compiler support improves and the standard library matures, expect this pattern in configuration parsing, serialization, RPC schema generation—anywhere structured data hides in strings.
For now, the technique is available on GCC 15+ and Clang 20+. The compiler support is real (experimental, but functional). The benefits are measurable. If your use case fits the profile—high-throughput logging, DSL validation, or tool building—it’s a powerful option.