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, like sum(values...), does not copy anything. Go passes the existing slice header directly, so the function’s parameter shares the same underlying array as values. 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 into largest, binding it to the parameter nums.
  • Inside largest, nums and the caller’s values now point at the same underlying array. Reading nums[0] reads the same memory as values[0].
  • The function loops over nums[1:], comparing each element to the running maximum m, 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 []int with three elements, copies in 1, 9, and 3, and passes that new slice as nums. This one is entirely private to the call — nothing in main can 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 min and max for 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) float64 that returns 0 when called with no arguments and the arithmetic mean otherwise. Test it with average(), average(4), and average(2, 4, 6); the last call should print 4.
  • Write concat(sep string, parts ...string) string that joins parts with sep between them using strings.Join. Build a []string{"a", "b", "c"} slice and call your function by spreading it with ....
  • Write countTrue(flags ...bool) int that returns how many of its arguments are true. Before running it, predict the output of countTrue() and countTrue(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 to len.
  • 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.