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.