Skip to content
C++
Language
since C++98
Intermediate

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++98

A 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 typePromoted type
floatdouble
bool, char, short, unsigned char, unsigned shortint or unsigned int
Array, functionPointer

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

cpp
// 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 / TypePurposeStandard
va_listOpaque type holding the traversal stateC++98
va_start(ap, last)Initialise ap, pointing at argument after lastC++98
va_arg(ap, T)Extract next argument as type TC++98
va_end(ap)Release resources; required before function returnsC++98
va_copy(dst, src)Duplicate traversal state into dstC++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)

cpp
#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

cpp
#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) == 6

Copying a va_list for two-pass processing

cpp
#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:

cpp
void log(int level, const char* fmt, ...);
void vlog(int level, const char* fmt, va_list ap); // callable from another variadic

Use 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:

cpp
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:

cpp
// 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_copy
  • std::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
cpp

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.