Reading and Writing JSON in Go with encoding/json

1. Overview

JSON is the most common data exchange format between services. Go has built-in JSON support in the standard library through the encoding/json package—you don’t need to install anything extra.

This guide helps you convert structs to JSON and back again, control field names using struct tags, handle dynamic JSON, and stream large data. You’ll also learn how to write custom types and test them. At the end, you’ll see common errors to avoid when shipping code to production.

The examples use Go 1.24 or later.

2. Writing JSON with json.Marshal

Let’s start with an order that contains customer information:

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{"Coffee", "Bread"},
		Total:     45000,
		Note:      "deliver before 9am",
		createdBy: "admin",
	}

	data, err := json.Marshal(order)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(data))
}

Output:

{"id":1001,"customer":{"name":"Lan"},"items":["Coffee","Bread"],"total":45000}

A few things to note in the code above:

  • json.Marshal returns []byte and error. You always need to check error.
  • The tag json:"id" changes the field name to id in JSON. Without a tag, Go uses the original field name ID.
  • Email is empty, so it’s omitted thanks to omitempty.
  • Note has tag json:"-" so it’s always skipped.
  • createdBy starts with a lowercase letter, meaning it’s unexported. encoding/json can’t see this field.
  • UTF-8 characters are preserved as-is and not escaped.

If you need pretty-printed JSON, for example when logging or writing config files, use json.MarshalIndent:

data, _ := json.MarshalIndent(order.Customer, "", "  ")
fmt.Println(string(data))
{
  "name": "Lan"
}

3. Struct tags: controlling output in detail

The json tag has the form json:"name,option,...". Common options:

Tag Meaning
json:"name" Change the field name
json:",omitempty" Omit the field if it’s a zero value: "", 0, false, nil, empty slice or map
json:",omitzero" Omit the field if it’s a zero value, including structs like time.Time (Go 1.24+)
json:",string" Write numbers or bools as strings
json:"-" Always omit

omitempty has no effect on structs. The example below shows the difference:

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 still appears because omitempty doesn’t treat an empty time.Time struct as “empty”. PaidAt uses omitzero so it’s correctly omitted. OrderID is written as the string "1001" thanks to the string option. This is useful when the client is JavaScript and can’t read 64-bit integers accurately.

4. Reading JSON with json.Unmarshal

For the reverse direction, pass a pointer to the destination variable:

input := []byte(`{
	"id": 1002,
	"Customer": {"name": "Minh", "email": "minh@example.com"},
	"items": ["Peach Tea"],
	"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:[Peach Tea] Total:20000 Note: createdBy:}

Notice two default behaviors:

  1. Keys are matched case-insensitively. "Customer" still matches the tag customer.
  2. Unknown keys like "coupon" are silently ignored. Fields not in the JSON keep their current value.

Note: If you pass a value instead of a pointer, like json.Unmarshal(input, order), you’ll get an error json: Unmarshal(non-pointer main.Order). The data isn’t written anywhere.

4.1. Rejecting unknown fields

With APIs that accept requests from clients, ignoring unknown keys often hides typos. You can enable strict mode using 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 only exists on Decoder, not as an option for json.Unmarshal.

5. Dynamic JSON with map[string]any

When you don’t know the structure in advance, such as webhooks from third parties, you can decode into 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

All numbers are decoded as float64. So order_id loses precision: the original value ends in 3 but the result ends in 2. This is a very hard bug to spot.

The workaround is to call UseNumber, which keeps numbers as the string type 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

If only part of the payload is dynamic, you can use json.RawMessage to defer decoding that part:

type Event struct {
	Type string          `json:"type"`
	Data json.RawMessage `json:"data"`
}

After reading Type, you call json.Unmarshal(e.Data, &order) with the appropriate struct. This is much safer type-wise than searching through map keys.

6. Streaming data with Encoder and Decoder

Marshal and Unmarshal work on []byte, so all data must fit in memory. json.Encoder and json.Decoder work directly with io.Writer and io.Reader. They’re ideal for HTTP bodies, files, and NDJSON data (one JSON per line).

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}

The loop calls Decode until it hits io.EOF, reading exactly one object at a time. Encode automatically adds a newline after each value. SetEscapeHTML(false) prevents Go from converting <, >, & to \u003c, \u003e, \u0026. By default Go escapes these for safety when embedding JSON in HTML.

In an HTTP handler, you use this same pattern:

func createOrder(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // max 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 limits the body size. This way clients can’t send huge payloads and drain your server’s memory.

7. Custom types

Suppose order status is stored as int in code but must be displayed as a string in JSON. Implement encoding.TextMarshaler and 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("invalid status: %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("invalid status: %q", b)
}

When used in a 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 uses a value receiver so it works with both Status and *Status. UnmarshalText must use a pointer receiver because it needs to modify the value. Using TextMarshaler instead of json.Marshaler has an added benefit: the Status type can be used as a map key, like map[Status]int.

When you need full control over the object, write MarshalJSON. Beware infinite recursion: if you call json.Marshal(o) inside with the same type, the function calls itself. The fix is to use an alias type:

func (o Order) MarshalJSON() ([]byte, error) {
	type alias Order // alias doesn't have MarshalJSON method
	return json.Marshal(struct {
		alias
		ItemCount int `json:"item_count"`
	}{alias(o), len(o.Items)})
}

alias has the same fields as Order but no methods, so json.Marshal uses the default behavior. Embedded fields are “flattened” into the outer object, plus the new item_count field.

8. Writing tests

Round-trip tests—write then read back—catch bugs when someone changes a tag or custom logic. File 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("marshal failed: %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("unmarshal failed: %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("expected unknown field error, got nil")
	}
}
go test ./...
ok  	example.com/shop	0.003s

Table-driven tests are the idiomatic Go style. Each case runs in its own t.Run, so when one fails, you immediately see which one.

9. Common errors

  • Field not appearing in JSON. Almost always because the field name is lowercase (unexported). Change it to uppercase and use a tag to set the JSON name.
  • null instead of []. A nil slice is written as null. If the client needs an empty array, initialize Items: []string{}.
  • Lost precision on large numbers. Happens when decoding into any. Use a struct with a specific type (int64), UseNumber, or tag with ,string.
  • Type error is silently skipped. When Unmarshal hits a *json.UnmarshalTypeError, it still fills other fields. Don’t use the struct if err != nil.
  • []byte becomes base64. This is intentional. If you want plain text, use string.
  • time.Time follows RFC 3339. For other formats like dd/MM/yyyy, write a custom type.
  • Calling json.Marshal inside MarshalJSON with the same type. Stack overflow results. Use an alias type like in section 7.

Note: encoding/json uses reflection, so it’s not the fastest option. For most APIs, its speed is plenty sufficient. Only when profiling shows JSON is the bottleneck should you consider a code-generation library. Go 1.25 also has an experimental encoding/json/v2, enabled via GOEXPERIMENT=jsonv2. This is not yet stable, so not suitable for production.

10. Conclusion

You’ve covered the main operations with encoding/json:

  • Use Marshal and Unmarshal for small data.
  • Use struct tags (omitempty, omitzero, string, -) to control output.
  • Use Decoder with DisallowUnknownFields and UseNumber for safer input.
  • Use Encoder and Decoder to stream data.
  • Use TextMarshaler or MarshalJSON for custom types.

The general principle is to prefer structs with explicit types over map[string]any, and always check error. You should also write round-trip tests for custom types. These three habits will help you avoid most JSON bugs in Go services.

golangjsonencoding/jsonbackend