Lire et écrire du JSON en Go avec encoding/json

1. Aperçu

JSON est le format d’échange de données le plus courant entre les services. Go supporte nativement JSON dans sa bibliothèque standard via le package encoding/json, vous n’avez rien d’autre à installer.

Cet article vous aide à convertir les struct en JSON et inversement, contrôler les noms de champs avec des struct tag, traiter du JSON dynamique et faire du streaming de grandes données. Vous apprendrez aussi comment écrire des types personnalisés et les tester. À la fin, nous verrons les erreurs courantes à éviter avant de mettre le code en production.

Les exemples utilisent Go 1.24 ou plus récent.

2. Écrire du JSON avec json.Marshal

Commençons par une commande contenant les informations du client :

package main

import (
	"encoding/json"
	"fmt"
	"log"
)

type Customer struct {
	Name  string `json:"name"`
	Email string `json:"email,omitempty"`
}

type Order struct {
	ID        int      `json:"id"`
	Customer  Customer `json:"customer"`
	Items     []string `json:"items"`
	Total     float64  `json:"total"`
	Note      string   `json:"-"`
	createdBy string
}

func main() {
	order := Order{
		ID:        1001,
		Customer:  Customer{Name: "Lan"},
		Items:     []string{"Cà phê", "Bánh mì"},
		Total:     45000,
		Note:      "livrer avant 9h",
		createdBy: "admin",
	}

	data, err := json.Marshal(order)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(data))
}

Résultat :

{"id":1001,"customer":{"name":"Lan"},"items":["Cà phê","Bánh mì"],"total":45000}

Il y a plusieurs points à noter dans le code ci-dessus :

  • json.Marshal retourne []byte et error. Vous devez toujours vérifier l’error.
  • Le tag json:"id" change le nom du champ en id dans le JSON. Sans tag, Go utilise le nom du champ tel quel, soit ID.
  • Email est vide et est omis grâce à omitempty.
  • Note a le tag json:"-" et est donc toujours omis.
  • createdBy commence par une minuscule, c’est-à-dire qu’il n’est pas exporté (unexported). encoding/json ne voit pas ce champ.
  • Les caractères vietnamiens sont préservés en UTF-8, pas d’échappement.

Si vous avez besoin de JSON lisible, par exemple pour les logs ou les fichiers de configuration, utilisez json.MarshalIndent :

data, _ := json.MarshalIndent(order.Customer, "", "  ")
fmt.Println(string(data))
{
  "name": "Lan"
}

3. Struct tag : contrôler précisément la sortie

Le tag json a la forme json:"nom,option,…". Les options courantes :

Tag Signification
json:"name" Changer le nom du champ
json:",omitempty" Omettre le champ s’il est zéro : "", 0, false, nil, slice ou map vide
json:",omitzero" Omettre le champ s’il est zéro, y compris les struct comme time.Time (Go 1.24+)
json:",string" Écrire les nombres ou booléens comme des chaînes
json:"-" Toujours omettre

omitempty n’a pas d’effet sur les struct. L’exemple ci-dessous montre la différence :

type Shipment struct {
	OrderID   int64     `json:"order_id,string"`
	ShippedAt time.Time `json:"shipped_at,omitempty"`
	PaidAt    time.Time `json:"paid_at,omitzero"`
}

data, _ := json.Marshal(Shipment{OrderID: 1001})
fmt.Println(string(data))
{"order_id":"1001","shipped_at":"0001-01-01T00:00:00Z"}

ShippedAt est toujours présent car omitempty ne considère pas un struct time.Time vide comme « vide ». PaidAt utilise omitzero et est donc correctement omis. OrderID est écrit comme la chaîne "1001" grâce à l’option string. C’est utile quand le client est JavaScript et ne peut pas traiter correctement les entiers 64-bit.

4. Lire du JSON avec json.Unmarshal

Dans l’autre sens, vous passez un pointeur vers la variable cible :

input := []byte(`{
	"id": 1002,
	"Customer": {"name": "Minh", "email": "minh@example.com"},
	"items": ["Trà đào"],
	"total": 20000,
	"coupon": "SALE10"
}`)

var order Order
if err := json.Unmarshal(input, &order); err != nil {
	log.Fatal(err)
}
fmt.Printf("%+v\n", order)
{ID:1002 Customer:{Name:Minh Email:minh@example.com} Items:[Trà đào] Total:20000 Note: createdBy:}

Remarquez deux comportements par défaut :

  1. Les clés sont appairées en ignorant la casse. "Customer" correspond toujours au tag customer.
  2. Les clés inconnues comme "coupon" sont silencieusement ignorées. Les champs absents du JSON conservent leur valeur actuelle.

Note : Si vous passez une valeur au lieu d’un pointeur, par exemple json.Unmarshal(input, order), vous recevrez l’erreur json: Unmarshal(non-pointer main.Order). Les données ne seront écrites nulle part.

4.1. Rejeter les champs inconnus

