DEV Community

Cover image for Variadic Functions and the append(s, more...) Spread in Go: The Gotchas
Gabriel Anhaia
Gabriel Anhaia

Posted on

Variadic Functions and the append(s, more...) Spread in Go: The Gotchas


You wrote a helper that takes a base slice and some extras, appends
them, and returns the result. It passed review. Every test was green.
Then a caller two packages away noticed its own slice had grown a
third element it never asked for. Nobody touched that slice. The
helper did, through a backing array they happened to share.

Variadic functions and the append(s, more...) spread are two of the
most-used tools in Go, and they sit right on top of the slice memory
model. When you understand the memory model they feel obvious. When
you don't, they produce bugs that pass go vet, pass tests, and only
show up when a caller's data is laid out a certain way. Here are the
gotchas worth knowing, with the copy-when-you-must rules that fix
them. Target is Go 1.23+.

A variadic parameter is just a slice

The ...T in a signature is not magic. Inside the function, the
parameter is a plain []T. Go builds that slice for you from the
arguments at the call site.

func sum(nums ...int) int {
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}
Enter fullscreen mode Exit fullscreen mode

Call sum(1, 2, 3) and Go allocates a fresh []int{1, 2, 3} and
passes it in. Call sum() with no arguments and you get something
worth pinning down, which is the first gotcha.

Gotcha 1: nil vs empty variadic

Call a variadic function with zero arguments and the parameter is
nil, not an empty non-nil slice.

func describe(nums ...int) {
    fmt.Println(nums == nil, len(nums))
}

func main() {
    describe()        // true 0
    describe(1, 2)    // false 2
}
Enter fullscreen mode Exit fullscreen mode

For ranging and len, nil and empty behave the same, so most code
never notices. The place it bites is any check that treats nil as a
distinct signal.

func setTags(tags ...string) {
    if tags == nil {
        // "caller passed nothing, keep existing tags"
        return
    }
    // "caller passed a list, replace tags (even if empty)"
    replaceAll(tags)
}
Enter fullscreen mode Exit fullscreen mode

That reads like a clean API: no arguments means "leave it alone," an
explicit list means "replace." It works for setTags() and
setTags("a", "b"). It breaks the moment a caller spreads a slice
that happens to be empty but non-nil:

existing := []string{}
setTags(existing...) // tags is empty, non-nil -> replaceAll runs
Enter fullscreen mode Exit fullscreen mode

Now tags == nil is false, so the function replaces the tags with an
empty list instead of keeping them. The caller passed an empty slice,
not "nothing," and the two paths diverge.

The rule: do not encode meaning in nil-vs-empty across a variadic
boundary. If "no value" and "empty value" are different states in
your API, take an explicit argument for it (a *[]string, a bool, a
dedicated method) instead of leaning on whether the variadic came out
nil. Spreading a slice with ... forwards its nil-ness, and callers
do not think about that.

Gotcha 2: spreading a slice does not copy it

There are two ways to call a variadic function. Pass individual
arguments, or spread an existing slice with .... They are not
equivalent under the hood.

xs := []int{1, 2, 3}
sum(xs...)      // spread: reuses xs' backing array
sum(1, 2, 3)    // individual: fresh backing array
Enter fullscreen mode Exit fullscreen mode

When you pass individual arguments, Go builds a new slice for the
parameter. When you spread with ..., Go passes your existing slice
directly. The variadic parameter and your xs share the same backing
array. If the function writes into its parameter by index, it writes
into your slice.

func firstToZero(nums ...int) {
    if len(nums) > 0 {
        nums[0] = 0
    }
}

func main() {
    xs := []int{7, 8, 9}
    firstToZero(xs...)
    fmt.Println(xs) // [0 8 9]  -- xs mutated
}
Enter fullscreen mode Exit fullscreen mode

firstToZero(xs...) mutated xs because nums and xs are the
same memory. Call it as firstToZero(7, 8, 9) and there is no xs to
mutate. Same function, different aliasing depending on how you called
it.

This is not a bug in Go. It is the documented behavior: ... forwards
the slice, it does not clone it. The bug is assuming a function can't
reach back into your data just because you "passed values."

The rule: if you spread a slice into a function you do not control,
assume that function can mutate your slice in place. If you need to
keep your copy pristine, spread a clone: f(slices.Clone(xs)...).

Gotcha 3: the shared-backing-array append trap

This is the big one, and it is where variadic append and slice views
collide. append(s, more...) reuses s's backing array when there is
spare capacity, and overwrites whatever lives past s's length.

