---
title: 'Variadic Functions'
source: 'https://academia.sh/en/courses/go-fundamentals/variadic-functions'
course: 'Go Fundamentals'
language: en
updated: '2026-09-13T05:08:45+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures what a variadic parameter carries across three separate call forms'
    - "Shows that opening an existing slice with ... does not copy it, and that the callee's change is visible to the caller"
    - 'Measures that a nil slice and an empty slice are the same length but separate in identity, and where that difference becomes visible'
---

# Variadic Functions

A variadic parameter given no argument at all becomes a nil slice, arguments written one by one produce a fresh slice, and opening an existing slice with ... shares the same backing array without copying it — a change the callee makes is visible to the caller. Limiting measurement: a nil slice and an empty slice are the same length but diverge on == nil, and this difference becomes visible in their JSON counterpart.

The previous lesson measured that a return value has to be used once it is bound to a name. This
lesson carries the same question to the argument side: when a function does not know in advance
how many arguments it will take, what does that parameter carry when it is given no argument at
all, and what is the relationship between the two when an existing slice is passed to that
parameter by being **opened**?

The Programming Foundations course (M01/K02) built pass-by-value and pass-by-reference using
Python; there, passing a list into a function meant sharing the list itself. **That concept is
not rebuilt here** — one sentence is enough to state the difference: in Go everything is passed
by value, slices included; what this lesson measures is how the difference between "pass by
value" and "passing a value that points to a shared backing array" shows up in variadic
arguments.

## Three Call Forms

In Go, putting `...` in front of a parameter makes that parameter accept a **variadic**
number of arguments, and inside the function body it behaves like a slice. The caller can call
it in three separate forms: giving no argument at all, listing arguments one by one, or opening
an existing slice already sitting in a variable with `...`. All three are valid syntax, but
they do not all produce the same thing behind the signature.

This course itself has been using variadic arguments from the very start: every
`fmt.Printf` and `fmt.Println` call is a call to a standard-library function taking a variadic
number of arguments. Being able to pass a single value in one call and five in another is
exactly thanks to the mechanism this lesson measures. This lesson asks how that same mechanism
behaves when we write our own functions with it.

Two helper functions measure this parameter's behavior from two directions: `properties`
returns its length and whether it is `nil`; `zeroFirst` changes its first element, letting us
test whether that change is visible to the caller.

```go
// helpers.go — two helpers for a variadic parameter
package main

func properties(numbers ...int) (length int, isNil bool) {
	return len(numbers), numbers == nil
}

func zeroFirst(numbers ...int) {
	if len(numbers) > 0 {
		numbers[0] = 0
	}
}
```

**LB23.** `properties`'s return signature uses the previous lesson's named return;
`numbers == nil` is the standard way to test whether a slice is `nil` — the same comparison
measured in the Zero Values lesson is run here over a parameter. `zeroFirst` also borrows a
technique from that same lesson: the `len(numbers) > 0` check exists so as not to panic by
indexing into a `nil` slice — reaching for a `nil` slice's zeroth element runs past the slice's
bounds, exactly as it would with an empty slice.

We run the three call forms side by side: with no argument at all, with arguments written one
by one, and with an existing slice opened with `...`.

```go
// variadic.go — no argument at all, a slice opened, an opened slice never being copied
package main

import "fmt"

func main() {
	l1, n1 := properties()
	fmt.Printf("%-36s length=%d nil=%v\n", "no argument given", l1, n1)

	l2, n2 := properties(1, 2, 3)
	fmt.Printf("%-36s length=%d nil=%v\n", "three arguments written one by one", l2, n2)

	source := []int{10, 20, 30}
	l3, n3 := properties(source...)
	fmt.Printf("%-36s length=%d nil=%v\n", "existing slice opened (...)", l3, n3)

	zeroFirst(1, 2, 3)
	fmt.Printf("%-36s %s\n", "did the one-by-one values change", "nothing observable, caller has no slice")

	zeroFirst(source...)
	fmt.Printf("%-36s source=%v\n", "did the opened slice change", source)
}
```

```
no argument given                    length=0 nil=true
three arguments written one by one   length=3 nil=false
existing slice opened (...)          length=3 nil=false
did the one-by-one values change     nothing observable, caller has no slice
did the opened slice change          source=[0 20 30]
```

**LB24.** The first three lines show what the same parameter carries across three different
call forms. Given no argument (`properties()`), the parameter is a `nil` slice — length `0`
and empty identity. When arguments are written one by one (`properties(1, 2, 3)`), the
compiler builds a **fresh slice** to hold these three values; this slice is not `nil`, because
something really was allocated somewhere. When an existing slice is opened with `...`
(`properties(source...)`), the length and nilness come out the same as the second line — but
this line's real difference shows up in the fourth and fifth lines.

