Go Variadic Functions — Middle Level¶
1. Introduction¶
At the middle level you understand variadic parameters as slice parameters with sugar at the call site. You design APIs around them deliberately, recognize when spread aliasing causes subtle mutation bugs, and know when to choose ...T over []T. You also understand the cost model and the common allocation traps.
2. Prerequisites¶
- Junior-level variadic material
- Solid grasp of slices: header, backing array, capacity vs length
- Familiarity with
append,copy - Comfort with
interface{}/anyand boxing
3. Glossary¶
| Term | Definition |
|---|---|
| Variadic of interface type | ...interface{} — every arg is boxed |
| Spread aliasing | Caller and callee share the backing array |
| Defensive copy | append([]T(nil), s...) to isolate from aliasing |
| Open-ended overload | API design that accepts 0..N values uniformly |
| Implicit slice | The slice the compiler builds for a literal-arg call |
errors.Join | Standard variadic that combines errors (Go 1.20+) |
4. Core Concepts¶
4.1 ...T vs []T Parameter¶
Compare two signatures:
func sumA(xs []int) int { /* ... */ return 0 } // takes a slice
func sumB(xs ...int) int { /* ... */ return 0 } // variadic
| Property | []int param | ...int param |
|---|---|---|
| Caller passes individual values | sumA([]int{1,2,3}) | sumB(1, 2, 3) |
| Caller passes existing slice | sumA(s) | sumB(s...) |
| Caller passes nothing | sumA(nil) | sumB() |
| Slice constructed by compiler? | No | Yes for literal args |
| Aliasing risk | Always (slice is shared) | Only with spread form |
Pick ...T when callers will most commonly write individual literal values (min(a, b, c)); pick []T when callers always have a slice already (processBatch(items)).
4.2 Spread Aliasing — When and Why It Bites¶
func reverse(xs ...int) {
for i, j := 0, len(xs)-1; i < j; i, j = i+1, j-1 {
xs[i], xs[j] = xs[j], xs[i]
}
}
s := []int{1, 2, 3, 4}
reverse(s...) // mutates s
fmt.Println(s) // [4 3 2 1]
This is fine when documented but surprising when not. Defensive copy when needed:
func reverseCopy(xs ...int) []int {
out := append([]int(nil), xs...) // independent backing array
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}
4.3 The Forwarding Pattern¶
The most common variadic mistake is forwarding without ...:
func inner(args ...any) { fmt.Println(args) }
func wrong(args ...any) {
inner(args) // BUG: passes the []any as ONE argument; inner sees [[a b c]]
}
func correct(args ...any) {
inner(args...) // forwards each element
}
correct("a", "b", "c") // inner receives 3 args
4.4 Append Is Variadic¶
The built-in append is variadic in its second parameter:
a := []int{1, 2, 3}
a = append(a, 4) // single value
a = append(a, 5, 6, 7) // multiple values
a = append(a, []int{8, 9}...) // spread
// The classic concat:
b := []int{20, 30}
c := append([]int{10}, b...) // [10 20 30]
4.5 The ...any (or ...interface{}) Trap¶
Variadic of an interface type is what makes fmt.Printf magical — and expensive:
func logf(format string, args ...any) {
fmt.Printf(format+"\n", args...)
}
logf("user %s scored %d", "ada", 42)
// "ada" and 42 are each boxed into interface{} values.
// 42 (int) → small heap allocation (or stack-allocated escape)
For high-frequency call sites, prefer typed APIs (see Optimize doc).
4.6 Generic Variadics (Go 1.18+)¶
func Sum[T int | float64 | string](xs ...T) T {
var total T
for _, x := range xs {
total += x
}
return total
}
Sum(1, 2, 3) // int 6
Sum(1.5, 2.5) // float64 4.0
Sum("Hello, ", "World!") // string "Hello, World!"
Type inference makes this clean. The element type is determined from the first argument.
5. Real-World Analogies¶
Buffet plates (we used this in junior). At middle level, recognize that you bring your own plate (slice) sometimes and they hand you one (literal args) other times — the food (data) is the same, but the plate differs.
Mailing list: a function that takes recipients via variadic vs. as a slice. If the caller usually constructs a list anyway (database query, file parse), take []T. If callers naturally write mail("a", "b", "c"), take ...T.
6. Mental Models¶
Model 1 — ...T is sugar for []T + spread¶
A variadic call is two macros expanded by the compiler:
sum(1, 2, 3) becomes sum([]int{1, 2, 3}) // (with stack-allocated array)
sum(s...) becomes sum(s) // (no copy, pass header)
Model 2 — Aliasing is determined by the call form¶
7. Pros & Cons¶
Pros¶
- Clean call sites for "0 or more" semantics
- Forwarding is a single token (
...) - Composable with the
appendfamily - Generic variadics replace many overload patterns
Cons¶
...anyis allocation-heavy- Aliasing is invisible in the function signature
- Cannot mix literal + spread at the same call
- Confusing to new Go programmers (two
...syntaxes with different meanings)
8. Use Cases¶
fmt.Printf-style logging- Middleware chain assembly:
Use(mw1, mw2, mw3) - Aggregators:
min,max,sum,avg - Combinators:
errors.Join(e1, e2, e3) - Functional options:
NewServer(WithAddr(...), WithTimeout(...)) - Builder patterns:
NewQuery().Where(...).OrderBy(...) - Concatenation:
append(a, b...) - Spread re-use:
f(reuse...)to avoid rebuilding
9. Code Examples¶
Example 1 — Aliasing Trap and Defensive Copy¶
package main
import "fmt"
func capture(items ...string) []string {
return items // CAUTION: aliases the caller's backing array
}
func captureCopy(items ...string) []string {
return append([]string(nil), items...)
}
func main() {
s := []string{"a", "b", "c"}
held1 := capture(s...)
held2 := captureCopy(s...)
s[0] = "X"
fmt.Println(held1) // [X b c] — aliased
fmt.Println(held2) // [a b c] — independent
}
Example 2 — fmt.Printf Wrapper¶
package main
import "fmt"
func tracef(prefix string, format string, args ...any) {
fmt.Printf("[%s] "+format+"\n", args...)
}
func main() {
tracef("DB", "query %q took %dms", "SELECT 1", 12)
}
Example 3 — errors.Join Pattern¶
package main
import (
"errors"
"fmt"
)
func combine(errs ...error) error {
nonNil := errs[:0]
for _, e := range errs {
if e != nil {
nonNil = append(nonNil, e)
}
}
if len(nonNil) == 0 {
return nil
}
return errors.Join(nonNil...)
}
func main() {
err := combine(nil, fmt.Errorf("disk full"), nil, fmt.Errorf("network down"))
fmt.Println(err)
}
Example 4 — Functional Options¶
package main
import (
"fmt"
"time"
)
type Server struct {
Addr string
Timeout time.Duration
MaxConn int
}
type Option func(*Server)
func WithAddr(a string) Option { return func(s *Server) { s.Addr = a } }
func WithTimeout(d time.Duration) Option { return func(s *Server) { s.Timeout = d } }
func WithMaxConn(n int) Option { return func(s *Server) { s.MaxConn = n } }
func NewServer(opts ...Option) *Server {
s := &Server{Addr: ":8080", Timeout: 30 * time.Second, MaxConn: 100}
for _, opt := range opts {
opt(s)
}
return s
}
func main() {
s := NewServer(WithAddr(":9000"), WithMaxConn(500))
fmt.Printf("%+v\n", s)
}
Example 5 — Middleware Composition¶
package main
import (
"fmt"
"net/http"
)
type Middleware func(http.Handler) http.Handler
func Chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println(r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}
func auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// ... check auth ...
next.ServeHTTP(w, r)
})
}
func main() {
final := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
}), logging, auth)
_ = final
}
Example 6 — Generic Variadic¶
package main
import "fmt"
type Numeric interface {
int | int64 | float64
}
func Sum[T Numeric](xs ...T) T {
var total T
for _, x := range xs {
total += x
}
return total
}
func main() {
fmt.Println(Sum(1, 2, 3)) // 6
fmt.Println(Sum(1.5, 2.5, 3.0)) // 7
fmt.Println(Sum[int64](100, 200)) // 300
}
10. Coding Patterns¶
Pattern 1 — Mandatory + Optional via Variadic¶
Pattern 2 — Builder Style¶
Pattern 3 — Variadic Constructor With Spread Default¶
func NewLogger(fields ...Field) *Logger {
base := []Field{TimestampField()}
return &Logger{fields: append(base, fields...)}
}
Pattern 4 — Variadic for Heterogeneous Args¶
type Field interface{}
type StringField struct{ K, V string }
type IntField struct{ K string; V int }
func log(msg string, fields ...Field) { /* ... */ }
log("login", StringField{"user", "ada"}, IntField{"attempts", 3})
This avoids the cost of ...any boxing — fields are concrete types implementing the Field interface.
11. Clean Code Guidelines¶
- Document aliasing: if your function may mutate or hold the spread slice, say so.
- Prefer typed variadics over
...anyfor safety and performance. - Use a struct param when you have many optional knobs; variadic of options is fine for 3-7 knobs.
- For required + optional, put required first, variadic last.
- Validate length at function entry if the function semantically requires N args.
func median(xs ...int) (int, error) {
if len(xs) == 0 {
return 0, errors.New("median of zero values")
}
// ...
return 0, nil
}
12. Product Use / Feature Example¶
A multi-target metric emitter:
package main
import (
"fmt"
"time"
)
type Metric struct {
Name string
Tags []string
Timestamp time.Time
}
type Sink interface {
Emit(Metric)
}
func emit(name string, tags []string, sinks ...Sink) {
m := Metric{Name: name, Tags: tags, Timestamp: time.Now()}
for _, s := range sinks {
s.Emit(m)
}
}
type stdoutSink struct{}
func (stdoutSink) Emit(m Metric) {
fmt.Printf("[stdout] %s %v\n", m.Name, m.Tags)
}
func main() {
emit("login", []string{"user:ada", "ip:127.0.0.1"}, stdoutSink{})
}
The sinks ...Sink lets the caller fan-out to 1 or N backends.
13. Error Handling¶
Wrap and join multiple errors using errors.Join (variadic-friendly, Go 1.20+):
package main
import (
"errors"
"fmt"
)
func runAll(steps ...func() error) error {
var errs []error
for i, step := range steps {
if err := step(); err != nil {
errs = append(errs, fmt.Errorf("step %d: %w", i, err))
}
}
return errors.Join(errs...)
}
func main() {
err := runAll(
func() error { return nil },
func() error { return errors.New("oops") },
func() error { return nil },
func() error { return errors.New("bad") },
)
fmt.Println(err)
}
errors.Join(nil, nil) returns nil. errors.Join(...) with all-nil inputs returns nil — safe and idiomatic.
14. Security Considerations¶
- Bound caller-controlled variadics:
- Never spread untrusted slices into expensive operations without size checks.
- Beware logging variadic args directly:
log("got", userInput...)may print sensitive data. Sanitize. ...anyallows passing pointers to private state — be deliberate about what you expose.
15. Performance Tips¶
- Cost decomposition for variadic call:
- Slice header: 24 bytes on the caller's stack.
- Backing array: stack if it doesn't escape, heap otherwise.
-
Per-arg interface boxing: only for
...any/...interface{}parameters. -
Spread is essentially free — passes the header you already have.
-
Inline-friendly variadics — small variadic functions can still be inlined by the compiler (Go 1.21+).
-
Profile before optimizing —
fmt-style functions look expensive but usually aren't a bottleneck. -
For very hot paths, write a non-variadic specialization:
16. Metrics & Analytics¶
type Tag struct{ Key, Value string }
func emit(metric string, tags ...Tag) {
// ... ship to backend ...
fmt.Println(metric, tags)
}
emit("requests.total", Tag{"path", "/users"}, Tag{"status", "200"})
Typed Tag struct avoids ...any boxing.
17. Best Practices¶
- Default to
[]Tparameter when callers naturally have a slice. - Prefer
...Twhen callers naturally write individual values. - Always handle the zero-args case.
- Document aliasing behavior in the doc comment.
- Use
append([]T(nil), v...)for defensive copy. - Use generics instead of
...anywhere possible (Go 1.18+). - Don't use a variadic to fake function overloading — separate functions are clearer.
18. Edge Cases & Pitfalls¶
Pitfall 1 — Spreading a Modified Local Slice¶
func filter(in ...int) (out []int) {
for _, v := range in {
if v > 0 {
out = append(out, v)
}
}
return
}
s := []int{-1, 2, -3, 4}
out := filter(s...) // no aliasing because filter only reads; out is a fresh slice
But:
func reorderInPlace(in ...int) []int {
sort.Ints(in) // modifies caller's slice via aliasing
return in
}
Pitfall 2 — Variadic + Goroutines¶
func startAll(tasks ...func()) {
for _, t := range tasks {
go t() // safe: t is fresh per iteration in Go 1.22+ for-range
}
}
Pitfall 3 — Forwarding Without Spread¶
Already covered, but worth reiterating: inner(args) vs inner(args...) are completely different.
Pitfall 4 — len(args) Overflow Bound¶
func avg(xs ...int) int {
sum := 0
for _, x := range xs {
sum += x // could overflow on huge inputs
}
return sum / len(xs)
}
Always think about overflow when summing user-provided variadics.
Pitfall 5 — Spread Inside Sprintf Formatting¶
nums := []int{1, 2, 3}
fmt.Sprintf("%v %v %v", nums) // prints "[1 2 3] %!v(MISSING) %!v(MISSING)"
fmt.Sprintf("%v %v %v", nums[0], nums[1], nums[2]) // works
fmt.Sprintf("%v %v %v", any(nums[0]), any(nums[1]), any(nums[2])) // verbose
// Or convert and spread:
args := make([]any, len(nums))
for i, n := range nums { args[i] = n }
fmt.Sprintf("%v %v %v", args...)
You cannot spread a typed slice into an ...any parameter — Go does NOT auto-convert.
19. Common Mistakes¶
| Mistake | Fix |
|---|---|
inner(args) instead of inner(args...) | Spread when forwarding |
Spreading []int into ...any parameter | Manually convert each element to any |
| Treating spread as a copy | append([]T(nil), s...) |
Using ...any everywhere for "flexibility" | Use generics or typed slices |
| Combining literal args with spread | Build a combined slice first |
Forgetting len(args) > 0 check | Always handle zero args explicitly |
20. Common Misconceptions¶
Misconception 1: "Variadic is just syntactic sugar with no runtime difference." Truth: Literal args force the compiler to construct a slice each call. For small lists this is stack-allocated, but ...any always allocates per arg.
Misconception 2: "Spread is just passing arguments separately." Truth: Spread passes the same slice — not separate values. The function receives one []T, not N individual args.
Misconception 3: "I can spread an array." Truth: Only slices can be spread. Convert with arr[:] first.
Misconception 4: "Passing too many args slows down fmt.Println significantly." Truth: fmt's allocation comes from interface boxing per arg, not from the variadic mechanism itself.
Misconception 5: "Variadic and []T parameters are interchangeable in API design." Truth: They have different ergonomics; pick based on the natural call site.
21. Tricky Points¶
nilslice passed via spread is indistinguishable from zero args at the receiver.[]any{1, "a", true}cannot be passed via spread to a non-...anyfunction.- The
errors.Join(nil, nil, nil) == nilinvariant relies on the function checking each arg. - Variadic + named results is fine; the slice param doesn't interact with naked returns.
- The compiler may stack-allocate the implicit slice — this is observable via
-gcflags="-m".
22. Test¶
package main
import (
"reflect"
"testing"
)
func collect(items ...int) []int {
return append([]int(nil), items...) // defensive copy
}
func TestCollect(t *testing.T) {
cases := []struct {
in []int
want []int
}{
{nil, nil},
{[]int{}, nil},
{[]int{5}, []int{5}},
{[]int{1, 2, 3}, []int{1, 2, 3}},
}
for _, c := range cases {
got := collect(c.in...)
if !reflect.DeepEqual(got, c.want) {
t.Errorf("collect(%v) = %v; want %v", c.in, got, c.want)
}
}
}
func TestCollect_NoAliasing(t *testing.T) {
s := []int{1, 2, 3}
got := collect(s...)
s[0] = 99
if got[0] != 1 {
t.Errorf("got was aliased: %v", got)
}
}
23. Tricky Questions¶
Q1: What does this print?
func touch(xs ...int) { xs[0] = -1 }
func main() {
s := []int{1, 2, 3}
touch(s...)
touch(s...)
fmt.Println(s)
}
[-1 2 3]. First call sets s[0] = -1; second call also sets s[0] = -1 (same value); s is mutated through aliasing. Q2: Is this legal?
A: No. You cannot mix individual args with spread in the same call. Build a combined slice first:sum(append([]int{1}, []int{2,3}...)...). Q3: What does this print?
func describe(args ...any) {
fmt.Println(len(args))
}
func main() {
s := []int{1, 2, 3}
describe(s) // ?
describe(s...) // ?
}
1 (s is one any-typed argument). Second prints — compile error: cannot use s (type []int) as type []any. To spread you'd need []any{1, 2, 3}. 24. Cheat Sheet¶
// Declare:
func f(args ...T) {}
// Call with values:
f(a, b, c)
// Call with no args:
f()
// Spread an existing slice:
s := []T{...}
f(s...)
// Forward in a wrapper:
func wrap(args ...T) { inner(args...) }
// Defensive copy:
mine := append([]T(nil), args...)
// `errors.Join` style:
return errors.Join(errs...)
// `append` concat:
combined := append(a, b...)
// Generic variadic (Go 1.18+):
func Sum[T Number](xs ...T) T { ... }
25. Self-Assessment Checklist¶
- I can choose between
...Tand[]Tbased on caller ergonomics - I know spread aliases the caller's backing array
- I know how to defensively copy (
append([]T(nil), s...)) - I correctly forward variadic args (
inner(args...)) - I avoid
...anyin hot paths - I write generic variadics where appropriate
- I document aliasing and zero-args behavior
- I can explain why spread of
[]intinto...anyfails
26. Summary¶
A variadic parameter ...T is a slice parameter with two call-site sugars: literal args (compiler builds a fresh slice) and spread (caller's slice is aliased). Choose ...T when callers naturally write individual values; choose []T when callers always have a slice. The forwarding pattern inner(args...) is required to pass-through; without ... you wrap the slice as a single argument. Avoid ...any in hot paths because of per-arg interface boxing. Use generics for typed flexibility.
27. What You Can Build¶
printf-style formatters with structured fields- Functional-option constructors
- Error joiners
- Middleware composers
- Generic aggregators
- Multi-sink emitters
28. Further Reading¶
- Go Spec — Passing arguments to ... parameters
errors.Joindocumentation- Dave Cheney — Functional options for friendly APIs
fmtsource code- Generics tutorial
29. Related Topics¶
- 2.6.1 Functions Basics
- 2.6.5 Closures (capture interactions)
- 2.6.7 Call by Value (slice header copy)
- 2.7.3 With Maps & Slices (aliasing details)
appendandcopybuilt-inserrors.Join,slices.Concat
30. Diagrams & Visual Aids¶
Spread vs Literal aliasing¶
LITERAL CALL: SPREAD CALL:
sum(1, 2, 3) sum(s...)
caller stack: caller stack:
[hidden array: 1,2,3] s = [hdr: ptr→array, len, cap]
↑ │
└── slice hdr ↓
↓ [array: 1,2,3]
function receives ↑
[]int(local view) function's xs hdr also points here
no aliasing FULL aliasing