Pour une API recevant des requêtes client, ignorer les clés inconnues cache souvent les erreurs de typage. Vous pouvez activer le mode strict via json.Decoder :

dec := json.NewDecoder(bytes.NewReader(input))
dec.DisallowUnknownFields()

var order Order
err := dec.Decode(&order)
fmt.Println(err)
json: unknown field "coupon"

DisallowUnknownFields n’existe que sur Decoder, pas d’option équivalente pour json.Unmarshal.

5. JSON dynamique avec map[string]any

Quand la structure est inconnue à l’avance, par exemple un webhook d’un tiers, vous pouvez décoder dans map[string]any :

var payload map[string]any
_ = json.Unmarshal([]byte(`{"event":"order.paid","order_id":9007199254740993}`), &payload)

fmt.Printf("%T %v\n", payload["event"], payload["event"])
fmt.Printf("%T %.0f\n", payload["order_id"], payload["order_id"])
string order.paid
float64 9007199254740992

Tous les nombres sont décodés en float64. Donc order_id perd en précision : la valeur d’origine finit par 3 mais le résultat finit par 2. C’est une erreur difficile à détecter.

La solution est d’appeler UseNumber, pour que les nombres restent sous forme de chaîne json.Number :

dec := json.NewDecoder(strings.NewReader(`{"order_id":9007199254740993}`))
dec.UseNumber()

var payload map[string]any
_ = dec.Decode(&payload)

id, _ := payload["order_id"].(json.Number).Int64()
fmt.Println(id)
9007199254740993

Si seule une partie du payload est dynamique, vous pouvez utiliser json.RawMessage pour différer le décodage de cette partie :

type Event struct {
	Type string          `json:"type"`
	Data json.RawMessage `json:"data"`
}

Après avoir lu Type, vous appelez json.Unmarshal(e.Data, &order) avec la struct appropriée. C’est beaucoup plus sûr que de chercher chaque clé dans la map.

6. Faire du streaming de données avec Encoder et Decoder

Marshal et Unmarshal travaillent sur []byte, donc toutes les données doivent tenir en mémoire. json.Encoder et json.Decoder travaillent directement avec io.Writer et io.Reader. Ils conviennent pour le corps HTTP, les fichiers et les données NDJSON (une ligne = un JSON).

func main() {
	input := strings.NewReader(`{"id":1,"total":10000}
{"id":2,"total":25000}
{"id":3,"total":5000}`)

	dec := json.NewDecoder(input)
	enc := json.NewEncoder(os.Stdout)
	enc.SetEscapeHTML(false)

	sum := 0.0
	for {
		var o Order
		err := dec.Decode(&o)
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			log.Fatal(err)
		}
		sum += o.Total
	}
	_ = enc.Encode(map[string]float64{"sum": sum})
}
{"sum":40000}

La boucle appelle Decode jusqu’à rencontrer io.EOF, chaque fois lisant exactement un objet. Encode ajoute automatiquement une nouvelle ligne après chaque valeur. SetEscapeHTML(false) empêche Go de convertir <, >, & en \u003c, \u003e, \u0026. Par défaut, Go échappe ces caractères pour la sécurité lors de l’imbrication de JSON dans HTML.

Dans un gestionnaire HTTP, voici le modèle correct :

func createOrder(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // maximum 1 MB
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()

	var o Order
	if err := dec.Decode(&o); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	_ = json.NewEncoder(w).Encode(o)
}

http.MaxBytesReader limite la taille du corps. Cela empêche le client d’envoyer une charge utile énorme qui épuiserait la mémoire du serveur.

7. Types personnalisés

Supposons que le statut de la commande est stocké en tant qu’int dans le code mais doit s’afficher sous forme de chaîne dans JSON. Vous implémentez encoding.TextMarshaler et encoding.TextUnmarshaler :

type Status int

const (
	StatusPending Status = iota
	StatusPaid
	StatusShipped
)

var statusNames = map[Status]string{
	StatusPending: "pending",
	StatusPaid:    "paid",
	StatusShipped: "shipped",
}

func (s Status) MarshalText() ([]byte, error) {
	name, ok := statusNames[s]
	if !ok {
		return nil, fmt.Errorf("status invalide : %d", s)
	}
	return []byte(name), nil
}

func (s *Status) UnmarshalText(b []byte) error {
	for k, v := range statusNames {
		if v == string(b) {
			*s = k
			return nil
		}
	}
	return fmt.Errorf("status invalide : %q", b)
}

Quand vous l’utilisez dans une struct :

type OrderStatus struct {
	ID     int    `json:"id"`
	Status Status `json:"status"`
}

data, _ := json.Marshal(OrderStatus{ID: 1001, Status: StatusPaid})
fmt.Println(string(data))
{"id":1001,"status":"paid"}

MarshalText utilise un value receiver donc fonctionne avec Status et *Status. UnmarshalText nécessite un pointer receiver car il doit modifier la valeur. Utiliser TextMarshaler au lieu de json.Marshaler a un avantage supplémentaire : le type Status peut être utilisé comme clé de map, par exemple map[Status]int.

