Maps in Go: Zero Values, Mutability, and Concurrency
Oct 2026 · 4 min read
Maps in Go distinguish default values from the presence of a key through comma-ok, and the delete operation is safe for keys that don't exist. Maps are not safe to access concurrently without synchronization, so a wrapper with RWMutex keeps reads and writes safe.
TL;DR: Maps in Go distinguish default values from the presence of a key through comma-ok, and the delete operation is safe for keys that don't exist. Maps are not safe to access concurrently without synchronization, so a wrapper with RWMutex keeps reads and writes safe. Full code: https://github.com/Sch39/go-series
Problem
A map lookup returns the zero value when the key doesn't exist. For types like int, a result of 0 alone is not enough to distinguish a key that actually stores 0 from a missing key.
Missing key is treated the same as zero value: code that only reads
m[key]can wrongly assume the key exists.Concurrent access is unsafe: reading and writing a map from different goroutines without synchronization can cause a data race or runtime failure.
Outcome
Readers will be able to:
Distinguish an existing key from a zero value using comma-ok.
Delete a key without needing to check its existence first.
Wrap a map with
sync.RWMutexfor read/write access from multiple goroutines.
Implementation
Folder Structure
go-series/G01-04-maps/
├── go.mod
└── registry/
├── registry.go
└── registry_test.goSetup
Initialize a single module (run from the go-series/ folder):
mkdir -p G01-04-maps
cd G01-04-maps
go mod init github.com/sch39/go-series/g0104Code Blocks
Block 1: Initialized map and constructor
go-series/G01-04-maps/registry/registry.go:
package registry
import "sync"
type Registry struct {
mu sync.RWMutex
entries map[string]int
}
func New() *Registry {
return &Registry{entries: make(map[string]int)}
}Goal: Provide a map that is ready to be written to and a mutex that protects the registry state.
Block 2: Write with an exclusive mutex
go-series/G01-04-maps/registry/registry.go:
func (r *Registry) Put(key string, value int) {
r.mu.Lock()
defer r.mu.Unlock()
r.entries[key] = value
}Goal: Keep the map assignment from running concurrently with other operations that modify the state.
Block 3: Lookup with comma-ok and a read lock
go-series/G01-04-maps/registry/registry.go:
func (r *Registry) Lookup(key string) (int, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
value, ok := r.entries[key]
return value, ok
}Goal: Return the zero value as well as an indicator of the key's existence, safely.
Block 4: Delete with an exclusive mutex
go-series/G01-04-maps/registry/registry.go:
func (r *Registry) Delete(key string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.entries, key)
}Goal: Delete a key safely without needing a preliminary lookup.
Test Blocks
Test 1: Zero value versus an existing key
go-series/G01-04-maps/registry/registry_test.go:
package registry_test
import (
"testing"
"github.com/sch39/go-series/g0104/registry"
)
func TestLookupDistinguishesMissingFromZero(t *testing.T) {
r := registry.New()
r.Put("zero", 0)
value, ok := r.Lookup("zero")
if !ok || value != 0 {
t.Fatalf("Lookup(zero) = %d, %v; want 0, true", value, ok)
}
missing, present := r.Lookup("missing")
t.Logf("zero=(%d,%v) missing=(%d,%v)", value, ok, missing, present)
if present || missing != 0 {
t.Errorf("Lookup(missing) = %d, %v; want 0, false", missing, present)
}
}Goal: Prove that a key storing 0 is different from a key that doesn't exist, even though both produce a value of 0.
Test 2: Delete and synchronized concurrent access
go-series/G01-04-maps/registry/registry_test.go:
func TestDeleteAndConcurrentAccess(t *testing.T) {
r := registry.New()
r.Put("temporary", 7)
r.Delete("temporary")
if _, ok := r.Lookup("temporary"); ok {
t.Fatal("Delete did not remove temporary")
}
done := make(chan struct{})
for i := 0; i < 4; i++ {
go func(i int) {
for j := 0; j < 100; j++ {
r.Put("shared", i+j)
_, _ = r.Lookup("shared")
}
done <- struct{}{}
}(i)
}
for i := 0; i < 4; i++ {
<-done
}
value, ok := r.Lookup("shared")
t.Logf("shared=(%d,%v) after concurrent access", value, ok)
if !ok {
t.Error("shared key missing after concurrent access")
}
}Goal: Prove that delete and concurrent access remain safe because all map operations go through the mutex.
Run Test
Run from the go-series/G01-04-maps/ folder:
go test -count=1 -v ./...Output:
=== RUN TestLookupDistinguishesMissingFromZero
registry_test.go:19: zero=(0,true) missing=(0,false)
--- PASS: TestLookupDistinguishesMissingFromZero (0.00s)
=== RUN TestDeleteAndConcurrentAccess
registry_test.go:47: shared=(100,true) after concurrent access
--- PASS: TestDeleteAndConcurrentAccess (0.00s)
PASS
ok github.com/sch39/go-series/g0104/registry 3.695sWhy It Works
Zero value and comma-ok: For the lookup value, ok := m[key], value becomes the zero value of the element type if the key doesn't exist, while ok indicates whether the key was found. Because of this, a key with a value of 0 can be distinguished from a missing key.
Mutability and delete: A map is a reference-like descriptor that can be modified via assignment and delete. delete(m, key) is safe when the key doesn't exist; it also doesn't require checking existence first.
Map synchronization: A regular map doesn't provide safety for concurrent read/write. RWMutex uses an exclusive lock for Put and Delete, as well as a shared read lock for Lookup, so operations that access the map don't race each other.
Edge Cases
Nil map: Reading, len, range, and delete on a nil map are safe; assignment to a nil map panics. Use make or a map literal before writing.
Concurrent read-only: Multiple map reads can run concurrently if the map isn't being modified. As soon as there's a write, all access needs the same synchronization rules.
Range order is not guaranteed: Map iteration doesn't promise any particular key order. Don't use range order as a contract or test assertion.
Trade-offs
Strategy | Pro | Con |
|---|---|---|
Regular map | Simple and fast on a single goroutine | Unsafe when accessed concurrently with writes |
| Clear control and supports concurrent reads | Lock boilerplate must be consistent |
| Suited for certain access patterns with many goroutines | API and trade-offs differ from a regular map |
Best practice: Use a regular map for single-goroutine state. If state is shared between goroutines, choose a single owner for the state or wrap all map operations with consistent synchronization.
Production Checklist
Does the lookup need to distinguish a missing key? Use the comma-ok form.
Make sure the map is initialized before assignment.
Don't access a map concurrently with a write without synchronization.
Use the same lock for all operations on a shared map.
Don't rely on the
rangeorder of a map.
References
Effective Go, Maps and comma-ok: https://go.dev/doc/effective_go.html#maps
Go Blog, Data Race Detector: https://go.dev/doc/articles/race_detector
Go 1.8 Release Notes, Concurrent Map Misuse: https://go.dev/doc/go1.8#concurrent_map_misuse