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.

va_list vs Variadic Templates: When to Use Each

Prefer variadic templates for all new C++ code. va_list retains a role only where printf-compatible C APIs or cross-compiler ABI stability are hard requirements.

FeatureC va_listC++11 variadic templatesC++17 fold expressions
Type safetyNoYesYes
Zero runtime overheadYesYesYes
Mixed typesBy conventionYes (heterogeneous packs)Yes
printf-compatible C APIYesNoNo
Compile-time processingNoYesYes
IDE/diagnostics supportLimitedFullFull

va_list remains useful for printf-compatible APIs and C interop β€” for example, wrapping vsnprintf or forwarding into a C library that accepts a va_list parameter. For everything else, variadic templates give you the same expressiveness with none of the undefined-behaviour traps.

Type-Safe printf with Variadic Templates

A compile-time format dispatcher eliminates the class of bugs where the format specifier and the argument type disagree. The function below walks the format string at runtime but dispatches each % tag through if constexpr, so the compiler rejects a call whose argument types do not match the tags.

cpp
// C++11
#include <cstdio>
#include <stdexcept>

// Forward declaration for the recursive base case.
void safe_printf(const char* fmt);

template<typename T, typename... Args>
void safe_printf(const char* fmt, T value, Args... args)
{
    for (; *fmt != '\0'; ++fmt) {
        if (*fmt != '%') { std::putchar(*fmt); continue; }
        ++fmt;
        if constexpr (std::is_integral_v<T>) {
            if (*fmt == 'd' || *fmt == 'i') { std::printf("%d", static_cast<int>(value)); safe_printf(fmt + 1, args...); return; }
        }
        if constexpr (std::is_floating_point_v<T>) {
            if (*fmt == 'f') { std::printf("%f", static_cast<double>(value)); safe_printf(fmt + 1, args...); return; }
        }
        if constexpr (std::is_convertible_v<T, const char*>) {
            if (*fmt == 's') { std::printf("%s", static_cast<const char*>(value)); safe_printf(fmt + 1, args...); return; }
        }
        throw std::runtime_error("safe_printf: type/format mismatch");
    }
}

inline void safe_printf(const char* fmt)
{
    for (; *fmt != '\0'; ++fmt)
        std::putchar(*fmt);
}

For production use, prefer std::format (C++20), which provides the same type-safety guarantee through a std::formatter specialisation mechanism and also handles all the edge cases around locale, width, and precision that the sketch above omits.

cpp
// C++20 β€” production alternative
#include <format>
#include <print>

std::print("value={} ratio={:.2f} name={}\n", 42, 3.14, "widget");

Forwarding Variadic Arguments: std::forward and Perfect Forwarding

Both && on the pack and std::forward on each element are required β€” omitting either one silently copies or moves when the caller's value category should have been preserved. The && collapses each argument to its original reference category (forwarding reference); std::forward then reinstates that category on expansion.

cpp
// C++11
#include <utility>
#include <iostream>

// A logging wrapper that forwards to two independent backends.
template<typename... Args>
void log_both(Args&&... args)
{
    backend_a(std::forward<Args>(args)...);   // pack expanded correctly
    // ERROR: backend_b(std::forward<Args>(args)...); β€” args were already moved!
    // Correct approach: capture copies before the first forward, or restrict to
    // lvalue-only overloads when multi-sink forwarding is needed.
}

// Common mistake: expanding std::forward outside a pack context.
template<typename T>
void bad_forward(T&& x)
{
    // std::forward<T>(x)...  // ERROR β€” x is not a pack; ellipsis is nonsensical here
    consume(std::forward<T>(x)); // correct: no ellipsis
}

Because std::forward moves from the pack on first expansion, multi-sink wrappers must either accept only lvalue packs or copy the arguments before forwarding to each backend.

cpp
// C++11 β€” single-sink forwarding wrapper (safe)
template<typename Sink, typename... Args>
void forward_to(Sink& sink, Args&&... args)
{
    sink.write(std::forward<Args>(args)...);
}

Fold Expressions (C++17)

Fold expressions collapse a parameter pack over a binary operator in a single expression, replacing the hand-rolled recursive template expansions that C++11 required. There are four forms:

cpp
// C++17
template<typename... Args>
auto sum_right(Args... args) { return (args + ...); }         // unary right fold

template<typename... Args>
auto sum_left(Args... args)  { return (... + args); }         // unary left fold

template<typename... Args>
auto sum_init_right(Args... args) { return (0 + ... + args); } // binary right fold with init

