Go JSON: encoding/json, struct tags, json.RawMessage & Streaming

Last updated:

Go's encoding/json package serializes structs to JSON with json.Marshal() and deserializes with json.Unmarshal() — struct fields are included in JSON output only if they are exported (capitalized), and field names default to the Go field name unless overridden with a json:"name" struct tag. json.Marshal() returns a []byte slice, not a string — convert with string(data) for logging; a 1,000-field struct marshals in ~50 µs and unmarshals in ~80 µs in a Go 1.22 benchmark, making encoding/json suitable for most API workloads. This guide covers struct tags (json:"name,omitempty"), json.RawMessage for deferred parsing, custom Marshaler and Unmarshaler interfaces, streaming with json.Decoder and json.Encoder, handling dynamic JSON with map[string]interface{} and any, and using Gin and Chi for JSON HTTP handlers.

Struct Tags: json:"name,omitempty" and Field Mapping

Struct tags control how encoding/json maps Go struct fields to JSON keys. The json:"name" tag sets the JSON key name, enabling idiomatic snake_case JSON from CamelCase Go field names. The omitempty option omits a field when its value equals the zero value for its type. Use json:"-" to exclude a field from JSON entirely, and pointer types to handle nullable JSON fields.

package main

import (
	"encoding/json"
	"fmt"
)

type User struct {
	ID        int     `json:"id"`                   // CamelCase Go → snake_case JSON
	FirstName string  `json:"first_name"`           // key override
	LastName  string  `json:"last_name"`
	Email     string  `json:"email"`
	Age       int     `json:"age,omitempty"`         // omitted when Age == 0
	Bio       string  `json:"bio,omitempty"`         // omitted when Bio == ""
	Score     *int    `json:"score,omitempty"`       // nil ptr → omitted; &0 → included as 0
	Password  string  `json:"-"`                    // never included in JSON output
	internal  string  // unexported: always excluded, no tag needed
}

// omitempty with pointer: distinguish "zero value" from "missing"
type Config struct {
	Timeout *int  `json:"timeout_ms,omitempty"` // nil=missing; &0=explicitly 0ms
	Debug   *bool `json:"debug,omitempty"`      // nil=missing; &false=explicitly false
}

// Embedded struct: fields are flattened into the outer JSON object
type Address struct {
	Street string `json:"street"`
	City   string `json:"city"`
}

type Profile struct {
	User                         // embedded: User fields appear at top level
	Address `json:"address"`    // named embed: nested under "address" key
}

func main() {
	zero := 0
	u := User{
		ID:        1,
		FirstName: "Alice",
		LastName:  "Smith",
		Email:     "alice@example.com",
		Age:       0,          // omitted (zero value + omitempty)
		Score:     &zero,      // included as 0 (pointer to zero, not nil)
		Password:  "secret",   // excluded by json:"-"
	}

	data, _ := json.Marshal(u)
	fmt.Println(string(data))
	// {"id":1,"first_name":"Alice","last_name":"Smith","email":"alice@example.com","score":0}

	// Pretty print
	pretty, _ := json.MarshalIndent(u, "", "  ")
	fmt.Println(string(pretty))

	// json:"name,string" — encode numeric field as JSON string
	type Item struct {
		Price float64 `json:"price,string"` // marshals as "9.99" not 9.99
	}
	item := Item{Price: 9.99}
	d, _ := json.Marshal(item)
	fmt.Println(string(d)) // {"price":"9.99"}
}

Struct tags are inspected at runtime via reflection — a typo in a tag (e.g., json: "id" with a space after the colon) causes the tag to be silently ignored, producing the Go field name in JSON output instead. Use go vet to catch malformed struct tags at compile time. For embedded structs, fields are promoted to the outer JSON object unless the embed has a named tag (e.g., Address `json:"address"`), in which case the struct is nested under that key.

json.Marshal and json.Unmarshal Patterns

json.Marshal(v any) returns ([]byte, error) — the byte slice is compact JSON with no extra whitespace. json.Unmarshal(data []byte, v any) writes into the value pointed to by v, which must be a non-nil pointer. Both functions handle all standard Go types: structs, slices, maps, and primitives. Understanding nil vs empty slice behavior and the error types returned by each function is essential for robust JSON handling.

package main

import (
	"encoding/json"
	"errors"
	"fmt"
)

