Go reicht jedes Funktions-Argument by value weiter — die aufgerufene Funktion arbeitet auf einer Kopie. Das ist eine bewusste Sprach-Entscheidung: Sie macht Datenfluss lokal, sichtbar und frei von Action-at-a-Distance. Wer diese Semantik gezielt aufheben will, deklariert den Parameter als Pointer. Drei Gründe rechtfertigen diesen Schritt: Mutation durch die Funktion soll für den Aufrufer sichtbar sein, ein großer Wert soll ohne Kopie weitergegeben werden, oder ein optionales Argument soll per nil als „nicht gesetzt" markiert werden. Dieser Artikel arbeitet alle drei Fälle sauber durch, klärt die Verbindung zur Receiver-Konvention für Methoden, beleuchtet die Performance-Frage mit einer Mess-Demo und macht die häufigsten Anti-Patterns explizit.

Default-Mechanik — Pass-by-Value

Die Go-Spec hält sich an einer Stelle knapp, an einer anderen explizit. Der Calls-Abschnitt nennt das Ergebnis: Argumente werden den Parametern zugewiesen, bevor die Funktion läuft. Aus dem Type-System-Teil:

Values of predeclared types, arrays, and structs are self-contained: Each such value contains a complete copy of all its data, and variables of such types store the entire value.

Die Konsequenz: Jeder Aufruf produziert eine Kopie. Wer n an f(x int) reicht, übergibt eine eigene int-Variable, deren Anfangswert dem aktuellen Wert von n entspricht. Was die Funktion daran ändert, bleibt in ihrem Frame.

Für vier Typ-Familien ist diese Kopie fast kostenlos — sie tragen intern einen kleinen Header, der seinerseits auf die eigentlichen Daten zeigt:

TypKopierte Größe (64-Bit)Geteilte Daten dahinter
string16 Byte (Header)Backing-Array (read-only)
[]T (Slice)24 Byte (Header)Backing-Array (mutierbar!)
map[K]V8 Byte (Pointer)Hash-Tabelle (mutierbar)
chan T8 Byte (Pointer)Channel-Runtime (mutierbar)
func(...) ...8 Byte (Pointer)Closure-Body
interface{}16 Byte (Header)dynamischer Wert

Genau diese Typen sind in Go „Referenz-Typen im Pass-by-Value-Mantel": Der Header wird kopiert, das Ziel hinter dem Header nicht. Wer einen Slice mutiert, schreibt durch den Header in das gemeinsame Backing-Array — das sieht der Aufrufer. Diese Eigenschaft ist im Abschnitt zu den Anti-Patterns weiter unten der entscheidende Punkt.

Der Beweis — Mutation ohne Pointer geht nicht

Eine simple Demo zeigt die Default-Semantik. Eine Funktion versucht, ihr Argument zu verändern — der Aufrufer sieht nichts davon:

Go value-only.go
package main

import "fmt"

func increment(n int) {
    n++ // mutiert nur die lokale Kopie
    fmt.Println("in increment: n =", n)
}

func main() {
    x := 10
    increment(x)
    fmt.Println("in main:      x =", x)
}
Output
in increment: n = 11
in main:      x = 10

Die Funktion sieht innerhalb ihres Frames das erwartete 11. Der Aufrufer behält 10. Das ist nicht ein Bug, sondern die Default-Semantik — und in den meisten Fällen genau das, was du willst, weil es Funktions-Aufrufe lokal denkbar macht.

Wer trotzdem will, dass die Funktion x ändert, hat in Go zwei Wege: das Ergebnis zurückgeben und vom Aufrufer wieder zuweisen — oder einen Pointer-Parameter verwenden. Die Konvention bevorzugt die Rückgabe-Variante, weil sie expliziter ist; der Pointer kommt ins Spiel, wenn Mutation wirklich der Punkt der Funktion ist.

Pointer-Parameter für sichtbare Mutation

Mit einem *int-Parameter passiert genau das Gewünschte: Die Funktion bekommt eine Kopie des Pointers, aber dieser Pointer zeigt auf dieselbe Variable im Aufrufer-Frame. Ein Schreibzugriff durch den Pointer ändert das Original:

Go pointer-mutation.go
package main

import "fmt"

func increment(n *int) {
    *n++ // schreibt durch den Pointer in die Aufrufer-Variable
    fmt.Println("in increment: *n =", *n)
}

