El equipo de Go publicó este 4 de noviembre la explicación completa de los dos paquetes que estrenó Go 1.27 para trabajar con JSON: encoding/json/v2 y encoding/json/jsontext. Ya no son un experimento: son estables, entran en la promesa de compatibilidad de Go 1 y, por defecto, son más estrictos que el encoding/json de siempre. Rechazan claves duplicadas y UTF-8 inválido, distinguen mayúsculas al leer, escriben los slices nil como [] y leen JSON hasta 10 veces más rápido.
encoding/json está en la biblioteca estándar desde antes de Go 1.0 y es de los paquetes más usados del lenguaje, pero arrastraba decisiones que no se podían corregir sin romper programas existentes. El trabajo para una versión nueva empezó en 2020, cuando Daniel Martí, uno de sus mantenedores, escribió cómo debería ser un hipotético v2, y Joe Tsai, que necesitaba un JSON más estricto para Protocol Buffers, se unió al proyecto. Se presentó en GopherCon y a discusión pública a fines de 2023, se propuso formalmente a inicios de 2025 y llegó como experimento en Go 1.25, detrás de GOEXPERIMENT=jsonv2. Go 1.27, publicado el 19 de agosto, lo dejó estable; la versión actual es la 1.27.3.
La novedad de diseño es separar el trabajo en dos capas. jsontext se encarga de la sintaxis: lee y escribe tokens y valores JSON en streaming, valida el texto y no usa reflection. json/v2 se encarga de la semántica: decide cómo un valor de Go se convierte en JSON y al revés. Y el encoding/json de siempre ya no tiene código propio: desde Go 1.27 llama a v2 con un juego de opciones que reproduce el comportamiento antiguo.
Claves de encoding/json/v2
- Misma idea, más funciones.
MarshalyUnmarshalse parecen a los de v1, pero aceptan opciones en cada llamada.MarshalWriteyUnmarshalReadtrabajan directo sobre unio.Writero unio.Reader, yMarshalEncodeyUnmarshalDecode, sobre elEncodery elDecoderdejsontext. - Métodos en streaming. A
MarshalJSONyUnmarshalJSONse sumanMarshalJSONToyUnmarshalJSONFrom, que escriben y leen sobre elEncodero elDecodersin armar un[]byteintermedio. Quien llama también puede definir cómo se serializa cualquier tipo conWithMarshalersyWithUnmarshalers. - Valores por omisión más seguros. UTF-8 inválido y nombres duplicados dan error; los slices y mapas nil salen como
[]y{}; los nombres se comparan distinguiendo mayúsculas;omitemptycambia de significado ytime.Durationya no tiene formato por omisión. - Otras diferencias. v2 no escapa
<,>ni&como HTML, escribe los mapas en orden no determinista (v1 ordenaba las claves, la opciónDeterministiclo recupera) y un arreglo de Go solo se llena con un arreglo JSON del mismo largo. - Rendimiento. Según el equipo de Go,
Marshalva a la par con v1 yUnmarshales hasta 10 veces más rápido. En un servicio de Kubernetes, pasar deUnmarshalJSONaUnmarshalJSONFromal leer especificaciones OpenAPI mejoró el rendimiento en órdenes de magnitud. Prometen más optimizaciones en Go 1.28. - Cambios desde el experimento. Quien probó v2 en Go 1.25 o 1.26 debe revisar su código: desaparecieron la opción de tag
format, la opciónunknown,DiscardUnknownMembersySkipFunc, yinlineahora se llamaembed. - Salida de emergencia. Si la implementación nueva rompe algo en un programa que usa v1, compilar con
GOEXPERIMENT=nojsonv2regresa a la anterior. Es temporal: se quitará en una versión futura.
Probamos cinco de esos cambios con Go 1.27.3 (imagen oficial golang:1.27 de Docker), sin ningún GOEXPERIMENT. Cada programa importa los dos paquetes, v1 con el alias jsonv1, como hace la documentación oficial, y las salidas son las que dio al ejecutarlo.
1. Un slice nil ya no es null. Muchos programas escritos con otras bibliotecas de JSON esperan una lista y fallan si reciben null. Una encuesta del equipo de Go mostró que la mayoría de los usuarios de Go prefería []:
package main
import (
jsonv1 "encoding/json"
"encoding/json/v2"
"fmt"
)
type Pedido struct {
ID int
Productos []string
}
func main() {
p := Pedido{ID: 7} // Productos es un slice nil
a, _ := jsonv1.Marshal(p)
b, _ := json.Marshal(p)
fmt.Println("v1:", string(a))
fmt.Println("v2:", string(b))
}go run ./nil (Go 1.27.3)v1: {"ID":7,"Productos":null}
v2: {"ID":7,"Productos":[]}2. Las claves duplicadas son un error. El estándar JSON no dice qué hacer con ellas: v1 se queda con la última; otros lectores pueden quedarse con la primera o fallar. Esa ambigüedad ya se aprovechó para escalar privilegios, como en la vulnerabilidad CVE-2017-12635 de CouchDB:
package main
import (
jsonv1 "encoding/json"
"encoding/json/v2"
"fmt"
)
type Usuario struct {
Rol string `json:"rol"`
}
func main() {
entrada := []byte(`{"rol": "lector", "rol": "admin"}`)
var u1 Usuario
err := jsonv1.Unmarshal(entrada, &u1)
fmt.Println("v1:", u1.Rol, err)
var u2 Usuario
err = json.Unmarshal(entrada, &u2)
fmt.Println("v2:", err)
}go run ./dup (Go 1.27.3)v1: admin <nil> v2: jsontext: duplicate object member name "rol"
3. Las mayúsculas cuentan. v1 acepta PUERTO, Puerto o puerto para el mismo campo. v2 solo acepta el nombre exacto; los demás se tratan como miembros desconocidos y se ignoran sin error, así que el campo se queda en cero. Para volver a la conducta anterior hay un tag por campo, json:"puerto,case:ignore", y una opción global, json.MatchCaseInsensitiveNames(true):
package main
import (
jsonv1 "encoding/json"
"encoding/json/v2"
"fmt"
)
type Config struct {
Puerto int `json:"puerto"`
}
func main() {
entrada := []byte(`{"PUERTO": 8080}`)
var c1, c2 Config
jsonv1.Unmarshal(entrada, &c1)
json.Unmarshal(entrada, &c2)
fmt.Println("v1:", c1.Puerto)
fmt.Println("v2:", c2.Puerto)
}go run ./caso (Go 1.27.3)v1: 8080 v2: 0
4. omitempty mira el JSON, no el valor de Go. En v2 un campo se omite si se escribiría como null, "", [] o {}. Un 0 o un false ya no cuentan como vacíos. Para omitir ceros de Go está omitzero, que funciona igual en v1 y en v2:
package main
import (
jsonv1 "encoding/json"
"encoding/json/v2"
"fmt"
)
type Producto struct {
Nombre string `json:"nombre"`
Stock int `json:"stock,omitempty"`
Nota string `json:"nota,omitempty"`
}
type ProductoV2 struct {
Nombre string `json:"nombre"`
Stock int `json:"stock,omitzero"`
}
func main() {
p := Producto{Nombre: "Teclado"} // Stock es 0 y Nota está vacía
a, _ := jsonv1.Marshal(p)
b, _ := json.Marshal(p)
c, _ := json.Marshal(ProductoV2{Nombre: "Teclado"})
fmt.Println("v1 omitempty:", string(a))
fmt.Println("v2 omitempty:", string(b))
fmt.Println("v2 omitzero: ", string(c))
}go run ./omit (Go 1.27.3)v1 omitempty: {"nombre":"Teclado"}
v2 omitempty: {"nombre":"Teclado","stock":0}
v2 omitzero: {"nombre":"Teclado"}5. time.Duration necesita que digas cómo escribirlo. v1 lo convierte en un número de nanosegundos que nada en el JSON identifica como tal. v2 da error hasta que eliges un formato; la opción FormatDurationAsNano de v1 recupera el número:
package main
import (
jsonv1 "encoding/json"
"encoding/json/v2"
"fmt"
"time"
)
type Tarea struct {
Espera time.Duration `json:"espera"`
}
func main() {
t := Tarea{Espera: 1500 * time.Millisecond}
a, _ := jsonv1.Marshal(t)
fmt.Println("v1:", string(a))
_, err := json.Marshal(t)
fmt.Println("v2:", err)
c, _ := json.Marshal(t, jsonv1.FormatDurationAsNano(true))
fmt.Println("v2 con opción:", string(c))
}go run ./dur (Go 1.27.3)v1: {"espera":1500000000}
v2: json: cannot marshal from Go time.Duration within "/espera": no default representation
v2 con opción: {"espera":1500000000}Qué cambia para quien ya usa encoding/json
En lo inmediato, nada obligatorio. encoding/json sigue cubierto por la promesa de compatibilidad, se mantendrá de forma indefinida y nadie tiene que migrar. Lo que sí cambió en Go 1.27 es su motor: ahora corre sobre v2, así que los programas existentes ganan velocidad al leer JSON sin tocar una línea, aunque el texto de algunos mensajes de error puede ser distinto. Si una prueba compara mensajes de error literalmente, conviene revisarla.
Para el código nuevo, v2 trae valores por omisión más seguros, y la migración se puede hacer por partes. Como cada diferencia de comportamiento tiene su opción, se puede usar v2 y conservar solo la conducta antigua que un sistema necesita (por ejemplo, jsontext.AllowDuplicateNames(true) si un proveedor manda claves repetidas), o seguir con v1 y endurecer solo una parte. Los dos paquetes conviven: un tipo con MarshalJSON funciona en v2, y uno con MarshalJSONTo funciona en v1. El equipo de Go publicó una guía de migración, y la documentación de encoding/json enumera cada diferencia con la opción que la controla.
El cambio de fondo es de seguridad: en servicios que pasan el mismo JSON por varios lectores, que todos entiendan lo mismo importa más que aceptar cualquier entrada. Seis años después de los primeros borradores, y con buena parte del trabajo hecho por personas que no trabajan en Google, Go tiene un JSON más estricto, más rápido y configurable sin dejar atrás a quien no quiera moverse. Si apenas empiezas con el lenguaje, el curso de Go de decodigo.com cubre structs y tags, y la herramienta JSON a tipos genera los structs de Go a partir de un ejemplo de JSON.
Fuente: The Go Blog, «A new Go API for JSON»; notas de Go 1.27; documentación de encoding/json/v2 y encoding/json/jsontext. Logotipo de portada: proyecto Go.