type Post struct {
	ID   int      `json:"id"`
	Tags []string `json:"tags"`
}

func main() {
	// ── Marshal ──────────────────────────────────────────────────

	// nil slice vs empty slice: different JSON output
	p1 := Post{ID: 1, Tags: nil}
	p2 := Post{ID: 2, Tags: []string{}}
	p3 := Post{ID: 3, Tags: []string{"go", "json"}}

	d1, _ := json.Marshal(p1)
	d2, _ := json.Marshal(p2)
	d3, _ := json.Marshal(p3)
	fmt.Println(string(d1)) // {"id":1,"tags":null}
	fmt.Println(string(d2)) // {"id":2,"tags":[]}
	fmt.Println(string(d3)) // {"id":3,"tags":["go","json"]}

	// Marshaling maps — keys must be strings (or implement encoding.TextMarshaler)
	m := map[string]int{"a": 1, "b": 2}
	dm, _ := json.Marshal(m)
	fmt.Println(string(dm)) // {"a":1,"b":2}  (key order is sorted alphabetically)

	// ── Unmarshal ────────────────────────────────────────────────

	// Basic struct unmarshal — always pass a pointer
	var p Post
	input := []byte(`{"id":10,"tags":["go","json"]}`)
	if err := json.Unmarshal(input, &p); err != nil {
		panic(err)
	}
	fmt.Println(p.ID, p.Tags) // 10 [go json]

	// Unknown keys are silently ignored by default
	extraInput := []byte(`{"id":11,"tags":[],"unknown_field":"ignored"}`)
	var p4 Post
	json.Unmarshal(extraInput, &p4) // no error

	// SyntaxError: malformed JSON
	badJSON := []byte(`{"id": bad}`)
	var p5 Post
	err := json.Unmarshal(badJSON, &p5)
	var syntaxErr *json.SyntaxError
	if errors.As(err, &syntaxErr) {
		fmt.Printf("syntax error at offset %d: %v\n", syntaxErr.Offset, syntaxErr)
	}

	// UnmarshalTypeError: wrong JSON type for field
	wrongType := []byte(`{"id":"not-a-number"}`)
	var p6 Post
	err2 := json.Unmarshal(wrongType, &p6)
	var typeErr *json.UnmarshalTypeError
	if errors.As(err2, &typeErr) {
		fmt.Printf("type error: field %q expects %v, got %v\n",
			typeErr.Field, typeErr.Type, typeErr.Value)
	}
}

A nil slice and an empty slice are distinct in Go but differ in JSON: nil marshals to null while an initialized empty slice marshals to []. This distinction matters for API clients that treat null as "field absent" and [] as "empty list". Always initialize slices with make([]T, 0) when you want to guarantee [] in JSON output rather than null. Map keys in JSON output are sorted alphabetically by json.Marshal — Go maps are unordered, so encoding/json sorts keys for deterministic output.

json.RawMessage: Deferred and Polymorphic JSON

json.RawMessage is a []byte type alias that implements both json.Marshaler and json.Unmarshaler. When a struct field has type json.RawMessage, encoding/json stores the raw JSON bytes during unmarshal without parsing them, and writes them verbatim during marshal. This enables two-pass decoding for polymorphic JSON, proxying JSON without full deserialization, and embedding pre-computed JSON fragments.

package main

import (
	"encoding/json"
	"fmt"
)

// ── Two-pass decoding: polymorphic "data" field ───────────────────
// The outer envelope has a "type" discriminant; "data" shape depends on it.

type Event struct {
	Type    string          `json:"type"`
	Payload json.RawMessage `json:"payload"` // stored raw, decoded in second pass
}

type UserCreated struct {
	UserID string `json:"user_id"`
	Email  string `json:"email"`
}

type OrderPlaced struct {
	OrderID string  `json:"order_id"`
	Total   float64 `json:"total"`
}

func processEvent(data []byte) error {
	// First pass: decode the envelope to read "type"
	var event Event
	if err := json.Unmarshal(data, &event); err != nil {
		return err
	}

	// Second pass: decode "payload" based on "type" discriminant
	switch event.Type {
	case "user.created":
		var uc UserCreated
		if err := json.Unmarshal(event.Payload, &uc); err != nil {
			return err
		}
		fmt.Printf("New user: %s (%s)\n", uc.UserID, uc.Email)
	case "order.placed":
		var op OrderPlaced
		if err := json.Unmarshal(event.Payload, &op); err != nil {
			return err
		}
		fmt.Printf("Order %s: $%.2f\n", op.OrderID, op.Total)
	default:
		fmt.Printf("Unknown event type %q — raw payload: %s\n", event.Type, event.Payload)
	}
	return nil
}