Quand vous avez besoin de contrôler l’objet entier, vous écrivez MarshalJSON. Attention à la récursion infinie : si à l’intérieur vous appelez json.Marshal(o) avec le même type, la fonction s’appellera récursivement. La solution est d’utiliser un type alias :

func (o Order) MarshalJSON() ([]byte, error) {
	type alias Order // alias n'a pas la méthode MarshalJSON
	return json.Marshal(struct {
		alias
		ItemCount int `json:"item_count"`
	}{alias(o), len(o.Items)})
}

alias a les mêmes champs que Order mais pas de méthode, donc json.Marshal utilise le comportement par défaut. Le champ imbriqué est « aplati » dans l’objet externe, avec item_count en plus.

8. Écrire des tests

Tester le round-trip, c’est-à-dire écrire puis relire, vous aide à détecter les erreurs si quelqu’un change un tag ou la logique personnalisée. Fichier status_test.go :

package main

import (
	"bytes"
	"encoding/json"
	"testing"
)

func TestStatusRoundTrip(t *testing.T) {
	tests := []struct {
		status Status
		want   string
	}{
		{StatusPending, `{"id":1,"status":"pending"}`},
		{StatusPaid, `{"id":1,"status":"paid"}`},
		{StatusShipped, `{"id":1,"status":"shipped"}`},
	}

	for _, tt := range tests {
		t.Run(tt.want, func(t *testing.T) {
			data, err := json.Marshal(OrderStatus{ID: 1, Status: tt.status})
			if err != nil {
				t.Fatalf("erreur marshal : %v", err)
			}
			if string(data) != tt.want {
				t.Errorf("got %s, want %s", data, tt.want)
			}

			var back OrderStatus
			if err := json.Unmarshal(data, &back); err != nil {
				t.Fatalf("erreur unmarshal : %v", err)
			}
			if back.Status != tt.status {
				t.Errorf("got %v, want %v", back.Status, tt.status)
			}
		})
	}
}

func TestRejectUnknownField(t *testing.T) {
	dec := json.NewDecoder(bytes.NewBufferString(`{"id":1,"coupon":"X"}`))
	dec.DisallowUnknownFields()

	var o OrderStatus
	if err := dec.Decode(&o); err == nil {
		t.Fatal("erreur unknown field attendue, reçu nil")
	}
}
go test ./...
ok  	example.com/shop	0.003s

Les tests basés sur une table (table-driven) sont une approche courante en Go. Chaque cas s’exécute dans t.Run séparé, donc si un cas échoue, vous voyez immédiatement lequel.

9. Erreurs courantes

  • Le champ n’apparaît pas dans JSON. Presque toujours parce que le nom du champ n’est pas exporté (minuscule). Changez-le en majuscule et utilisez un tag pour nommer le JSON.
  • null au lieu de []. Une slice nil est écrite en null. Si le client a besoin d’un array vide, initialisez Items: []string{}.
  • Perte de précision sur les grands nombres. Se produit lors du décodage dans any. Utilisez une struct avec un type spécifique (int64), ou UseNumber, ou un tag ,string.
  • Les erreurs de type de données sont avalées. Quand Unmarshal rencontre *json.UnmarshalTypeError, il remplit quand même les autres champs. N’utilisez pas cette struct si err != nil.
  • []byte devient base64. C’est un comportement intentionnel. Si vous voulez du texte ordinaire, utilisez string.
  • time.Time suit RFC 3339. Pour un format différent comme dd/MM/yyyy, vous devez écrire un type personnalisé.
  • Appeler json.Marshal à l’intérieur de MarshalJSON avec le même type. Le résultat est un débordement de pile (stack overflow). Utilisez un alias type comme à la section 7.

Note : encoding/json utilise la réflexion, donc ce n’est pas le choix le plus rapide. Pour la plupart des API, sa vitesse est largement suffisante. Ce n’est que si le profiling montre que JSON est un goulot d’étranglement que vous devriez envisager une bibliothèque générant du code. Go 1.25 propose également une version expérimentale encoding/json/v2, activée via GOEXPERIMENT=jsonv2. Cette version n’est pas stable, elle n’est donc pas appropriée pour la production.

10. Conclusion

Vous avez parcouru les opérations principales avec encoding/json :

  • Utilisez Marshal et Unmarshal pour les petites données.
  • Utilisez les struct tag (omitempty, omitzero, string, -) pour contrôler la sortie.
  • Utilisez Decoder avec DisallowUnknownFields et UseNumber pour lire les données de façon plus sûre.
  • Utilisez Encoder et Decoder pour faire du streaming de données.
  • Utilisez TextMarshaler ou MarshalJSON pour les types personnalisés.

Le principe général est de préférer les struct avec des types explicites à la place de map[string]any, et toujours vérifier l’error. Vous devriez aussi écrire des tests round-trip pour les types personnalisés. Ces trois habitudes vous aident à éviter la plupart des erreurs JSON dans les services écrits en Go.

golangjsonencoding/jsonbackend