Right, so you’ve graduated from simple map[string]int and now you’re getting fancy. You want a map[string][]int. Maybe you’re grouping users by their department, or tracking all the scores for a player. It feels like the right tool for the job, and it is! But this is where you step on the first of several rakes hidden in the grass. The designers gave us a powerful tool, but they forgot to include the safety manual. Let’s write it ourselves.

The core concept here is simple: a map value can be of any type, including slices, other maps, or structs. There’s no magic. But the behavior of these composite types, especially mutable ones like slices and maps, is what causes all the headaches.

The Cardinal Sin: Modifying a Slice in Place

This is the mistake everyone makes once. Only once, if they’re lucky. Let’s set the scene.

teams := map[string][]string{
    "Avengers": {"Iron Man", "Captain America"},
    "X-Men":    {"Wolverine", "Cyclops"},
}

// You want to add "Thor" to the Avengers.
avengers := teams["Avengers"]
avengers = append(avengers, "Thor")
teams["Avengers"] = avengers // This line is your salvation. Never forget it.

fmt.Println(teams["Avengers"]) // [Iron Man Captain America Thor]

See that last assignment? teams["Avengers"] = avengers? That is not optional. When you do avengers := teams["Avengers"], you are copying the slice header (the little struct that contains a pointer to the underlying array, a length, and a capacity) into the variable avengers. Appending to avengers modifies its local copy of the header. The map’s value still holds the old header, pointing to the old array. Without the re-assignment, your change is lost to the ether. The map is oblivious.

Forgetting to re-assign the slice back into the map is like telling your friend a brilliant idea but forgetting to tell your boss. The person who needs to know doesn’t know.

The Nil Map Panic and Proper Initialization

This one bites you when you try to be clever and initialize maps lazily. You check if a key exists, and if it doesn’t, you create a new slice for it. But you have to do it right.

// The WRONG way. This will panic.
var villainLairs map[string][]string // This map is nil. It's not just empty, it's uninitialized.

lair := villainLairs["Vulture"] // This is fine... lair is the zero value for a slice: nil.
lair = append(lair, "Queens Warehouse") // This is ALSO fine. Append works on nil slices.
villainLairs["Vulture"] = lair // PANIC: assignment to entry in nil map

You just tried to assign a value to a key in a nil map. The runtime slaps you with a panic. The map doesn’t exist yet; there’s nowhere to put the key.

// The RIGHT way. Always initialize the map.
villainLairs := make(map[string][]string) // Now it's a non-nil, empty map.

// The safe pattern for appending:
lair, exists := villainLairs["Vulture"]
if !exists {
    // Key doesn't exist? Create a new, empty slice for it.
    lair = make([]string, 0)
}
lair = append(lair, "Queens Warehouse")
villainLairs["Vulture"] = lair // Re-assignment is still crucial!

This pattern is so common it’s tedious. Which is why, if you look at any professional Go codebase, you’ll see the next trick used everywhere.

The One-Liner Elegance (and Why It Works)

We can collapse that entire safe pattern into a single, elegant line. It’s idiomatic, it’s safe, and it’s what you should use 99% of the time.

villainLairs := make(map[string][]string)

// Behold:
villainLairs["Vulture"] = append(villainLairs["Vulture"], "Queens Warehouse")

This looks like it should panic if the key doesn’t exist, but it doesn’t. Here’s the magic: villainLairs["Vulture"] returns the zero value for a slice for a non-existent key, which is nil. And the append function is brilliantly designed to handle nil slices perfectly. append(nil, "Queens Warehouse") allocates a new underlying array and returns a new slice header pointing to it. You then assign this new header back to the key "Vulture" in the map. It works for both the initial creation and subsequent appends. It’s concise, safe, and efficient. This is why append is a built-in function and not something in the standard library—it needs this deep language integration to work this smoothly.

When You Need More Than Append

Sometimes you’re not just appending; you’re modifying elements in place. This is where you need to be acutely aware that you’re dealing with a copy of the slice header, but the underlying array is shared.

scores := map[string][]int{
    "Alice": {85, 92, 78},
}

// Get Alice's scores
aliceScores := scores["Alice"]

// Modify the first score in the slice we just got
aliceScores[0] = 100

// Print what's in the map now
fmt.Println(scores["Alice"]) // [100 92 78]

Wait, that worked without re-assigning! Why? Because aliceScores is a copy of the slice header, but both headers (the one in the map and the one in our variable aliceScores) point to the same underlying array. Modifying an element of the slice modifies the shared array. This is usually what you want, but it can be a source of subtle bugs if you’re not expecting it. You only need to re-assign to the map when the operation (like append) might cause the slice header to change (i.e., it needs to point to a new array due to reallocation).

The same logic applies to maps of maps (map[string]map[int]bool) or maps of structs. For structs, remember you can’t address the map value directly to modify a field (teams["Avengers"][0] = "Spider-Man" is illegal). You must copy the whole struct out, modify it, and assign it back. It’s a bit clunky, but it’s the price of clarity. Now you know the rules. Go forth and don’t panic.