func main() {
    x := 10
    increment(&x) // Adresse von x mitgeben
    fmt.Println("in main:      x =", x)
}
Output
in increment: *n = 11
in main:      x = 11

Drei Beobachtungen:

  • Der Parameter-Typ ist *int, nicht int. Die Signatur dokumentiert die Mutations-Absicht für jeden Leser.
  • Der Aufrufer reicht &x, nicht x. Auch das ist sichtbar — niemand kann versehentlich eine Mutation triggern, ohne den Adress-Operator zu schreiben.
  • Innerhalb der Funktion steht *n (Dereferenzierung), nicht n. Das n allein wäre der Pointer-Wert.

Damit ist der Mutations-Kontrakt sowohl in der Signatur als auch am Aufruf-Ort offensichtlich. Das ist Gos Antwort auf C++-Referenz-Parameter, die für den Leser unsichtbar sind: Go bevorzugt explizite Syntax über Sprach-Bequemlichkeit.

Struct-Mutation — der typische Praxis-Fall

Bei int ist die Rückgabe-Variante meist schöner. Spannender wird Pointer-Mutation bei Structs — etwa einer Config, deren einzelne Felder von verschiedenen Funktionen gesetzt werden:

Go struct-mutation.go
package main

import "fmt"

type Config struct {
    Host    string
    Port    int
    Timeout int
}

// Ohne Pointer — Mutation versickert
func setPortValue(c Config, port int) {
    c.Port = port // ändert nur die Kopie
}

// Mit Pointer — Mutation am Original
func setPortPtr(c *Config, port int) {
    c.Port = port // Go entzuckert das zu (*c).Port = port
}

func main() {
    c := Config{Host: "localhost", Port: 80, Timeout: 30}

    setPortValue(c, 8080)
    fmt.Printf("nach setPortValue: %+v\n", c)

    setPortPtr(&c, 8080)
    fmt.Printf("nach setPortPtr:   %+v\n", c)
}
Output
nach setPortValue: {Host:localhost Port:80 Timeout:30}
nach setPortPtr:   {Host:localhost Port:8080 Timeout:30}

Bemerkenswert: Beim Pointer-Parameter steht im Funktions-Body trotzdem c.Port, nicht (*c).Port. Go nennt das Selector Auto-Dereferenz — der Compiler ergänzt das * für dich, wenn der Selektor links ein Pointer ist. Die Spec formuliert es so:

For a value x of type T or *T where T is not a pointer or interface type, x.f denotes the field or method at the shallowest depth in T where there is such an f.

Heißt: c.Port funktioniert sowohl auf Config als auch auf *Config. Das macht die Konvertierung von Value- zu Pointer-Receivern (oder umgekehrt) in einem Refactoring fast geräuschlos — der Body bleibt identisch, nur die Signatur ändert sich.

Performance — Kopie vs. Pointer-Übergabe

Der zweite Grund für Pointer-Parameter ist die Vermeidung der Kopie bei großen Werten. Ein Struct mit zehn Strings, drei Slice-Headern und ein paar int64-Feldern kann schnell 200–300 Byte groß werden — bei jedem Funktions-Aufruf komplett zu kopieren, kostet messbar Zeit und Cache.

Faustregel aus dem Go-Code-Review-Comments-Dokument:

If the receiver is a large struct or array, a pointer receiver is more efficient.

Was „groß" konkret heißt, ist plattform- und workload-abhängig. Der oft zitierte Schwellenwert liegt bei etwa 64–128 Byte (eine bis zwei Cache-Lines), unterhalb dessen die Kopie meist günstiger ist als die Indirection durch einen Pointer. Aber: messen statt raten.

Ein einfacher Benchmark macht den Unterschied sichtbar:

Go bench_test.go
package main

import "testing"

type Big struct {
    A [128]int64 // 1024 Byte
}

func sumValue(b Big) int64 {
    var s int64
    for _, v := range b.A {
        s += v
    }
    return s
}

func sumPtr(b *Big) int64 {
    var s int64
    for _, v := range b.A {
        s += v
    }
    return s
}

func BenchmarkValue(b *testing.B) {
    x := Big{}
    for i := 0; i < b.N; i++ {
        _ = sumValue(x)
    }
}

func BenchmarkPtr(b *testing.B) {
    x := Big{}
    for i := 0; i < b.N; i++ {
        _ = sumPtr(&x)
    }
}
Output
BenchmarkValue-8   3000000   420 ns/op
BenchmarkPtr-8    20000000    62 ns/op

