Go Maps and Structs
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
Slices handle order, maps handle lookup, structs handle modelling. Almost every Go program is built from those three pieces. This chapter covers the last two and where their edges are.
Declaring and using maps
ages := map[string]int{
"ann": 30,
"bob": 25,
}
ages["cindy"] = 28
fmt.Println(ages["ann"]) // 30
For an empty map that you intend to fill, use make:
m := make(map[string]int) // writable immediately
var m2 map[string]int // nil map: readable, not writable
m2["a"] = 1 // panic: assignment to entry in nil map
A nil map supports len, range and reads, which is why “declaring and filling later” bugs appear only on the first write. If a function returns a map, the caller cannot tell whether it was initialised — document it or always return a non-nil map.
Capacity is a hint, not a requirement:
m := make(map[string]int, 128) // pre-size to avoid rehashing
comma-ok: “missing” versus “zero”
v := ages["dave"] // 0 — is Dave 0 years old, or absent?
v, ok := ages["dave"] // 0, false
The two-value form is the only way to tell them apart, and the same shape appears in type assertions and channel receives:
if _, ok := ages["ann"]; ok {
fmt.Println("present")
}
Use _, ok := when you only care about presence — go vet will tell you if you read a value you then ignore.
Delete and iterate
delete(ages, "bob") // deleting a missing key is a no-op, not an error
len(ages)
Iteration order is randomised on purpose so that code depending on insertion order breaks in development rather than in production:
keys := make([]string, 0, len(ages))
for k := range ages {
keys = append(keys, k)
}
slices.Sort(keys)
for _, k := range keys {
fmt.Println(k, ages[k])
}
Deleting during iteration is specified and safe. Adding during iteration is not specified — the new entry may or may not be visited. Restructure the loop if you need to insert.
Maps are reference types
func birthday(m map[string]int, name string) {
m[name]++ // visible to the caller
}
ages := map[string]int{"ann": 30}
birthday(ages, "ann")
fmt.Println(ages["ann"]) // 31
The map value is a pointer to a runtime header, so passing it does not copy the contents. Two consequences:
Maps are not safe for concurrent use. Simultaneous writes from two goroutines produce a fatal concurrent map writes error that recover cannot catch. Guard with sync.Mutex, or use sync.Map for the narrow case of a mostly-read cache.
You cannot take the address of a map element, because the element may move during growth:
p := &ages["ann"] // compile error: cannot take address of ages["ann"]
If you need stable addresses, store pointers as the value type: map[string]*User.
Struct definitions
type User struct {
ID int
Name string
Email string
Tags []string
}
u := User{ID: 1, Name: "ann"} // keyed literal — prefer this
u2 := User{1, "bob", "b@x.com", nil} // positional: breaks on every field change
u.Name = "anna"
Keyed literals survive refactoring; positional ones break the moment a field is added or reordered. Linters flag them for that reason.
Pointers auto-dereference for field access:
p := &u
p.Name = "cindy" // same as (*p).Name = "cindy"
Structs are values, so assignment copies every field — but only the fields themselves:
a := User{Name: "ann", Tags: []string{"go"}}
b := a
b.Name = "bob"
fmt.Println(a.Name) // ann — independent copy
b.Tags[0] = "rust"
fmt.Println(a.Tags[0]) // rust — the slice header was copied, the array was not
That is what “shallow copy” means in practice: the struct is duplicated, the data its reference fields point at is shared.
Exported fields
Capitalised identifiers are exported — visible outside the package; lower-case ones are package-private. The same rule drives JSON encoding and reflection:
type Article struct {
Title string `json:"title"`
Content string `json:"content"`
views int `json:"-"`
}
views never appears in JSON because it is unexported; the tag on it is documentation only. Struct tags are just string metadata read by encoding/json, yaml, validators and ORMs — the compiler ignores them, but go vet checks their syntax.
An unexported field is also invisible to encoding/json on decode, which is why “field silently stays zero after unmarshal” almost always means a missing capital letter.
Embedding: composition, not inheritance
type Profile struct {
Bio string
}
func (p Profile) Describe() string {
return "bio: " + p.Bio
}
type Account struct {
Profile // embedded: the field name is the type name
Username string
}
a := Account{Profile: Profile{Bio: "gopher"}, Username: "ann"}
fmt.Println(a.Bio) // promoted field, same as a.Profile.Bio
fmt.Println(a.Describe()) // promoted method
Embedding promotes fields and methods to the outer type, so callers do not have to spell out the inner value. What it does not do is dispatch dynamically: defining Describe on Account shadows the embedded method, it does not override it. Any code holding a Profile still calls Profile.Describe.
Embed an interface when you want to borrow a method set and implement only part of it — a common shape for test doubles and decorators, covered in the interfaces chapter.
Common mistakes
1. Writing to a nil map
var m map[string]int then m[k] = v panics. Use make.
2. Using m[k] to test for presence
A stored zero value is indistinguishable from a missing key. Use comma-ok.
3. Relying on map iteration order
Randomised. Sort the keys.
4. Sharing a map across goroutines
Fatal concurrent map writes. Lock it, or give each goroutine its own copy.
5. Taking the address of a map element
Compile error. Store pointers as values instead.
6. Positional struct literals
Every field addition breaks the build, and swapped same-typed fields compile silently. Use keyed literals.
7. Assuming struct assignment deep-copies
Slice and map fields still share their backing data.
Summary
makemaps before writing; usev, ok := m[k]to distinguish missing from zero.- Iteration order is random,
deleteis safe,lenis O(1), element addresses are not available. - Maps are reference-like and not concurrency-safe.
- Structs are values with shallow copies; only capitalised fields are exported and JSON-visible.
- Embedding composes and promotes; it never overrides.
Next: pointers and methods — value versus pointer receivers, and when a pointer is actually worth it.