Go Maps and Structs

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

Go 1.24

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

  • make maps before writing; use v, ok := m[k] to distinguish missing from zero.
  • Iteration order is random, delete is safe, len is 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.