The slice won't fit the slot
You have a slice of error messages collected from a batch process. You want to pass them all to a logging function that accepts a variable number of arguments. You try passing the slice directly. The compiler rejects the code. The function expects individual arguments, not a slice wrapper.
This happens because Go distinguishes between a collection and its contents. A variadic function is designed to receive a list of separate values. A slice is a single value that points to a list. You need a way to tell the compiler to open the slice and hand over the elements one by one.
The syntax is the spread operator, written as .... When you append ... to a slice variable in a function call, Go unpacks the slice. Each element becomes a distinct argument. The function receives exactly what it expects.
Variadic functions and the unpacking rule
A variadic function accepts a flexible number of arguments. The signature uses ... before the type of the last parameter.
// Sum adds all integers passed to it.
func Sum(numbers ...int) int {
total := 0
for _, n := range numbers {
total += n
}
return total
}
Inside the function, numbers is a slice of type []int. The compiler transforms the variadic signature into a slice parameter automatically. You can use len, cap, and range on the parameter just like any other slice.
When you call the function, you can pass individual values:
result := Sum(1, 2, 3)
Or you can pass a slice, provided you unpack it:
values := []int{1, 2, 3}
result := Sum(values...)
The ... after values instructs the compiler to treat the slice as a sequence of arguments. Without it, the compiler sees a single []int value and looks for a function that takes a slice, not a variadic function.
Convention aside: Variadic parameters must be the last parameter in the signature. You cannot place them in the middle. The compiler enforces this rule. If you need fixed arguments before the variadic part, list them first. func Printf(format string, args ...any) is the standard pattern.
Section closer: Variadic functions receive a slice internally. Treat the parameter as a slice once you're inside the function body.
What happens under the hood
Unpacking is not free. When you call Sum(values...), the compiler generates code that allocates a new slice and copies the elements from values into it. The new slice is passed to the function.
This copy has two consequences. First, the function receives a distinct slice. Modifying the slice inside the function does not affect the original values variable. Second, the allocation adds overhead. If you unpack a large slice in a tight loop, you trigger repeated heap allocations and memory copies.
The copy behavior is intentional. It preserves the invariant that function arguments are passed by value. Even though slices are reference types, the header is copied. Unpacking creates a new header and a new backing array reference. The function cannot accidentally mutate the caller's slice structure.
Ah-ha: Unpacking a nil slice works perfectly. It passes zero arguments. The function receives a nil slice internally. This allows you to write code that handles optional lists without special casing:
// Optional values can be nil.
extra := []int(nil)
// This calls Sum with zero arguments.
Sum(base..., extra...)
Convention aside: gofmt controls the spacing around the spread operator. The tool removes any space between the variable and the dots. Write values..., not values .... Most editors run gofmt on save. Trust the formatter and don't argue about indentation.
Section closer: Unpacking copies data. Measure before optimizing, but watch the heap when unpacking large slices in hot paths.
Realistic usage: merging and logging
The most common use of unpacking is with the append builtin. append is variadic. This makes it the standard tool for merging slices.
// Merge combines two slices into a new slice.
func Merge(a, b []int) []int {
// Start with a copy of a to avoid mutating the original.
result := make([]int, len(a))
copy(result, a)
// Unpack b to append each element individually.
result = append(result, b...)
return result
}
Here, b... unpacks the second slice. append receives the elements of b as separate arguments and adds them to result. This pattern appears everywhere in Go codebases.
Another frequent scenario involves the fmt package. Functions like fmt.Println and fmt.Errorf accept ...any. You often collect items in a slice and want to format them.
// LogTags prints a label followed by a list of tags.
func LogTags(label string, tags []string) {
// Convert to any slice because fmt accepts ...any.
// []string is not assignable to []any, so unpacking fails directly.
// This example shows the correct path via conversion.
anys := make([]any, len(tags))
for i, tag := range tags {
anys[i] = tag
}
fmt.Printf("[%s] ", label)
fmt.Println(anys...)
}
Section closer: append and fmt drive most unpacking usage. Master these patterns and you'll handle 90% of variadic scenarios.
Type safety and the assignment trap
Go's type system is strict about unpacking. You can only unpack a slice if the element type matches the variadic parameter type exactly, or if the slice element type is identical to the parameter type.
This creates a common pitfall with interfaces. Suppose you have a function that accepts ...any:
func PrintAny(args ...any) {
for _, arg := range args {
fmt.Println(arg)
}
}
And you have a slice of integers:
ints := []int{1, 2, 3}
You cannot unpack ints directly:
// This fails to compile.
// PrintAny(ints...)
The compiler rejects the code with cannot use ints (type []int) as type []any in argument to PrintAny. The issue is that []int is not assignable to []any. Slices are invariant in Go. A slice of ints is not a slice of any, even though an int is assignable to any.
To fix this, you must convert the elements manually. Create a slice of any, copy the values, and unpack that:
// Convert ints to any before unpacking.
anys := make([]any, len(ints))
for i, v := range ints {
anys[i] = v
}
PrintAny(anys...)
This loop allocates a new slice and boxes each integer into an interface value. The cost is higher than simple unpacking. If you call this pattern frequently, consider changing the function signature to accept []int directly, or use a generic function if your Go version supports it.
Ah-ha: You can unpack multiple slices into a single variadic call, but you must merge them first. Go does not support func(a..., b...). You have to combine the slices into one, then unpack the result:
a := []int{1, 2}
b := []int{3, 4}
// Merge then unpack.
combined := append(a, b...)
Sum(combined...)
Section closer: Type safety is strict. Convert explicitly when types differ. Unpacking requires exact type matches.
Performance considerations
Unpacking triggers allocation and copying. The compiler generates a call to runtime.growslice or a direct copy depending on the size. For small slices, the allocator is fast. For large slices, the cost becomes visible.
If you are passing a slice to a variadic function in a performance-critical loop, consider these alternatives:
- Change the function signature to accept a slice parameter
[]Tinstead of...T. This eliminates the unpacking overhead. - Reuse a buffer. If you must use a variadic function, avoid unpacking inside the loop. Build the arguments once outside the loop.
- Use
appendcarefully.appendmay reallocate the backing array. If you append to a slice repeatedly, pre-allocate capacity withmake([]T, 0, capacity).
Convention aside: The Go community accepts the verbosity of explicit error handling and boilerplate because it makes the unhappy path visible. Similarly, the verbosity of manual type conversion for unpacking makes the type boundary explicit. The compiler forces you to acknowledge the conversion. This prevents silent data loss or unexpected interface boxing.
Section closer: Unpacking is convenient, not free. Profile your code. Optimize only when the data shows a bottleneck.
Decision matrix
Use the ... operator when you have a slice and need to call a function that expects individual arguments.
Use a slice parameter when the function needs to mutate the underlying array or when you want to avoid the allocation cost of unpacking.
Use append when you need to merge multiple slices before passing them to a variadic function.
Use a fixed number of parameters when the count of arguments is known and small; variadic functions add complexity for no benefit.
Use manual conversion loops when you must unpack a slice of one type into a variadic parameter of a different type.
Section closer: Pick the tool that matches the data flow. Unpack to call variadic functions. Use slices to store and transfer data.