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))
}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
}Common options in tags:
json:"name": Changes the key in the JSON.omitempty: inencoding/jsonv1 omitsfalse,0, nil pointers and interfaces, and zero-length strings, arrays, slices, or maps. A zero-value struct is not considered empty.omitzero: omits the Go zero value and respects anIsZero() boolmethod if the type defines one. It’s useful for numbers, booleans, and structs liketime.Time.-: 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))
}Output:
{"id":100,"product_name":"Laptop"}
Notice that:
idandproduct_nameare lowercase.pricehas disappeared because it was 0 and hadomitzero.Passwordhas 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)
}Output:
Decoded product: {ID:50 Nombre:Mouse Precio:0 Password:}
When decoding:
- Maps
"product_name"to theNombrefield 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"])
}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 withio.Writer/io.Readerand 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)
}
}