Alright, let’s get our hands dirty with the workhorses of Go’s JSON story: json.Marshal and json.Unmarshal. These two functions are your primary gateway between the structured, type-safe world of Go and the flexible, but loosey-goosey, world of JSON. They seem simple on the surface, but the devil—and the real power—is in the details.

Think of Marshal as your meticulous packer. You give it a Go thing (a struct, a map, a slice), and it carefully wraps it up into a neat []byte parcel, ready to be shipped over the network or dumped into a file. Unmarshal is the unpacker on the other side. It takes that []byte parcel and, with a bit of guidance from you on what you expect to find inside, tries to reassemble it into a Go thing on your side.

Here’s the most basic, “Hello, World” level example. It works exactly as you’d hope.

type Dog struct {
    Name string
    Breed string
    Age int
}

func main() {
    // Marshal: Go struct -> JSON bytes
    myDog := Dog{Name: "Rex", Breed: "German Shepherd", Age: 3}
    jsonBytes, err := json.Marshal(myDog)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(jsonBytes))
    // Output: {"Name":"Rex","Breed":"German Shepherd","Age":3}

    // Unmarshal: JSON bytes -> Go struct
    var newDog Dog
    jsonData := []byte(`{"Name":"Lassie","Breed":"Collie","Age":7}`)
    err = json.Unmarshal(jsonData, &newDog)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("%+v\n", newDog)
    // Output: {Name:Lassie Breed:Collie Age:7}
}

Controlling the Output with Struct Tags

You immediately noticed the output used "Name" and "Age". In Go’s JSON world, that’s considered screaming. The convention is to use lowerCamelCase for JSON keys. This is where struct tags come in—a small piece of metadata that lets you give the json package very specific instructions on how to handle each field.

type Dog struct {
    Name    string `json:"name"`
    Breed   string `json:"breed"`
    Age     int    `json:"age"`
    // The omitempty tag means if the field is a zero value, leave it out of the JSON.
    VetVisits []time.Time `json:"vetVisits,omitempty"`
}

Now, marshaling our dog will output {"name":"Rex","breed":"German Shepherd","age":3}. Beautiful. The omitempty tag is a workhorse for keeping your JSON clean and minimal. But be warned: omitempty considers false, 0, "" (empty string), nil slices/maps, and a time.Time zero value all as “empty”. This is usually what you want, but it’s crucial to know.

The Unmarshal Gambit: map[string]interface{} and Type Uncertainty

Sometimes, you don’t know the structure of the JSON you’re receiving. Maybe it’s a loosely defined API or a user-supplied config file. In these cases, you can throw type safety out the window and unmarshal into a map[string]interface{}.

var unknownData map[string]interface{}
jsonStr := `{"name":"Rex","favoriteToy":{"type":"ball","color":"red"}}`
err := json.Unmarshal([]byte(jsonStr), &unknownData)
if err != nil {
    log.Fatal(err)
}

// You now have to do type assertions to get your values out. It's messy.
name := unknownData["name"].(string)
fmt.Println(name) // Rex

// This is a nested map. The chaos is contagious.
toy := unknownData["favoriteToy"].(map[string]interface{})
toyType := toy["type"].(string)
fmt.Println(toyType) // ball

This is the JSON package’s way of saying, “Fine, you want to play it fast and loose? Here you go, have fun.” It’s powerful, but it’s a recipe for panic-induced runtime errors if you’re not extremely careful with your type assertions. Use this sparingly, like a sharp knife you respect but are slightly afraid of.

When Things Go Sideways: Common Pitfalls

The json package is mostly brilliant, but it has opinions, and you need to understand them to avoid pulling your hair out.

  1. The Unmarshal Target Must Be a Pointer. This isn’t a suggestion. Unmarshal needs to be able to modify the value you’re passing in. If you pass a plain Dog instead of a *Dog, the function will get a copy of your struct, populate that copy, and then throw it away, leaving your original variable blissfully unchanged. You’ll get no error, just a whole lot of nothing. It’s the most common “gotcha.”

  2. Unexported Fields are Invisible. Fields that start with a lowercase letter are not exported. The json package can’t see them, so they will be utterly ignored by both Marshal and Unmarshal. This is by design and is actually a good thing for control.

  3. The Mystery of the Missing Milliseconds. This one is a classic head-scratcher. time.Time marshals to an RFC 3339 string by default, which is great. But when you unmarshal that string back into a time.Time, you lose the original monotonic clock reading. It’s a different Time object now. The package is technically correct (the best kind of correct), but it’s a subtlety that has tripped up many a programmer dealing with high-precision timing.

So there you have it. json.Marshal and Unmarshal are your powerful, sometimes-opinionated allies. Use struct tags to stay in control, be wary of the interface{} trap, and always, always remember to pass a pointer to Unmarshal. Master these basics, and you’ve got 95% of your JSON needs in Go covered.