template<typename... Args>
auto sum_init_left(Args... args)  { return (args + ... + 0); } // binary left fold with init

Printing every argument in one expression, calling a function on each element, and testing a predicate over the whole pack are the three most common uses:

cpp
// C++17
#include <iostream>

template<typename... Args>
void print_all(Args&&... args)
{
    (std::cout << ... << args) << '\n';   // left fold over operator<<
}

template<typename F, typename... Args>
void for_each_arg(F f, Args&&... args)
{
    (f(args), ...);                        // comma fold β€” calls f on every element
}

template<typename... Args>
bool all_positive(Args... args)
{
    return ((args > 0) && ...);            // short-circuits like &&
}

Note that unary folds over +, *, &&, and || are ill-formed for an empty pack β€” the compiler cannot produce a value when there are no operands. Use the binary form with an identity initialiser (0 for +, 1 for *, true for &&, false for ||) to handle the empty-pack case.

Variadic Class Templates and Index Sequences

std::index_sequence and std::make_index_sequence provide a way to generate a compile-time list of integer indices, which is the key primitive for expanding a tuple into a function call. Both were introduced in C++14.

cpp
// C++14
#include <tuple>
#include <utility>
#include <iostream>

// Retrieve the Nth element of a variadic pack (like std::tuple_element).
template<std::size_t I, typename... Ts>
decltype(auto) pack_get(Ts&&... ts)
{
    return std::get<I>(std::forward_as_tuple(std::forward<Ts>(ts)...));
}

// Expand a tuple into a function call β€” the pre-C++17 idiom.
template<typename F, typename Tuple, std::size_t... Is>
decltype(auto) apply_impl(F&& f, Tuple&& t, std::index_sequence<Is...>)
{
    return std::forward<F>(f)(std::get<Is>(std::forward<Tuple>(t))...);
}

template<typename F, typename Tuple>
decltype(auto) my_apply(F&& f, Tuple&& t)
{
    constexpr auto N = std::tuple_size_v<std::remove_reference_t<Tuple>>;
    return apply_impl(std::forward<F>(f), std::forward<Tuple>(t),
                      std::make_index_sequence<N>{});
}

std::apply (C++17) ships this pattern in the standard library and should be preferred over hand-rolled apply_impl in any codebase with C++17 or later.

cpp
// C++17
#include <functional>
#include <tuple>

auto result = std::apply([](auto... xs){ return (xs + ...); },
                         std::make_tuple(1, 2, 3));  // result == 6

Index sequences are also the right tool for printing tuple elements, initialising arrays from packs, and any task that requires iterating over a compile-time index in lockstep with a pack.

Common Patterns: min/max/sum/zip over a Pack

Fold expressions make arithmetic reductions over a pack a one-liner; recursive templates are only needed when the operation has no binary-operator form.

cpp
// C++17
#include <algorithm>
#include <initializer_list>

// Sum every element regardless of type (homogeneous pack).
constexpr auto sum(auto... vals)
{
    return (vals + ...);
}

// Minimum via recursion β€” handles heterogeneous packs where std::min cannot.
template<typename T>
constexpr T my_min(T first) { return first; }

template<typename T, typename... Rest>
constexpr T my_min(T first, Rest... rest)
{
    return first < my_min(rest...) ? first : my_min(rest...);
}

// Minimum via initializer_list β€” simpler, but requires a homogeneous pack.
template<typename T, typename... Ts>
constexpr T il_min(T first, Ts... rest)
{
    return std::min({first, static_cast<T>(rest)...});
}

For element-wise operations across multiple packs simultaneously (zip_transform), C++23 introduced std::ranges::views::zip_transform. A C++17 workaround packs both sequences into a tuple-of-tuples and applies std::apply on each row:

cpp
// C++17 β€” zip two packs and apply a binary function element-wise
template<typename F, typename... As, typename... Bs>
void zip_transform(F f, std::tuple<As...> a, std::tuple<Bs...> b)
{
    static_assert(sizeof...(As) == sizeof...(Bs), "pack size mismatch");
    [&]<std::size_t... Is>(std::index_sequence<Is...>){
        (f(std::get<Is>(a), std::get<Is>(b)), ...);
    }(std::make_index_sequence<sizeof...(As)>{});
}

For the common case of summing, averaging, or reducing a compile-time list of constants, prefer constexpr fold expressions over template metaprogramming; the compiler error messages are dramatically clearer.

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