Manche Funktionen sollen mit einem Argument auskommen, manche mit drei — und manche mit so vielen, wie der Aufrufer gerade in der Hand hat. Für diesen Fall kennt Go Variadic Functions: Funktionen, deren letzter Parameter mit ...T gekennzeichnet ist und beliebig viele Argumente des Typs T einsammelt. Du kennst das aus der Stdlib, ohne es vielleicht bewusst zu sehen: fmt.Println("a", "b", "c"), append(s, 1, 2, 3), max(7, 4, 9) — alle drei sind variadic. Im Inneren der Funktion landet alles in einem ganz normalen []T-Slice, mit len, range, Index-Zugriff. Dazu kommt der Spread-Operator slice..., der einen existierenden Slice 1:1 als variadic-Argumente weitergibt — kompakt, aber mit einer Aliasing-Falle. Dieser Artikel arbeitet die Syntax sauber durch, zeigt die Aufruf-Formen, das Zero-Argument-Verhalten, die Interaktion mit any, die Performance-Implikationen und die Patterns, in denen variadic-Funktionen wirklich glänzen.

Die Syntax — ...T am letzten Parameter

Die Go-Spec definiert die Parameter-Deklaration sehr knapp — variadic ist nur ein optionales ... vor dem Typ des letzten Parameters:

EBNF ParameterDecl (Go-Spec)
ParameterList = ParameterDecl { "," ParameterDecl } .
ParameterDecl = [ IdentifierList ] [ "..." ] Type .

Aus der Spec wörtlich: „The final incoming parameter in a function signature may have a type prefixed with .... A function with such a parameter is called variadic and may be invoked with zero or more arguments for that parameter."

Drei harte Regeln folgen daraus:

  • Genau ein Variadic-Parameter pro Funktion — nicht zwei.
  • Der Variadic-Parameter ist immer der letzte in der Liste.
  • Vor dem Variadic-Parameter dürfen beliebig viele reguläre Parameter stehen.
Go signatures.go
package main

// Nur ein Variadic-Parameter
func sum(nums ...int) int { return 0 }

// Reguläre Parameter zuerst, Variadic am Ende
func printf(format string, args ...any) {}

// Mehrere Fixed-Parameter, dann Variadic
func logAt(level int, tag string, parts ...string) {}

// FEHLER: Variadic darf nicht in der Mitte stehen
// func wrong(parts ...string, suffix string) {}

// FEHLER: nur ein Variadic-Parameter erlaubt
// func wrong2(a ...int, b ...string) {}

Im Inneren der Funktion ist nums ein ganz normales Slice vom Typ []int. Das ist der gedankliche Kern: ...T am Parameter ist nur Syntax-Zucker, das was ankommt ist immer ein []T.

Klassiker aus der Stdlib

Bevor wir tiefer gehen, ein Blick auf die Funktionen, denen du täglich begegnest — alle sind variadic:

Go stdlib_variadics.go
package main

import (
    "fmt"
    "strings"
)

func main() {
    // fmt.Println(a ...any) — beliebig viele Argumente jeden Typs
    fmt.Println("Wert:", 42, true, 3.14)

    // append(s []T, vs ...T) []T — Slice um beliebig viele Werte erweitern
    s := []int{1, 2}
    s = append(s, 3, 4, 5)
    fmt.Println(s)

    // strings.Join(elems []string, sep string) — hier KEIN Variadic
    //   (zur Abgrenzung: hier wird ein bereits existierender Slice akzeptiert)
    fmt.Println(strings.Join([]string{"a", "b", "c"}, "-"))

    // max / min (Go 1.21+) — generisch und variadic
    fmt.Println(max(7, 4, 9, 2))
    fmt.Println(min(7, 4, 9, 2))
}
Output
Wert: 42 true 3.14
[1 2 3 4 5]
a-b-c
7
2

Wichtig ist die Abgrenzung im Beispiel: strings.Join nimmt einen fertigen []string als regulären Parameter — das ist kein Variadic. fmt.Println und append dagegen sind variadic — der Aufrufer schreibt die Werte einzeln, nicht als Slice. Beide Welten existieren parallel, und welche du in deinem Code wählst, hängt davon ab, ob die Aufrufer typischerweise einen Slice in der Hand haben oder einzelne Werte.