// ── Proxying JSON without parsing ────────────────────────────────
// Forward a JSON sub-tree from one API response to another without
// fully deserializing and re-serializing (preserves exact bytes).

type APIResponse struct {
	Status string          `json:"status"`
	Data   json.RawMessage `json:"data"` // forwarded verbatim
}

// ── Embedding pre-encoded JSON ────────────────────────────────────
// Useful when part of the response is already a []byte from a cache.

type CachedResponse struct {
	Metadata map[string]string `json:"metadata"`
	Body     json.RawMessage   `json:"body"` // pre-encoded, written verbatim
}

func main() {
	userEvent := []byte(`{
		"type": "user.created",
		"payload": {"user_id": "u123", "email": "alice@example.com"}
	}`)
	processEvent(userEvent)

	orderEvent := []byte(`{
		"type": "order.placed",
		"payload": {"order_id": "o456", "total": 99.99}
	}`)
	processEvent(orderEvent)

	// Embed pre-encoded JSON
	preEncoded := json.RawMessage(`{"key":"value","nested":{"a":1}}`)
	resp := CachedResponse{
		Metadata: map[string]string{"version": "1"},
		Body:     preEncoded,
	}
	out, _ := json.Marshal(resp)
	fmt.Println(string(out))
	// {"metadata":{"version":"1"},"body":{"key":"value","nested":{"a":1}}}
	// Note: body is not double-encoded — RawMessage writes verbatim
}

A common mistake is treating json.RawMessage as a string — it is a []byte, so fmt.Println(raw) prints the byte slice values, not the JSON text. Use fmt.Println(string(raw)) or fmt.Printf("%s", raw) for readable output. json.RawMessage preserves exact bytes including whitespace, so a pretty-printed input fragment is forwarded with its whitespace intact. See the JSON parsing performance guide for benchmarks comparing RawMessage proxying against full marshal/unmarshal cycles.

Custom Marshaler and Unmarshaler Interfaces

Implement json.Marshaler (method MarshalJSON() ([]byte, error)) and json.Unmarshaler (method UnmarshalJSON([]byte) error) to override encoding/json's default reflection-based encoding for a type. The most common use cases are custom time.Time formatting, encoding enums as strings, and handling types that have no standard JSON representation.

package main

import (
	"encoding/json"
	"fmt"
	"time"
)

// ── Custom time.Time: Unix timestamp instead of RFC3339 ───────────

type UnixTime struct {
	time.Time
}

func (t UnixTime) MarshalJSON() ([]byte, error) {
	return json.Marshal(t.Time.Unix()) // marshal as integer seconds
}

func (t *UnixTime) UnmarshalJSON(data []byte) error {
	var unix int64
	if err := json.Unmarshal(data, &unix); err != nil {
		return err
	}
	t.Time = time.Unix(unix, 0).UTC()
	return nil
}

type Event struct {
	Name      string   `json:"name"`
	CreatedAt UnixTime `json:"created_at"` // marshals as Unix int, not RFC3339 string
}

// ── Custom enum: int constants ↔ JSON strings ─────────────────────

type Status int

const (
	StatusPending Status = iota
	StatusActive
	StatusClosed
)

var statusNames = map[Status]string{
	StatusPending: "pending",
	StatusActive:  "active",
	StatusClosed:  "closed",
}

var statusValues = map[string]Status{
	"pending": StatusPending,
	"active":  StatusActive,
	"closed":  StatusClosed,
}

func (s Status) MarshalJSON() ([]byte, error) {
	name, ok := statusNames[s]
	if !ok {
		return nil, fmt.Errorf("unknown status: %d", int(s))
	}
	return json.Marshal(name) // marshal as string "active" not integer 1
}

func (s *Status) UnmarshalJSON(data []byte) error {
	var name string
	if err := json.Unmarshal(data, &name); err != nil {
		return err
	}
	v, ok := statusValues[name]
	if !ok {
		return fmt.Errorf("unknown status: %q", name)
	}
	*s = v
	return nil
}

