Scope beantwortet die Frage: „An welchen Stellen im Code ist ein bestimmter Name sichtbar?" In Go ist die Antwort glücklicherweise einfach — die Sprache kennt nur fünf Scope-Ebenen, und alle folgen lexikalischer Bindung: was du im Code schreibst, gilt im jeweils umschließenden Block. Es gibt kein dynamisches Scoping, keine versteckten Capturing-Regeln wie in JavaScript, kein var-vs-let-Doppel wie in modernem JavaScript. Aber: Wer die Ebenen nicht klar im Kopf hat, läuft in zwei spezifische Fallen — Shadowing (eine innere Variable verdeckt eine äußere) und Export-Sichtbarkeit (Großschreibung entscheidet, ob ein Identifier außerhalb des Pakets benutzbar ist). Dieser Artikel arbeitet alle fünf Scope-Ebenen sauber durch und macht die Stolperfallen explizit.
Die fünf Scope-Ebenen
Go ordnet jeden Identifier (Variable, Konstante, Typ, Funktion, Label, Paketname) genau einem Block zu. Blocks sind ineinander geschachtelt, und ein Identifier ist sichtbar vom Punkt seiner Deklaration bis zum Ende des einschließenden Blocks. Die Ebenen — von außen nach innen:
| Ebene | Wer wohnt hier | Lebensdauer |
|---|---|---|
| Universe Block | Predeclared Identifiers (int, string, true, nil, len, make, panic, ...) | Immer und überall |
| Package Block | Alle Top-Level-Deklarationen einer Source-Datei (var, const, type, func außerhalb von Funktionen) | Gesamtes Paket |
| File Block | Importierte Paketnamen (Scope = nur die .go-Datei, die den import enthält) | Diese Datei |
| Function Block | Parameter, Result-Variablen, Receiver | Gesamter Funktions-Rumpf |
| Local Block | Alle Variablen innerhalb von { ... }, inkl. if/for/switch-Initialisierungen | Bis zur schließenden } |
Das ist die gesamte Liste. Es gibt keine modulweiten Geheimsphären, keine Closures mit eigener Scope-Klasse, kein „static" wie in C. Wer das im Hinterkopf hat, weiß bei jedem Identifier sofort, wo er gilt.
Universe Block — die eingebauten Identifier
Der Universe Block ist Gos „Welt": alles, was vorab existiert, lebt hier. Die Spec listet die predeclared Identifier auf — du musst sie nicht importieren, sie sind einfach da:
// Typen
bool byte complex64 complex128 error float32 float64
int int8 int16 int32 int64
rune string
uint uint8 uint16 uint32 uint64 uintptr
any comparable
// Konstanten
true false iota
// Wert
nil
// Eingebaute Funktionen
append cap clear close complex copy delete imag len
make max min new panic print println real recoverPraktisch heißt das: du kannst eine eigene Variable namens int deklarieren — und damit das eingebaute int für den lokalen Block überdecken. Das ist die ultimative Shadowing-Falle. Lint-Tools wie revive und staticcheck warnen davor:
package main
import "fmt"
func main() {
int := "ich bin eine Variable" // <-- legal, aber schlecht
fmt.Println(int)
// var x int // jetzt nicht mehr möglich — int ist eine string-Variable
// staticcheck warnt: "should not use built-in type int as name for variable"
}ich bin eine VariableNiemals predeclared Identifier überschreiben. Auch nicht len, new, error — das produziert sehr verwirrende Folgefehler.
Package Block und Export-Regel
Alles, was auf Top-Level einer Source-Datei steht (var, const, type, func außerhalb einer Funktion), landet im Package Block — sichtbar in allen Dateien desselben Pakets, unabhängig davon, in welcher .go-Datei es deklariert wurde:
package server
var defaultPort = 8080 // klein -> nur paket-intern
const Version = "1.0.0" // groß -> auch außerhalb sichtbar
type Config struct { // groß -> exportiert
Host string // groß -> exportiertes Feld
port int // klein -> nur paket-intern
}
func New() *Config { return &Config{Host: "localhost"} } // groß -> exportiert
func validate() bool { return true } // klein -> nur paket-internHier kommt die Go-spezifische Mechanik: Groß- oder Kleinschreibung des ersten Buchstabens entscheidet über den Export.
- Großbuchstabe (
Config,Version,Host,New) — Identifier ist exportiert. Andere Pakete können ihn alsserver.Config,server.Version,server.New()ansprechen. - Kleinbuchstabe (
defaultPort,port,validate) — Identifier ist package-private. Außerhalb des Pakets nicht zugreifbar — auch nicht über Reflection (außer mit Hacks).
Diese Regel gilt auch innerhalb von Structs: ein klein geschriebenes Feld ist nur im eigenen Paket les- und schreibbar. Das ist Gos einziger Sichtbarkeits-Mechanismus — es gibt kein private/public/protected-Schlüsselwort.
File Block — Importe gelten pro Datei
Ein Detail, das viele übersehen: Importe gelten nicht paket-weit, sondern nur in der Datei, in der sie stehen. Wenn a.go import "fmt" schreibt und b.go ebenfalls fmt braucht, muss b.go den Import wiederholen. Aliase wirken nur in der Datei, in der sie definiert sind:
package myapp
import f "fmt"
func Hello() { f.Println("hi") }package myapp
import "fmt" // muss neu deklariert werden
// f.Println("...") // <-- Fehler, f ist nur in a.go bekannt
func World() { fmt.Println("world") }Das hat zwei praktische Konsequenzen:
goimportsund IDEs verwalten Imports pro Datei. Wer einen Import alphabetisch sortiert hat, freut sich, wenn das so bleibt — und es bleibt so, weil jede Datei ihre Liste eigenständig hat.- Import-Aliase haben keinen paketweiten Effekt. Das ist Absicht: Niemand soll versehentlich Reibung in fremden Dateien erzeugen, indem er irgendwo einen Alias setzt.
Function Block und lokale Blocks
Innerhalb einer Funktion gibt es einen Function Block, der Parameter, Result-Variablen und Receiver einschließt. Darin schachteln sich weitere lokale Blocks — jeder { ... } ist ein Block, ebenso die impliziten Blocks von if, for, switch und ihren Klauseln:
package main
import "fmt"
func process(items []int) int { // Function Block — items lebt hier
total := 0 // Function-Block-lokale Variable
for _, item := range items { // for-Block — item lokal zu diesem Block
temp := item * 2 // for-Body-Block — temp lokal zu jedem Iteration-Body
total += temp
if item > 10 { // if-Block
big := item * 100 // if-Body-Block — big nur hier sichtbar
fmt.Println("big:", big)
}
// big ist hier außerhalb nicht mehr sichtbar
}
// item, temp, big sind hier alle außerhalb nicht mehr sichtbar
return total
}
func main() {
fmt.Println(process([]int{5, 12, 3, 20}))
}big: 1200
big: 2000
4080Ein Identifier ist sichtbar ab dem Ende seiner Deklaration bis zum Ende des Blocks. Das „ab dem Ende der Deklaration" ist subtil: in var x = x + 1 referenziert das rechte x nicht das gerade deklarierte, sondern ein eventuell äußeres — der neue Identifier ist erst „aktiv", sobald der gesamte VarSpec abgeschlossen ist.
Shadowing — wenn ein innerer Name den äußeren verdeckt
Shadowing ist Gos häufigste Scope-Falle. Sie passiert, wenn ein innerer Block einen Namen deklariert, den ein äußerer Block ebenfalls trägt:
package main
import "fmt"
var name = "Paket-Variable"
func main() {
fmt.Println(name) // Paket-Variable
name := "Funktions-Variable" // shadowt die Paket-Variable
fmt.Println(name) // Funktions-Variable
{
name := "Block-Variable"
fmt.Println(name) // Block-Variable
}
fmt.Println(name) // Funktions-Variable — Block-Variable ist weg
}Paket-Variable
Funktions-Variable
Block-Variable
Funktions-VariableBewusstes Shadowing ist legitim und gelegentlich nützlich (z. B. um eine Variable in einem if-Block temporär zu „klonen"). Unbewusstes Shadowing dagegen ist eine Bug-Quelle — der häufigste Fall ist die err-Variable in geschachtelten if-Initialisierungen, der bereits im var-vs-:=-Artikel gezeigte Bug:
package main
import (
"fmt"
"strconv"
)
func main() {
var err error
n := 0
if s := "42"; s != "" {
// Achtung: := erzeugt NEUES err im if-Block
n, err := strconv.Atoi(s)
fmt.Println("inneres:", n, err)
_ = n
_ = err
}
// äußeres err ist immer noch nil — das innere ist weg
fmt.Println("äußeres err:", err, "n:", n)
}inneres: 42 <nil>
äußeres err: <nil> n: 0Hier ist sowohl das innere n als auch das innere err neu deklariert — die äußeren bleiben unverändert. Lint dagegen: go vet -vettool=$(which shadow) oder golangci-lint mit shadow-Setting im govet-Block.
Forward References — vorwärts schauen erlaubt
Anders als bei lokalen Variablen darfst du im Package Block Identifier vor ihrer Deklaration referenzieren — die Reihenfolge im File ist egal:
package main
import "fmt"
// Funktion benutzt Konstante, die weiter unten steht — OK
func main() {
fmt.Println("Version:", AppVersion)
fmt.Println(double(3))
}
// Konstante zuerst hier deklariert
const AppVersion = "1.0.0"
// Funktion die main vorher schon aufgerufen hat
func double(x int) int { return x * 2 }Version: 1.0.0
6Das gilt strikt nur im Package Block. Innerhalb einer Funktion gilt die normale Reihenfolge — du kannst nicht auf ein n zugreifen, das du erst zehn Zeilen weiter unten deklarierst.
Closures und Scope — eine kurze Vorausschau
Funktionen sind in Go First-Class-Werte und können Closures sein: sie schließen lexikalisch ihre umgebende Scope ein. Das ist ein Scope-Mechanismus — keine Magie:
package main
import "fmt"
func makeCounter() func() int {
count := 0 // gehört zum Function Block von makeCounter
return func() int {
count++ // closet count ein — lebt weiter, solange die Closure lebt
return count
}
}
func main() {
next := makeCounter()
fmt.Println(next()) // 1
fmt.Println(next()) // 2
fmt.Println(next()) // 3
// Jeder Aufruf von makeCounter schafft eine NEUE count-Variable
another := makeCounter()
fmt.Println(another()) // 1 — eigene count
fmt.Println(next()) // 4 — alte count
}1
2
3
1
4Wichtig zu verinnerlichen: Closures fangen Variablen, nicht Werte. Wenn eine for-Schleife eine Closure erzeugt, die i benutzt, dann zeigen alle Closures auf dieselbe i — bis Go 1.22 ein berüchtigter Anfänger-Stolperer. Ab Go 1.22 wurde das Verhalten geändert: Die for-Loop-Variable bekommt pro Iteration eine neue Bindung. Bei älterem Code (oder mit GOEXPERIMENT=loopvar deaktiviert) gilt die alte Regel — Details im Closures-Artikel.
Häufige Stolperfallen
Shadowing in if/for-Initialisierungen ist der Bug Nummer eins.
if x, err := foo(); err != nil { ... } ist sauber — die innere err ist explizit lokal. Aber x, err := foo() in einem if-Body, wenn außen schon ein x existiert, legt ein NEUES x an. Aktiviere den shadow-Analyzer in CI — er fängt diese Bugs.
Predeclared Identifier überschreiben ist legal, aber katastrophal.
len := "Hallo" ist syntaktisch erlaubt — und macht die eingebaute len-Funktion im gesamten Block unbrauchbar. staticcheck hat dafür die SA4006/SA4021-Warnungen. Niemals Variablen int, string, error, len, new, nil nennen.
Importe gelten pro Datei, nicht paket-weit.
Wenn du eine neue .go-Datei im Paket anlegst, musst du dort eigene Imports schreiben. Aliase und Dot-Imports (. "fmt") wirken nur in der Datei, in der sie stehen. Das ist auch der Grund, warum goimports auf File-Ebene operiert.
Großbuchstabe = exportiert, Kleinbuchstabe = privat — keine Ausnahmen.
Es gibt in Go keine Möglichkeit, einen klein geschriebenen Identifier irgendwie doch zu exportieren. Auch Reflection sieht private Felder nur lesend (mit Hacks). Wer ein Feld exportieren will, muss es großschreiben. Wer Helper-Funktionen verstecken will, schreibt sie klein.
Block-Scope endet streng bei der schließenden Klammer.
Eine Variable, die in einem if-Body deklariert wird, existiert hinter der schließenden } nicht mehr — nicht als nil, nicht als irgendetwas. Wer ihren Wert außerhalb braucht, deklariert sie vor dem if-Statement.
for-Loop-Variablen pre-1.22 waren shared, post-1.22 sind sie per-Iteration.
In Go ≤1.21 gab es eine i-Variable für die ganze Schleife — Closures, die i einfingen, sahen am Ende alle den letzten Wert. Ab Go 1.22 bekommt jede Iteration eine neue Bindung. Wer noch mit alten Codebases oder älteren Go-Versionen arbeitet, kennt das alte Verhalten — und sollte vorsichtshalber explizit kopieren: i := i; go func(){ ... }().
Type-Parameter haben eigenen Scope.
Bei func F[T any](x T) T { ... } ist T sichtbar vom Ende seines Namens bis zum Ende des Funktions-Rumpfes. Innerhalb der Funktion kannst du T wie jeden anderen Typ verwenden — aber außerhalb nicht. Bei Typ-Deklarationen type Stack[T any] struct { ... } gilt der Scope nur innerhalb der TypeSpec.
Der Blank Identifier hat keinen Scope.
_ ist eine Sprach-Konvention für „verwerfen", er bindet nichts. Du kannst beliebig oft _ auf der linken Seite einer Zuweisung haben — sie konflikten nicht miteinander, und du kannst _ auch nicht zurücklesen. Praktisch in Multi-Return-Funktionen (data, _ := os.ReadFile(...)) oder beim erzwingen von Side-Effect-Imports (import _ "database/sql/driver").
Weiterführende Ressourcen
Externe Quellen
- Declarations and scope – Go Language Specification
- Blocks – Go Specification
- Predeclared identifiers – Go Specification
- Exported identifiers – Go Specification
- Loop variable scoping change in Go 1.22 – Release Notes