Im Inneren ist es ein Slice

Sobald die Funktion betreten wird, ist der Variadic-Parameter ein ganz normaler Slice. len, range, Index — alles funktioniert wie gewohnt:

Go inside_is_slice.go
package main

import "fmt"

func describe(nums ...int) {
    // nums ist []int
    fmt.Printf("Typ: %T, Länge: %d\n", nums, len(nums))

    // range funktioniert
    for i, n := range nums {
        fmt.Printf("  [%d] = %d\n", i, n)
    }

    // Index-Zugriff
    if len(nums) > 0 {
        fmt.Println("erstes Element:", nums[0])
    }
}

func main() {
    describe(10, 20, 30)
    fmt.Println("---")
    describe(7)
}
Output
Typ: []int, Länge: 3
  [0] = 10
  [1] = 20
  [2] = 30
erstes Element: 10
---
Typ: []int, Länge: 1
  [0] = 7
erstes Element: 7

Konsequenz: Alles, was du mit einem []T machen kannst, kannst du mit dem Variadic-Parameter machen. Sortieren, filtern, kopieren, an andere Funktionen weiterreichen — alles legal. Es ist kein magischer Tupel, sondern Standard-Go.

Zero-Argument-Aufruf — nil-Slice, kein Panic

Eine Variadic-Funktion darf ohne ein einziges Argument für den Variadic-Teil aufgerufen werden. Was kommt dann an? Ein nil-Slicelen == 0, aber identisch mit nil:

Go zero_args.go
package main

import "fmt"

func inspect(nums ...int) {
    fmt.Printf("len=%d  nil? %v  slice=%v\n",
        len(nums), nums == nil, nums)
}

func main() {
    inspect()              // gar kein Argument
    inspect(1)             // ein Argument
    inspect(1, 2, 3)       // drei Argumente
    inspect([]int{}...)    // Spread eines leeren (aber nicht-nil) Slice
    inspect([]int(nil)...) // Spread eines nil-Slice
}
Output
len=0  nil? true  slice=[]
len=1  nil? false  slice=[1]
len=3  nil? false  slice=[1 2 3]
len=0  nil? false  slice=[]
len=0  nil? true  slice=[]

Drei Beobachtungen, die hier zusammenkommen:

  • Beim direkten Zero-Argument-Aufruf (inspect()) ist nums == nil. Es gibt keinen allokierten Slice — der Compiler übergibt schlicht die Nil-Slice-Repräsentation.
  • Beim Spread eines leeren, aber nicht-nil Slice ([]int{}...) ist nums ebenfalls leer, aber nicht nil. Der Aufrufer entscheidet, was reicht.
  • len(nums) == 0 ist der robuste Check — er deckt beide Fälle ab. Wer nums == nil schreibt, schließt die zweite Variante aus.

Der Unterschied ist meistens irrelevant, schlägt aber bei encoding/json zu: ein nil-Slice wird zu null, ein leerer Slice zu []. Wer das durchreicht, muss aufpassen.

Die zwei Aufruf-Formen

Eine Variadic-Funktion kann auf zwei Wegen aufgerufen werden. Beide sind erlaubt, aber sie tun nicht dasselbe — und du darfst sie nicht mischen:

Go call_forms.go
package main

import "fmt"

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

func main() {
    // (A) Einzelne Argumente — der Compiler packt sie in einen neuen []int
    fmt.Println(sum(1, 2, 3))

    // (B) Spread — bestehenden Slice direkt weiterreichen
    values := []int{4, 5, 6, 7}
    fmt.Println(sum(values...))

    // Auch zulässig: gar kein Argument
    fmt.Println(sum())
}
Output
6
22
0

Form (A) — einzelne Argumente — ist das, was man im Alltag schreibt. Der Compiler legt für jeden Aufruf einen frischen []int an und steckt die Argumente hinein.

Form (B) — der Spread values... — sagt dem Compiler: „Ich habe schon einen []int, nimm den unverändert." Das ist effizient (keine Kopie der Elemente), hat aber eine wichtige Folge, die der nächste Abschnitt aufrollt.

Der Spread-Operator — und seine Aliasing-Falle

