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.Marshal devuelve []byte y error. Siempre debes verificar el error.
  • El tag json:"id" cambia el nombre del campo a id en el JSON. Sin tag, Go usa el nombre del campo original, que sería ID.
  • Email está vacío, así que se omite gracias a omitempty.
  • Note tiene el tag json:"-" así que siempre se omite.
  • createdBy empieza con minúscula, es decir, no es exportado. encoding/json no 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:

  1. Las claves se comparan sin distinguir mayúsculas y minúsculas. "Customer" sigue coincidiendo con el tag customer.
  2. 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 error json: 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.
  • null en lugar de []. Un slice nil se escribe como null. Si el cliente necesita un array vacío, inicializa Items: []string{}.
  • Pérdida de precisión en números grandes. Ocurre cuando decodificas en any. Usa un struct con tipo específico (int64), o UseNumber, o el tag ,string.
  • El error de tipo de dato se silencia. Cuando Unmarshal encuentra *json.UnmarshalTypeError, aún así completa los otros campos. No uses ese struct si err != nil.
  • []byte se convierte en base64. Este es un comportamiento intencional. Si quieres texto sin codificar, usa string.
  • time.Time según RFC 3339. Para otros formatos como dd/MM/yyyy, debes escribir un tipo personalizado.
  • Llamar a json.Marshal dentro de MarshalJSON con el mismo tipo. El resultado es un desbordamiento de pila. Usa un tipo alias como en la sección 7.

Nota: encoding/json usa 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 experimental encoding/json/v2, habilitada con GOEXPERIMENT=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 Marshal y Unmarshal para datos pequeños.
  • Usa struct tags (omitempty, omitzero, string, -) para controlar la salida.
  • Usa Decoder con DisallowUnknownFields y UseNumber para leer datos de forma más segura.
  • Usa Encoder y Decoder para hacer streaming de datos.
  • Usa TextMarshaler o MarshalJSON para 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.

golangjsonencoding/jsonbackend