DEV Community

Cover image for The Unstable Address: A Deep Dive Into Why Go Maps Aren't Directly Modifiable
mahdi
mahdi

Posted on

The Unstable Address: A Deep Dive Into Why Go Maps Aren't Directly Modifiable

If you've spent any time with Go, you've almost certainly run into this famous compile error: cannot assign to struct field in map. It usually happens when you're trying to do something that feels completely natural.

You have a map of structs, and you just want to change one little thing.

package main

type Book struct {
    Title string
    Pages int
}

func main() {
    library := make(map[string]Book)
    library["gopl"] = Book{Title: "The Go Programming Language", Pages: 380}


    library["gopl"].Pages = 400 //  Error: cannot assign to struct field in map
}
Enter fullscreen mode Exit fullscreen mode

This error can be confusing. Why can't you do this? The answer reveals a core design philosophy of Go: a deep commitment to memory safety and predictability. Let's walk through the "why" and explore the right way to handle this situation.

The Mystery of the Moving Buckets

The root of the issue lies in the nature of Go's maps. A map is a hash table, a dynamic data structure designed for fast lookups. To stay efficient, it needs to be able to grow.

Think of a map as a magical, self-organizing filing cabinet. When you start adding too many files, the cabinet magically adds more drawers and rearranges the existing files to keep everything evenly distributed and easy to find.

This "rearranging" is the key. When a Go map grows, it allocates a new, larger block of memory for its data (called buckets) and moves the existing elements from the old location to the new one.

This means the memory address of a value you place inside a map is not stable. It could be in one place now, and a completely different place later.

The Golden Rule: Addressability

This brings us to Go's golden rule for memory safety: you can only get a pointer to something that has a stable memory location. This "something" is called an addressable value.

Because map elements can move at any time, they are not addressable.

The Go compiler forbids you from taking the address of a map element (&myMap["key"]) to prevent a catastrophic bug: creating a dangling pointer. A dangling pointer is a pointer that points to a memory location that no longer holds the data you expect, leading to unpredictable crashes and data corruption.

The error cannot assign to struct field is the compiler's way of enforcing this rule. Modifying a field (.Pages = 400) requires getting a temporary pointer to the struct, and since the struct is in a map, that's not allowed.

Visualizing The Two Kinds of Maps

  1. Map of Values (The Problematic Case) map[string]Book In this map, the Book structs live directly inside the map's buckets. When the map rearranges its buckets, the data itself moves.

  1. Map of Pointers (The Idiomatic Solution) map[string]*Book Here, the map's buckets only contain pointers. The actual Book structs live elsewhere in a stable location (the heap). When the map rearranges, it only moves the pointers. The data itself stays put.


Because the data's address is stable, you can safely modify it through the pointer.

The Ultimate Proof: Peeking Under the Hood

For those who want to see this rearrangement happen with their own eyes, we can use Go's unsafe package to peek directly at the memory addresses the map uses.

Warning: This code is for demonstration only. Using the unsafe package breaks Go's safety guarantees and should be avoided in production code.

This program prints the memory address of a map's internal "bucket array" before and after forcing it to grow.

package main

import (
    "fmt"
    "reflect"
    "unsafe"
)

type Book struct {
    Index int
    Pages int
}


type hmap struct {
    count     int
    flags     uint8
    B         uint8
    noverflow uint16
    hash0     uint32
    buckets   unsafe.Pointer 
}

func bucketArrayPtr(m interface{}) unsafe.Pointer {
    h := (*hmap)(unsafe.Pointer(reflect.ValueOf(m).Pointer()))
    return h.buckets
}

func main() {
    vm := make(map[int]Book)
    vm[1] = Book{Index: 1}
    fmt.Printf("Value-map buckets @ %p\n", bucketArrayPtr(vm))

    // Force a grow
    for i := 2; i <= 500; i++ {
        vm[i] = Book{Index: i}
    }
    fmt.Printf("Value-map buckets @ %p (after grow)\n\n", bucketArrayPtr(vm))

    pm := make(map[int]*Book)
    pm[1] = &Book{Index: 1}
    heapAddr := pm[1] 

    fmt.Printf("Pointer-map buckets @ %p, Book #1 @ %p\n", bucketArrayPtr(pm), heapAddr)

    // Force a grow
    for i := 2; i <= 500; i++ {
        pm[i] = &Book{Index: i}
    }
    fmt.Printf("Pointer-map buckets @ %p (after grow), Book #1 @ %p\n", bucketArrayPtr(pm), pm[1])
}
Enter fullscreen mode Exit fullscreen mode

The Output and What It Means
When you run this, you'll see something like this:

value-map buckets @ 0xc00010c000
value-map buckets @ 0xc00012a000 (after grow)

pointer-map buckets @ 0xc000138000, Book#1 @ 0xc00000a0f0
pointer-map buckets @ 0xc000150000 (after grow), Book#1 @ 0xc00000a0f0
Enter fullscreen mode Exit fullscreen mode

1- Value-Map: The bucket address changed. This is definitive proof that the data stored inside the map moved.

2- Pointer-Map: The bucket address changed, but the address of Book #1 on the heap stayed exactly the same.

This code provides concrete evidence of our theory. The map's internal storage is unstable, but the data you point to from within it can be perfectly stable.

The Solutions: Two Ways Forward

So, how do you fix the original code?

Solution 1: The "Copy-Modify-Replace" Pattern

If you must use a map of values, you have to follow this three-step dance:

1- Copy the struct out of the map into a temporary variable.

2- Modify the temporary variable.

3- Replace the value in the map with your modified copy.

library := make(map[string]Book)
library["gopl"] = Book{Title: "The Go Programming Language", Pages: 380}

tmp := library["gopl"]   // 1. Copy
tmp.Pages = 400          // 2. Modify
library["gopl"] = tmp    // 3. Replace
Enter fullscreen mode Exit fullscreen mode

This works, but it's verbose and can be inefficient if the struct is large.

Solution 2: The Go Way - Use Pointers

The more common and idiomatic solution is to simply store pointers in your map from the beginning.

library := make(map[string]*Book)
library["gopl"] = &Book{Title: "The Go Programming Language", Pages: 380}

library["gopl"].Pages = 400 
Enter fullscreen mode Exit fullscreen mode

This is cleaner, more efficient, and communicates your intent to modify the values.

Conclusion: A Feature, Not a Bug

The inability to directly modify a value in a map isn't a flaw in Go; it's a deliberate design decision. A look at the Go team's internal discussions (like GitHub Issue #3117) shows they chose predictability and memory safety over a minor syntactic convenience.

So the next time you encounter this error, don't see it as a limitation. See it as Go protecting you from a whole class of subtle, dangerous bugs. It's a reminder to be explicit about your data, leading to code that is safer, clearer, and easier to reason about.

Resources

Issue #3117

Addressable values in Go (and unaddressable ones too)

Go Class: 12 Structs, Struct tags & JSON

Top comments (0)