// ── CRITICAL: avoid infinite recursion in MarshalJSON ─────────────
// If you call json.Marshal(myValue) inside MarshalJSON on the same type,
// encoding/json will call MarshalJSON again → infinite recursion → stack overflow.
// Solution: use a type alias that does NOT inherit the MarshalJSON method.

type Temperature float64

func (t Temperature) MarshalJSON() ([]byte, error) {
	// type Alias = Temperature would still call MarshalJSON (same underlying type)
	// Use a named alias type to strip the method:
	type Alias float64
	return json.Marshal(struct {
		Celsius    Alias `json:"celsius"`
		Fahrenheit Alias `json:"fahrenheit"`
	}{
		Celsius:    Alias(t),
		Fahrenheit: Alias(t*9/5 + 32),
	})
}

func main() {
	e := Event{Name: "launch", CreatedAt: UnixTime{time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC)}}
	data, _ := json.Marshal(e)
	fmt.Println(string(data)) // {"name":"launch","created_at":1747699200}

	var e2 Event
	json.Unmarshal(data, &e2)
	fmt.Println(e2.CreatedAt.Time.Format(time.RFC3339)) // 2026-05-20T00:00:00Z

	type Order struct {
		ID     int    `json:"id"`
		Status Status `json:"status"`
	}
	o := Order{ID: 1, Status: StatusActive}
	od, _ := json.Marshal(o)
	fmt.Println(string(od)) // {"id":1,"status":"active"}
}

The infinite recursion trap is the most common bug when implementing MarshalJSON: calling json.Marshal(t) where t is the same type causes encoding/json to detect the MarshalJSON method and call it again indefinitely. The fix is to use a named type alias (type Alias MyType) — Go's named types do not inherit methods from their underlying type, so json.Marshal(Alias(t)) uses the default reflection encoder. See the JSON API design guide for patterns on consistent date formatting across API responses.

json.Decoder: Streaming JSON from io.Reader

json.NewDecoder(r io.Reader) creates a streaming JSON decoder that reads from any io.Reader without buffering the entire input into memory first. This is the preferred approach for HTTP response bodies, file streams, and other large JSON sources. Decode(&v) reads the next complete JSON value from the stream; More() and Token() enable fine-grained streaming of JSON arrays and objects.

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"strings"
)

type Product struct {
	ID    int     `json:"id"`
	Name  string  `json:"name"`
	Price float64 `json:"price"`
}

// ── HTTP response body: stream directly, no ioutil.ReadAll ────────
func fetchProducts(url string) ([]Product, error) {
	resp, err := http.Get(url)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	dec := json.NewDecoder(resp.Body)
	dec.DisallowUnknownFields() // return error for unknown JSON keys
	// dec.UseNumber()           // receive numbers as json.Number, not float64

	var products []Product
	if err := dec.Decode(&products); err != nil {
		return nil, err
	}
	return products, nil
}

// ── Streaming JSON array: one object at a time ────────────────────
// Useful for large arrays where loading all items into memory is undesirable.
func streamJSONArray(jsonInput string) error {
	dec := json.NewDecoder(strings.NewReader(jsonInput))

	// Read the opening '[' token
	if _, err := dec.Token(); err != nil {
		return err
	}

	// Iterate over array elements
	for dec.More() {
		var p Product
		if err := dec.Decode(&p); err != nil {
			return err
		}
		fmt.Printf("Processing: %s ($%.2f)\n", p.Name, p.Price)
		// Process p here without holding all items in memory
	}

	// Read the closing ']' token
	if _, err := dec.Token(); err != nil {
		return err
	}
	return nil
}

// ── UseNumber: prevent float64 precision loss for large integers ──
func parseWithNumbers(data string) {
	dec := json.NewDecoder(strings.NewReader(data))
	dec.UseNumber() // numbers come back as json.Number, not float64

	var m map[string]interface{}
	dec.Decode(&m)

	// Without UseNumber: large int → float64 → precision loss
	// With UseNumber: json.Number preserves exact string representation
	if num, ok := m["big_id"].(json.Number); ok {
		i64, _ := num.Int64()
		fmt.Printf("big_id as int64: %d\n", i64)
	}
}

