Leggere e scrivere JSON in Go con la standard library

Prima o poi in Go finisci a leggere JSON: la risposta di un’API, un file di configurazione, i dati che un servizio scrive su disco. La buona notizia è che serve solo encoding/json, che sta nella standard library: nessuna dipendenza da installare.

La parte che confonde all’inizio non è la sintassi, è la scelta tra i due approcci possibili. Vediamoli entrambi su un caso concreto.

Il file di partenza

Prendiamo le rilevazioni di una stazione di sensori, sensori.json. È un esempio utile perché ha annidamento e tipi diversi, non solo stringhe:

{
  "station": "bergamo-01",
  "readings": [
    {
      "sensor": "indoor-temp",
      "value": 21.4,
      "unit": "C",
      "timestamp": "2026-09-12T08:30:00Z",
      "meta": { "floor": 1, "room": "studio" }
    }
  ]
}

Approccio 1: le struct

Quando sai che forma hanno i dati, li mappi su delle struct. Un tipo per ogni livello di annidamento:

type Station struct {
	Name     string    `json:"station"`
	Readings []Reading `json:"readings"`
}

type Reading struct {
	Sensor    string    `json:"sensor"`
	Value     float64   `json:"value"`
	Unit      string    `json:"unit"`
	Timestamp time.Time `json:"timestamp"`
	Meta      Meta      `json:"meta"`
}

type Meta struct {
	Floor int    `json:"floor"`
	Room  string `json:"room"`
}

Le stringhe fra apici inversi sono i tag: dicono a quale chiave JSON corrisponde ogni campo. Servono perché le convenzioni non coincidono: in JSON le chiavi sono minuscole, in Go un campo deve iniziare per maiuscola per essere esportato, e senza esportazione encoding/json non riesce nemmeno a scriverci dentro. È l’errore numero uno di chi inizia: campi minuscoli che restano misteriosamente vuoti, senza che venga segnalato alcun errore.

Nota Timestamp time.Time: non devi convertire la data a mano. time.Time sa decodificarsi da una stringa in formato RFC 3339, che è quello che praticamente ogni API produce.

Leggere il file

f, err := os.Open("sensori.json")
if err != nil {
	log.Fatalf("apertura file: %v", err)
}
defer f.Close()

var s Station
if err := json.NewDecoder(f).Decode(&s); err != nil {
	log.Fatalf("decodifica JSON: %v", err)
}

Quasi tutti gli esempi in circolazione usano os.ReadFile seguito da json.Unmarshal. Funziona, ma carica l’intero file in memoria prima di iniziare a decodificarlo. json.NewDecoder legge in streaming: su un file da qualche kilobyte è indifferente, su un export da 200 MB è la differenza tra funzionare e no. A parità di righe scritte, tanto vale prendere l’abitudine giusta.

L’altro dettaglio è la & davanti a s. Va passato un puntatore, perché la funzione deve poter scrivere nella tua variabile. Passando il valore compila lo stesso ma non decodifica niente.

A quel punto i dati sono normali valori Go:

fmt.Printf("stazione %s, %d rilevazioni\n", s.Name, len(s.Readings))
for _, r := range s.Readings {
	fmt.Printf("  %-16s %6.1f %s  %s\n",
		r.Sensor, r.Value, r.Unit, r.Timestamp.Format("15:04"))
}
stazione bergamo-01, 2 rilevazioni
  indoor-temp        21.4 C  08:30
  indoor-humidity    48.2 %  08:30

Approccio 2: dati di forma ignota

A volte la forma non la conosci, o cambia. In quel caso si decodifica dentro una mappa generica:

var generico map[string]any
if err := json.Unmarshal(data, &generico); err != nil {
	log.Fatal(err)
}

Comodo, ma il prezzo si paga subito: ogni valore è un any, quindi per usarlo devi verificarne il tipo a mano.

readings, ok := generico["readings"].([]any)
if !ok {
	log.Fatal("campo readings assente o di tipo inatteso")
}
for _, r := range readings {
	m, ok := r.(map[string]any)
	if !ok {
		continue
	}
	fmt.Printf("  %v = %v (%T)\n", m["sensor"], m["value"], m["value"])
}

Due cose da sapere prima di usarlo sul serio.

Tutti i numeri diventano float64. Non esiste il tipo intero in JSON, e il decoder generico non ha una struct da cui dedurre che volevi un int. Il %T sopra stampa float64 anche per un valore scritto come 1. Su valori grandi si perde precisione senza nessun avviso.

La forma valore, ok := x.(tipo) non è opzionale. Se ometti ok e l’asserzione fallisce, il programma va in panic. Su dati esterni, che per definizione non controlli, è garanzia di crash in produzione.

Quando usare cosa

Le struct praticamente sempre. Sono più verbose da scrivere una volta, ma poi il compilatore lavora per te: un campo che non esiste è un errore di compilazione, non un valore nil scoperto a runtime. E i campi del JSON che non ti servono puoi semplicemente ometterli dalla struct, vengono ignorati.

La mappa generica ha senso in tre casi: stai esplorando dei dati per capire come sono fatti, devi far passare un pezzo di JSON senza toccarlo, oppure la struttura è davvero variabile. Fuori da lì, stai rimandando il problema.

Scrivere JSON

Il percorso inverso usa gli stessi tag:

type Reading struct {
	Sensor string  `json:"sensor"`
	Value  float64 `json:"value"`
	Note   string  `json:"note,omitempty"`
	cached bool
}

r := Reading{Sensor: "indoor-temp", Value: 21.4, cached: true}
b, _ := json.Marshal(r)
fmt.Println(string(b))
{"sensor":"indoor-temp","value":21.4}

Due comportamenti visibili in questo output. omitempty fa sparire note perché è la stringa vuota, utile per non sporcare il JSON con campi nulli. E il campo cached, minuscolo quindi non esportato, non compare affatto: è la stessa regola di prima, vista dall’altro lato.

Per scrivere su file o su una risposta HTTP, l’equivalente in streaming del decoder:

enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", "  ")
enc.Encode(r)

Gli errori, leggerli

Un ultimo consiglio pratico: gli errori di encoding/json sono precisi, ma solo se li stampi. Se il JSON contiene "value": "21.4", cioè una stringa dove ti aspetti un numero, ottieni:

json: cannot unmarshal string into Go struct field Reading.value of type float64

Nome del campo e tipo atteso, cioè tutto quello che serve. Il motivo per cui tanti ci perdono tempo è che l’errore viene scartato con _ invece di essere letto.

← Tutti i post