The C Path — learn C, visually

📚 The Standard Library

Variadic functions: how printf really works

⏱ 14 min · free interactive lesson · quizzes, visualizations & a real compiler

▶ Open the interactive lesson — free, no signup
Why you're learning this

printf takes two arguments one moment and ten the next — you've leaned on that magic since your very first program without knowing how it's possible. Today you build a working mini-printf of your own, and learn why printf(user_input) is a genuine security hole that attackers have exploited for decades.

Since day one you've called printf("x=%d y=%d", x, y) — a function that somehow accepts any number of arguments of any type. Today the magic trick is revealed: such functions are called variadic (they take a variable number of arguments), and the machinery behind them is the three-dot ... ellipsis and stdarg.h. By the end you'll have written your own.

The machinery: va_list and friends

A declaration like int my_sum(int count, ...) means: one named parameter, then anything. Inside, four macros from stdarg.h walk the extras:

macrorole
va_list ap;declares a cursor over the unnamed arguments
va_start(ap, last)points the cursor just past the last named parameter
va_arg(ap, type)yields the next argument as type and advances — you must know the type; nothing checks
va_copy(dst, src)clones a cursor (C99) — needed to walk the args twice
va_end(ap)cleanup; required before returning

Notice what's missing: any way to ask "how many arguments are there?" or "what type is next?". The callee is blind — that information must be smuggled in separately, which is exactly what printf's format string does. Step through the simplest possible example:

This spot has an interactive trace widget — open the interactive lesson to play with it.

🧠 Checkpoint: How does a variadic function know how many unnamed arguments it received?

  • va_start returns the count
  • sizeof(ap) reveals it
  • It can’t — the caller must communicate it (count parameter, format string, or sentinel)
  • va_arg returns NULL after the last one
Show answer

It can’t — the caller must communicate it (count parameter, format string, or sentinel) — The mechanism is completely blind: no count, no types, no end marker. Every variadic API layers its own protocol on top — printf’s format string, my_sum’s count, execl’s NULL sentinel. Get the protocol wrong and it’s UB.

Build your own printf

Now the real thing, miniaturized. The format string is the type map: each % letter tells us which type to pull with va_arg:

miniprintf.c
#include <stdio.h>
#include <stdarg.h>

void mini_printf(const char *fmt, ...) {
    va_list ap;
    va_start(ap, fmt);
    for (const char *p = fmt; *p; p++) {
        if (*p != '%') { putchar(*p); continue; }
        switch (*++p) {                /* char after '%' */
        case 'd': printf("%d", va_arg(ap, int));      break;
        case 'f': printf("%g", va_arg(ap, double));   break;
        case 's': fputs(va_arg(ap, const char *), stdout); break;
        case 'c': putchar(va_arg(ap, int));           break;
        case '%': putchar('%');                       break;
        }
    }
    va_end(ap);
}

int main(void) {
    mini_printf("%s scored %d points (%f%%) — grade %c\n",
                "ada", 97, 97.5, 'A');
    return 0;
}
terminal
$ gcc miniprintf.c -o miniprintf && ./miniprintf
ada scored 97 points (97.5%) — grade A
# the format string told us the type sequence:
# char* , int , double , int(char)
💡

To build a logging wrapper around real printf, don't re-parse formats — pass the whole va_list along to vprintf/vfprintf/vsnprintf: va_start(ap, fmt); vfprintf(stderr, fmt, ap); va_end(ap);. That's the v-family's whole purpose (and where va_copy shines if you must format twice).

Default argument promotions: why %f handles float

Ever wondered why printf has %d for int and %f for double — but no specifier for char, short, or float? Because they can never arrive. For arguments matched by ..., the compiler applies the default argument promotions: char and short are promoted to int, and float is promoted to double. So va_arg(ap, char) is simply wrong — the value on the stack is an int:

promotions.c — what the callee actually receives
void f(int count, ...);

char  c = 'A';       /* passed through ... as: int    */
short s = 7;         /* as: int                       */
float x = 2.5f;      /* as: double                    */

f(3, c, s, x);
/* inside f, the ONLY correct pulls are:
     va_arg(ap, int)     for c
     va_arg(ap, int)     for s
     va_arg(ap, double)  for x
   va_arg(ap, char) / (short) / (float) are UB —
   those types can never come through the ellipsis.  */

🧠 Checkpoint: You pass a float f to printf. Which specifier is correct?

  • %f — the float was promoted to double on the way in
  • %hf for half precision
  • Floats can’t be printed without a cast
  • %lf is required for the promotion
Show answer

%f — the float was promoted to double on the way in — Default argument promotions convert every float argument to double before it enters the .... So %f (which expects double) is exactly right — printf never sees a real float. (%lf is also accepted for printf since C99, but it’s the same thing.)

The price: zero type safety

💀

If va_arg pulls the wrong type — printf("%d", 3.14), printf("%s", 42) — the behavior is undefined: garbage output if you're lucky, a segfault (or an exploitable hole — look up "format string vulnerability") if you're not. Modern compilers check literal printf formats for you (-Wformat, and you can extend it to your own functions with GCC's format attribute) — but only when the format is a literal. Never write printf(user_input); write printf("%s", user_input).

Since the callee can't count arguments, every variadic API picks a protocol:

🧠 Checkpoint: What does printf("%s", 42) do?

  • Prints "42"
  • Compile error, guaranteed
  • Undefined behavior — printf dereferences 42 as if it were a char pointer
  • Prints the address 42
Show answer

Undefined behavior — printf dereferences 42 as if it were a char pointer — va_arg pulls the 42 and treats it as a char* to walk and print — reading address 42 is UB, usually a segfault. Compilers catch this for literal formats (-Wformat is on by default in warnings), which is one great reason to keep formats literal.

🎉

C23 tidied this corner: va_start(ap) no longer needs the second argument (it's ignored if given), and a function may now be fully variadic — int f(...) with no named parameters at all, something previously illegal. The old two-argument form still works everywhere.

This spot has an interactive editor widget — open the interactive lesson to play with it.

🎉 That's the standard library toured — every header worth knowing, from printf to setjmp. Next part we put it all to work: algorithms, Big-O, and classic data structures in C.

▶ Practice this lesson interactively (with live gcc)