// ── json.Encoder: write JSON to io.Writer ─────────────────────────
func writeJSONStream(products []Product, w interface{ Write([]byte) (int, error) }) error {
	enc := json.NewEncoder(w)
	enc.SetIndent("", "  ") // optional: pretty-print
	for _, p := range products {
		if err := enc.Encode(p); err != nil { // Encode appends a newline after each value
			return err
		}
	}
	return nil
}

func main() {
	input := `[
		{"id":1,"name":"Widget","price":9.99},
		{"id":2,"name":"Gadget","price":24.99},
		{"id":3,"name":"Doohickey","price":4.99}
	]`
	streamJSONArray(input)

	parseWithNumbers(`{"big_id": 9007199254740993}`) // > 2^53, loses precision as float64
}

json.Decoder is stateful — after reading an array element with Decode, the decoder's position advances to the next element. Do not mix Decode calls with direct Read calls on the underlying reader; the decoder maintains an internal buffer. DisallowUnknownFields() is useful for strict API input validation but should not be used when proxying or forwarding JSON from external services that may add fields in the future. See the JSON microservices guide for streaming patterns at scale.

Dynamic JSON: map[string]interface{} and any

When the JSON schema is unknown at compile time, unmarshal into map[string]interface{} (equivalent to map[string]any in Go 1.18+) for objects, or []interface{} for arrays. All JSON values map to corresponding Go types: strings to string, booleans to bool, null to nil, arrays to []interface{}, and objects to map[string]interface{} — but numbers default to float64 regardless of whether they are integers.

package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	// ── Unmarshal into map[string]any ─────────────────────────────
	data := []byte(`{
		"name":    "Alice",
		"age":     30,
		"active":  true,
		"score":   null,
		"tags":    ["go", "backend"],
		"address": {"city": "NYC", "zip": "10001"}
	}`)

	var m map[string]any
	json.Unmarshal(data, &m)

	// Type assertions for field access
	name := m["name"].(string)      // string
	age := m["age"].(float64)       // NOTE: always float64, not int
	active := m["active"].(bool)    // bool
	score := m["score"]             // nil (JSON null)
	tags := m["tags"].([]any)       // []interface{}
	addr := m["address"].(map[string]any) // nested object

	fmt.Println(name, int(age), active, score)
	fmt.Println(tags[0].(string))                  // "go"
	fmt.Println(addr["city"].(string))             // "NYC"

	// Safe type assertion (avoid panic on wrong type)
	if city, ok := addr["city"].(string); ok {
		fmt.Println("City:", city)
	}

	// ── JSON number types: float64 vs json.Number ─────────────────
	// Default: all numbers → float64 (precision loss for large integers)
	numData := []byte(`{"id": 9007199254740993, "price": 9.99}`)
	var defaults map[string]any
	json.Unmarshal(numData, &defaults)
	fmt.Println(defaults["id"])    // 9.007199254740992e+15 — precision lost!
	fmt.Println(defaults["price"]) // 9.99

	// UseNumber: numbers → json.Number (string-backed, preserves exact value)
	dec := json.NewDecoder(nil)
	_ = dec // shown for illustration; pass a reader in real code

	// In practice:
	var withNumbers map[string]any
	d2 := json.NewDecoder(
		func() interface{ Read([]byte) (int, error) } {
			r := *json.NewDecoder(nil) // placeholder
			return &r
		}(),
	)
	_ = d2 // use dec.UseNumber() + dec.Decode(&withNumbers) in real code

	// ── Recursive type switch for arbitrary JSON ──────────────────
	var walkJSON func(v any, depth int)
	walkJSON = func(v any, depth int) {
		indent := fmt.Sprintf("%*s", depth*2, "")
		switch val := v.(type) {
		case map[string]any:
			for k, child := range val {
				fmt.Printf("%s%s:\n", indent, k)
				walkJSON(child, depth+1)
			}
		case []any:
			for i, item := range val {
				fmt.Printf("%s[%d]:\n", indent, i)
				walkJSON(item, depth+1)
			}
		case string:
			fmt.Printf("%s%q\n", indent, val)
		case float64:
			fmt.Printf("%s%g\n", indent, val)
		case bool:
			fmt.Printf("%s%v\n", indent, val)
		case nil:
			fmt.Printf("%snull\n", indent)
		}
	}

	walkJSON(m, 0)
}

