Variadic Functions in Go and the Slice Spread Operator
A variadic parameter arrives as a slice, and a slice passed with three dots shares its memory. See the rules, the fmt forwarding bug and how options use them.

fmt.Println(1, "two", 3.0) takes any number of arguments, and so does append. Go lets you write your own functions that do the same, with one syntax at the declaration and one at the call. It is a small feature with two or three sharp edges, and every one of them involves the same fact: inside the function, the parameter is just a slice. This how-to covers the rules, the sharing behavior that surprises people, the forwarding mistake that go vet catches, and the places where a variadic parameter is the wrong shape. It is part of the Go basics path, picking up from functions with multiple returns.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
Declaring and calling
Put ... before the type of the last parameter. The function sees a slice of that type:
package main
import "fmt"
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
func describe(nums ...int) string {
return fmt.Sprintf("len=%d nil=%t", len(nums), nums == nil)
}
func main() {
fmt.Println(sum(), sum(1), sum(1, 2, 3))
xs := []int{4, 5, 6}
fmt.Println(sum(xs...))
fmt.Println(describe())
fmt.Println(describe(1, 2))
fmt.Println(describe(xs...))
fmt.Println(describe([]int{}...))
}0 1 6
15
len=0 nil=true
len=2 nil=false
len=3 nil=false
len=0 nil=falseCalling sum() with nothing is valid and gives zero, because the loop has nothing to add. The second form, sum(xs...), takes an existing slice and expands it into the arguments. The suffix is required: sum(xs) is a compile error, since a []int is not an int. The last four lines show what the function receives. With no arguments nums is nil. With values it is a new slice of that length. With a slice spread, it is the same slice the caller had, and spreading an empty but non-nil slice keeps it non-nil. That distinction matters only if your function checks nums == nil, which it should not.
Rules the compiler enforces: only the final parameter can be variadic, a function has at most one, and you cannot mix individual values and a spread in one call. f(1, xs...) does not compile. If you need a fixed argument plus a list, the fixed one comes first in the signature and the list is the variadic part, as in fmt.Printf(format string, a ...any).
Spreading a slice shares it
When you spread a slice, the function gets the slice itself. It does not get a copy. That is efficient and it has an effect that is easy to miss:
package main
import "fmt"
func zeroFirst(nums ...int) {
nums[0] = 0
}
func main() {
xs := []int{1, 2, 3}
zeroFirst(xs...)
fmt.Println(xs)
zeroFirst(1, 2, 3)
ys := []int{7, 8, 9}
zeroFirst(ys[1:]...)
fmt.Println(ys)
}[0 2 3]
[7 0 9]The first call changes the caller's slice, because nums and xs share a backing array. The literal call zeroFirst(1, 2, 3) builds a fresh slice that nobody else sees, so its write is harmless. The last call spreads a subslice and changes ys[1]. This is the same aliasing described in slices from the header to the backing array, and the same rule applies: if the function modifies elements and the caller still needs the originals, copy first with slices.Clone. If a function documents that it does not modify its arguments, it is a promise you have to keep by hand, since the signature does not say so.
Forwarding arguments, and the missing dots
Wrapper functions that forward their variadic arguments to something like fmt.Sprintf are where most variadic bugs live. The rule is simple and easy to forget: the forwarded slice needs its own ..., or it is passed as a single argument.
package main
import "fmt"
func logf(format string, args ...any) string {
return fmt.Sprintf(format, args...)
}
func logBad(format string, args ...any) string {
return fmt.Sprintf(format, args)
}
func main() {
fmt.Println(logf("%s is %d", "ada", 36))
fmt.Println(logBad("%s is %d", "ada", 36))
}ada is 36
[ada %!s(int=36)] is %!d(MISSING)The good version prints ada is 36. The bad one wrapped both arguments into a single []any value, so the first verb %s formatted the whole slice, which turns the number inside it into %!s(int=36), and the %d found nothing left, printing %!d(MISSING). This is a quiet bug because the output looks nearly right and nothing panics. go vet knows the printf family and any wrapper of it that it can recognize, and it says so:
package main
import "fmt"
func logBad(format string, args ...any) string {
return fmt.Sprintf(format, args)
}
func main() {
fmt.Println(logBad("%s is %d", "ada", 36))
}# example.test/lab
# [example.test/lab]
./main.go:6:9: missing ... in args forwarded to printf-like functionThe vet output names the wrapper and tells you the call is missing the ... expansion, which is the exact fix. Run go vet ./... in CI so that a wrapper of this kind cannot ship, and name wrappers with the conventional f suffix (logf, errorf) so that vet and readers treat them as format functions.
You cannot spread a different slice type
A variadic ...any parameter looks like it accepts anything, and the spread form is stricter. Only a []any can be spread into it:
package main
import "fmt"
func main() {
names := []string{"ada", "grace"}
fmt.Println(names...)
}# example.test/lab
./main.go:7:14: cannot use names (variable of type []string) as []any value in argument to fmt.PrintlnThe compiler says a []string cannot be used as []any in the argument to fmt.Println. The two slice types have different element sizes in memory, so there is no conversion. The fix is a loop that builds the []any, shown in the empty interface article, or passing the slice as one argument, fmt.Println(names), which prints it as [ada grace]. The same error appears for any variadic parameter whose element type is an interface.
What it costs
A call like sum(1, 2, 3) needs a slice for the arguments. The compiler builds it on the caller's stack when the callee does not keep it, so the call is cheap:
package main
import (
"fmt"
"testing"
)
func sum(nums ...int) (total int) {
for _, n := range nums {
total += n
}
return total
}
var sink int
func main() {
xs := []int{1, 2, 3}
fmt.Println("literal arguments:", testing.AllocsPerRun(100, func() { sink = sum(1, 2, 3) }))
fmt.Println("spread slice:", testing.AllocsPerRun(100, func() { sink = sum(xs...) }))
fmt.Println("no arguments:", testing.AllocsPerRun(100, func() { sink = sum() }))
}literal arguments: 0
spread slice: 0
no arguments: 0All three allocate nothing. The picture changes if the function stores or returns the slice, which forces it onto the heap, and for ...any parameters where each argument may be boxed. That is the reason a log call with several arguments can allocate even though the variadic slice itself does not. For a hot path, measure with -benchmem and see where the allocations actually come from before blaming the variadic.
Where variadic parameters are useful
Aggregation of the same kind of thing. sum, max, strings.Join-like helpers, filepath.Join(elem ...string), errors.Join(errs ...error). If callers usually have a handful of values at hand, the variadic form reads better than building a slice.
Printf-style formatting. The format string, args ...any shape is idiomatic for logging and error helpers.
Optional configuration. A constructor that takes opts ...Option lets callers pass zero or many settings and add new ones without breaking existing calls. That is the functional options pattern, and functional options in Go explained with an example builds one in full.
Where a slice parameter is better
If callers will almost always have a slice already, a variadic parameter forces them to write f(xs...) every time and gives the function a shared slice it might mutate. A plain []T parameter says what it is. If the function needs two or three distinct pieces of information, give it named parameters or a struct instead, because a variadic list loses the meaning of position. And if the list can be large, say so in the API: append(dst, src...) is fine, and passing a million elements as separate literal arguments is not something anyone writes. The language rules are in the specification on passing arguments to variadic parameters, and go vet's printf checker is documented with the vet command.