Wenn du slice... an eine Variadic-Funktion übergibst, ist der Parameter im Inneren der Funktion derselbe Slice wie außerhalb — gleiches Underlying-Array, gleiches len, gleiches cap. Es wird nicht kopiert. Das ist effizient, kann aber zur Überraschung führen:

Go spread_aliasing.go
package main

import "fmt"

func zeroAll(nums ...int) {
    // nums teilt das Underlying-Array mit dem Aufrufer
    for i := range nums {
        nums[i] = 0
    }
}

func main() {
    data := []int{10, 20, 30}
    fmt.Println("vor zeroAll:", data)
    zeroAll(data...)
    fmt.Println("nach zeroAll:", data)
    // Achtung: data wurde im Aufrufer verändert!

    // Ohne Spread — der Compiler legt einen FRISCHEN Slice an
    fmt.Println("---")
    data2 := []int{10, 20, 30}
    zeroAll(data2[0], data2[1], data2[2])
    fmt.Println("nach zeroAll (ohne Spread):", data2)
}
Output
vor zeroAll: [10 20 30]
nach zeroAll: [0 0 0]
---
nach zeroAll (ohne Spread): [10 20 30]

Das ist die zentrale Eigenheit: Spread aliasiert. Die Funktion sieht nicht nur die Werte, sondern hält denselben Slice-Header in der Hand. Wer den Slice verändert (in-place schreibt), trifft den Aufrufer.

In der Praxis ist das fast nie ein Problem — die meisten Variadic-Funktionen lesen nur. Aber sobald eine Funktion schreibt (sortiert, mutiert, vertauscht), musst du dir der Aliasing-Eigenschaft bewusst sein. Wenn die Funktion den Slice ohnehin mutieren darf, ist es performant. Wenn nicht, kopiere vor dem Aufruf:

Go spread_copy.go
// Defensiv kopieren, wenn der Aufrufer den Slice unverändert behalten will
snapshot := make([]int, len(data))
copy(snapshot, data)
zeroAll(snapshot...)
// data ist hier unverändert

Mischen verboten — Spread und Einzelargumente

Sobald du den Spread slice... benutzt, ist er das einzige Argument für den Variadic-Teil. Du kannst nicht zusätzlich einzelne Werte davor- oder dahinterstellen:

Go no_mixing.go
package main

func sum(nums ...int) int { return 0 }

func main() {
    tail := []int{2, 3, 4}

    // FEHLER: einzelne Werte + Spread sind nicht kombinierbar
    // sum(1, tail...)

    // KORREKT: vorher zusammenbauen
    all := append([]int{1}, tail...)
    sum(all...)

    // Oder einzeln, ohne Spread
    sum(1, 2, 3, 4)
}

Der Compiler-Fehler lautet „too many arguments in call" — vielleicht ist das nicht die offensichtlichste Meldung, aber er meint: Wenn tail... da steht, ist das schon das ganze Argument. Die 1 davor ist überzählig.

Das gilt natürlich nur für den variadic-Teil. Reguläre Parameter davor sind erlaubt und üblich:

Go fixed_plus_spread.go
func logf(level int, format string, args ...any) {}

func main() {
    params := []any{"alice", 42}

    // OK: zwei reguläre Argumente, dann der Spread für args
    logf(1, "user=%s age=%d", params...)
}

...any — die Variadic mit Universal-Typ

Eine der häufigsten Variadic-Signaturen in der Stdlib ist ...any (vor Go 1.18: ...interface{} — exakt dasselbe, nur als Alias kürzer). fmt.Println, fmt.Printf-Argumente, log.Print — alle nehmen ...any und akzeptieren damit Werte beliebigen Typs:

Go any_variadic.go
package main

import "fmt"

func dump(label string, vs ...any) {
    fmt.Printf("%s: %d Werte\n", label, len(vs))
    for i, v := range vs {
        fmt.Printf("  [%d] %T = %v\n", i, v, v)
    }
}

func main() {
    dump("mix", 42, "hallo", 3.14, true, []int{1, 2})
}
Output
mix: 5 Werte
  [0] int = 42
  [1] string = hallo
  [2] float64 = 3.14
  [3] bool = true
  [4] []int = [1 2]