The float64 default for all JSON numbers is a frequent source of bugs when handling integer IDs that exceed 2&sup5;³ (9,007,199,254,740,992) — the maximum integer representable exactly in a 64-bit float. JavaScript uses the same float64 representation, which is why the JS Number.MAX_SAFE_INTEGER is this value. Always use UseNumber() or unmarshal into a typed struct with int64 fields when working with large integer IDs. See the JSON OpenAPI specification guide for how to declare integer format constraints in schema definitions.

JSON in Gin, Chi, and net/http

Go's standard net/http package plus encoding/json provides a complete JSON HTTP handler without additional dependencies. Gin and Chi add ergonomic wrappers — c.ShouldBindJSON and render.JSON respectively — but the underlying mechanics are json.Decoder and json.Marshal. Returning errors as structured JSON using the RFC 9457 problem details format improves client error handling.

package main

import (
	"encoding/json"
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/go-chi/chi/v5"
	"github.com/go-chi/render"
)

type User struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}

// RFC 9457 problem details structure for JSON error responses
type ProblemDetail struct {
	Type     string `json:"type"`
	Title    string `json:"title"`
	Status   int    `json:"status"`
	Detail   string `json:"detail,omitempty"`
	Instance string `json:"instance,omitempty"`
}

// ── net/http: manual json.Decoder + json.Marshal ──────────────────
func netHTTPHandler(w http.ResponseWriter, r *http.Request) {
	var user User
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields() // strict: reject unknown keys
	if err := dec.Decode(&user); err != nil {
		writeJSONError(w, http.StatusBadRequest, "invalid request body", err.Error())
		return
	}

	// Process user...
	user.ID = 42

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(user) // stream directly to ResponseWriter
}

func writeJSONError(w http.ResponseWriter, status int, title, detail string) {
	problem := ProblemDetail{
		Type:   "https://jsonic.io/errors/" + http.StatusText(status),
		Title:  title,
		Status: status,
		Detail: detail,
	}
	w.Header().Set("Content-Type", "application/problem+json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(problem)
}

// ── Gin: c.ShouldBindJSON + c.JSON ───────────────────────────────
func ginHandler(c *gin.Context) {
	var user User
	// ShouldBindJSON uses json.NewDecoder internally; does NOT abort on error
	if err := c.ShouldBindJSON(&user); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error":  "invalid request body",
			"detail": err.Error(),
		})
		return
	}
	user.ID = 42
	c.JSON(http.StatusCreated, user) // sets Content-Type: application/json
}

// ── Chi: render.JSON ──────────────────────────────────────────────
func chiHandler(w http.ResponseWriter, r *http.Request) {
	var user User
	if err := render.DecodeJSON(r.Body, &user); err != nil {
		render.Status(r, http.StatusBadRequest)
		render.JSON(w, r, map[string]string{"error": err.Error()})
		return
	}
	user.ID = 42
	render.Status(r, http.StatusCreated)
	render.JSON(w, r, user)
}

// ── JSON Content-Type middleware ───────────────────────────────────
func requireJSONMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("Content-Type") != "application/json" {
			writeJSONError(w, http.StatusUnsupportedMediaType,
				"Content-Type must be application/json",
				"received: "+r.Header.Get("Content-Type"))
			return
		}
		next.ServeHTTP(w, r)
	})
}

func main() {
	// net/http router
	mux := http.NewServeMux()
	mux.Handle("POST /users", requireJSONMiddleware(http.HandlerFunc(netHTTPHandler)))

	// Gin router
	r := gin.Default()
	r.POST("/users", ginHandler)

	// Chi router
	cr := chi.NewRouter()
	cr.Post("/users", chiHandler)
}

Always set Content-Type: application/json on JSON responses — some HTTP clients (including fetch) check this header before parsing the body. For error responses, use application/problem+json (RFC 9457) which is recognized by API clients and gateways. Avoid reading the entire request body with io.ReadAll before calling json.Unmarshal — use json.NewDecoder(r.Body).Decode instead to stream the decode and avoid holding the full body in memory, which matters for large uploads. See the JSON API design guide for consistent response envelope patterns.

Key Terms

