context.Context ist nicht bloß ein weiterer Typ aus der Standardbibliothek — es ist ein Vertrag zwischen Funktionen, der quer durch das gesamte Go-Ökosystem konsistent eingehalten wird. Wer eine Funktion mit ctx context.Context als ersten Parameter sieht, weiß sofort: Diese Funktion respektiert Cancellation, kann an Deadlines gebunden werden und propagiert Request-Scoped Werte weiter. Genau diese Konsistenz macht Context-aware APIs in Go so robust komponierbar — vom net/http-Handler über den Datenbank-Treiber bis hin zur eigenen Geschäftslogik.
Die Konventionen sind nicht willkürlich. Sie stehen schwarz auf weiß in der offiziellen Dokumentation auf pkg.go.dev/context, im Go Blog, in Effective Go und im Google Go Style Guide. Linter wie golangci-lint mit dem containedctx- oder contextcheck-Plug-in setzen sie maschinell durch. Dieser Artikel kodifiziert die Regeln, zeigt die typischen Fehlbedienungen und liefert den goldenen Pfad für Bibliotheks-APIs, Service-Schichten und Tests.
ctx ist der erste Parameter, immer
Die wichtigste und am strengsten durchgesetzte Konvention: context.Context ist immer der erste Parameter einer Funktion, immer benannt ctx und immer mit dem Typ context.Context (kein Alias, kein eigener Wrapper-Typ). Diese Regelmäßigkeit erlaubt es Lesern, Linter und Code-Generatoren, Context-aware Funktionen auf einen Blick zu erkennen — ohne den Funktionskörper inspizieren zu müssen.
// Richtig — Konvention eingehalten
func (s *UserService) GetByID(ctx context.Context, id int64) (*User, error) {
return s.repo.FindByID(ctx, id)
}
func FetchAll(ctx context.Context, client *http.Client, urls []string) ([]Result, error) {
// ...
}Falsch sind dagegen alle Varianten, in denen ctx an einer anderen Position auftaucht, einen anderen Namen bekommt oder durch einen domänenspezifischen Wrapper ersetzt wird. Solche Signaturen sind technisch zwar gültiges Go, brechen aber den Vertrag mit dem Rest des Ökosystems — database/sql, net/http, gRPC und unzählige Bibliotheken erwarten genau die kanonische Form.
// Falsch — ctx nicht zuerst
func GetByID(id int64, ctx context.Context) (*User, error) { /* ... */ }
// Falsch — anderer Parametername
func GetByID(c context.Context, id int64) (*User, error) { /* ... */ }
// Falsch — eigener Wrapper-Typ
type RequestCtx struct{ context.Context }
func GetByID(ctx RequestCtx, id int64) (*User, error) { /* ... */ }golangci-lint mit aktivierten Lintern wie contextcheck und revive (context-as-argument) erkennt diese Verstöße automatisch. In jeder ernstgemeinten Go-Codebase gehört das in die CI-Pipeline — die Konvention ist zu wichtig, um sie dem Code-Review allein zu überlassen.
Niemals ctx in einen Struct speichern
Ein hartnäckiger Anti-Pattern: context.Context als Feld in einem Struct ablegen, um ihn sich nicht durch jede Methodensignatur „durchschleifen" zu müssen. Diese Abkürzung ist falsch — und zwar prinzipiell, nicht nur stilistisch.
Der Grund ist der Lebenszyklus-Mismatch: Ein Context beschreibt eine Operation (typischerweise einen Request, eine Job-Ausführung, eine RPC). Ein Struct dagegen beschreibt eine Komponente (einen Service, einen Repository, einen Client), die viele Operationen überlebt. Speichert man den Context im Struct, friert man die Operations-Lebenszeit in der Komponenten-Lebenszeit ein — entweder ist der Context veraltet, sobald er gebraucht wird, oder Cancellation propagiert nicht dorthin, wo sie hin müsste.
// Falsch — klassischer Anti-Pattern
type UserService struct {
repo Repository
ctx context.Context // ← niemals!
}
func NewUserService(ctx context.Context, repo Repository) *UserService {
return &UserService{repo: repo, ctx: ctx}
}
func (s *UserService) GetByID(id int64) (*User, error) {
// welcher ctx ist das? der vom Konstruktor.
// wann läuft er ab? unklar. cancelt er korrekt? nein.
return s.repo.FindByID(s.ctx, id)
}Korrekt ist die Trennung: Der Struct hält Konfiguration und Abhängigkeiten, die Methode bekommt den ctx bei jedem Aufruf neu. Ein Service-Instanz lebt vielleicht für die gesamte Prozess-Lebenszeit; jede einzelne Methodenausführung bekommt aber den frischen, zur konkreten Operation passenden Context.
// Richtig — Service hält Abhängigkeiten, Methode bekommt ctx
type UserService struct {
repo Repository
}
func NewUserService(repo Repository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) GetByID(ctx context.Context, id int64) (*User, error) {
return s.repo.FindByID(ctx, id)
}Die einzig diskussionswürdige Ausnahme sind sehr dünne Wrapper, die einen *http.Request umschließen und intern dessen Context lesen — und selbst dort lautet die saubere Lösung, in der Wrapper-Methode r.Context() aufzurufen, statt den Context zu kopieren. Der Linter containedctx flaggt Struct-Felder vom Typ context.Context zuverlässig.
context.WithValue nur für Request-Scoped Daten
context.WithValue ist die wohl missverstandenste Funktion der Standardbibliothek. Sie sieht aus wie ein bequemer Daten-Transport, der einem die mühsame Parameter-Übergabe erspart. Sie ist aber kein generischer Daten-Container — und sie absichtlich so zu missbrauchen, kostet Type-Safety, Lesbarkeit und Testbarkeit.
Der dokumentierte, eng umrissene Einsatzzweck lautet: Request-Scoped Daten, die orthogonal zur Geschäftslogik durch viele Schichten propagieren müssen — Trace-IDs, Request-IDs, authentifizierter User, Locale, Auth-Token. Das verbindende Kriterium: Daten, die alle Schichten potentiell brauchen (z. B. für Logging), aber die niemand explizit in seine Signaturen aufnehmen will, weil sie für die jeweilige Domänen-Funktion irrelevant sind.
// Falsch — Geschäfts-Parameter durch Context geschmuggelt
ctx = context.WithValue(ctx, "userID", 42)
ctx = context.WithValue(ctx, "limit", 100)
ctx = context.WithValue(ctx, "sortBy", "name")
results, err := repo.Search(ctx) // welche Parameter? Lies den Code.Hier ist alles falsch: Die Signatur lügt (sie suggeriert, Search brauche nur einen Context), die Werte sind any-typisiert, der Compiler kann keine fehlenden Werte erkennen, Tests müssen den Context präparieren statt einfach Parameter zu übergeben. Wenn eine Funktion Daten braucht, gehören sie in die Signatur — Punkt.
// Richtig — Parameter sind Parameter
results, err := repo.Search(ctx, SearchOptions{
UserID: 42,
Limit: 100,
SortBy: "name",
})Typed unexported keys
Wenn context.WithValue legitim eingesetzt wird (Trace-IDs & Co.), gibt es eine eiserne Regel für die Schlüssel: Niemals plain string, sondern ein unexported typed struct{}. Der Grund ist Kollisionsvermeidung — Context-Werte leben in einem prozessweiten flachen Schlüsselraum, und zwei Pakete, die beide den Schlüssel "userID" benutzen, überschreiben sich gegenseitig.
Das offizielle Pattern aus der context-Dokumentation: Ein nicht-exportierter Typ als Schlüssel, dazu kleine WithX/XFromContext-Helper, die den Schlüssel kapseln. Außerhalb des Pakets kann niemand denselben Schlüssel konstruieren — die Type-Identität ist privat.
package tracing
import "context"
// unexported Typ — kein anderes Paket kann ihn nachbauen
type traceIDKey struct{}
// WithTraceID legt eine Trace-ID im Context ab.
func WithTraceID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, traceIDKey{}, id)
}
// TraceIDFromContext liest die Trace-ID; ok == false, wenn keine vorhanden.
func TraceIDFromContext(ctx context.Context) (string, bool) {
id, ok := ctx.Value(traceIDKey{}).(string)
return id, ok
}Aufrufer sehen niemals den Schlüssel, sondern nur die getypten Helper — fehlerhafte Casts, Tippfehler im String-Schlüssel und Paket-übergreifende Kollisionen sind dadurch konstruktiv ausgeschlossen. Genau dieses Pattern findet sich in der Standardbibliothek (z. B. httptrace.ClientTrace) und in jedem gut gepflegten Observability-Stack.
Wer Goroutinen startet, akzeptiert ctx
Sobald eine Funktion eine Goroutine startet, übernimmt sie Verantwortung für deren Lebensdauer. Die Standard-Antwort dafür heißt Context: Eine Funktion, die Goroutinen startet, muss ctx akzeptieren und ihn an die Goroutine weiterreichen — oder explizit dokumentieren, dass die Goroutine bewusst den Aufruf überlebt (was selten und gefährlich ist).
Wer das ignoriert, baut Goroutine-Leaks: Die aufrufende Operation wird gecancelt, die Goroutine läuft aber munter weiter, hängt an einem Channel-Receive, einer HTTP-Antwort oder einem time.Sleep — und ist für die Lebensdauer des Prozesses verloren. Bei Servern mit hohem Request-Aufkommen wächst die Goroutine-Zahl monoton; irgendwann kippt der Prozess.
// Falsch — Goroutine kennt kein Cancel
func fetchAsyncBad(url string) <-chan Result {
out := make(chan Result, 1)
go func() {
resp, err := http.Get(url) // blockiert ggf. minutenlang
out <- Result{Resp: resp, Err: err}
}()
return out
}
// Richtig — Goroutine respektiert Cancel
func fetchAsync(ctx context.Context, url string) <-chan Result {
out := make(chan Result, 1)
go func() {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
out <- Result{Err: err}
return
}
resp, err := http.DefaultClient.Do(req)
out <- Result{Resp: resp, Err: err}
}()
return out
}So wandert das Cancel-Signal vom Aufrufer durch Funktion und Goroutine bis hinunter in den HTTP-Client, der die Verbindung sauber abbricht.
Context in Tests: Background, WithTimeout, T.Context
In Tests gibt es drei sinnvolle Quellen für einen Context, und welche man wählt, hängt vom Szenario ab. context.Background() ist die einfache Wahl für Tests, in denen Context nur ein Pflicht-Argument ist und Cancellation keine Rolle spielt. context.WithTimeout kommt zum Einsatz, wenn der Test prüfen soll, dass eine langsame Operation auch wirklich abbricht. t.Context() (Go 1.24+) ist die modernste und meistens beste Wahl: das *testing.T liefert einen Context, der automatisch gecancelt wird, sobald der Test endet — egal ob durch Erfolg, Fehler, Timeout oder t.Fatal. Damit verschwindet eine ganze Klasse von Test-Leaks: Goroutines, die der Test gestartet hat und vergessen hat zu stoppen, werden zuverlässig benachrichtigt.
func TestUserService_GetByID(t *testing.T) {
ctx := t.Context() // wird bei Test-Ende automatisch gecancelt
svc := NewUserService(fakeRepo{})
user, err := svc.GetByID(ctx, 42)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if user.ID != 42 {
t.Errorf("ID = %d, want 42", user.ID)
}
}
func TestRepository_Timeout(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
defer cancel()
_, err := slowRepo{}.FindByID(ctx, 1)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("err = %v, want DeadlineExceeded", err)
}
}context.WithCancelCause und context.Cause
Seit Go 1.20 gibt es eine erweiterte Variante des Cancel-Mechanismus, die nicht nur „abgebrochen ja/nein" signalisiert, sondern den Grund mitliefert. context.WithCancelCause liefert eine Cancel-Funktion, die einen error als Ursache entgegennimmt. context.Cause(ctx) liest diesen Grund aus.
Das ist Gold wert für Debugging und strukturiertes Logging: Statt nur context canceled im Log-Output zu sehen, erfährt man den konkreten Auslöser — etwa payment service unreachable, user pressed cancel oder parent deadline exceeded. Klassisches ctx.Err() liefert weiterhin nur context.Canceled oder context.DeadlineExceeded; context.Cause(ctx) liefert den darunterliegenden Fehler.
ctx, cancel := context.WithCancelCause(parent)
defer cancel(nil) // Ressourcen freigeben; nil = kein expliziter Grund
go func() {
if err := paymentService.Ping(ctx); err != nil {
cancel(fmt.Errorf("payment service unreachable: %w", err))
}
}()
// Später im Aufrufer:
if ctx.Err() != nil {
log.Printf("abort: %v (cause: %v)", ctx.Err(), context.Cause(ctx))
// abort: context canceled (cause: payment service unreachable: dial tcp ...)
}Faustregel: Wenn der Grund eines Cancels später irgendwo gelesen, geloggt oder unterschieden werden muss, ist WithCancelCause die richtige Wahl. Für reines Cleanup ohne Diagnose-Bedarf reicht das klassische WithCancel.
Schichtenarchitektur mit korrekter ctx-Propagation
In typischen Backend-Services wandert der Context vom HTTP-Handler über die Service-Schicht ins Repository und von dort in den Datenbank-Treiber. Jede Schicht reicht ihn unverändert weiter — oder verlängert ihn (z. B. mit einem Sub-Timeout), wenn die Schicht eine eigene Operations-Zusicherung geben muss.
// Repository-Schicht
package userrepo
type Repository interface {
FindByID(ctx context.Context, id int64) (*User, error)
Create(ctx context.Context, u *User) (int64, error)
}
type PostgresRepo struct {
db *sql.DB
}
func (r *PostgresRepo) FindByID(ctx context.Context, id int64) (*User, error) {
const q = `SELECT id, email, created_at FROM users WHERE id = $1`
row := r.db.QueryRowContext(ctx, q, id) // ctx wandert in den Treiber
var u User
if err := row.Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil {
return nil, fmt.Errorf("find user %d: %w", id, err)
}
return &u, nil
}
// Service-Schicht
package userservice
type Service struct {
repo userrepo.Repository
cache Cache
}
func (s *Service) GetByID(ctx context.Context, id int64) (*User, error) {
if u, ok := s.cache.Get(ctx, id); ok {
return u, nil
}
u, err := s.repo.FindByID(ctx, id) // ctx unverändert weiter
if err != nil {
return nil, err
}
s.cache.Set(ctx, id, u)
return u, nil
}
// HTTP-Handler — ctx aus Request
func (h *UserHandler) GetByID(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // wird gecancelt, wenn Client die Verbindung schließt
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
user, err := h.svc.GetByID(ctx, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(user)
}Drei Schichten, ein durchgehender Context — bricht der Client die Verbindung ab, propagiert das Signal automatisch durch Handler, Service, Repository bis in den database/sql-Treiber, der die laufende Query abbricht. Kein Code in den Mittelschichten muss explizit auf Cancel reagieren; sie reichen ctx einfach weiter.
TraceID als Request-Scoped Wert
Hier ein vollständiges, legitimes Beispiel für context.WithValue: Tracing. Eine Middleware generiert beim Request-Eintritt eine Trace-ID, hängt sie an den Context, und alle nachgelagerten Schichten — vor allem Logger — lesen sie aus. Kein Geschäftscode in der Mitte muss von TraceIDs wissen; der Wert reist orthogonal mit.
package tracing
import (
"context"
"crypto/rand"
"encoding/hex"
"log/slog"
)
type traceIDKey struct{}
func WithTraceID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, traceIDKey{}, id)
}
func FromContext(ctx context.Context) string {
if id, ok := ctx.Value(traceIDKey{}).(string); ok {
return id
}
return ""
}
func NewTraceID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
func LoggerFromContext(ctx context.Context, base *slog.Logger) *slog.Logger {
if id := FromContext(ctx); id != "" {
return base.With("trace_id", id)
}
return base
}// Middleware — TraceID am Request-Eintritt
func TraceMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Trace-ID")
if id == "" {
id = tracing.NewTraceID()
}
ctx := tracing.WithTraceID(r.Context(), id)
w.Header().Set("X-Trace-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Geschäftslogik liest TraceID nur indirekt über den Logger
func (s *Service) GetByID(ctx context.Context, id int64) (*User, error) {
log := tracing.LoggerFromContext(ctx, s.log)
log.Info("user lookup", "user_id", id)
return s.repo.FindByID(ctx, id)
}Der entscheidende Punkt: GetByID weiß nichts vom Schlüssel traceIDKey{}. Es ruft LoggerFromContext auf — eine getypte API. Genau so soll context.WithValue aussehen: ein gekapselter, semantisch klarer Kanal für Querschnitts-Daten, nicht ein offener Eimer für irgendwas.
Interessantes
ctx als erster Parameter, benannt ctx
context.Context ist immer der erste Parameter, immer ctx benannt, immer mit dem exakten Typ — keine Aliase, keine Wrapper. Linter (contextcheck, revive) setzen das durch.
Niemals ctx in einen Struct speichern
Struct-Lebenszeit und Operation-Lebenszeit kollidieren. Service hält Abhängigkeiten, Methode bekommt ctx bei jedem Aufruf neu. Der Linter containedctx flaggt das.
context.WithValue nur für Request-Scoped Daten
Trace-IDs, Auth-Tokens, Locale — ja. Geschäfts-Parameter wie limit, userID, sortBy — nein. Was die Funktion fachlich braucht, gehört in die Signatur.
WithValue-Key: unexported typed struct
Schlüssel sind nie string, sondern ein paketprivater type fooKey struct{}. Plus Get/Set-Helper, die den Schlüssel kapseln — Aufrufer sehen ihn nie.
Goroutine starten? → ctx-Parameter Pflicht
Wer Goroutinen startet, übernimmt deren Lebensdauer-Verantwortung. Cancellation muss durch — sonst Goroutine-Leak.
t.Context() in Go 1.24+ für sauberes Test-Lifecycle
t.Context() liefert einen pro-Test Context, der bei Test-Ende automatisch cancelt. Eliminiert eine ganze Klasse von Test-Goroutine-Leaks.
WithCancelCause + context.Cause für „warum?"
Seit Go 1.20: Cancel mit error-Begründung. context.Cause(ctx) liest sie. Gold wert für Debugging und strukturiertes Logging.
context.Background nie aus Library-Code
Bibliotheken erfinden keine Contexts — der Caller liefert ihn. context.Background() nur in main, Tests und am absoluten Top-Level eines Servers.
Weiterführende Ressourcen
Externe Quellen
context— Go Documentation- Go Concurrency Patterns: Context (Go Blog)
context.WithCancelCause(Go 1.20+)testing.T.Context(Go 1.24+)- Effective Go
- Google Go Style Guide — Context