func main() {
    base := []int{1, 2, 3, 4, 5}
    head := base[:2]                 // [1 2], len 2, cap 5

    extra := []int{99, 98}
    grown := append(head, extra...)  // room in base -> writes in place

    fmt.Println(base)  // [1 2 99 98 5]
    fmt.Println(grown) // [1 2 99 98]
}
Enter fullscreen mode Exit fullscreen mode

head has length 2 but capacity 5, inherited from base. The spread
append needs two more slots, base has them, so append writes 99
and 98 into base[2] and base[3]. You never named base in the
append call. You mutated it anyway.

The reverse direction is the same trap from the other side. A function
that appends to its slice argument can leak writes back to the caller:

func withDefault(items []string) []string {
    return append(items, "default")
}

func main() {
    buf := make([]string, 2, 4) // len 2, cap 4
    buf[0], buf[1] = "a", "b"

    x := withDefault(buf)
    y := withDefault(buf)

    fmt.Println(x) // [a b default]
    fmt.Println(y) // [a b default]
    fmt.Println(buf[:3]) // [a b default]
}
Enter fullscreen mode Exit fullscreen mode

Both x and y wrote "default" into the same buf[2], because
buf had spare capacity and append reused it each time. The two
calls did not produce independent results. They stomped the same slot.

Why it slips through review: the bug only fires when the source slice
has extra capacity. Build base with exact-capacity make([]int, 5, 5)
or with a literal that gets trimmed, and the append allocates a fresh
array, and everything looks independent. Your tests pass because your
fixtures have no spare capacity. Production slices, sliced and passed
around, do.

The copy-when-you-must rules

You do not need to clone every slice. You need to clone at the
boundaries where sharing turns into a bug. Three rules cover it.

Rule 1: clone before you append to a slice you were handed. If you
did not create the backing array, do not assume the space past len
is yours to write. Force a fresh array first.

func withDefault(items []string) []string {
    out := slices.Clone(items) // own backing array
    return append(out, "default")
}
Enter fullscreen mode Exit fullscreen mode

slices.Clone (Go 1.21+) returns a slice with its own array at the
current length, so the following append can never reach the caller's
data. slices.Clone(nil) returns nil, which is fine here.

Rule 2: clone before you keep a spread argument past the call. If a
variadic function stores its parameter somewhere that outlives the
call, clone it. The caller may reuse or mutate the slice they spread.

type Bus struct {
    handlers []Handler
}

func (b *Bus) Register(hs ...Handler) {
    b.handlers = append(b.handlers, slices.Clone(hs)...)
}
Enter fullscreen mode Exit fullscreen mode

Without the clone, if the caller did bus.Register(shared...) and
later mutated shared, your stored handlers would change under you.

Rule 3: use the three-index slice to cap capacity when you hand out a
view.
If you return a sub-slice of a larger array and do not want the
receiver's append to bleed into the rest, cut the capacity to the
length with s[low:high:high].

func firstTwo(base []int) []int {
    return base[:2:2] // len 2, cap 2 -> append must reallocate
}
Enter fullscreen mode Exit fullscreen mode

Now any append the caller does forces a new array, because there is
no spare capacity to reuse. It is the cheapest way to make a returned
view append-safe without a copy up front.

When you do not need to copy

Copying is not free, and Go's slice sharing is a feature when you use
it on purpose. You can skip the clone when:

  • You built the slice locally and nobody else holds a reference to it.
  • You are appending to a slice whose only job is to grow, and you keep reassigning the result: s = append(s, xs...).
  • The function is documented to take ownership of its argument, and callers know not to touch the slice after handing it over.

The trouble starts when ownership is implicit. Two pieces of code both
believe they own a backing array, one of them appends, and the other
one's data moves. Make ownership explicit at the boundary and the
whole category of bug disappears.

The one-line mental model

A variadic parameter is a slice. Spreading with ... forwards that
slice by reference, not by copy. append reuses spare capacity and
overwrites past len. Put those three facts together and every gotcha
above is the same gotcha: two names for one backing array, and one of
them wrote. Clone at the boundary where that matters, cap capacity
when you hand out a view, and stop encoding meaning in nil-vs-empty.

Slices are one of those Go features that reward reading the spec's
version of what happens instead of your intuition's. The Complete
Guide to Go Programming
walks the slice header, backing arrays, and
append's growth rules in the same detail this post touches, so the
memory model stops being a source of surprises. Hexagonal
Architecture in Go
is the companion for keeping this at the right
boundary, so ownership of a slice is a decision your ports and
adapters make on purpose instead of by accident.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)