Innerhalb der Funktion ist vs ein []any — jeder Wert ist in ein Interface verpackt, mit dem dynamischen Typ als Information. Das Auspacken passiert per Type Assertion oder Type Switch:

Go any_typeswitch.go
package main

import "fmt"

func classify(vs ...any) {
    for _, v := range vs {
        switch x := v.(type) {
        case int:
            fmt.Println("int:", x*2)
        case string:
            fmt.Println("string:", len(x), "Zeichen")
        case bool:
            fmt.Println("bool:", !x)
        default:
            fmt.Printf("anderer Typ %T\n", x)
        }
    }
}

func main() {
    classify(7, "hallo", true, 3.14)
}
Output
int: 14
string: 5 Zeichen
bool: false
anderer Typ float64

Eine subtile Falle bei ...any: Wenn du einen []string per Spread an eine ...any-Funktion übergeben willst, funktioniert das nicht direkt. Der Spread-Operator ist typ-strikt — er erwartet einen Slice des exakten Element-Typs des Variadic-Parameters:

Go any_spread_gotcha.go
package main

import "fmt"

func main() {
    names := []string{"alice", "bob"}

    // FEHLER: cannot use names (variable of type []string) as []any value
    // fmt.Println(names...)

    // KORREKT: explizit in []any umpacken
    args := make([]any, len(names))
    for i, n := range names {
        args[i] = n
    }
    fmt.Println(args...)
    // Output: alice bob

    // Alternative: einzelne Werte (kein Spread)
    fmt.Println(names[0], names[1])
}

[]string und []any sind in Go unterschiedliche Typen — auch wenn jedes String ein any ist. Die Konvertierung muss du Element für Element machen. Genau das ist einer der Gründe, warum Generics (siehe Abschnitt 11) bei vielen Variadic-Funktionen die bessere Wahl sind.

printf vs. println — die %v-vs-Spread-Falle

Eine spezifische Quelle für Verwirrung bei fmt: Wie übergibst du einen Slice an einen Format-String mit %v? Hier liefern Spread und Nicht-Spread völlig unterschiedliche Ergebnisse:

Go printf_spread.go
package main

import "fmt"

func main() {
    nums := []int{1, 2, 3}

    // (1) Slice als EIN Wert übergeben — %v formatiert den Slice
    fmt.Printf("ohne Spread: %v\n", nums)

    // (2) Slice spreaden — die Elemente werden einzeln zu Argumenten
    fmt.Printf("mit Spread:  %v %v %v\n", nums...)

    // (3) FALSCH: ein Format-Verb, drei Argumente
    //     fmt: %!(EXTRA int=2, int=3) Hinweis
    fmt.Printf("nur ein %%v:  %v\n", nums...)
}
Output
ohne Spread: [1 2 3]
mit Spread:  1 2 3
nur ein %v:  1%!(EXTRA int=2, int=3)

Merkregel: %v druckt ein Argument. Wer einen Slice komplett darstellen will, übergibt den Slice — ohne Spread. Wer die Elemente einzeln in einen Format-String einsetzen will, spreadt und sorgt für die passende Anzahl Format-Verben.

Performance — die unsichtbare Allokation

Jeder Aufruf einer Variadic-Funktion mit einzelnen Argumenten erzeugt logisch einen neuen Slice. In der Praxis erkennt der Go-Compiler oft, dass dieser Slice nicht aus der Funktion entkommt, und legt ihn auf dem Stack ab (Escape-Analyse) — dann ist die Allokation gratis. Wenn der Slice aber escaped (etwa weil die Funktion ihn in einem Goroutine-Closure oder einer globalen Datenstruktur speichert), passiert eine echte Heap-Allokation:

Go variadic_alloc.go
package main

import "fmt"

// Stack-OK: nums wird nur gelesen, escapet nicht
func sumS(nums ...int) int {
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}

// Escape: nums verlässt die Funktion via Closure
var stash func() []int

func capture(nums ...int) {
    stash = func() []int { return nums }
}

func main() {
    // Im Hot-Path-Code lohnt sich ein Blick auf:
    //   go build -gcflags="-m" ./...
    fmt.Println(sumS(1, 2, 3))
    capture(10, 20)
    fmt.Println(stash())
}