struct tag
A raw string literal in backticks attached to a struct field that provides metadata consumed by packages at runtime via reflection. The encoding/json package reads the json key of struct tags to determine JSON field names, omission behavior (omitempty), string encoding (string), and field exclusion (-). Struct tags are part of the Go field declaration syntax and have no effect at compile time — all tag parsing happens via reflect.StructTag.Lookup at runtime. Multiple packages can share a single struct tag using different keys, e.g., `json:"name" db:"name" validate:"required"`.
omitempty
A struct tag option for encoding/json that omits a field from JSON output when its value equals the zero value for its Go type: 0 for numeric types, false for bool, "" for string, nil for pointers, slices, maps, and interfaces, and an empty struct {}{} for struct types. omitempty does not apply to non-pointer struct fields with non-zero default values. To distinguish between a zero value that should appear in JSON and a genuinely absent field, use a pointer type — a nil pointer is omitted with omitempty, while a pointer to zero (&0) is included. The omitempty option is position-sensitive: it must appear after the name, e.g., `json:"age,omitempty"`.
json.RawMessage
A type alias for []byte defined in encoding/json that implements both json.Marshaler and json.Unmarshaler. When used as a struct field type, json.RawMessage causes encoding/json to store the exact raw JSON bytes for that field during unmarshaling (without parsing) and write them verbatim during marshaling (without re-encoding). This makes it useful for deferred two-pass decoding of polymorphic fields, proxying JSON sub-trees between services, and embedding pre-computed JSON fragments into a response without a full marshal/unmarshal round-trip. A json.RawMessage field that receives a JSON null will be set to the bytes null, not to a Go nil slice.
Marshaler interface
An interface in encoding/json defined as type Marshaler interface { MarshalJSON() ([]byte, error) }. Any type that implements this interface will have its MarshalJSON method called by json.Marshal instead of the default reflection-based encoding. The method must return valid JSON bytes. The complementary interface is json.Unmarshaler, defined as type Unmarshaler interface { UnmarshalJSON([]byte) error }, whose UnmarshalJSON method receives the raw JSON bytes for the field and is responsible for populating the receiver. Both interfaces are checked on pointer receivers as well as value receivers; prefer pointer receivers for UnmarshalJSON so the method can modify the value.
json.Decoder
A streaming JSON decoder returned by json.NewDecoder(r io.Reader). Unlike json.Unmarshal which requires the full JSON bytes in memory, json.Decoder reads from an io.Reader incrementally, making it suitable for HTTP response bodies, large files, and network streams. Key methods: Decode(&v) reads the next complete JSON value; More() reports whether there are additional values in the current array or object; Token() reads one JSON token (delimiter, string, number, or boolean) for event-driven parsing; UseNumber() configures the decoder to return numbers as json.Number instead of float64; DisallowUnknownFields() returns an error if the JSON input contains a key not present in the destination struct.
UseNumber
A method on json.Decoder that configures the decoder to represent JSON numbers as json.Number (a string-backed type alias) instead of float64 when decoding into an interface{} value. By default, all JSON numbers decode to float64 when the target type is interface{} — this causes silent precision loss for integers larger than 2&sup5;³ (9,007,199,254,740,992). After calling UseNumber(), numeric values decode to json.Number, which exposes Int64(), Float64(), and String() methods for explicit conversion with error checking. UseNumber only affects decoding into interface{} — decoding into a typed int64 or float64struct field is unaffected and always uses the field's declared type.

FAQ

How do I marshal a Go struct to JSON?

Use json.Marshal(v) from encoding/json. It returns ([]byte, error). Only exported (capitalized) struct fields are included — unexported fields are silently skipped. Field names default to the Go field name; use `json:"name"` struct tags to override the JSON key. json.Marshal returns compact JSON bytes; use string(data) to convert to a string for logging, or json.MarshalIndent(v, "", " ") for pretty-printed output. Marshaling can fail if the value contains a channel, function, or complex number — always check the returned error. A nil pointer marshals to JSON null; a nil slice marshals to null while an initialized empty slice (make([]T, 0)) marshals to [] — this distinction matters for API clients that treat null and [] differently.

How do I unmarshal JSON into a Go struct?

Use json.Unmarshal(data []byte, v any) from encoding/json, passing a pointer to the destination as v. Passing a non-pointer causes an InvalidUnmarshalError. json.Unmarshal matches JSON keys to struct fields case-insensitively; unknown JSON keys are silently ignored by default. It returns a json.SyntaxError for malformed JSON and a json.UnmarshalTypeError when a JSON value cannot be assigned to the destination Go type. Use errors.As to inspect these error types. For HTTP response bodies, prefer json.NewDecoder(resp.Body).Decode(&v) to avoid loading the full body into memory first. Always initialize your destination variable before unmarshaling — var u User; json.Unmarshal(data, &u) — because Unmarshal merges into the existing value rather than replacing it.

