Dos hermanos acróbatas: Unmarshal y Decode
Si programas en Go y trabajas con JSON, tarde o temprano te enfrentas a la elección: ¿usar json.Unmarshal o json.NewDecoder.Decode? La pregunta es tan popular que en Stack Overflow ha acumulado miles de visitas y decenas de votos. Pero la respuesta que encontrarás allí a menudo se reduce a "depende de la tarea".
Hoy no daremos una respuesta abstracta. Realizaremos nuestra propia investigación: analizaremos el funcionamiento interno de ambos métodos, escribiremos benchmarks, mostraremos ejemplos reales de código y estableceremos reglas claras sobre cuándo usar cada uno. ¡Abróchense los cinturones, que se pone intenso!
Resumen rápido: ¿qué son Unmarshal y Decode?
Ambas herramientas hacen lo mismo: convierten JSON en una estructura de Go. Pero lo hacen de manera diferente.
json.Unmarshal— recibe como entrada[]bytey un puntero a una estructura. Todo de una vez: los bytes están listos, la estructura está preparada, ¡adelante! Funciona "de frente": deserializa todo el array de datos en memoria.json.NewDecoder.Decode— trabaja con un flujo (io.Reader). Creas un decodificador, le pasas un reader (por ejemplo,os.Fileohttp.Response.Body) y llamas aDecode. El decodificador lee los datos en fragmentos, sin cargar todo en memoria de una vez.
Parecería que la diferencia es obvia: para datos grandes, Decoder; para datos pequeños, Unmarshal. Pero el diablo, como siempre, está en los detalles.
El funcionamiento interno: cómo trabajan bajo el capó
json.Unmarshal — un disparo de precisión
Unmarshal es una función que:
- Recibe un slice de bytes.
- Asigna memoria para la estructura resultante.
- En una sola pasada analiza todo el JSON y llena la estructura.
Todo es simple y elegante. Pero hay un detalle: si pasas a Unmarshal un JSON grande (por ejemplo, 100 MB), primero debes leer esos 100 MB en memoria. Luego, Unmarshal asignará aún más memoria para la estructura. Resultado: consumo doble de RAM.
json.NewDecoder.Decode — el fontanero
Decoder funciona de otra manera:
- Se crea un nuevo decodificador que envuelve un
io.Reader. - El decodificador lee los datos en búferes (por defecto, 4 KB).
- A medida que lee, analiza el JSON token por token y llena la estructura.
La ventaja es que no almacenas todo el JSON en memoria. La desventaja es un poco más de sobrecarga en la gestión del búfer y la lectura en flujo.
Experimento: los benchmarks no mienten
Verifiquemos la teoría en la práctica. Escribamos un benchmark simple para tres escenarios:
- JSON pequeño (1 KB)
- JSON mediano (100 KB)
- JSON grande (10 MB)
package main
import ( "bytes" "encoding/json" "testing")
type Data struct { ID int `json:"id"` Name string `json:"name"` Items []int `json:"items"`}
func generateJSON(size int) []byte { data := Data{ ID: 1, Name: "test", Items: make([]int, size), } for i := 0; i < size; i++ { data.Items[i] = i } b, _ := json.Marshal(data) return b}
func BenchmarkUnmarshal(b *testing.B) { jsonData := generateJSON(1000) // 1 KB var result Data for i := 0; i < b.N; i++ { json.Unmarshal(jsonData, &result) }}
func BenchmarkDecoder(b *testing.B) { jsonData := generateJSON(1000) // 1 KB var result Data for i := 0; i < b.N; i++ { reader := bytes.NewReader(jsonData) json.NewDecoder(reader).Decode(&result) }}Resultados (en mi portátil, Go 1.22):