Why a Read-Only Go Map Still Needs Synchronisation
A Go map that nobody writes to is safe for concurrent reads. That sounds simple, until "nobody writes to it" quietly turns into "nobody writes to the map, but the values contain slices, and the map itself is replaced during reloads".
Read-only is a property of the whole reachable object graph, not just the map variable. You also need to publish the finished map correctly, and you need to decide what callers are allowed to do with the values they get back.
Concurrent reads are fine, if the map is genuinely finished
The built-in Go map supports multiple concurrent readers when the map is no longer being modified. Looking up keys and ranging over the map are both fine in that situation. The Go documentation's warning is about simultaneous reads and writes, not about readers needing a lock merely because they happen to run in different goroutines.
This is a perfectly reasonable pattern:
func serve(m map[string]string, jobs <-chan string) {
for key := range jobs {
value := m[key]
fmt.Println(value)
}
}
func main() {
messages := map[string]string{
"en": "hello",
"fr": "bonjour",
}
jobs := make(chan string)
go serve(messages, jobs)
go serve(messages, jobs)
jobs <- "en"
jobs <- "fr"
close(jobs)
}
The map is constructed before the goroutine starts. The go statement provides the relevant happens-before edge, so the new goroutine can see the initialised map. There are no later writes to the map, and both goroutines only read it.
The dangerous bit is usually publication
Building a map in one goroutine and making it available to another is a publication operation. It needs synchronisation just like any other shared state. A mutex unlock, channel send, goroutine creation, or suitable atomic operation can establish the required ordering.
This version has a race:
var current map[string]string
func reload() {
current = loadMessages()
}
func lookup(key string) string {
return current[key]
}
The problem is not that loadMessages returns a map. The problem is that one goroutine writes the shared variable current while another reads it, with no ordering between those operations. The map may be internally complete, but the publication of the map header is still unsynchronised.
The race detector should make this visible:
go test -race ./...
A completed object can still be unsafely published. This is the same broad memory-model issue you meet with configuration structs, caches, and pointers. Immutability after construction does not remove the need to publish the pointer or map variable safely.
Use a mutex when reloads are simple
A read-write mutex is often the clearest answer when configuration is read frequently and replaced occasionally. Hold the read lock while copying the map reference out, and the write lock while replacing it.
type Config struct {
mu sync.RWMutex
m map[string]string
}
func (c *Config) Lookup(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
value, ok := c.m[key]
return value, ok
}
func (c *Config) Replace(next map[string]string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m = next
}
There is no need to keep the read lock after returning a string, integer, or another immutable value. The lookup copies that value out while the map reference is protected. Do not apply that reasoning blindly to a returned pointer or slice, because the object behind it may still be shared.
A useful refinement is to copy the incoming map before storing it. That prevents the caller from retaining an alias and modifying the supposedly read-only snapshot later:
func cloneMap(src map[string]string) map[string]string {
dst := make(map[string]string, len(src))
for key, value := range src {
dst[key] = value
}
return dst
}
Atomic snapshots work well for replace-only configuration
If the map is replaced as a whole and never edited in place, atomic.Value can make the ownership model explicit. Readers load one snapshot. Reloaders build a new map privately, then publish it in one operation.
type Messages struct {
value atomic.Value // stores map[string]string
}
func (m *Messages) Load(key string) (string, bool) {
messages := m.value.Load().(map[string]string)
value, ok := messages[key]
return value, ok
}
func (m *Messages) Store(next map[string]string) {
m.value.Store(cloneMap(next))
}
Initialise the value before any reader can call Load. Every stored value must have the same concrete type. The map passed to Store is cloned here because atomic.Value protects publication, not the caller's later behaviour.
Quick detour: this is also why an atomic pointer to a configuration struct is not magic. It gives you an atomic snapshot of the pointer. It does not freeze the struct, its slices, its maps, or anything those fields point to. The snapshot boundary is only as strong as the ownership discipline behind it.
A read-only map can return mutable state
Consider a map of slices:
type Registry struct {
byName map[string][]string
}
func (r *Registry) Hosts(name string) []string {
return r.byName[name]
}
The map is never modified, but the returned slice shares its backing array with the registry. A caller can mutate that array, while another goroutine is reading it. The race is now between the slice users, not between map operations.
Return a copy when the registry owns the data:
func (r *Registry) Hosts(name string) []string {
hosts := r.byName[name]
return append([]string(nil), hosts...)
}
The same warning applies to pointer values, nested maps, byte slices, and interface values containing any of those. A map of strings is easy to make read-only. A map of rich objects needs an explicit ownership rule.
Do not confuse a copied map with a copied data structure
Assigning a map to another variable copies a small map descriptor, not all of its entries:
copyOfMap := original
Both variables refer to the same underlying map. The same is true when a map is placed inside a struct and that struct is copied. If one alias writes, every alias is involved in the concurrent access problem.
Making a new map and copying entries gives you a new top-level map, but it is still only a shallow copy. If the values are slices or pointers, the nested objects remain shared.
What to check in a code review
- Find every writer. Search for assignments, deletes, increments, and mutations hidden inside helper functions.
- Check how the map reaches each goroutine. A channel, mutex, goroutine start, or atomic operation should make publication obvious.
- Inspect the value type. Slices, maps, pointers, byte buffers, and interfaces can expose mutable state.
- Check whether a reload replaces the map or edits it in place. Replacement supports snapshot designs; in-place edits require coordination with readers.
- Run
go test -race ./...under realistic tests. The race detector finds executions, so it cannot prove that an untested reload path is safe.
The official Go map guidance says concurrent map reads and writes must be synchronised. The Go memory model adds the part that tends to be missed: shared data must also be published through a happens-before relationship.
So yes, remove the lock around a genuinely immutable map if profiling says it matters. Just make the immutability real, publish the snapshot safely, and do not hand out aliases that let somebody quietly turn your read-only map back into shared mutable state.