Variadic Arguments
C-style variadic functions using the ellipsis syntax and va_list macros, their type-safety pitfalls, and the modern C++ alternatives that replace them.
Variadic Argumentssince C++98A mechanism inherited from C that allows a function to accept an unbounded,
heterogeneous argument list via an ellipsis parameter (...), accessed at
runtime through the va_list family of macros in <cstdarg>.
Overview
C++ inherited variadic C functions wholesale. The standard library still depends
on this mechanism β std::printf, std::scanf, and their kin are all variadic
functions. Understanding the machinery matters both for reading legacy code and
for knowing exactly why the modern replacements exist.
The ellipsis designates a slot that absorbs any number of additional arguments after the named parameters. The compiler performs no type checking on those arguments. The caller and callee agree on types purely by convention β typically encoded in a format string or a terminator sentinel. Getting that convention wrong is undefined behaviour, usually a crash or a silent data corruption.
Default argument promotions apply to every variadic argument automatically:
| Passed type | Promoted type |
|---|---|
float | double |
bool, char, short, unsigned char, unsigned short | int or unsigned int |
| Array, function | Pointer |
This means requesting float via va_arg is always wrong β you must request
double.
Since C++11, passing an object of class type with a non-trivial copy
constructor, non-trivial move constructor, or non-trivial destructor through
... is conditionally supported with implementation-defined semantics. In
practice it silently compiles on most toolchains but gives garbage or crashes.
Treat non-trivial types as banned from variadic argument lists.
Syntax
// Declaration β comma before ellipsis is optional
int printf(const char* fmt, ...);
void log(int severity, ...); // no preceding named parameter (C++ only, not C)At least one named parameter must precede ... when you need va_start (which
takes the last named parameter). A bare (...) function is legal in C++ but
you cannot retrieve the arguments portably.
The access macros live in <cstdarg>:
| Macro / Type | Purpose | Standard |
|---|---|---|
va_list | Opaque type holding the traversal state | C++98 |
va_start(ap, last) | Initialise ap, pointing at argument after last | C++98 |
va_arg(ap, T) | Extract next argument as type T | C++98 |
va_end(ap) | Release resources; required before function returns | C++98 |
va_copy(dst, src) | Duplicate traversal state into dst | C++11 |
Every va_start must be matched by a va_end before the function returns or
throws. va_copy similarly requires its own va_end.
Examples
Format-driven dispatch (classic pattern)
#include <cstdarg>
#include <cstdio>
#include <cstring>
// Minimal reimplementation of a printf-style function.
void debug_print(const char* fmt, ...)
{
va_list ap;
va_start(ap, fmt);
for (const char* p = fmt; *p != '\0'; ++p) {
if (*p != '%') { std::putchar(*p); continue; }
switch (*++p) {
case 'd': std::printf("%d", va_arg(ap, int)); break;
case 'f': std::printf("%f", va_arg(ap, double)); break; // NOT float
case 's': std::printf("%s", va_arg(ap, char*)); break;
case 'p': std::printf("%p", va_arg(ap, void*)); break;
default: std::putchar('%'); std::putchar(*p); break;
}
}
va_end(ap);
}
int main()
{
debug_print("value=%d ratio=%f name=%s\n", 42, 3.14, "widget");
}Sentinel-terminated list
#include <cstdarg>
#include <initializer_list> // for comparison below
// Sum an unknown count of ints, terminated by INT_MIN sentinel.
int sum_sentinel(int first, ...)
{
int total = first;
va_list ap;
va_start(ap, first);
int n;
while ((n = va_arg(ap, int)) != INT_MIN)
total += n;
va_end(ap);
return total;
}
// sum_sentinel(1, 2, 3, INT_MIN) == 6Copying a va_list for two-pass processing
#include <cstdarg>
#include <cstdio>
#include <cstring>
// Measure, then format β two passes over the same argument list.
std::string vformat(const char* fmt, va_list ap)
{
va_list ap2;
va_copy(ap2, ap); // C++11
int len = std::vsnprintf(nullptr, 0, fmt, ap);
std::string buf(len + 1, '\0');
std::vsnprintf(buf.data(), buf.size(), fmt, ap2);
va_end(ap2);
buf.resize(len);
return buf;
}
std::string format(const char* fmt, ...)
{
va_list ap;
va_start(ap, fmt);
auto result = vformat(fmt, ap);
va_end(ap);
return result;
}The v-prefixed C standard library functions (vprintf, vsprintf,
vsnprintf, vscanf, β¦) all accept a va_list specifically to enable this
kind of delegation without restarting argument traversal.
Best Practices
Provide a v-prefixed overload. Any function that accepts ... and
processes it should expose a va_list variant so callers can chain calls:
void log(int level, const char* fmt, ...);
void vlog(int level, const char* fmt, va_list ap); // callable from another variadicUse compiler format-string annotations. GCC and Clang support
__attribute__((format(printf, fmt_idx, args_idx))) which enables the same
type checking that printf itself receives. MSVC has _Printf_format_string_
via SAL. Mark your format-string functions accordingly.
Always call va_end. Even on exception paths. If the function can throw,
use a RAII wrapper:
struct VAGuard {
va_list& ap;
~VAGuard() { va_end(ap); }
};Document the terminator or count convention explicitly. The compiler enforces nothing; documentation is the only safety net.
Common Pitfalls
Mismatching va_arg type. Requesting the wrong type is undefined
behaviour β no diagnostic, no runtime check. The narrowest mistake is
va_arg(ap, float) instead of va_arg(ap, double) (due to default promotion).
Calling va_start on the wrong parameter. va_start must receive the
last named parameter exactly as declared. Passing a reference, a parameter
that has undergone default promotion, or a register-qualified parameter is
undefined behaviour.
Non-trivial types. Passing std::string, std::vector, or any class with
a non-trivial special member through ... is at best conditionally supported,
at worst silent UB. Modern compilers may not warn.
Omitting va_end. On some platforms va_list is implemented as a heap
allocation or a register-save area that va_end must release. Omitting it is
UB and a resource leak.
Forgetting that va_copy needs its own va_end. A copy is an independent
va_list and must be closed independently.
Modern Alternatives
C-style variadic arguments are deprecated for new code. C++11 introduced variadic templates (parameter packs), which are fully type-safe, work with non-trivial types, and eliminate all of the UB scenarios above:
// C++11 β type-safe, zero overhead, works with any type
template<typename... Args>
void safe_log(int level, Args&&... args)
{
(std::cout << ... << args) << '\n'; // C++17 fold expression
}C++17 fold expressions reduce parameter packs over a binary operator
without manual recursion, making many va_list idioms expressible in a single
line. For capturing a variadic list in a lambda, C++14 generic lambdas accept
auto&&... args, and C++20 explicit template lambdas make the pack visible by
name.
For format-string use cases specifically, std::format (C++20) accepts a
format string and variadic template arguments, combining printf-style
ergonomics with full type safety and no UB.
Retain va_list-based interfaces only when you need ABI stability across
translation units compiled with different compilers, when wrapping a C API
that exposes va_list, or when implementing the internals of a logging
framework that must forward to vsnprintf.
See Also
<cstdarg>βva_list,va_start,va_arg,va_end,va_copystd::printf/std::vprintfβ canonical variadic format functions- Parameter packs β the type-safe C++11 replacement
- Fold expressions (C++17) β pack reduction without recursion
std::format(C++20) β type-safe formatted output
The file is ready. Paste it into `content/reference/language/variadic-arguments.mdx`. The page covers the full C-style mechanism (va_list macros, promotions, va_copy for two-pass use), concrete runnable examples including the RAII va_end guard pattern, and a clear modernization path through variadic templates, fold expressions, and std::format β without over-selling the C++20 features as universally available.