Was du im normalen Anwendungscode mitnehmen solltest:

  • Für 99 % der Aufrufe ist die Variadic-Form frei. Der Compiler räumt auf, der Profiler zeigt nichts.
  • In Hot Paths (tight Loops, Hochfrequenz-Logging) kann die Allokation messbar werden — dann lohnt sich entweder eine func(s []T)-Variante daneben oder die direkte Übergabe eines vorbereiteten Slice per Spread.
  • Beim Spread (slice...) entsteht keine zusätzliche Allokation — der bestehende Slice wird weitergereicht.
  • Verifiziere im Zweifel mit go build -gcflags="-m" ./... (zeigt Escape-Entscheidungen) und go test -bench=. -benchmem.

Praxis-Patterns

Variadic-Funktionen erscheinen vor allem in drei wiederkehrenden Mustern.

Option-Pattern. Statt einer Funktion mit zehn Parametern bietest du eine Variadic-Liste von Option-Werten an. Der Aufrufer setzt nur, was er ändern will:

Go option_pattern.go
package main

import "fmt"

type Server struct {
    Host    string
    Port    int
    TLS     bool
    Timeout int
}

type Option func(*Server)

func WithPort(p int) Option       { return func(s *Server) { s.Port = p } }
func WithTLS(tls bool) Option     { return func(s *Server) { s.TLS = tls } }
func WithTimeout(sec int) Option  { return func(s *Server) { s.Timeout = sec } }

// Variadic: beliebig viele Optionen, in beliebiger Reihenfolge
func NewServer(host string, opts ...Option) *Server {
    s := &Server{Host: host, Port: 8080, Timeout: 30}
    for _, opt := range opts {
        opt(s)
    }
    return s
}

func main() {
    s := NewServer("localhost", WithPort(9000), WithTLS(true))
    fmt.Printf("%+v\n", s)
}
Output
&{Host:localhost Port:9000 TLS:true Timeout:30}

Logging-Wrapper. Einen Logger in den Lärm der App hineinreichen — und mit Variadic die args... direkt durchschleifen:

Go log_wrapper.go
package main

import "fmt"

func Info(format string, args ...any) {
    fmt.Printf("[INFO] "+format+"\n", args...)
}

func Warn(format string, args ...any) {
    fmt.Printf("[WARN] "+format+"\n", args...)
}

func main() {
    Info("user %q logged in (age=%d)", "alice", 30)
    Warn("disk usage at %d%%", 92)
}
Output
[INFO] user "alice" logged in (age=30)
[WARN] disk usage at 92%

Die Stelle args... ist der typische Durchschleifer: Der Wrapper nimmt sein eigenes ...any und gibt es per Spread an fmt.Printf weiter — kein Auspacken, keine Kopie.

Aggregat-Funktionen. Klassisch sum, max, min, concat — alle profitieren von der einfachen Variadic-Syntax:

Go aggregates.go
package main

import "fmt"

func concat(parts ...string) string {
    total := 0
    for _, p := range parts {
        total += len(p)
    }
    buf := make([]byte, 0, total)
    for _, p := range parts {
        buf = append(buf, p...)
    }
    return string(buf)
}

func main() {
    fmt.Println(concat("Hallo", " ", "Welt", "!"))
}
Output
Hallo Welt!

Generics + Variadic — typsicher seit Go 1.18

Seit Go 1.18 lassen sich Variadic-Parameter mit Type-Parameters kombinieren. Das löst den any-Typverlust und gibt dem Aufrufer echten Compiler-Schutz:

Go generic_variadic.go
package main

import (
    "cmp"
    "fmt"
)

// T muss vergleichbar (Ordered) sein — int, float, string, ...
func Min[T cmp.Ordered](first T, rest ...T) T {
    m := first
    for _, v := range rest {
        if v < m {
            m = v
        }
    }
    return m
}

func main() {
    fmt.Println(Min(7, 4, 9, 2))             // int
    fmt.Println(Min("banana", "apfel", "c")) // string
    fmt.Println(Min(3.14, 2.71, 1.41))       // float64
}
Output
2
apfel
1.41

Der Trick first T, rest ...T: Mindestens ein Wert ist Pflicht (sonst hätte Min() keinen vernünftigen Rückgabewert), weitere sind variadic. Genau diese Signatur nutzen die eingebauten max und min seit Go 1.21.

