Choosing a data structure¶
You now have four building blocks for modelling data: custom types, structs, slices, and maps (plus arrays, which you rarely reach for directly). Real programs combine them. This article is a practical guide to picking the right one — and the right combination — for a given job.
The one-line guide¶
| You need to… | Reach for |
|---|---|
| keep an ordered sequence, iterate, allow duplicates, index by position | slice []T |
| look something up by key, test membership, count, or group | map map[K]V |
| describe one thing with a fixed set of named, differently-typed fields | struct |
| give a value a distinct name/behaviour over an existing type (units, IDs, enums) | custom type type T U |
| a fixed-size, comparable, value-copied buffer | array [N]T (rare) |
The rest of the article expands each of these and then shows how they compose.
Slices — the default collection¶
If you just need "a bunch of values in order," use a slice. It is the
workhorse: ordered, growable with append, cheap to iterate, and laid out
contiguously in memory (cache-friendly).
scores := []int{90, 85, 90}
scores = append(scores, 70)
total := 0
for _, s := range scores {
total += s
}
fmt.Println(total) // output: 335
Its weakness is lookup: finding whether a value is present, or finding it by some key, is an O(n) scan. That's fine for small or rarely-searched collections — don't reach for a map just because you have a slice.
Maps — keyed access¶
Use a map when you look things up by a key, rather than walk the whole collection. Average O(1) get/set. Four classic jobs:
Lookup by key:
Counting (frequency): the missing-key zero value makes ++ just work:
counts := map[string]int{}
for _, w := range []string{"go", "py", "go"} {
counts[w]++
}
fmt.Println(counts["go"]) // output: 2
Grouping (one-to-many) with map[K][]V:
byParity := map[string][]int{}
for _, n := range []int{1, 2, 3, 4} {
if n%2 == 0 {
byParity["even"] = append(byParity["even"], n)
} else {
byParity["odd"] = append(byParity["odd"], n)
}
}
fmt.Println(byParity["even"]) // output: [2 4]
Set / membership with map[T]struct{} (or map[T]bool):
seen := map[string]struct{}{}
seen["go"] = struct{}{}
_, ok := seen["go"]
fmt.Println(ok) // output: true
Map weaknesses: iteration order is randomised, there's per-entry hashing overhead, and a map is the wrong tool for a fixed, known set of fields — that's a struct's job.
Structs — one thing with named fields¶
When a value has a fixed, known shape — several attributes of different types that belong together — use a struct. The field names and types are checked by the compiler.
type User struct {
Name string
Age int
}
u := User{Name: "Ada", Age: 36}
fmt.Println(u.Name) // output: Ada
Resist modelling a known record as map[string]any: you lose type
safety, field-name checking, and clarity. A map is for open-ended keys;
a struct is for a closed set of fields.
Custom types — meaning and behaviour¶
Use a defined type (type T U) when an underlying type isn't enough on
its own: to stop incompatible values mixing, to attach methods, or to
build an enumeration with iota.
type Celsius float64
type Fahrenheit float64 // distinct: can't accidentally add the two
type Suit int
const (
Spades Suit = iota
Hearts
Diamonds
Clubs
)
fmt.Println(Hearts) // output: 1
Combining them — the real patterns¶
Most data models are compositions. The common ones:
| Pattern | Meaning |
|---|---|
[]User |
an ordered list of records — the everyday shape |
map[int]User |
records indexed by id, for O(1) lookup |
map[string][]Order |
one-to-many grouping (orders per customer) |
map[string]struct{} |
a set |
| a struct with slice/map fields | an aggregate that owns collections |
A struct that owns collections, indexed for fast access:
type Library struct {
Name string
Books []string
}
shelf := map[string]Library{}
shelf["scifi"] = Library{Name: "Sci-Fi", Books: []string{"Dune"}}
fmt.Println(shelf["scifi"].Books[0]) // output: Dune
Head-to-head: which works better when¶
Slice vs. map for lookup. For a handful of elements, a linear scan over a slice is simpler and often faster (no hashing, cache-friendly). Switch to a map when the collection is large or you look up by key frequently.
Map vs. struct. Known field names → struct (compile-time safety).
Keys decided at runtime → map. Don't fake a struct with map[string]any.
Array vs. slice. Default to a slice. Use an array only when the length
is genuinely fixed and part of the type, or you want value-copy /
comparability (e.g. [32]byte for a hash).
Custom type vs. raw primitive. Wrap a primitive when its meaning
matters (a UserID shouldn't be addable to an OrderID) or when you want
methods on it. Otherwise the plain type is fine.
From Python: the mapping is loose — Go's slice ≈
list, map ≈dict, struct ≈ a@dataclass(or a class with fixed attributes), and themap[T]struct{}set ≈set. The big difference is that Go fixes a struct's fields at compile time, whereas a Python object can grow attributes at will.
A worked example¶
Counting word lengths groups several of these together — a map keyed by length, whose values are slices of structs:
type Word struct {
Text string
Length int
}
byLen := map[int][]Word{}
for _, t := range []string{"go", "rust", "py", "java"} {
w := Word{Text: t, Length: len(t)}
byLen[w.Length] = append(byLen[w.Length], w)
}
fmt.Println(byLen[2][0].Text, byLen[2][1].Text) // output: go py
That's map[int][]Word: a map for keyed grouping, a slice for the
ordered group, and a struct for each record — each chosen for what it
does best.
When different types need to share behaviour behind one abstraction, none of these is the answer — that's what interfaces are for, covered in a later article.
Cheat sheet¶
- Ordered, iterate, duplicates → slice
- Lookup / membership / count / group → map
- Fixed named fields for one entity → struct
- Distinct meaning, methods, or enum → custom type
- Fixed-size comparable buffer → array
- Real models combine these —
[]Struct,map[K]Struct,map[K][]V,map[K]struct{}.