The `nil` result in the first line confirms the pattern established in the first lesson: giving
no argument at all is not an error, it is a defined zero value. The second line shows, for the
first time, that it is not a variable but the **call itself** that produces a slice — writing
`1, 2, 3` builds a slice at the moment of the call, even though no slice literal
(`[]int{1, 2, 3}`) ever appears in the source.

Reading the first three lines by looking only at the `length` and `nil` columns, the second and
third lines cannot be told apart: both read `length=3 nil=false`. This shows a limit of the
measurement — the `properties` function itself has no way of knowing whether the slice passed
to it is **fresh** or **shared**, because that information does not show up directly in any of
the slice header's fields (length, capacity, pointer). Seeing this distinction needs a
different measurement, `zeroFirst`.

## An Opened Slice Is Not Copied

This ambiguity reveals one more limit of the "defined" class. Each of the eight defined lines
in an earlier lesson gave a certain answer to a specific question; here the question is
different — it asks whether a function, by looking at its own parameter, can **know** how
connected that parameter is to its caller. The answer is no: the slice header carries no such
information, it can only be uncovered by observing behavior (changing it and watching the
result).

The fourth and fifth lines run the same function (`zeroFirst`) with two different call forms.
In the call `zeroFirst(1, 2, 3)`, the slice that gets changed is a fresh one built only for that
call — there is no variable on the caller's side corresponding to this slice at all, so the
question "did it change" does not even mean anything here. In the call `zeroFirst(source...)`,
the situation is different: `source` is `[10 20 30]` before the call, `[0 20 30]` after it.
Writing `numbers[0] = 0` inside the function changed the caller's own `source` variable.

**LB25.** This shows that a slice opened with `...` is **not copied**. Go does not allocate a
new backing array for the call `f(slice...)`; it passes `slice`'s own header (length, capacity,
pointer to the backing array) directly into the parameter. The parameter and `source` **share**
the same backing array; an element change made through the parameter becomes visible through
`source` as well, since it reads the same backing array.

This can look like it contradicts the "pass by value" rule (built as a concept in the
Programming Foundations course, not rebuilt here) — as if the slice were passing "by
reference." But what is passed is still a **value**: the slice header itself is copied, only
the backing array that header points to is not copied. The difference between
`zeroFirst(1, 2, 3)` and `zeroFirst(source...)` is not in the passing rule, it is in **which**
backing array is shared: in the first, the parameter points to its own fresh backing array that
no one else sees from anywhere else; in the second, the parameter points to `source`'s
**already existing** backing array, and `source` keeps seeing that same backing array too.

This distinction means that arguments written one by one are **always** safe, while an opened
slice **sometimes** carries a sharing risk. In a call like `sum(1, 2, 3)`, the function changing
its arguments never affects the caller, because there is no caller-side slice to share at all.
In a call like `sum(slice...)`, the function writing into its own parameter can also change
`slice` itself. This raises a question that needs asking while writing a function: if a
variadic function **writes** into its own parameter, that writing being able to affect the
caller too should be stated in the source — the signature does not show this risk on its own,
because `func zeroFirst(numbers ...int)` looks exactly the same for both the
`zeroFirst(1, 2, 3)` and `zeroFirst(source...)` calls.

This explains why almost none of the standard library's variadic functions **write** into their
own parameter: `fmt.Println` only reads its arguments, it never changes them. If a function you
are writing needs to write into its own variadic parameter, this lesson carries a warning: that
write can quietly leak into a slice the caller opened with `...`.

## Limiting Measurement: A Nil Slice and an Empty Slice Are Not the Same

In the first line we saw a `nil` slice, in the second and third lines slices that were not
`nil`. But a slice that is not `nil` can also be **empty**. Comparing `properties()` with
`properties([]int{}...)` makes this difference clear: the first has no argument at all, the
second has one argument — that argument, built with `[]int{}`, is a slice with zero elements
that really does exist.

```go
// limiter.go — a nil slice and an empty slice are not the same thing
package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	lNil, nNil := properties()
	lEmpty, nEmpty := properties([]int{}...)

	fmt.Printf("%-33s length=%d nil=%v\n", "no argument (nil slice)", lNil, nNil)
	fmt.Printf("%-33s length=%d nil=%v\n", "empty slice opened ([]int{}...)", lEmpty, nEmpty)

	var nilSlice []int
	emptySlice := []int{}

	bNil, _ := json.Marshal(nilSlice)
	bEmpty, _ := json.Marshal(emptySlice)
	fmt.Printf("%-33s json=%s\n", "nil slice's json counterpart", bNil)
	fmt.Printf("%-33s json=%s\n", "empty slice's json counterpart", bEmpty)
}
```

