Encoding & JSON
The encoding/json package is one of the most used packages in Go. It converts between Go structs and JSON with struct tags controlling the mapping. The same patterns apply to encoding/xml, encoding/csv, and other encoding packages. Understanding JSON encoding well prepares you for all of them.
Marshal & Unmarshal
json.Marshal converts a Go value to JSON bytes. json.Unmarshal does the reverse.
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func main() {
// Struct to JSON
u := User{ID: 1, Name: "Alice", Email: "alice@example.com"}
data, err := json.Marshal(u)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))
// JSON to struct
jsonStr := `{"id":2,"name":"Bob","email":"bob@example.com"}`
var u2 User
err = json.Unmarshal([]byte(jsonStr), &u2)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%+v\n", u2)
}
{"id":1,"name":"Alice","email":"alice@example.com"}
{ID:2 Name:Bob Email:bob@example.com}
Only exported fields (uppercase) are included in JSON output. Unexported fields are silently ignored.
Struct Tags
Struct tags control the JSON field names and behavior.
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
Description string `json:"description,omitempty"`
InternalSKU string `json:"-"`
}
The tag options:
json:"name"— sets the JSON field namejson:"name,omitempty"— omits the field if it has a zero value (empty string, 0, nil, false, empty slice/map)json:"-"— always omits the fieldjson:",string"— encodes a number or bool as a JSON string
func main() {
p := Product{
ID: 42,
Name: "Widget",
Price: 9.99,
Description: "", // zero value — will be omitted
InternalSKU: "WDG-042",
}
data, _ := json.MarshalIndent(p, "", " ")
fmt.Println(string(data))
}
{
"id": 42,
"name": "Widget",
"price": 9.99
}
The Description field is omitted because it is empty and tagged with omitempty. The InternalSKU field is omitted because it is tagged with -.
Custom MarshalJSON & UnmarshalJSON
When the default encoding does not fit, implement the json.Marshaler or json.Unmarshaler interface.
type Timestamp struct {
time.Time
}
func (t Timestamp) MarshalJSON() ([]byte, error) {
formatted := t.Time.Format("2006-01-02T15:04:05Z")
return json.Marshal(formatted)
}
func (t *Timestamp) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
parsed, err := time.Parse("2006-01-02T15:04:05Z", s)
if err != nil {
return err
}
t.Time = parsed
return nil
}
type Event struct {
Name string `json:"name"`
CreatedAt Timestamp `json:"created_at"`
}
This gives you full control over how a type appears in JSON.
Streaming with json.Decoder & json.Encoder
For reading JSON from an io.Reader or writing to an io.Writer, use json.Decoder and json.Encoder instead of Marshal/Unmarshal.
func DecodeFromHTTP(resp *http.Response, v any) error {
defer resp.Body.Close()
return json.NewDecoder(resp.Body).Decode(v)
}
func EncodeToHTTP(w http.ResponseWriter, v any) error {
w.Header().Set("Content-Type", "application/json")
return json.NewEncoder(w).Encode(v)
}
Streaming is more memory-efficient for large payloads because it does not buffer the entire JSON document in memory.
Decoding a Stream of JSON Objects
json.Decoder can read multiple JSON values from a single stream.
func DecodeStream(r io.Reader) ([]Event, error) {
dec := json.NewDecoder(r)
var events []Event
// Read opening bracket
_, err := dec.Token()
if err != nil {
return nil, err
}
for dec.More() {
var e Event
if err := dec.Decode(&e); err != nil {
return nil, err
}
events = append(events, e)
}
return events, nil
}
The json.RawMessage Trick
json.RawMessage delays JSON decoding. It stores raw JSON bytes, letting you inspect a field before deciding how to decode the rest.
type Message struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
type TextPayload struct {
Text string `json:"text"`
}
type ImagePayload struct {
URL string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
}
func HandleMessage(data []byte) error {
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
return err
}
switch msg.Type {
case "text":
var p TextPayload
if err := json.Unmarshal(msg.Payload, &p); err != nil {
return err
}
fmt.Println("Text:", p.Text)
case "image":
var p ImagePayload
if err := json.Unmarshal(msg.Payload, &p); err != nil {
return err
}
fmt.Printf("Image: %s (%dx%d)\n", p.URL, p.Width, p.Height)
default:
return fmt.Errorf("unknown message type: %s", msg.Type)
}
return nil
}
func main() {
textJSON := `{"type":"text","payload":{"text":"hello"}}`
HandleMessage([]byte(textJSON))
imageJSON := `{"type":"image","payload":{"url":"pic.png","width":800,"height":600}}`
HandleMessage([]byte(imageJSON))
}
Text: hello
Image: pic.png (800x600)
This pattern is essential for APIs that use polymorphic JSON — where the structure of one field depends on the value of another.
Working with APIs: Decode the Response Body
A common pattern for consuming JSON APIs in Go.
type APIResponse struct {
Data []User `json:"data"`
Total int `json:"total"`
Page int `json:"page"`
}
func FetchUsers(baseURL string, page int) ([]User, error) {
url := fmt.Sprintf("%s/api/users?page=%d", baseURL, page)
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, body)
}
var apiResp APIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("decode failed: %w", err)
}
return apiResp.Data, nil
}
Other Encoding Packages
The same patterns apply to other encoding packages in the standard library.
encoding/xml
type Feed struct {
XMLName xml.Name `xml:"feed"`
Title string `xml:"title"`
Entries []Entry `xml:"entry"`
}
type Entry struct {
Title string `xml:"title"`
Link string `xml:"link,attr"`
Summary string `xml:"summary"`
}
encoding/csv
func ReadCSV(r io.Reader) ([][]string, error) {
reader := csv.NewReader(r)
return reader.ReadAll()
}
func WriteCSV(w io.Writer, records [][]string) error {
writer := csv.NewWriter(w)
for _, record := range records {
if err := writer.Write(record); err != nil {
return err
}
}
writer.Flush()
return writer.Error()
}
Common Pitfalls
- Unexported fields are ignored. Only fields starting with an uppercase letter are marshaled/unmarshaled. This is the most common source of "my JSON is empty" bugs.
- Forgetting to close the response body. Always
defer resp.Body.Close()after an HTTP call. Failing to close leaks connections. - Decoding into a nil pointer.
json.Unmarshalneeds a pointer to an allocated value. Passing a nil pointer causes a runtime error. - omitempty with zero values. The
omitemptytag omits zero values, which includes0for numbers andfalsefor bools. If zero is a valid value in your domain, do not useomitempty— or use a pointer type sonilis the zero value instead. - Number precision. JSON numbers are decoded as
float64by default when unmarshaling intoany. For large integers, usejson.DecoderwithUseNumber()to preserve precision. - Not handling unknown fields. By default, unknown JSON fields are silently ignored during unmarshaling. Use
json.DecoderwithDisallowUnknownFields()for strict parsing.
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
err := dec.Decode(&v)
Key Takeaways
json.Marshalandjson.Unmarshalhandle conversion between Go values and JSON bytes.- Struct tags (
json:"name,omitempty") control field names and serialization behavior. - Implement
MarshalJSON/UnmarshalJSONfor custom encoding logic. - Use
json.Decoder/json.Encoderfor streaming JSON withio.Reader/io.Writer. json.RawMessageenables flexible schemas where decoding depends on a discriminator field.- The same struct tag and streaming patterns apply to
encoding/xml,encoding/csv, and other encoding packages. - Always check for errors and close response bodies when working with APIs.