Bei diesem 1024-Byte-Struct ist die Pointer-Variante rund sieben Mal schneller — die Kopie dominiert die eigentliche Arbeit. Bei einem 16-Byte-Struct wäre der Unterschied umgekehrt oder vernachlässigbar.

Was du daraus mitnimmst: Optimiere nicht im Voraus, sondern miss bei verdächtig großen Strukturen. go test -bench=. ist eine Zeile Aufwand und gibt belastbare Zahlen.

Optional/Nullable — nil als „kein Wert"

Der dritte legitime Grund für Pointer-Parameter ist die Darstellung von Optionalität. Go kennt kein Option<T> und kein null für Value-Typen — wer einer Funktion „kein Wert" mitgeben will, hat zwei Wege: ein zweites bool-Argument („vorhanden") oder einen Pointer, dessen nil für „nicht gesetzt" steht:

Go optional.go
package main

import "fmt"

// timeout == nil  →  Default verwenden
// timeout != nil  →  Wert übernehmen
func openWithDefault(host string, timeout *int) {
    t := 30 // Default
    if timeout != nil {
        t = *timeout
    }
    fmt.Printf("connect %s (timeout=%ds)\n", host, t)
}

func intPtr(v int) *int { return &v }

func main() {
    openWithDefault("api.local", nil)        // Default greift
    openWithDefault("api.local", intPtr(5))  // expliziter Wert
}
Output
connect api.local (timeout=30s)
connect api.local (timeout=5s)

Das Pattern ist üblich in REST-Client-Bibliotheken (JSON-Felder, die fehlen dürfen) und in der encoding/json-Stdlib selbst: Ein *int-Feld in einem Struct unterscheidet „nicht angegeben" (nil) von „explizit 0" (Pointer auf 0). Mit einem reinen int ginge diese Unterscheidung verloren.

Die Kehrseite: Jeder Zugriff braucht einen nil-Check, sonst paniciert die Dereferenzierung. Das ist der häufigste Bug rund um Optional-Pointer — Details im nil-Pointer-Artikel.

**T — der seltene Fall des Pointer-Pointers

Eine kuriose, aber gelegentlich nützliche Konstruktion: ein Parameter vom Typ **T. Den brauchst du, wenn die Funktion den Pointer selbst umsetzen soll — nicht den Wert, auf den er zeigt. Typischer Anwendungsfall: das Einfügen am Anfang einer Linked List, das Auflösen eines Allocator-Handles, oder das Tauschen zweier Pointer.

Go ptr-to-ptr.go
package main

import "fmt"

type Node struct {
    Val  int
    Next *Node
}

// Setzt den Head der Liste um — head selbst muss veränderbar sein
func pushFront(head **Node, val int) {
    *head = &Node{Val: val, Next: *head}
}

func main() {
    var head *Node
    pushFront(&head, 3)
    pushFront(&head, 2)
    pushFront(&head, 1)

    for n := head; n != nil; n = n.Next {
        fmt.Printf("%d ", n.Val)
    }
    fmt.Println()
}
Output
1 2 3 

Würdest du head *Node statt head **Node deklarieren, könnte pushFront die Knoten erreichen, aber nicht die Variable head im Aufrufer umsetzen — der Head bliebe nil. Die zweite Indirektions-Stufe ist genau dafür da.

In der Praxis sieht man **T selten. Wenn du es im Anwendungs-Code findest, lohnt sich meist die Frage, ob ein Wrapper-Struct (List mit Methode PushFront) nicht die idiomatischere Lösung wäre.

Receiver-Konvention — derselbe Mechanismus

Ein Method-Receiver ist semantisch nichts anderes als ein erster Parameter — die gleichen Regeln für Pass-by-Value gelten. func (c Config) Get() ... arbeitet auf einer Kopie, func (c *Config) Set(...) auf dem Original:

Go receiver.go
package main

import "fmt"

type Counter struct{ N int }

// Value-Receiver — kann nicht mutieren
func (c Counter) View() int { return c.N }

// Pointer-Receiver — mutiert
func (c *Counter) Inc() { c.N++ }

func main() {
    c := Counter{}
    c.Inc() // Go entzuckert zu (&c).Inc()
    c.Inc()
    c.Inc()
    fmt.Println("N =", c.View())
}
Output
N = 3

