json-go-marshaling-unmarshaling-struct-tags

JSON in Go: Marshaling, Unmarshaling, and Struct Tags

  • 5 min

The encoding/json package allows converting Go values into JSON and JSON into Go values. It’s one of those tools you’ll use constantly in APIs.

JSON appears constantly in APIs, configuration files, and document storage. That’s why we’re going to convert Go data to JSON and vice versa.

Unlike JavaScript, where JSON is almost native, in Go (being statically typed) we need a translation process. We call this process Marshaling (packing to JSON) and Unmarshaling (unpacking to Go).

We will use the stable standard package encoding/json and its field tags. Go 1.26 also includes encoding/json/v2, but it is still experimental and not covered by the Go 1 compatibility promise.

Marshaling: from Go to JSON

The process of converting a Go data structure (like a Struct or a Map) into a JSON byte sequence is called Marshaling.

The key function is json.Marshal.

package main

import (
    "encoding/json"
    "fmt"
)

type Usuario struct {
    Nombre string
    Edad   int
    Admin  bool
}

func main() {
    u := Usuario{"Luis", 30, true}

    // Convert the struct to JSON (returns []byte and error)
    jsonData, err := json.Marshal(u)
    if err != nil {
        panic(err)
    }

    // Convert bytes to string to print it
    fmt.Println(string(jsonData))
}
Copied!

Output: {"Nombre":"Luis","Edad":30,"Admin":true}

Only exported fields are processed

Notice the output above. The JSON keys are "Nombre", "Edad". What would happen if we had used nombre (lowercase) in the struct?

The field would disappear from the JSON.

The encoding/json package only serializes exported fields, i.e., those starting with a capital letter. Unexported fields are ignored.

Field Tags

Generally, in APIs we don’t want to return "Nombre", but "nombre" (camelCase) or "first_name" (snake_case).

Since we cannot change the struct field to lowercase, we add metadata using struct tags.

They are strings written in backticks to the right of the type.

type Producto struct {
    ID          int     `json:"id"`            // Rename to "id"
    Nombre      string  `json:"product_name"`  // Rename to "product_name"
    Precio      float64 `json:"price,omitzero"`  // Rename and omit if zero value
    Password    string  `json:"-"`             // Completely IGNORE
}
Copied!

Common options in tags:

  1. json:"name": Changes the key in the JSON.
  2. omitempty: in encoding/json v1 omits false, 0, nil pointers and interfaces, and zero-length strings, arrays, slices, or maps. A zero-value struct is not considered empty.
  3. omitzero: omits the Go zero value and respects an IsZero() bool method if the type defines one. It’s useful for numbers, booleans, and structs like time.Time.
  4. -: the field is always ignored (useful for internal or sensitive data).

Example with Tags

func main() {
    p := Producto{
        ID:       100,
        Nombre:   "Laptop",
        Precio:   0,        // Zero value
        Password: "123",    // Will be ignored
    }

    bytes, err := json.Marshal(p)
    if err != nil {
        return
    }
    fmt.Println(string(bytes))
}
Copied!

Output: {"id":100,"product_name":"Laptop"}

Notice that:

  • id and product_name are lowercase.
  • price has disappeared because it was 0 and had omitzero.
  • Password has disappeared (because it had -).

Unmarshaling: from JSON to Go

The reverse operation is Unmarshaling. It consists of taking a JSON string and populating an existing Struct.

The function is json.Unmarshal(data []byte, v any).

Important: You must pass a POINTER to the target struct. If you pass it by value, Unmarshal will modify a copy and your original struct will remain empty.

func main() {
    jsonInput := `{"id": 50, "product_name": "Mouse", "extra": "ignored data"}`

    var p Producto

    // Pass &p (Pointer) and convert the string to []byte
    err := json.Unmarshal([]byte(jsonInput), &p)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }

    fmt.Printf("Decoded product: %+v\n", p)
}
Copied!

Output: Decoded product: {ID:50 Nombre:Mouse Precio:0 Password:}

When decoding:

  • Maps "product_name" to the Nombre field thanks to the Tag.
  • Ignores by default keys that don’t correspond to the struct (like "extra").

If we want to reject unknown data, we use a json.Decoder and call DisallowUnknownFields() before Decode.

Generic JSON (map[string]any)

What if you receive a JSON and don’t know its structure? Or is it a dynamic JSON where fields change?

In that case, you can unmarshal into a map or an empty interface.

jsonInput := `{"name": "Unknown", "attributes": {"strength": 10}}`

var result map[string]any
if err := json.Unmarshal([]byte(jsonInput), &result); err != nil {
    return
}

fmt.Println(result["name"])

// To access "strength", we need to do a Type Assertion
attributes, ok := result["attributes"].(map[string]any)
if ok {
    fmt.Println(attributes["strength"])
}
Copied!

Although flexible, it forces you to check type assertions. Additionally, numbers are decoded as float64 by default; a Decoder with UseNumber() allows preserving them as json.Number. Whenever possible, it’s preferable to define a struct.

Encoder and Decoder vs Marshal and Unmarshal

  • Marshal/Unmarshal: Work with []byte. They require having all the JSON in memory.
  • Encoder/Decoder: Work with io.Writer/io.Reader and fit well with files, connections, and HTTP bodies.

In a web handler, Encoder allows writing to the ResponseWriter. Marshal is still appropriate if we need the bytes before sending, to calculate a signature, or to ensure encoding works before writing headers. Additionally, Encoder.Encode adds a newline at the end.

// Example in a web handler
func Handler(w http.ResponseWriter, r *http.Request) {
    p := Producto{ID: 1, Nombre: "Stream"}

    w.Header().Set("Content-Type", "application/json")
    if err := json.NewEncoder(w).Encode(p); err != nil {
        log.Printf("encoding JSON response: %v", err)
    }
}
Copied!