How does JSON marshaling/unmarshaling work in Go?

5 minbeginnergojsonencoding-jsonnet-http

Quick Answer

json.Marshal(v) converts a Go value into a JSON []byte; json.Unmarshal(data, &v) parses JSON bytes into a Go value, using reflection to match JSON object keys to struct fields. Field matching uses the json:"..." struct tag if present, or falls back to a case-insensitive match against the field's Go name. Only exported struct fields (capitalized) can be marshaled or unmarshaled at all, since reflection can't see unexported fields. For HTTP handlers, json.NewEncoder(w).Encode(v) and json.NewDecoder(r.Body).Decode(&v) are the idiomatic streaming variants, writing/reading directly to/from an io.Writer/io.Reader without needing an intermediate []byte buffer.

Detailed Answer

encoding/json is reflection-driven, which is convenient but has a few sharp edges worth knowing.

Basic marshal and unmarshal

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

u := User{ID: 1, Name: "Alice"}
b, _ := json.Marshal(u)
fmt.Println(string(b))  // {"id":1,"name":"Alice"}

var u2 User
json.Unmarshal([]byte(`{"id":2,"name":"Bob"}`), &u2)
fmt.Println(u2.Name)  // "Bob"

Streaming directly to/from HTTP, without a []byte buffer

func getUserHandler(w http.ResponseWriter, r *http.Request) {
    user := User{ID: 1, Name: "Alice"}
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)   // writes directly to the response, no intermediate []byte
}

func createUserHandler(w http.ResponseWriter, r *http.Request) {
    var u User
    if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return
    }
    // ... use u ...
}

Encoder/Decoder avoid allocating a full intermediate []byte for the whole payload, which matters for large request/response bodies.

Unexported fields are invisible to JSON

type Account struct {
    Balance float64 `json:"balance"`
    secret  string   // unexported: never appears in Marshal output, never populated by Unmarshal
}

Reflection, which encoding/json relies on entirely, can't see unexported fields — this is a common source of confusion when a field mysteriously never shows up in serialized output.

A common gotcha: unmarshaling into an interface

var data map[string]interface{}
json.Unmarshal([]byte(`{"count": 5}`), &data)
fmt.Printf("%T\n", data["count"])  // float64, not int!

encoding/json always decodes JSON numbers into float64 when the target type is interface{}, since JSON itself doesn't distinguish integer and floating-point number types. This surprises people expecting an int back.

Related Resources