What a map is (and when to use it)
A map stores key-value pairs, letting you look up a value by its key efficiently. Use maps when you need fast membership tests ("have we seen this?"), counting, grouping, indexing by an ID, or building configuration tables. In Go, maps are reference types: the map variable holds a header pointing to runtime-managed data.
1) Creating maps: literals, make, and nil behavior
Map literals
Use a literal when you know initial entries at compile time. This is readable and common in production code for small tables.
package main
import "fmt"
func main() {
ports := map[string]int{
"http": 80,
"https": 443,
}
fmt.Println(ports["https"]) // 443
}Creating an empty map with make
Use make when you want an empty map you can write to. Optionally provide an initial capacity hint (not a limit) to reduce allocations when you expect many entries.
usersByID := make(map[int]string)
counts := make(map[string]int, 1000) // capacity hintNil maps: readable, but not writable
A nil map is the zero value of a map type. Reading from a nil map is safe and returns the zero value for the value type. Writing to a nil map panics.
package main
import "fmt"
func main() {
var m map[string]int // nil
fmt.Println(m["missing"]) // 0 (safe)
// m["x"] = 1 // panic: assignment to entry in nil map
}When to initialize
Initialize with
makewhen you will insert/update entries.Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
Leave as nil when a map is optional and you only need reads, or you want to signal “not set” without allocating. Many APIs treat nil maps as empty for reads.
Initialize at the boundary: if a function will write to a map passed in, either document that it must be non-nil or allocate it inside and return it.
func ensureMap(m map[string]int) map[string]int {
if m == nil {
m = make(map[string]int)
}
return m
}2) Insert, update, delete, and lookup with the “comma ok” idiom
Insert and update use the same syntax
Assigning to a key inserts it if missing, or overwrites it if present.
scores := make(map[string]int)
scores["alice"] = 10 // insert
scores["alice"] = 11 // updateDelete
delete removes a key if present. Deleting a missing key is a no-op (safe).
delete(scores, "alice")
delete(scores, "missing") // safeLookup: distinguish “missing” from “present with zero value”
Indexing a map returns the value type’s zero value when the key is absent. If the value type can naturally be zero (like 0, "", false), you often need to know whether the key existed. Use the “comma ok” idiom.
package main
import "fmt"
func main() {
counts := map[string]int{"go": 0}
v := counts["missing"]
fmt.Println(v) // 0, but key is missing
if v, ok := counts["go"]; ok {
fmt.Println("go present with value", v)
}
if v, ok := counts["missing"]; !ok {
fmt.Println("missing key; v is", v)
}
}Common pitfall: using a map lookup directly in conditionals
Because missing keys yield a zero value, code like if counts[word] > 0 can be misleading if you need to distinguish “never seen” from “seen but count is 0”. Prefer comma ok when existence matters.
// Existence check
if _, ok := counts[word]; ok {
// key exists
}3) Iteration: range order is not guaranteed; sorting keys for deterministic output
Map iteration order is intentionally randomized
When you iterate over a map with range, the order is not specified and can change between runs. Do not rely on it for stable output, tests, or user-facing formatting.
for k, v := range scores {
fmt.Println(k, v)
}Deterministic iteration: collect keys, sort, then index
For stable output (logs, CLI output, tests), sort the keys. This is the idiomatic pattern.
package main
import (
"fmt"
"sort"
)
func main() {
ports := map[string]int{"https": 443, "http": 80, "ssh": 22}
keys := make([]string, 0, len(ports))
for k := range ports {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("%s => %d\n", k, ports[k])
}
}If your keys are integers, use sort.Ints. For custom ordering, use sort.Slice.
4) Map value types: pointers vs structs, and updating nested values
Why value type choice matters
Maps return a copy of the stored value when the value type is a struct. That means you cannot modify struct fields directly through the map index expression. This is a frequent beginner pitfall and also a design choice you should make consciously in production code.
Pitfall: cannot assign to a field of a struct stored in a map
type User struct {
Name string
Score int
}
func main() {
users := map[string]User{
"alice": {Name: "Alice", Score: 10},
}
// users["alice"].Score = 11 // compile error
}Pattern A (common): read-modify-write the struct
Get the value, modify the local copy, then store it back.
u := users["alice"]
u.Score++
users["alice"] = uCombine with comma ok if the key might be missing.
if u, ok := users["bob"]; ok {
u.Score += 5
users["bob"] = u
}Pattern B: store pointers when you need in-place updates
If you frequently update fields, consider map[string]*User. Then you can mutate through the pointer. Be mindful of nil pointers and shared mutation.
users := map[string]*User{
"alice": {Name: "Alice", Score: 10},
}
users["alice"].Score++When inserting, allocate a new struct and store its address.
users["bob"] = &User{Name: "Bob", Score: 0}Nested updates: map values that contain maps
When a struct contains a map field, you still need to ensure the inner map is initialized before writing to it.
type Profile struct {
Tags map[string]bool
}
profiles := make(map[string]Profile)
p := profiles["alice"]
if p.Tags == nil {
p.Tags = make(map[string]bool)
}
p.Tags["golang"] = true
profiles["alice"] = pIf you use pointers, the nested update can be simpler, but you still must initialize the inner map.
profiles := map[string]*Profile{"alice": {}}
if profiles["alice"].Tags == nil {
profiles["alice"].Tags = make(map[string]bool)
}
profiles["alice"].Tags["golang"] = true5) Concurrency note: maps are not safe for concurrent writes
Go’s built-in maps are not safe for concurrent writes. If one goroutine writes while another reads or writes without synchronization, your program can crash with a runtime error or exhibit data races.
Use a
sync.Mutexto guard a normal map when you need shared mutable state.Use
sync.RWMutexwhen you have many readers and fewer writers.Consider
sync.Mapfor specific patterns (highly concurrent, mostly reads, or keys that live for the program lifetime). It has different tradeoffs and API.Prefer message passing: keep the map owned by one goroutine and communicate via channels when appropriate.
// Sketch: mutex-protected map (pattern preview)
type SafeCounts struct {
mu sync.Mutex
m map[string]int
}
func (s *SafeCounts) Inc(key string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.m == nil {
s.m = make(map[string]int)
}
s.m[key]++
}Exercises
Exercise 1: Word frequency counter
Goal: Given a slice of words, build a map of word => count, then print results in deterministic (sorted) order.
Steps:
Create
counts := make(map[string]int).Loop over the words and increment:
counts[w]++.Collect keys into a slice, sort it, then print
keyandcounts[key].
package main
import (
"fmt"
"sort"
"strings"
)
func main() {
text := "go is fun and go is fast"
words := strings.Fields(text)
counts := make(map[string]int)
for _, w := range words {
counts[w]++
}
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("%s: %d\n", k, counts[k])
}
}Exercise 2: Configuration map with validation using comma ok
Goal: Validate required configuration keys and parse values. Use comma ok to produce clear error messages when keys are missing.
Scenario: You receive configuration as map[string]string with required keys: "host", "port", "env". Optional key: "debug" (defaults to false).
Steps:
Write a function
LoadConfig(cfg map[string]string) (Config, error).For each required key, use
v, ok := cfg["key"]; if!okreturn an error that names the missing key.Convert
portto an integer; validate range (1–65535).For optional
debug, if missing, keep default; if present, parsetrue/false.
package main
import (
"fmt"
"strconv"
)
type Config struct {
Host string
Port int
Env string
Debug bool
}
func LoadConfig(cfg map[string]string) (Config, error) {
var c Config
host, ok := cfg["host"]
if !ok || host == "" {
return c, fmt.Errorf("missing required config: host")
}
c.Host = host
portStr, ok := cfg["port"]
if !ok || portStr == "" {
return c, fmt.Errorf("missing required config: port")
}
port, err := strconv.Atoi(portStr)
if err != nil {
return c, fmt.Errorf("invalid port %q: %w", portStr, err)
}
if port < 1 || port > 65535 {
return c, fmt.Errorf("port out of range: %d", port)
}
c.Port = port
env, ok := cfg["env"]
if !ok || env == "" {
return c, fmt.Errorf("missing required config: env")
}
c.Env = env
if debugStr, ok := cfg["debug"]; ok && debugStr != "" {
b, err := strconv.ParseBool(debugStr)
if err != nil {
return c, fmt.Errorf("invalid debug %q: %w", debugStr, err)
}
c.Debug = b
}
return c, nil
}
func main() {
cfg := map[string]string{
"host": "localhost",
"port": "8080",
"env": "dev",
"debug": "true",
}
c, err := LoadConfig(cfg)
if err != nil {
panic(err)
}
fmt.Printf("%+v\n", c)
}