Variadic Functions
A variadic function is a function that accepts a variable number of arguments of the same type — zero, one, or many — without the caller having to build a slice literal by hand. Go’s own standard library leans on this heavily: fmt.Println, fmt.Sprintf, and the builtin append are all variadic. Learning how variadic parameters work, and exactly how they interact with slices under the hood, lets you write flexible, idiomatic APIs while avoiding a couple of subtle aliasing bugs.
Overview / How it works
A function parameter becomes variadic when its type is prefixed with three dots: ...T. Inside the function body, that parameter behaves exactly like an ordinary slice of type []T — you can range over it, index into it, take its len, and pass it along to other functions. Two rules constrain where a variadic parameter can appear: it must be the last parameter in the parameter list, and a function may have at most one variadic parameter. Both rules exist so the compiler can always tell, unambiguously, where the fixed arguments end and the variable-length ones begin.
The compiler treats the two ways of calling a variadic function differently, and the difference matters:
- Calling with individual values, like
sum(1, 2, 3), makes Go allocate a brand-new slice and copy the arguments into it before passing that slice to the function. The caller’s original values are never touched. - Calling by spreading an existing slice with the
...operator, likesum(values...), does not copy anything. Go passes the existing slice header directly, so the function’s parameter shares the same underlying array asvalues. If the function writes to elements of that parameter, the caller sees the change too.
There is also a special case worth knowing precisely: if you call a variadic function and supply zero arguments for the variadic part, the parameter’s value inside the function is nil, not an empty-but-non-nil slice. This is safe to range over and safe to pass to len (both treat nil slices as having zero elements), so it rarely bites you — but it does mean you should not assume the parameter is non-nil if you ever compare it directly to another slice or pass it somewhere that treats nil specially.
Variadic syntax is ultimately sugar: anything you write with ...T you could write by hand as a plain []T parameter and ask every caller to build a slice literal. The variadic form just moves that bookkeeping into the compiler, which is why it reads so naturally at call sites like fmt.Println("x =", x, "y =", y).
Syntax
func functionName(fixedParam Type, variadicParam ...Type) ReturnType {
// variadicParam has type []Type inside the body
}
| Part | Meaning |
|---|---|
...Type |
Marks the final parameter as variadic; the caller may pass zero or more values of Type. |
variadicParam inside the body |
Has the concrete type []Type — range, index, and len all work normally. |
name(a, b, c) |
Passes each value individually; Go builds a new slice to hold them. |
name(existingSlice...) |
Spreads an existing slice into the call without copying it. |
name() |
Calls with zero variadic arguments; the parameter is nil inside the function. |
Examples
Example 1: summing an arbitrary list of numbers
package main
import "fmt"
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
func main() {
fmt.Println(sum())
fmt.Println(sum(1, 2, 3))
fmt.Println(sum(10, 20, 30, 40, 50))
}
Output:
0
6
150
sum takes zero or more int values. The first call passes none, so nums is nil and the loop body never runs, giving 0. The second and third calls pass individual values; Go collects each group into its own new slice before the function ever sees them.
Example 2: mixing fixed parameters with a variadic one
package main
import (
"fmt"
"strings"
)
func joinWithPrefix(prefix string, parts ...string) string {
joined := strings.Join(parts, ", ")
return prefix + joined
}
func main() {
result := joinWithPrefix("Fruits: ", "apple", "banana", "cherry")
fmt.Println(result)
empty := joinWithPrefix("Nothing: ")
fmt.Println(empty)
}
Output:
Fruits: apple, banana, cherry
Nothing:
prefix is a normal, required string parameter, and parts is variadic and comes last, which is the only legal position for it. strings.Join is itself just a function that takes a []string, which is exactly the type parts has inside the body — no special handling needed. The second call shows that omitting the variadic arguments entirely is perfectly legal; parts is nil, and strings.Join on a nil slice returns an empty string.
Example 3: spreading a slice with the ... operator
package main
import "fmt"
func largest(nums ...int) int {
if len(nums) == 0 {
panic("largest: no arguments")
}
m := nums[0]
for _, n := range nums[1:] {
if n > m {
m = n
}
}
return m
}
func main() {
values := []int{4, 8, 15, 16, 23, 42}
fmt.Println(largest(values...))
fmt.Println(largest(1, 9, 3))
}
Output:
42
9
When you already have a []int, like values, you cannot pass it directly to a function expecting ...int — you must spread it with values.... That tells the compiler “unpack this slice as the variadic arguments” instead of trying to match it against a single parameter. The second call shows that the same function still works perfectly well with a plain, comma-separated argument list.
How it works step by step
Walking through largest(values...) from Example 3:
- Go evaluates
values, which is a slice header pointing at an underlying array of six ints. - Because the call uses
..., Go does not allocate a new slice or copy any elements — it passes the existing slice header straight intolargest, binding it to the parameternums. - Inside
largest,numsand the caller’svaluesnow point at the same underlying array. Readingnums[0]reads the same memory asvalues[0]. - The function loops over
nums[1:], comparing each element to the running maximumm, and returns the largest value it finds. - For the second call,
largest(1, 9, 3), there is no existing slice to spread. Go allocates a fresh, unnamed[]intwith three elements, copies in1,9, and3, and passes that new slice asnums. This one is entirely private to the call — nothing inmaincan see or alias it.
Common Mistakes
Mistake 1: passing a slice without spreading it
A slice is not automatically unpacked — forgetting the ... is a compile error, not a runtime surprise:
package main
import "fmt"
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
func main() {
values := []int{1, 2, 3}
fmt.Println(sum(values))
}
This fails to compile with something like cannot use values (variable of type []int) as int value in argument to sum, because without ..., Go tries to match values against a single int argument. The fix is to spread the slice:
package main
import "fmt"
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
func main() {
values := []int{1, 2, 3}
fmt.Println(sum(values...))
}
Output:
6
Mistake 2: trying to declare more than one variadic parameter
Only the last parameter in a function signature may be variadic — this does not compile:
package main
func combine(a ...int, b ...int) []int {
return append(a, b...)
}
func main() {}
Go reports a syntax error because ... is only allowed on the final parameter. If you need two groups of values, accept the first as a plain slice and keep the variadic one last:
package main
import "fmt"
func combine(prefix []int, rest ...int) []int {
result := append([]int{}, prefix...)
result = append(result, rest...)
return result
}
func main() {
fmt.Println(combine([]int{1, 2}, 3, 4, 5))
}
Output:
[1 2 3 4 5]
Mistake 3: assuming a spread slice is copied
Because spreading passes the original slice header, writes inside the function are visible to the caller — this compiles and runs fine, but the result surprises people who expect value-like isolation:
package main
import "fmt"
func zeroOut(nums ...int) {
for i := range nums {
nums[i] = 0
}
}
func main() {
values := []int{1, 2, 3}
zeroOut(values...)
fmt.Println(values)
}
Output:
[0 0 0]
values itself changed, even though zeroOut never returned anything back to main. If you want a variadic function to leave the caller’s slice untouched, copy it first inside the function, for example with append([]int{}, nums...), before mutating the copy.
Best Practices
- Keep the variadic parameter last and use at most one per function — the language enforces this, but design your APIs with it in mind from the start.
- If a zero-argument call would be meaningless (like “the largest of no numbers”), consider requiring at least one fixed argument, e.g.
func largest(first int, rest ...int) int, so the empty case simply cannot happen. - Remember that spreading a slice does not copy it; if the function will mutate its variadic parameter, either document that side effect clearly or copy the slice internally first.
- Use Go 1.21+’s builtin
minandmaxfor simple pairwise or list comparisons; reach for a custom variadic function when you need extra logic beyond a plain comparison. - Keep every element of a variadic parameter semantically uniform — don’t use position within a variadic list to mean different things, since there is no way for the compiler to enforce that convention for you.
- Document what an empty call means for your function, since Go always allows calling a variadic function with zero variadic arguments.
Practice Exercises
- Write
average(nums ...float64) float64that returns0when called with no arguments and the arithmetic mean otherwise. Test it withaverage(),average(4), andaverage(2, 4, 6); the last call should print4. - Write
concat(sep string, parts ...string) stringthat joinspartswithsepbetween them usingstrings.Join. Build a[]string{"a", "b", "c"}slice and call your function by spreading it with.... - Write
countTrue(flags ...bool) intthat returns how many of its arguments aretrue. Before running it, predict the output ofcountTrue()andcountTrue(true, false, true), then verify your prediction.
Summary
- A variadic parameter is written
...T, must be the last parameter, and only one is allowed per function. - Inside the function body, a variadic parameter has the ordinary slice type
[]T. - Calling with individual values copies them into a new, private slice; calling with
slice...spreads an existing slice without copying, sharing its underlying array. - Calling with zero variadic arguments passes
nil, which is safe to range over and pass tolen. - Because a spread slice is not copied, a variadic function can mutate the caller’s data through it — copy internally if that is not what you want.
