How does JSON marshaling/unmarshaling work in Go?
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.