Alright, let’s talk about variadic functions. You know that feeling when you’re writing a function and you think, “I’d love to accept any number of arguments here, but I don’t want to manually create a slice and pass it in every single time”? Well, the Go designers felt that too, and they gave us the ...T parameter, also known as the variadic parameter. It’s the syntactic sugar that makes functions like fmt.Println possible. It’s not magic, but it’s pretty darn convenient.

At its core, a variadic parameter is just a clever bit of compiler trickery. Under the hood, it’s a slice. That’s the most important thing to remember. When you write a function that accepts a variadic parameter, you are writing a function that accepts a slice. The compiler just lets you call that function without explicitly wrapping your arguments in []T{} every time.

Here’s the basic syntax. You declare it in the function signature by putting an ellipsis (...) before the element type.

func concat(separator string, strings ...string) string {
    // strings is of type []string
    if len(strings) == 0 {
        return ""
    }
    result := strings[0]
    for _, s := range strings[1:] {
        result += separator + s
    }
    return result
}

func main() {
    // You can call it with multiple arguments...
    message := concat(" ", "Hello", "variadic", "world!")
    fmt.Println(message) // Output: Hello variadic world!

    // ...or with none!
    empty := concat("-")
    fmt.Printf("Empty result: '%s'\n", empty) // Output: Empty result: ''

    // And because it's just a slice, you can also pass a pre-existing slice...
    words := []string{"This", "is", "a", "slice"}
    message2 := concat("-", words...)
    fmt.Println(message2) // Output: This-is-a-slice
}

The Rules of the Game

There are a few non-negotiable rules. First, the variadic parameter must be the final parameter in the function’s parameter list. This makes perfect sense when you think about it. How would the compiler know where the list of variadic arguments ends if it could be followed by another named parameter? It can’t. So this is a hard requirement.

Second, you can only have one variadic parameter per function. Again, this is a compiler limitation to avoid utter ambiguity. If you could have two, how would the compiler know which argument belongs to which slice? You can’t, so you can’t.

The Slice Passthrough Gotcha

Remember I said the variadic parameter is just a slice? This leads to the most common pitfall. When you pass an existing slice into a variadic function using the slice... syntax, you are passing a reference to that underlying array. This means the function can modify the elements of your original slice.

func makeUppercase(strings ...string) {
    for i := range strings {
        strings[i] = strings.ToUpper(strings[i])
    }
}

func main() {
    mySlice := []string{"hello", "world"}
    makeUppercase(mySlice...) // We're passing the slice itself, not a copy
    fmt.Println(mySlice) // Output: [HELLO WORLD] - The original is modified!
}

This isn’t a bug; it’s working as intended. But if you don’t expect it, it can bite you. If you need to ensure your original slice remains untouched, you have to be explicit about it. You’d need to have the function internally create a copy of the slice, or you’d need to pass a copy yourself at the call site: makeUppercase(append([]string{}, mySlice...)...).

The Empty Slice and Nil

What happens if you call a variadic function with zero arguments for the variadic parameter? The language spec is clear: a nil slice is created. This is beautifully efficient. However, you must always handle this case in your function logic. Iterating over a nil slice with range is perfectly safe—it will execute zero times—but trying to index it will cause a panic.

func printLengthAndCap(strings ...string) {
    fmt.Printf("len: %d, cap: %d, is nil: %t\n", len(strings), cap(strings), strings == nil)
}

func main() {
    printLengthAndCap() // len: 0, cap: 0, is nil: true
    printLengthAndCap("a", "b") // len: 2, cap: 2, is nil: false
}

This is why the concat function at the top checked len(strings) == 0 before trying to use strings[0]. Defensive programming is your friend here.

Mixing and Matching with Other Types

You can happily mix a variadic parameter with other fixed parameters. The fixed parameters come first, and then you get your all-you-can-eat slice buffet at the end.

func logEvent(priority int, message string, tags ...string) {
    fmt.Printf("[%d] %s", priority, message)
    if len(tags) > 0 {
        fmt.Printf(" Tags: %s\n", strings.Join(tags, ", "))
    } else {
        fmt.Println()
    }
}

func main() {
    logEvent(1, "Application started")
    logEvent(2, "Database connection slow", "perf", "db", "warning")
}

So, there you have it. Variadic functions are one of those features that are simple on the surface but have just enough nuance to be interesting. Use them to make your APIs cleaner and more flexible, but always remember: you’re just working with a slice. And watch your fingers around that original array.