Leer y escribir JSON en Go con encoding/json
1. Descripción general
JSON es el formato de intercambio de datos más común entre servicios. Go soporta JSON de forma nativa en su librería estándar a través del paquete encoding/json, no necesitas instalar nada adicional.
Este artículo te ayuda a convertir structs a JSON y viceversa, controlar nombres de campos con struct tags, manejar JSON dinámico y hacer streaming de datos grandes. También aprenderás cómo escribir tipos personalizados y probarlos. Al final, veremos errores comunes para que los evites cuando lleves tu código a producción.
Los ejemplos usan Go 1.24 en adelante.
2. Escribir JSON con json.Marshal
Empecemos con un pedido que contiene información del cliente:
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{"Café", "Sándwich vietnamita"},
Total: 45000,
Note: "entregar antes de las 9am",
createdBy: "admin",
}
data, err := json.Marshal(order)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))
}
Resultado:
{"id":1001,"customer":{"name":"Lan"},"items":["Café","Sándwich vietnamita"],"total":45000}
Hay varios puntos importantes que notar en el código anterior:
json.Marshaldevuelve[]byteyerror. Siempre debes verificar elerror.- El tag
json:"id"cambia el nombre del campo aiden el JSON. Sin tag, Go usa el nombre del campo original, que seríaID. Emailestá vacío, así que se omite gracias aomitempty.Notetiene el tagjson:"-"así que siempre se omite.createdByempieza con minúscula, es decir, no es exportado.encoding/jsonno ve este campo.- Los caracteres especiales se mantienen en su forma UTF-8 original, no se escapan.
Si necesitas JSON legible, por ejemplo para logs o archivos de configuración, usa json.MarshalIndent:
data, _ := json.MarshalIndent(order.Customer, "", " ")
fmt.Println(string(data))
{
"name": "Lan"
}
3. Struct tags: controla la salida en detalle
El tag json tiene la forma json:"nombre,opción,...". Las opciones más comunes son:
| Tag | Significado |
|---|---|
json:"name" |
Cambia el nombre del campo |
json:",omitempty" |
Omite el campo si es un zero value: "", 0, false, nil, o un slice/map vacío |
json:",omitzero" |
Omite el campo si es un zero value, incluyendo structs como time.Time (Go 1.24+) |
json:",string" |
Escribe números o booleanos como cadenas |
json:"-" |
Siempre lo omite |
omitempty no tiene efecto con structs. El ejemplo a continuación muestra la diferencia:
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 sigue presente porque omitempty no considera que un struct vacío de time.Time esté vacío. PaidAt usa omitzero así que se omite como se esperaba. OrderID se escribe como la cadena "1001" gracias a la opción string. Esto es útil cuando el cliente es JavaScript y no puede leer números enteros de 64 bits con precisión.
4. Leer JSON con json.Unmarshal
En la dirección contraria, pasas un puntero a la variable destino:
input := []byte(`{
"id": 1002,
"Customer": {"name": "Minh", "email": "minh@example.com"},
"items": ["Té de durazno"],
"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:[Té de durazno] Total:20000 Note: createdBy:}
Observa estos dos comportamientos por defecto:
- Las claves se comparan sin distinguir mayúsculas y minúsculas.
"Customer"sigue coincidiendo con el tagcustomer. - Las claves desconocidas como
"coupon"se ignoran silenciosamente. Los campos que no están en el JSON mantienen su valor actual.
Nota: Si pasas un valor en lugar de un puntero, por ejemplo
json.Unmarshal(input, order), obtendrás el errorjson: Unmarshal(non-pointer main.Order). Los datos no se escriben en ningún lugar.
4.1. Rechazar campos desconocidos
Con una API que recibe solicitudes del cliente, ignorar claves desconocidas suele ocultar errores de tipografía en los nombres de campo. Puedes activar el modo estricto mediante 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 solo existe en Decoder, no hay una opción equivalente para json.Unmarshal.
5. JSON dinámico con map[string]any
Cuando no conoces la estructura de antemano, por ejemplo un webhook de un tercero, puedes decodificar a 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
Todo número se decodifica a float64. Por lo tanto, order_id pierde precisión: el valor original termina en 3 pero el resultado termina en 2. Este es un error muy difícil de detectar.
La solución es llamar a UseNumber, para que los números se mantengan como cadenas 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 solo parte del payload es dinámica, puedes usar json.RawMessage para posponer la decodificación de esa parte:
type Event struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
Después de leer Type, llamas a json.Unmarshal(e.Data, &order) con el struct adecuado. Este enfoque es mucho más seguro en cuanto a tipos que buscar cada clave en un map.
6. Hacer streaming de datos con Encoder y Decoder
Marshal y Unmarshal funcionan con []byte, así que todos los datos deben estar en memoria. json.Encoder y json.Decoder trabajan directamente con io.Writer e io.Reader. Son adecuados para cuerpos HTTP, archivos y datos NDJSON (cada línea es 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}
El bucle llama a Decode hasta encontrar io.EOF, leyendo exactamente un objeto cada vez. Encode añade automáticamente un salto de línea después de cada valor. SetEscapeHTML(false) evita que Go convierta <, >, & en \u003c, \u003e, \u0026. Por defecto, Go escapa estos caracteres para que sea seguro incrustar JSON en HTML.
En un manejador HTTP, usa este patrón:
func createOrder(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // máximo 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 limita el tamaño del cuerpo. De esta forma, el cliente no puede enviar un payload masivo que agote la memoria del servidor.
7. Tipos personalizados
Supongamos que el estado del pedido se almacena como int en el código pero debe mostrarse como una cadena en el JSON. Implementas encoding.TextMarshaler y 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("estado no válido: %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("estado no válido: %q", b)
}
Cuando se usa en un 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 usa un valor receptor, así que funciona tanto con Status como con *Status. UnmarshalText requiere un receptor de puntero porque necesita modificar el valor. Usar TextMarshaler en lugar de json.Marshaler tiene una ventaja adicional: el tipo Status se puede usar como clave en un map, por ejemplo map[Status]int.
Cuando necesitas controlar todo el objeto, escribes MarshalJSON. Ten cuidado con la recursión infinita: si dentro llamas a json.Marshal(o) con el mismo tipo, la función se llamará a sí misma. La solución es usar un tipo alias:
func (o Order) MarshalJSON() ([]byte, error) {
type alias Order // alias no tiene el método MarshalJSON
return json.Marshal(struct {
alias
ItemCount int `json:"item_count"`
}{alias(o), len(o.Items)})
}
alias tiene los mismos campos que Order pero no tiene métodos, así que json.Marshal usa el comportamiento predeterminado. El campo incrustado se “aplana” en el objeto externo, más el campo item_count.
8. Escribir pruebas
Las pruebas round-trip, es decir, escribir y luego leer de nuevo, te ayudan a detectar errores cuando alguien cambia un tag o la lógica personalizada. Archivo 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("error de 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("error de 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("se esperaba error de campo desconocido, se recibió nil")
}
}
go test ./...
ok example.com/shop 0.003s
Las pruebas de tabla (table-driven) son la forma habitual de escribir en Go. Cada caso se ejecuta en su propio t.Run, así que cuando un caso falla, ves inmediatamente cuál está mal.
9. Errores comunes
- El campo no aparece en el JSON. Casi siempre se debe a que el nombre del campo está en minúsculas (no exportado). Cámbialo a mayúsculas y usa un tag para establecer el nombre JSON.
nullen lugar de[]. Un slicenilse escribe comonull. Si el cliente necesita un array vacío, inicializaItems: []string{}.- Pérdida de precisión en números grandes. Ocurre cuando decodificas en
any. Usa un struct con tipo específico (int64), oUseNumber, o el tag,string. - El error de tipo de dato se silencia. Cuando
Unmarshalencuentra*json.UnmarshalTypeError, aún así completa los otros campos. No uses ese struct sierr != nil. []bytese convierte en base64. Este es un comportamiento intencional. Si quieres texto sin codificar, usastring.time.Timesegún RFC 3339. Para otros formatos comodd/MM/yyyy, debes escribir un tipo personalizado.- Llamar a
json.Marshaldentro deMarshalJSONcon el mismo tipo. El resultado es un desbordamiento de pila. Usa un tipo alias como en la sección 7.
Nota:
encoding/jsonusa reflection, así que no es la opción más rápida. Para la mayoría de APIs, su velocidad es completamente suficiente. Solo cuando el profiling muestre que JSON es el cuello de botella deberías considerar librerías que generan código. Go 1.25 también tiene una versión experimentalencoding/json/v2, habilitada conGOEXPERIMENT=jsonv2. Esta versión aún no es estable, así que no es adecuada para producción.
10. Conclusión
Has pasado por las operaciones principales con encoding/json:
- Usa
MarshalyUnmarshalpara datos pequeños. - Usa struct tags (
omitempty,omitzero,string,-) para controlar la salida. - Usa
DecoderconDisallowUnknownFieldsyUseNumberpara leer datos de forma más segura. - Usa
EncoderyDecoderpara hacer streaming de datos. - Usa
TextMarshaleroMarshalJSONpara tipos personalizados.
El principio general es preferir structs con tipos explícitos en lugar de map[string]any, y siempre verificar error. También debes escribir pruebas round-trip para los tipos personalizados. Estos tres hábitos te ayudan a evitar la mayoría de errores JSON en los servicios escritos en Go.