Effective Go formuliert die Regel kompakt:

Value methods can be invoked on pointers and values, but pointer methods can only be invoked on pointers.

Mit einer Konvenienz-Ausnahme: Wenn der Wert adressierbar ist, ergänzt der Compiler das & automatisch — c.Inc() wird zu (&c).Inc(). Bei nicht-adressierbaren Werten (Map-Element, Funktions-Return, Interface-Wert) geht das nicht, dann brauchst du eine Variable.

Die zweite Konvention aus den Code-Review-Comments — nicht mischen:

Don't mix receiver types. Choose either pointers or struct types for all available methods.

Heißt: Wenn auch nur eine Methode eines Typs einen Pointer-Receiver braucht (typischerweise eine mutierende Methode), bekommen alle Methoden des Typs Pointer-Receiver. Das vermeidet subtile Bugs rund um Method-Sets und Interface-Implementierungen.

Wann KEIN Pointer-Parameter

Pointer-Parameter sind nicht „immer schneller" oder „immer flexibler". Mehrere Typen tragen ihre Indirection schon eingebaut — sie per Pointer weiterzureichen ist redundant und meist ein Code-Smell.

TypPointer-Parameter sinnvoll?Grund
int, bool, kleine Structs (≤ 16 B)SeltenKopie ist günstiger als Indirection
stringNeinHeader ist 16 Byte, Backing-Array immutable
[]T (Slice)Nur wenn neu zugewiesenHeader trägt schon Pointer auf Daten
map[K]VNeinMap-Wert ist intern bereits ein Pointer
chan TNeinChannel-Wert ist intern bereits ein Pointer
func(...) ...NeinFunction-Value ist 8 Byte Pointer auf Closure
interface{} / anyNeinInterface-Header ist bereits indirekt
große Structs (> 64 B)JaKopie wäre teuer
Struct, das mutiert werden sollJaMutation muss durchschlagen

Zwei Beispiele für die roten Zeilen, die in echten Code-Bases immer wieder auftauchen:

Go bad-patterns.go
// Anti-Pattern: Pointer auf Slice — fast immer überflüssig
func appendAll(dst *[]int, src []int) {
    *dst = append(*dst, src...)
}
// Besser: Slice zurückgeben (wie append() selbst es macht)
func appendAllGood(dst []int, src []int) []int {
    return append(dst, src...)
}

// Anti-Pattern: Pointer auf Map
func put(m *map[string]int, k string, v int) {
    (*m)[k] = v
}
// Besser: Map direkt nehmen — sie ist intern schon ein Pointer
func putGood(m map[string]int, k string, v int) {
    m[k] = v
}

Die einzige legitime Stelle für *[]T ist die Funktion, die den Slice-Header selbst umsetzen muss — typischerweise weil sie append aufruft und das Ergebnis (möglicherweise ein neues Backing-Array) sichtbar machen will. Auch da bevorzugt idiomatisches Go aber den Return-Stil von append.

Sicherheits-Aspekt — Mutation klar dokumentieren

Ein Pointer-Parameter ist eine Erlaubnis zur Mutation am Caller-Daten. Das ist mächtig — und beim Lesen leicht zu übersehen, wenn die Signatur nicht spricht. Zwei Konventionen helfen, das beherrschbar zu halten:

Methodenname spricht die Mutation aus. Set, Update, Apply, Reset, Inc, Append, Fill sind Verben, die einen Pointer-Parameter erwarten lassen. Get, View, Compute, Find, Sum versprechen Reinheit — in deren Signatur sollte kein *T auftauchen, der mutiert.

GoDoc-Kommentar nennt den Effekt. Wenn eine Funktion ihren Parameter mutiert, sagt der Doc-Kommentar es:

Go documented-mutation.go
// ApplyDefaults füllt leere Felder in cfg mit Default-Werten.
// Mutiert *cfg in place; cfg darf nicht nil sein.
func ApplyDefaults(cfg *Config) {
    if cfg.Host == "" {
        cfg.Host = "localhost"
    }
    if cfg.Port == 0 {
        cfg.Port = 8080
    }
}

Wer die Doku liest, weiß: Nach dem Aufruf ist die Caller-Variable verändert. Ohne diesen Hinweis muss der Leser den Body verstehen, um den Kontrakt zu sehen — und das ist genau die Stelle, an der Bugs entstehen.

