The problem with fixed parameters
You are building a utility to join strings with a separator. Your first attempt looks clean: two parameters, a loop, a result. Then the product manager asks for three strings. You add a parameter. Then five. Then a dynamic list from a database. The function signature bloats. The caller starts passing empty strings just to fill slots. The code feels rigid.
Go solves this with the ... syntax. It tells the compiler to accept zero, one, or a hundred arguments of a specific type. Inside the function, those arguments arrive as a single slice. The signature stays readable. The caller passes exactly what they have. The compiler handles the packing.
Variadic functions are not a special calling convention. They are slice parameters with automatic argument collection.
How the syntax actually works
The ... operator sits directly before the type in the parameter list. func greet(names ...string) means the function expects a variable number of strings. The compiler rewrites this internally to func greet(names []string). The only difference is what happens at the call site.
When you call greet("Alice", "Bob"), Go does not pass two separate string values. It allocates a temporary slice, copies the arguments into it, and passes the slice header. Inside the function, you iterate over names exactly like any other slice. The ... syntax is purely a convenience for the caller. It removes the manual slice construction step.
Think of it like a mail sorter. Fixed parameters are labeled mailboxes. You must put exactly one letter in each. A variadic parameter is a collection bin. You drop as many letters as you want, and the sorter wraps them in a single envelope before handing it to the processing desk.
Variadic parameters always go last in the signature. Go requires this because the compiler needs to know where the fixed arguments end and the variable arguments begin.
Minimal example
Here is the simplest variadic function: a number sum that accepts any count of integers.
func sum(numbers ...int) int {
// The compiler treats numbers as []int internally
total := 0
for _, n := range numbers {
// Accumulate each element from the auto-packed slice
total += n
}
// Return the final aggregate
return total
}
func main() {
// Pass three arguments. The compiler builds a temporary []int{1, 2, 3}
result := sum(1, 2, 3)
// Pass zero arguments. The slice is empty, loop skips, returns 0
empty := sum()
// Pass five arguments. No signature change needed
large := sum(10, 20, 30, 40, 50)
}
The function works identically whether you pass zero arguments or fifty. The loop handles the empty slice gracefully. The compiler guarantees type safety across every argument.
Variadic functions remove boilerplate without sacrificing type checking.
What happens under the hood
The magic is limited to the call site. When the compiler sees sum(1, 2, 3), it generates code that looks roughly like this:
- Allocate a slice header with length 3 and capacity 3.
- Copy
1,2, and3into the backing array. - Pass the slice header to
sum.
If the slice does not escape the function, the backing array lives on the stack. If the function stores the slice or returns it, the compiler moves the allocation to the heap. This is standard Go escape analysis. Variadic parameters do not bypass it.
The slice header itself is three words: a pointer to the data, a length, and a capacity. Passing a variadic parameter costs the same as passing any other slice. The overhead is the temporary allocation and copy at the call site. For small argument counts, this is negligible. For tight loops with thousands of calls, the allocation adds up.
The compiler also enforces strict type matching. Every argument must match the declared type exactly. Implicit conversions do not happen. If you mix types, the build fails immediately.
Type safety travels with the slice. No runtime surprises.
Realistic usage and unpacking
Real code rarely sums integers. It formats logs, builds query parameters, or constructs file paths. Here is a logging helper that takes a severity level and a variable number of messages.
func log(level string, parts ...string) {
// Join the variable messages with a space separator
msg := ""
for i, p := range parts {
if i > 0 {
msg += " "
}
msg += p
}
// Print the formatted log line
fmt.Printf("[%s] %s\n", level, msg)
}
func main() {
// Call with two variable arguments
log("INFO", "server started", "port 8080")
// Call with one variable argument
log("WARN", "high latency detected")
}
You will often have a slice already and need to pass it to a variadic function. Go provides the ... operator at the call site to unpack an existing slice. Without it, the compiler treats the slice as a single element.
func join(sep string, words ...string) string {
// Reuse the standard library for the actual joining logic
return strings.Join(words, sep)
}
func main() {
// Existing slice from a database query
tags := []string{"go", "backend", "concurrency"}
// Unpack the slice so each element becomes a separate argument
result := join("-", tags...)
// Without the trailing ..., tags would be treated as a single []string element
// and the compiler would reject the type mismatch
}
The trailing ... tells the compiler to spread the slice contents into individual arguments. It is the reverse of the automatic packing that happens when you pass literal values. The community uses this pattern constantly with fmt.Printf, strings.Join, and path.Join.
Unpacking is explicit. The compiler refuses to guess your intent.
Pitfalls and compiler feedback
Variadic functions are simple, but a few patterns trip up newcomers.
Passing the wrong type triggers an immediate build failure. The compiler rejects the program with cannot use "three" (untyped string constant) as int value in argument to sum if you mix strings and integers. Every argument must match the declared type.
Forgetting to unpack a slice is the most common mistake. If you call join("-", tags) instead of join("-", tags...), the compiler sees a []string where it expects a string. You get cannot use tags (variable of type []string) as string value in argument to join. The fix is always the trailing ....
Zero arguments are valid and often intentional. sum() returns 0. log("DEBUG") prints an empty message. If your function requires at least one argument, you must check the length manually. The compiler does not enforce a minimum count.
Performance matters in hot paths. Each variadic call allocates a temporary slice. If you call a variadic function inside a tight loop processing millions of records, those allocations trigger garbage collection pressure. The standard library mitigates this in fmt by reusing buffers, but your own functions do not get that optimization automatically.
Slice allocation is real. Profile before optimizing.
When to reach for variadic parameters
Choose the right tool for the signature. Variadic parameters solve specific problems. They are not a replacement for slices or fixed arguments.
Use a variadic parameter when the caller naturally has a small, unpredictable number of values and you want to avoid manual slice construction. Use a slice parameter when the data already exists as a collection, when you need to pass nil explicitly, or when the function modifies the backing array. Use fixed parameters when the number of inputs is known, stable, and semantically distinct. Use a struct or options pattern when you need named configuration fields alongside variable data.
Keep signatures predictable. The caller should never guess how many arguments are required.