Wenn du Min[int](nums...) mit einem []int aufrufen willst: Der Spread funktioniert für den variadic-Teil — der erste Pflicht-Parameter muss aber separat herausgezogen werden, was an der Aufruf-Stelle etwas hässlich wird. In der Praxis ist die func Min[T cmp.Ordered](vs ...T) T-Variante ohne Pflicht-Parameter oft pragmatischer; sie muss dann len(vs) == 0 selbst abfangen.

Häufige Stolperfallen

Spread teilt das Underlying-Array — Mutation im Callee trifft den Caller.

f(slice...) reicht den Slice-Header 1:1 weiter — keine Kopie, gleiches Backing-Array. Wer in der Funktion in den Slice schreibt (nums[i] = 0, sort.Ints(nums)), verändert die Daten beim Aufrufer. Lese-only-Variadics sind safe, schreibende brauchen eine defensive copy davor.

Mit Spread können keine zusätzlichen Variadic-Argumente kommen.

sum(1, tail...) ist Compile-Fehler. Sobald tail... als variadic-Argument auftaucht, ist es das Argument — keine Mischformen erlaubt. Lösung: vorher append([]int{1}, tail...) und das Ergebnis spreaden, oder ganz auf den Spread verzichten.

Nur ein Variadic-Parameter, immer am Ende.

func f(a ...int, b ...string) ist Syntax-Fehler. func f(parts ...string, suffix string) ebenso. Wer mehrere variable Listen braucht, nimmt zwei reguläre Slice-Parameter (a []int, b []string) — dann liegt der Aufruf eben in f([]int{1, 2}, []string{"x"}).

Zero-Argument-Aufruf liefert nil-Slice (nicht leeren Slice).

f() ohne Argumente: nums == nil ist true, len(nums) == 0. Beim Spread eines leeren Slice (f([]int{}...)) dagegen ist nums nicht-nil. Bei encoding/json wird das sichtbar: nil-Slice serialisiert zu null, leerer Slice zu []. Für robuste Checks len(nums) == 0 schreiben, nicht nums == nil.

fmt.Printf("%v", slice) ist nicht dasselbe wie fmt.Printf("%v", slice...).

Ohne Spread: ein Format-Verb, ein Argument (der ganze Slice — Output: [1 2 3]). Mit Spread: ein Format-Verb pro Element. Wer die Anzahl der %vs nicht anpasst, kriegt %!(EXTRA ...)-Hinweise. Bei Logging-Wrappers ist format+"\n", args... der korrekte Durchschleifer.

append(s, t...) ist ein Variadic-Aufruf, kein Sprach-Operator.

append ist eine ganz normale variadic-Funktion mit Signatur append(slice []T, elems ...T) []T. Der Spread t... greift hier die Element-Variante — ohne ihn würdest du den Slice t als ein Element anhängen wollen, was Typ-Fehler ergibt. Verstehen, warum der Spread da steht, hilft beim Lesen der Stdlib.

Variadic auf Methoden mit Pointer-Receiver ist erlaubt.

Es gibt keinen Unterschied zwischen Funktion und Methode in Sachen Variadic. func (s *Server) Log(args ...any) ist legal und üblich. Auch hier gilt: nur ein Variadic-Parameter, immer am Ende der Methodensignatur.

Jede Aufruf-Stelle kann eine Allokation kosten.

Bei einzelnen Argumenten legt der Compiler logisch einen []T an. Escape-Analyse räumt das meistens auf den Stack — aber in Hot Paths kann es zählen. go build -gcflags=&quot;-m&quot; zeigt es; -benchmem misst es. Der Spread slice... ist allokationsfrei (gleicher Slice-Header).

...any verliert die Typ-Information — bei vielen Args sind Generics besser.

func f(vs ...any) verpackt jedes Element in ein Interface. Das kostet ein Boxing pro Wert (mehr Allokationen, langsamerer Zugriff) und der Compiler kann am Aufruf-Ort nicht prüfen, ob die Typen passen. Wenn alle Argumente denselben Typ haben sollen, ist func f[T any](vs ...T) typsicher und meist schneller. fmt-Familie bleibt aus Kompatibilitätsgründen bei ...any.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Funktionen

Zur Übersicht