sch39
Part 4 of 4 — Go Fundamentals for Developers

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.

  1. Missing key is treated the same as zero value: code that only reads m[key] can wrongly assume the key exists.

  2. 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.RWMutex for read/write access from multiple goroutines.

Implementation

Folder Structure

text
go-series/G01-04-maps/
├── go.mod
└── registry/
    ├── registry.go
    └── registry_test.go

Setup

Initialize a single module (run from the go-series/ folder):

bash
mkdir -p G01-04-maps
cd G01-04-maps

go mod init github.com/sch39/go-series/g0104

Code Blocks

Block 1: Initialized map and constructor

go-series/G01-04-maps/registry/registry.go:

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:

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:

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:

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:

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:

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:

bash
go test -count=1 -v ./...

Output:

text
=== 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.695s

Why 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

map + sync.RWMutex

Clear control and supports concurrent reads

Lock boilerplate must be consistent

sync.Map

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 range order of a map.

References