Stilistisch besser, wenn machbar: Return statt Mutate. Ein neuer Config-Wert zurückgeben ist explizit, kopiert aber den ganzen Struct. Bei kleinen Structs lohnt der Tausch fast immer; bei großen oder zustandsbehafteten überwiegt der Pointer-Stil.

Häufige Stolperfallen

Pointer auf Slice, Map oder Channel — meistens Anti-Pattern.

Slice, Map und Channel tragen intern bereits einen Pointer auf ihre Daten. *[]T, *map[K]V oder *chan T als Parameter braucht es nur, wenn die Funktion den Header selbst umsetzen muss — etwa weil sie append aufruft und das Ergebnis zurückreichen will. In 95 % der Fälle reicht der Wert.

nil-Pointer-Parameter ohne Check paniciert beim Zugriff.

Wer *T-Parameter akzeptiert, muss am Funktions-Anfang prüfen, ob er nil ist — sonst wird der erste Feld-Zugriff zum invalid memory address or nil pointer dereference. Bei Optional-Pointern ist nil ein legaler Eingabe-Wert, bei Mutations-Pointern oft ein Caller-Bug, der per panic oder return error behandelt wird.

Pointer-Parameter erlaubt dem Callee, Caller-Daten zu verändern — klar dokumentieren.

Ein *T in der Signatur ist eine Mutations-Lizenz. Das ist mächtig und beim Lesen leicht zu übersehen. Konvention: Verb-Methodenname (Set, Update) und ein GoDoc-Satz, der die Mutation explizit nennt (mutates *cfg in place). Reine Funktionen sollten nie *T-Parameter mutieren — wenn sie es trotzdem tun, ist es ein Bug.

Receiver-Typen nicht mischen — alle Methoden eines Typs gleich.

Sobald eine Methode Pointer-Receiver braucht (mutiert oder großer Struct), bekommen alle Methoden des Typs Pointer-Receiver. Mixed-Receivers führen zu subtilen Method-Set-Bugs: Ein T (nicht *T) erfüllt nur die Value-Methoden, nicht die Pointer-Methoden — Interface-Checks scheitern dann lautlos in einem Branch und nicht im anderen.

sync.Mutex per Value passed = neuer Mutex.

Klassiker, der unbemerkt durchgeht: Ein Struct mit eingebettetem sync.Mutex wird per Wert weitergegeben, jede Kopie hat ihren eigenen Mutex, die Synchronisierung ist gebrochen. go vet warnt mit passes lock by value. Regel: Typen mit Mutex (oder anderen Sync-Primitiven) werden immer per Pointer übergeben.

Pointer-Mutate vs. Value-Return — idiomatisches Go bevorzugt Return.

Wo die Kopie nicht weh tut, ist Rückgabe lesbarer: cfg = WithDefaults(cfg) macht den Datenfluss sichtbar. ApplyDefaults(&cfg) versteckt ihn in der Signatur. Pointer-Mutation greift, wenn die Kopie teuer wäre, wenn der Typ Identität hat (Logger, Mutex-Träger) oder wenn die Mutation Teil des Methoden-Namens ist.

**T ist selten richtig — meistens ein Refactoring-Signal.

Pointer-to-Pointer als Parameter braucht es für Linked-List-Inserts oder Allocator-Handles. Wer es im Anwendungs-Code findet, sollte fragen, ob ein Wrapper-Struct mit Methode (z. B. List.PushFront) nicht klarer wäre. Drei Sterne (***T) sind in fast 100 % der Fälle ein Bug.

Pointer-Receiver-Methode braucht adressierbaren Wert beim Aufruf.

m["k"].Inc() schlägt fehl, wenn Inc Pointer-Receiver ist — Map-Elemente sind nicht adressierbar, der Compiler kann das & nicht automatisch einfügen. Workaround: Wert in eine Variable holen, mutieren, zurückschreiben. Oder den Map-Wert direkt als *T speichern.

Generics: Type-Parameter T vs. *T — Pointer-Container muss explizit sein.

Eine generische Funktion func F[T any](x T) bekommt T per Wert, selbst wenn der Aufrufer einen Pointer-Typ instanziiert hat (dann ist T eben *Foo, und der Pointer wird kopiert — das Ziel bleibt geteilt). Wer Mutation am instanziierten Typ erzwingen will, deklariert func F[T any](x *T) — dann ist der Caller verpflichtet, eine adressierbare Variable mitzugeben.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Pointer

Zur Übersicht