Le context est l’un des packages les plus mal compris et pourtant les plus puissants de la bibliothèque standard Go.
Un context (context.Context) permet de transmettre la représentation de l’état d’exécution du programme à un instant donné.
Il peut :
- porter des métadonnées (utilisateur authentifié (ou juste son id), trace id, span id, etc),
- signaler un changement de l’état d’execution (annulation de requête, extinction de l’application)
- diriger l’état d’éxecution (ne pas dépasser 5s, timeout).
Et c’est ce qui le rend remarquable : le mécanisme est standardisé et présent dans toute la bibliothèque standard de Go.
🎯 Pourquoi le contexte existe-t-il ?
En Go, il faut savoir que tout est goroutine (la fonction main est elle même une goroutine). Lorsque l’on travaille avec des goroutines et des opérations asynchrones (appels HTTP, bases de données, files d’attente, etc.), une question cruciale se pose : comment propager l’état d’exécution à travers des appels imbriqués ?
Le context résout ce problème en fournissant une interface standardisée pour :
| Fonctionnalité | Description | Cas d’usage |
|---|---|---|
| Annulation | Arrêter proprement une opération longue | Timeout utilisateur, interruption par l’utilisateur |
| Deadline | Limiter la durée maximale d’une opération | Éviter les opérations qui durent trop longtemps |
| Métadonnées | Propager des données à travers les appels | IDs de requête, tokens, logging corrélé |
| Propagation | Transmettre le contexte aux appels enfants | Appels HTTP, bases de données, workers |
ℹ️ Le
contextest par convention passée à chaque fonction (qui en a besoin) en premier paramètre.Il représente le contexte d’exécution d’une fonction / méthode.
📦 Le principe : une interface minimaliste et puissante
Le cœur du package context tient en 4 méthodes définies par l’interface context.Context :
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
Et c’est tout. Quatre méthodes, pas une de plus, largement de quoi couvrir l’annulation, les deadlines et le passage de métadonnées.
🔍 Décryptage de l’interface
Deadline()
Retourne l'heure limite à laquelle le contexte sera annulé, et un booléen indiquant si une deadline est définie.
👉 Permet de savoir combien de temps il reste avant que l'opération ne doive s'arrêter.
Done()
Retourne un channel qui est fermé lorsque le contexte est annulé (timeout, deadline dépassée, ou annulation manuelle).
👉 C'est le mécanisme principal pour attendre l'annulation : <-ctx.Done() bloque jusqu'à annulation.
Err()
Retourne la raison de l'annulation :
context.Canceled(annulation manuelle)context.DeadlineExceeded(timeout/délai dépassé)
Permet de différencier les types d'annulation.
Value(key any)
Permet de stocker et récupérer des métadonnées associées au contexte.
ℹ️ Les clés doivent être comparables (généralement des types personnalisés pour éviter les collisions).
🏗️ La hiérarchie des contextes
Le package context fournit plusieurs fonctions pour créer des contextes :
1. context.Background() - Le contexte racine
ctx := context.Background()
C’est le point de départ de toute hiérarchie de contextes. Il n’est jamais annulé, n’a pas de deadline, et ne contient pas de valeurs.
⚠️ À utiliser uniquement au début d’un programme ou dans des tests.
2. context.TODO() - Placeholder temporaire
ctx := context.TODO()
Similaire à Background(), mais utilisé comme placeholder lorsque l’on ne sait pas encore quel contexte utiliser.
C’est un signal pour le développeur : “il faudra remplacer ce contexte plus tard”.
C’est une fonction que je n’utilise pas personnellement.
3. context.WithCancel(parent) - Annulation manuelle
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // ⚠️ Toujours appeler cancel, même en cas d'erreur
// Dans une goroutine
go func() {
select {
case <-ctx.Done():
// Le contexte a été annulé
fmt.Println("Annulé :", ctx.Err())
case <-time.After(5 * time.Second):
fmt.Println("Travail terminé")
}
}()
// Pour annuler manuellement
cancel()
Crée un nouveau contexte qui peut être annulé manuellement via la fonction cancel().
4. context.WithTimeout(parent, duration) - Annulation après durée
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
select {
case <-ctx.Done():
if ctx.Err() == context.DeadlineExceeded {
fmt.Println("Timeout dépassé !")
}
case <-time.After(1 * time.Second):
fmt.Println("Travail terminé à temps")
}
Crée un contexte qui sera automatiquement annulé après la durée spécifiée.
5. context.WithDeadline(parent, deadline) - Annulation à date précise
ctx, cancel := context.WithDeadline(
context.Background(),
time.Now().Add(3*time.Second),
)
defer cancel()
// Equivalent à WithTimeout, mais avec une date absolue plutôt qu'une durée
6. context.WithValue(parent, key, value) - Ajout de métadonnées
// Définir une clé de type personnalisé (pour éviter les collisions)
type requestIDKey struct{}
var requestID = requestIDKey{}
// Ajouter une valeur au contexte
ctx := context.WithValue(context.Background(), requestID, "abc123")
// Récupérer la valeur plus loin dans la chaîne d'appel
if id, ok := ctx.Value(requestID).(string); ok {
fmt.Println("Request ID :", id) // Affiche : Request ID : abc123
}
⚠️ Bonnes pratiques pour les clés : ne jamais utiliser un type intégré nu (
string,int…) comme clé.
Toujours définir son propre type (même avec unstringou unintcomme type sous-jacent) pour éviter les collisions entre bibliothèques :// ✅ un type nommé, même basé sur string, ne collisionne jamais avec un autre package type userIDKey string const userID userIDKey = "userID" ctx := context.WithValue(context.Background(), userID, "abc123")
🌳 Propagation à travers les appels
Le context prend tout son intérêt une fois qu’on le fait circuler dans la chaîne d’appels.
On le passe explicitement, en premier paramètre, à chaque fonction ; le signal d’annulation, lui, se propage automatiquement à tous les contextes dérivés.
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Passer le contexte à une fonction qui en a besoin
result, err := fetchData(ctx, "https://api.placeholderjson.dev/shipments")
if err != nil {
fmt.Println("Erreur :", err)
return
}
fmt.Println("Résultat :", result)
}
func fetchData(ctx context.Context, url string) (string, error) {
// Créer une requête HTTP associée au contexte (idiomatique depuis Go 1.13)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
client := http.Client{}
resp, err := client.Do(req)
if err != nil {
// Si le contexte est annulé, err sera context.DeadlineExceeded ou context.Canceled
return "", err
}
defer resp.Body.Close()
// Lire le corps de la réponse
body, err := io.ReadAll(resp.Body)
return string(body), err
}
Ici, si la requête HTTP prend plus de 5 secondes, le context sera automatiquement annulé, et la requête HTTP sera interrompue.
Le tout, sans code supplémentaire !
🎯 Les avantages du contexte en Go
1. Standardisation ✅
Avant le context, chaque bibliothèque gérait l’annulation à sa manière :
- Un channel pour l’annulation
- Un paramètre
timeout - Une struct de configuration personnalisée
Avec context, tout le monde utilise la même interface.
Les bibliothèques de la stdlib (net/http, database/sql, etc.) et la plupart des bibliothèques tierces l’ont adopté.
C’est devenu le standard depuis plusieurs années.
2. Composition 🧩
Les contextes peuvent être enchaînés :
// Contexte parent avec timeout
parentCtx, parentCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer parentCancel()
// Contexte enfant avec sa propre deadline (plus courte)
childCtx, childCancel := context.WithTimeout(parentCtx, 5*time.Second)
defer childCancel()
// Le contexte enfant sera annulé dans 5 secondes
// OU si le parent est annulé avant (dans 10 secondes)
3. Sécurité 🔒
Le context permet d’éviter les fuites de goroutines :
func process(ctx context.Context) {
go func() {
select {
case <-ctx.Done():
// Nettoyage et sortie propre
fmt.Println("Goroutine annulée")
return
case <-time.After(1 * time.Hour):
// Travail terminé
}
}()
}
Sans context, la goroutine pourrait continuer à tourner indéfiniment si l’opération principale est annulée.
4. Traçabilité 📊
Grâce à Value(), on peut propager des métadonnées utiles :
type correlationIDKey struct{}
func handleRequest(w http.ResponseWriter, r *http.Request) {
// Extraire ou générer un ID de corrélation
ctx := context.WithValue(r.Context(), correlationIDKey{}, generateCorrelationID())
// Passer le contexte aux fonctions appelées
result, err := processRequest(ctx)
if err != nil {
// Le log contiendra l'ID de corrélation
logError(ctx, err)
}
}
func logError(ctx context.Context, err error) {
if id, ok := ctx.Value(correlationIDKey{}).(string); ok {
log.Printf("[Correlation ID: %s] Erreur : %v", id, err)
}
}
⚠️ Les pièges à éviter
Piège 1 : Ne pas appeler cancel() ❌
// ❌ MAUVAIS : fuite de mémoire
func bad() {
ctx, cancel := context.WithCancel(context.Background())
// Oubli de defer cancel()
go func() {
<-ctx.Done()
}()
}
// ✅ BON : toujours appeler cancel
func good() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // Même si le contexte n'est pas utilisé
go func() {
<-ctx.Done()
}()
}
Pourquoi ? Chaque appel à WithCancel, WithTimeout ou WithDeadline alloue des ressources internes. Si cancel() n’est pas appelé, ces ressources fuient.
Piège 2 : Passer nil comme contexte ❌
// ❌ MAUVAIS : contexte nil
func badHandler(w http.ResponseWriter, r *http.Request) {
doSomething(nil) // Panique possible
}
// ✅ BON : toujours utiliser r.Context() ou context.Background()
func goodHandler(w http.ResponseWriter, r *http.Request) {
doSomething(r.Context())
}
Un contexte nil provoquera une panique lors de l’appel à Done(), Err(), etc.
Piège 3 : Utiliser un type intégré nu comme clé ❌
// ❌ MAUVAIS : collision possible avec d'autres bibliothèques
ctx := context.WithValue(context.Background(), "user", "john")
ctx = context.WithValue(ctx, "user", "admin") // Écrase la valeur précédente !
// ✅ BON : utiliser un type personnalisé (struct vide...)
type userContextKey struct{}
var userKey = userContextKey{}
ctx := context.WithValue(context.Background(), userKey, "john")
// ✅ BON aussi : un type nommé basé sur string reste distinct de string
type userIDKey string
const userID userIDKey = "userID"
ctx = context.WithValue(ctx, userID, "abc123")
Le problème n’est pas string ou int en tant que tels, mais l’utilisation du type intégré nu : si deux bibliothèques emploient la clé string "user", elles vont s’écraser mutuellement. Un type nommé, même type userIDKey string, est en revanche propre à son package et ne peut jamais entrer en collision avec un autre, car la comparaison des clés porte sur le type et la valeur.
struct{} ou string : lequel choisir ?
Les deux formes protègent des collisions ; le choix dépend surtout de la portée de la clé.
- Une clé privée à un package (le cas le plus courant) → je recommande
struct{}.
C’est l’idiome de la bibliothèque standard : coût nul (type de taille 0, pas d’allocation), une seule valeur possible, et collision strictement impossible si le type reste non exporté. Son unique « défaut », ne pas être lisible, n’en est pas un ici, puisqu’on logue la valeur (l’ID), jamais la clé. - Un ensemble de clés nommées et partagées entre plusieurs services (typiquement une lib « utils ») → un
type ContextKey stringest plus pragmatique : constantes lisibles au call site (ctx.Value(contextkey.RequestID)) et stringifiables pour le debug.
Le compromis assumé : le type étant exporté, sa valeur reste « devinable », donc l’encapsulation est un peu plus faible.
En résumé : struct{} par défaut, string (type nommé) quand on veut un registre de clés partagé et lisible.
Et si l’on veut le meilleur des deux, unicité forte et nom pour le debug, l’idiome de la stdlib est un struct{ name string } référencé par pointeur (deux pointeurs distincts ne sont jamais égaux) :
// La clé porte un nom lisible, mais l'identité repose sur le pointeur
type contextKey struct{ name string }
func (k *contextKey) String() string { return "monpkg context key: " + k.name }
var correlationIDKey = &contextKey{"correlation-id"}
// Utilisation
ctx := context.WithValue(context.Background(), correlationIDKey, "abc123")
id, _ := ctx.Value(correlationIDKey).(string)
Même avec deux &contextKey{"correlation-id"} de même contenu, les pointeurs diffèrent : aucune collision possible, tout en gardant un nom exploitable pour le logging ou le debug.
Piège 4 : Ignorer l’erreur du contexte ❌
// ❌ MAUVAIS : ne vérifie pas pourquoi le contexte est annulé
func badWorker(ctx context.Context) {
<-ctx.Done()
fmt.Println("Contexte annulé") // Mais POURQUOI ?
}
// ✅ BON : toujours vérifier ctx.Err()
func goodWorker(ctx context.Context) {
<-ctx.Done()
if ctx.Err() == context.DeadlineExceeded {
fmt.Println("Timeout dépassé")
} else if ctx.Err() == context.Canceled {
fmt.Println("Annulation manuelle")
}
}
Savoir pourquoi le contexte a été annulé permet de prendre les bonnes décisions (retry, logging approprié, etc.).
Piège 5 : Bloquer sur Done() sans timeout ❌
// ❌ MAUVAIS : peut bloquer indéfiniment
func badWait(ctx context.Context) {
<-ctx.Done() // Que se passe-t-il si le contexte n'est JAMAIS annulé ?
}
// ✅ BON : toujours avoir un mécanisme de timeout de secours
func goodWait(ctx context.Context) {
select {
case <-ctx.Done():
// Contexte annulé
case <-time.After(30 * time.Second):
// Timeout de secours
}
}
Même si le contexte parent n’a pas de deadline, il est prudent d’avoir un timeout de secours pour éviter les blocages infinis.
Piège 6 : Modifier les valeurs du contexte ❌
// ❌ MAUVAIS : le contexte est IMMUABLE
ctx := context.WithValue(context.Background(), userKey, "john")
// Cela ne fonctionnera PAS comme attendu
ctx.Value(userKey).(string) = "admin" // Erreur de compilation
// ✅ BON : créer un nouveau contexte avec la nouvelle valeur
ctx = context.WithValue(ctx, userKey, "admin")
Les contextes sont immuables 👉 il faut donc créer un nouveau contexte avec la nouvelle valeur.
Piège 7 : Oublier que WithValue ne copie pas les valeurs ❌
// ❌ MAUVAIS : modification de la valeur originale
config := Config{Timeout: 5 * time.Second}
ctx := context.WithValue(context.Background(), configKey, &config)
// Plus tard, quelqu'un modifie config...
config.Timeout = 10 * time.Second
// Tous les codes utilisant ce contexte voient la modification !
// ✅ BON : toujours copier les valeurs si elles sont mutables
ctx := context.WithValue(context.Background(), configKey, &Config{
Timeout: config.Timeout, // Copie des valeurs
})
Si vous stockez un pointeur dans le contexte et que vous modifiez l’objet pointé, tous les codes utilisant ce contexte verront la modification.
📊 Exemple complet : Un serveur HTTP avec contexte
Un exemple qui rassemble tout ce qu’on a vu jusqu’ici :
package main
import (
"context"
"fmt"
"log"
"net/http"
"time"
)
type requestIDKey struct{}
func main() {
http.HandleFunc("/api/data", handleData)
server := &http.Server{
Addr: ":8080",
// Timeout de lecture global
ReadTimeout: 5 * time.Second,
}
log.Println("Serveur démarré sur :8080")
log.Fatal(server.ListenAndServe())
}
func handleData(w http.ResponseWriter, r *http.Request) {
// Générer un ID de requête unique
requestID := fmt.Sprintf("req-%d", time.Now().UnixNano())
ctx := context.WithValue(r.Context(), requestIDKey{}, requestID)
// Créer un contexte avec timeout pour cette requête
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
// Passer le contexte aux fonctions de traitement
data, err := fetchExternalData(ctx)
if err != nil {
logError(ctx, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Traiter les données
result, err := processData(ctx, data)
if err != nil {
logError(ctx, err)
http.Error(w, "Processing Error", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "Résultat : %s", result)
}
func fetchExternalData(ctx context.Context) (string, error) {
// Simuler un appel externe
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(1 * time.Second):
return "data from external source", nil
}
}
func processData(ctx context.Context, data string) (string, error) {
// Simuler un traitement
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(500 * time.Millisecond):
return fmt.Sprintf("Processed: %s", data), nil
}
}
func logError(ctx context.Context, err error) {
requestID := ctx.Value(requestIDKey{}).(string)
log.Printf("[%s] Erreur : %v", requestID, err)
}
✅ Bonnes pratiques résumées
| Pratique | Description |
|---|---|
| ✅ Passer le contexte en premier paramètre | Convention Go : func foo(ctx context.Context, ...) |
✅ Toujours utiliser defer cancel() |
Éviter les fuites de mémoire |
| ✅ Utiliser des types personnalisés pour les clés | Éviter les collisions : type myKey struct{} |
✅ Toujours vérifier ctx.Err() |
Savoir pourquoi le contexte a été annulé |
| ✅ Ne pas stocker de pointeurs mutables | Éviter les effets de bord |
| ✅ Créer des contextes enfants pour les sous-opérations | Permettre une annulation fine |
| ✅ Documenter les attentes de contexte | Les appelants savent quoi passer |
🎓 Conclusion : Le contexte, bien plus qu’un simple timeout
Le context fait beaucoup plus qu’annuler une opération. C’est le fil qui relie une chaîne d’appels : une même interface partout dans la stdlib, des contextes qu’on enchaîne, des goroutines qui s’arrêtent proprement, et des métadonnées qui suivent la requête jusqu’au log.
C’est là que Go se démarque : peu d’écosystèmes réunissent tout ça dans un seul type, aussi simple, et adopté d’un bout à l’autre de la bibliothèque standard.
Au fond, bien utiliser le context, c’est accepter que lancer une goroutine ne suffit pas : encore faut-il savoir l’arrêter.
ℹ️ En tant normal, notre code tourne toujours dans un contexte d’exécution.
Le contexte devrait donc être passé dans la plupart des fonctions.
À bientôt pour de nouvelles explorations de Go ! 🚀
📚 Ressources
- Documentation du package
context: la référence officielle de l’API. - Go Concurrency Patterns: Context : l’article du blog Go officiel sur les bonnes pratiques.
- Context Propagation in Go: Beyond
context.WithCancel: pour aller plus loin sur la propagation du contexte.

