Rust 1.99 lets you write C-style variadic functions in Rust itself. Those are functions that take a variable number of arguments, like C's printf. The Rust release team shipped it on 1 October, and it's the first stable release where you can define one, not just call one.

We built a small library with it, called it from a C program, and checked the compiler's guard rails. Everything below ran on Rust 1.99.0 on Linux. Here's how to do it, and the rules that stop it going wrong.

Update to 1.99 and start a project

If you already use rustup, the official release post says one command gets you there:

Tap to enlarge
rustup update stable

No Rust yet? Install rustup from rustup.rs first. Then check the version. Running rustc --version should show a line starting with rustc 1.99.0. Ours read rustc 1.99.0 (b940084d7 2026-09-28).

Make a library project with cargo new --lib varsum. We want C code to link against it, so tell Cargo to build a static library as well as the usual Rust one. Open Cargo.toml and make it look like this:

[package]
name = "varsum"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["staticlib", "rlib"]

The 2024 edition matters for one small thing later. In that edition, the attribute that stops Rust renaming your function has to be written as #[unsafe(no_mangle)].

Write the function

Here's the core of it. Put this in src/lib.rs:

use std::ffi::{c_int, c_long};

/// Sums `count` C `long` values.
///
/// # Safety
/// The caller must pass exactly `count` further arguments, each a C `long`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rs_sum(count: c_int, mut args: ...) -> c_long {
    let mut total: c_long = 0;
    for _ in 0..count {
        total += unsafe { args.next_arg::<c_long>() };
    }
    total
}

Three things are going on here. The ... at the end of the parameter list is the variadic part, and you give it a name, here args, so you can read from it. The function is extern "C", because the release post says 1.99 stabilises variadic definitions for the "C" and "C-unwind" ABIs. And each call to next_arg pulls the next argument off the list and treats it as the type you ask for.

According to the standard library docs for VaList, the type behind ... is VaList, which is "ABI-compatible with va_list in C." It's set up automatically when the function starts, which the docs say is equivalent to calling va_start in C. Dropping it is the same as va_end, and cloning it is the same as va_copy. The next_arg method is the equivalent of C's va_arg.

Look at the first parameter, count. A variadic function has no built-in way of knowing how many arguments it got. printf works it out from the format string. Here, the caller says how many numbers follow. That's the contract, and it's why the doc comment spells it out.

Add a quick test at the bottom of the same file, then run cargo test:

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn sums() {
        assert_eq!(unsafe { rs_sum(3, 10 as c_long, 20 as c_long, 12 as c_long) }, 42);
    }
}

Ours passed first time.

Call it from C

Usually the whole point of a C-ABI variadic function is that something written in C will call it. Maybe a C library wants a callback with ... in its signature, or you're rewriting a C library in Rust and need to keep its interface exactly as it was. Build the static library with cargo build --release, then write a tiny C program, main.c:

#include <stdio.h>
long rs_sum(int count, ...);
int main(void) {
    printf("%ld\n", rs_sum(4, 1L, 2L, 3L, 4L));
    return 0;
}

On our Linux box this compiled and linked with:

cc main.c target/release/libvarsum.a -o demo -lpthread -ldl -lm

The extra system libraries on the end are what our toolchain needed to link a Rust static library. Yours may be different on another platform. Running ./demo printed 10.

You can also hand the argument list on to a C function that takes a va_list. That's how you'd write a printf-style logger that adds a prefix and then lets C's vprintf do the formatting. Declare vprintf with a VaList parameter in an unsafe extern "C" block, and pass args straight through. In our test, a Rust function written that way and called from C as rs_log("%s is %d\n", "answer", 42) printed our prefix followed by "answer is 42."

The rules that keep it safe

The function has to be unsafe. We tried leaving that off, and the compiler stopped us with "functions with a C variable argument list must be unsafe." That's fair enough. The compiler can't check that the caller passed the right number or types of arguments, so the person calling it has to promise they did. The release post's own example says so in its safety comment: it "must be called with (at least) 2 i32 arguments."

Not every type can be read. What next_arg accepts is controlled by a trait called VaArgSafe. The docs say it's always implemented for c_int, c_long, c_longlong, their unsigned versions, c_double, and raw pointers. The trait itself is still marked experimental, and you can't implement it outside the core library. But it's what decides what you're allowed to read.

The reason comes down to C's own rules. When C passes variable arguments, the docs explain, signed integers smaller than c_int are promoted to c_int, unsigned ones to c_uint, and c_float to c_double. So you can't read an f32. We tried, and got "the trait bound f32: VaArgSafe is not satisfied." Read a c_double instead. The docs also warn that implementations for types like i32 or usize "shouldn't be relied upon directly, because they may not be available on all platforms." That's why our example uses c_long rather than i64.

The VaList docs list the safety conditions for each next_arg call. There has to be another argument to read, and its actual type has to be compatible with the one you ask for. For integers, the value has to fit in both types. The Rust Reference section on C-variadic functions has the full language rules.

Why it's worth knowing

A lot of the world's systems code is still C, and much of Rust's progress comes from replacing it one piece at a time. Last month's four Linux kernel root flaws were a reminder of how long mistakes can sit in old C code. The man who found them says the bugs had been in the kernel's networking code for ten to twenty-one years. Every interface Rust can match exactly is one less piece that has to stay in C, and variadics were one of the awkward gaps.

The release also stabilises naked variadic functions with other ABIs, which have to be written in inline assembly. That's one for runtime and operating system developers. For everyone else, the recipe is short. Run rustup update, write an unsafe extern "C" function with ..., and use C types for every argument you read.