```
no argument (nil slice)           length=0 nil=true
empty slice opened ([]int{}...)   length=0 nil=false
nil slice's json counterpart      json=null
empty slice's json counterpart    json=[]
```

The first two lines show a difference `len` cannot tell apart: both have a length of `0`, but
one is `nil` and the other is not. `[]int{}`, even though it is empty, is a slice **allocated**
somewhere; it has no elements, but it has an identity. A `nil` slice, on the other hand, is
never allocated anywhere, and it has no identity either. `len` does not tell these two apart
because length counts how many elements are held, not whether something was allocated.

The difference shows up in a concrete place: the `encoding/json` package's converter. A `nil`
slice is written in JSON as `null`; an empty but non-`nil` slice is written as `[]`. Both carry
the information "there are no elements at all," yet the converter treats these two cases as
**separate** counterparts — one meaning "there is no value," the other "there is a value but it
is empty." This choice made by the standard library turns the difference between `nil` and an
empty slice from a mere language rule into a result visible in a real program's output.

This pair of counterparts also contrasts with the same two slices behaving in an entirely
identical way in a different context. When walked with a `for range` loop, there is no
difference at all between a `nil` slice and an empty slice — both finish the loop without ever
iterating. When `append` is called, both again behave the same — elements can be added to
either. The difference only shows up when **identity** is asked (`== nil`) or when
**identity-sensitive** code (like `encoding/json`) comes into play. This shows that the
`defined` class carries one more internal distinction of its own: some operations look at a
slice's identity, some look only at its content.

This adds one more limit to the course's zero-value measurement. A `nil` slice carries a
defined zero value, as measured in the Zero Values lesson, and is open to operations like
`append`. But being `nil` is not the same thing as "being empty," and this difference can
surface not only in an `== nil` comparison but also in other code that processes the slice
(here, in a converter). Whether a function knows it received no argument at all (`nil`) or was
called with an empty slice (`[]int{}...`) can change the behavior of the callee.

At the end of eight lessons, we can return to the question asked at the opening: how does a
language decide what stands in for the thing you did not write? This topic asked this question in
six separate places: what is required for a source to compile, what a variable's type is
inferred to be, what fills a constant sequence, what part of a control structure is skipped, what
happens when a return value is unwritten, and now, what happens when a parameter is given no
argument at all. The answer was never uniform: sometimes the language put a defined value in
place, sometimes the compiler never accepted the source at all, sometimes the result was left
to runtime. The 8/4/3 split measured in the Zero Values lesson showed that none of these three
answers dominates — all three are part of the language's design.

The `nil` slice measured in this lesson is the **most visible** member of that split, because
almost every operation on a slice (`len`, `append`, `for range`) absorbs the `nil` case without
friction — a programmer could work with a `nil` slice for days and never notice. This
invisibility ends, exactly as this lesson showed, at one boundary: the moment identity is
asked, or identity-sensitive code comes into play, being `nil` suddenly becomes visible.

Throughout this topic's eight lessons, the measurement followed the same order every time: put two
cases side by side, run the difference to show it, then find where that very difference itself
breaks down. This last lesson carries the same order too — three call forms were run side by
side, the difference between them (sharing) was measured, and even that difference was found to
have its own limit (the distinction between nil and empty). The next topic carries the same
order from a single function's parameter to an entire family of types.

## Summary

- If a variadic parameter is given no argument at all, the parameter becomes a `nil` slice.
- When arguments are written one by one, the compiler builds a fresh slice; this slice is not
  `nil`.
- When an existing slice is opened with `...`, it is not copied — the parameter shares the same
  backing array as the caller's slice, and the callee's change is visible to the caller.
- A `nil` slice and an empty slice are the same in terms of `len` (`0`) but diverge in terms of
  `== nil`.
- This difference does not stay confined to a comparison: the `encoding/json` package writes a
  `nil` slice as `null`, an empty slice as `[]`.
- Operations like `for range` and `append` do not tell a `nil` slice apart from an empty one;
  the difference only shows up in identity-sensitive operations (`== nil`, `encoding/json`).

## Next Step

A parameter given no argument at all becoming a `nil` slice is the most visible consequence of
zero values seen in this course so far. This topic closes here; the next topic looks at the type
itself — how do a slice, a map, and a struct meet the unwritten, and when a value is assigned
to a variable, is what results a **copy**, or, as we saw in this lesson, a **shared**
backing array?