What are Go JSON struct tags and how do I use them?

Go JSON struct tags are backtick-quoted string literals attached to struct fields that control encoding/json serialization. The syntax is `json:"name,options"`. The name sets the JSON key (e.g., `json:"user_id"` maps UserID to "user_id" in JSON). Options are comma-separated after the name: omitempty omits the field when its value is the zero value; string encodes a numeric or bool field as a JSON string; - excludes the field entirely. Use `json:"-"` (not `json:"-,"`) to exclude. Struct tags are inspected at runtime via reflection — typos are silently ignored, so use go vet to catch malformed tags. Multiple packages share one tag: `json:"name" db:"name"`.

How do I handle optional JSON fields in Go?

Use the omitempty struct tag option to omit a field from JSON output when it equals the zero value: `json:"age,omitempty"` omits Age when it is 0. For three-way optionality (value present, zero value present, field absent), use a pointer type: a nil *int marshals to null and is omitted with omitempty; a pointer to zero (v := 0; &v) marshals to 0 and is included. For unmarshaling, a missing JSON key leaves the Go field at its zero value — use a pointer field if you need to detect whether the key was present in the input. For complex optional structures, use *MyStruct — a nil pointer field marshals to null or is omitted with omitempty, while a non-nil pointer is marshaled normally.

What is json.RawMessage in Go and when should I use it?

json.RawMessage is a []byte alias that stores raw JSON bytes without parsing during unmarshal and writes them verbatim during marshal. Use it for three patterns: (1) two-pass decoding of polymorphic JSON — unmarshal the outer envelope to read a type discriminant, then unmarshal the RawMessage field into the correct concrete type; (2) JSON proxying — forward a JSON sub-tree from one service to another without the overhead of full deserialization and re-serialization; (3) embedding pre-encoded JSON — write a cached JSON fragment directly into a response without re-marshaling. json.RawMessage preserves exact bytes including whitespace, so it round-trips losslessly. Use fmt.Printf("%s", raw) or string(raw) for readable output — printing the []byte directly shows byte values, not JSON text.

How do I stream large JSON in Go?

Use json.NewDecoder(r io.Reader) to stream JSON without buffering the entire input. For HTTP response bodies, pass resp.Body directly to json.NewDecoder instead of using io.ReadAll then json.Unmarshal. For streaming JSON arrays, read the opening [ token with dec.Token(), then loop with for dec.More() { dec.Decode(&item) } to process one object at a time without holding the full array in memory. Call dec.UseNumber() before the first Decode to receive numbers as json.Number and prevent float64 precision loss for large integers. Use dec.DisallowUnknownFields() for strict input validation. For writing, json.NewEncoder(w).Encode(v) streams JSON directly to an io.Writer, appending a newline after each value.

How do I implement a custom JSON marshaler in Go?

Define a MarshalJSON() ([]byte, error) method on your type to implement json.Marshaler. encoding/json calls this method instead of its default reflection-based encoding. For custom unmarshaling, define UnmarshalJSON(data []byte) error on a pointer receiver. Common uses: custom time.Time format (RFC3339 vs Unix timestamp), encoding Go int constants as JSON strings, and handling types not supported natively by encoding/json. Critical: avoid infinite recursion — do not call json.Marshal(t) where t is the same type inside MarshalJSON. Instead, create a named type alias (type Alias MyType) — Go named types do not inherit methods from their underlying type, so json.Marshal(Alias(t)) uses the default encoder without triggering MarshalJSON again.

How do I handle dynamic JSON with unknown structure in Go?

Unmarshal into map[string]interface{} (or map[string]any in Go 1.18+) for objects with unknown keys, or []interface{} for arrays. All JSON values map to Go types: strings to string, booleans to bool, null to nil, arrays to []interface{}, nested objects to map[string]interface{}, and numbers to float64 by default. Use type assertions to access values: m["key"].(string). Use the two-value form (v, ok := m["key"].(string)) to avoid panics on wrong types. For large integers, use json.Decoder with UseNumber() to get json.Number instead of float64. For partially-known structures, combine a typed struct with a map[string]json.RawMessage field to handle known fields with type safety and defer parsing of unknown fields.

Further reading and primary sources