Maps with Struct or Interface Keys
Understand how to use structs and interfaces as map keys in Go, the comparability requirements that govern them, equality semantics, and the pitfalls to avoid when relying on composite or dynamic key types.
A map's key type must be comparable: the language needs to check whether two keys are equal using == and !=. This is what allows a map to find, update, and remove entries. The set of comparable types includes booleans, numbers, strings, pointers, channels, and—importantly for this section—structs whose fields are all comparable, as well as interfaces whose dynamic values are comparable.
Struct and interface keys unlock patterns that would otherwise require nested maps, manual key concatenation, or extra bookkeeping. But they also introduce subtle failure modes around mutability and runtime panics that developers need to understand before they rely on them.
How Map Keys Get Compared
Every map operation that looks up a key first hashes the key and then checks equality. If two keys produce the same hash but are not equal, they are treated as separate entries. If they produce different hashes, the map does not even test equality—the lookup immediately misses.
Comparability is what makes equality checking possible. When a type supports ==, Go guarantees that this operator is consistent with the hash function the runtime uses for that type. Structs are comparable when every one of their fields is comparable. Arrays are comparable when their element type is comparable. Interfaces are comparable when their dynamic type is comparable; if the concrete value inside an interface is a slice, map, or function, an equality check will compile but panic at runtime.
Why comparability matters:
A map key is not stored as raw memory. The runtime computes a hash of the key’s value at insertion time and uses that hash to place the entry in a bucket. If a key later changes in a way that alters its hash, the map cannot locate the entry—even though it still exists in memory. This is the central tension with mutable key types, whether they are structs with pointer fields or interfaces holding mutable concrete values.
Struct Keys — Composite Lookups Without Nested Maps
The most immediate use of a struct as a map key is to combine multiple dimensions into a single lookup. Instead of a map of maps, which forces you to check for and initialize inner maps, a struct key gives you a flat table.
type RouteKey struct {
Path string
Country string
}
hits := make(map[RouteKey]int)
A visitor from France to the home page increments the counter with one expression:
hits[RouteKey{Path: "/", Country: "FR"}]++
This avoids the ceremony of a nested map, where you must check whether the inner map exists before writing:
// Without struct keys — inner map must exist
func addNested(m map[string]map[string]int, path, country string) {
inner, ok := m[path]
if !ok {
inner = make(map[string]int)
m[path] = inner
}
inner[country]++
}
The struct version is a single map, a single read-modify-write operation, and a single mental model. When you retrieve a value, a missing key returns the zero value for the value type—so hits[RouteKey{"/faq", "DE"}] is 0 without any extra check.
Cleaner composite lookups:
If your lookup involves two or more independent attributes, a struct key often produces shorter, more readable code than a map of maps. The struct name also documents what the combination represents.
Every Field Must Be Comparable
The struct itself is comparable only if all its fields are comparable. Slices, maps, and functions are not comparable, so a field of any of those types makes the entire struct ineligible as a map key. The compiler rejects this at compile time.
type BadKey struct {
ID int
Tags []string // slice — not comparable
}
// Compile error: invalid map key type BadKey
// m := make(map[BadKey]int)
If you need to incorporate slice-like data into a key, you must either convert the slice to a string (e.g., by hashing or serializing it into a fixed representation) or use an array, since arrays are comparable.
Pointer Fields and Value Equality
A struct with a pointer field is comparable, because pointers themselves are comparable—they compare memory addresses. However, the equality semantics shift: two structs with different pointer fields that happen to point to equal values are not equal keys.
type Person struct {
Name *string
}
nameA := "Alice"
nameB := "Alice"
k1 := Person{Name: &nameA}
k2 := Person{Name: &nameB}
m := make(map[Person]string)
m[k1] = "found"
_, exists := m[k2] // false — different pointers
The struct Person compares the addresses stored in Name, not the string content. If you want value-based equality, embed the value directly rather than a pointer.
Mutating a Struct Key After Insertion
Go does not prevent you from modifying a struct after it has been used as a map key, but doing so is a logic error. The map stores the entry based on the hash and equality of the key as it existed at insertion. If you later change a field that affects equality, the map loses the entry.
type Vertex struct {
X, Y int
}
m := make(map[Vertex]string)
v := Vertex{X: 1, Y: 2}
m[v] = "point A"
v.X = 99
// v is no longer the same key the map knows
_, ok := m[v] // false, even though entry still exists in the map
The entry is still present inside the map's internal data structure, but there is no way to retrieve or delete it through normal map operations—the original key value is gone from your variable, and the map has no reference to it. This is a memory leak and a correctness bug.
Lost entries from mutation:
Mutating a struct field that participates in equality after the struct has been used as a map key silently orphans the map entry. The entry cannot be reached, updated, or deleted, and it occupies memory until the map is discarded. Always treat struct map keys as immutable; if a field must change, delete the old entry, mutate, and re-insert.
Arrays as an Alternative to Struct Keys
If you need a simple composite key without naming fields, an array is comparable when its element type is comparable. A [2]string can serve the same purpose as a struct with two string fields, though a struct with named fields is usually clearer.
hits := make(map[[2]string]int)
hits[[2]string{"/", "FR"}]++
The same immutability warning applies: you cannot mutate the array after insertion because you would have to replace the entire array variable, which would give you a new value—the map would not recognize it.
Interface Keys — Dynamic Types With Runtime Risks
An interface value can be used as a map key if the concrete value it holds is comparable. Because interface{} (or any) accepts every Go type, including non-comparable ones, using an interface as a map key shifts comparability checking from compile time to runtime.
When Interface Keys Work
When the dynamic type is a comparable type—int, string, a comparable struct, a pointer—the map behaves normally.
m := make(map[interface{}]string)
m[42] = "integer"
m["hello"] = "string"
m[struct{ ID int }{1}] = "struct"
fmt.Println(m[42]) // "integer"
fmt.Println(m["hello"]) // "string"
Two interface keys are equal if their dynamic types are identical and the values are equal. int(42) and int64(42) are different types, so they produce different keys:
m := make(map[interface{}]string)
var a interface{} = int(42)
var b interface{} = int64(42)
m[a] = "int"
_, exists := m[b] // false — different dynamic types
Runtime Panics With Non-Comparable Dynamic Values
If the concrete value inside the interface is a slice, map, or function, the map operation compiles but panics at runtime the moment it tries to hash or compare the key.
m := make(map[interface{}]string)
key := interface{}([]int{1, 2, 3})
m[key] = "this will panic"
// panic: runtime error: hash of unhashable type []int
The same panic occurs on retrieval, not just insertion:
m := make(map[interface{}]string)
// some earlier code might have inserted an entry with a comparable key
val := m[[]int{1, 2, 3}] // panics
This makes map[interface{}]... dangerous in codebases where the source of keys is not fully controlled—for example, when a function accepts any keys and passes them straight into a map.
Runtime panic with non-comparable interface keys:
A map with interface{} keys compiles silently, but it panics the moment a non-comparable value (slice, map, function) is used as a key. There is no compile-time protection. If you must accept arbitrary key types, validate comparability before using the value in a map operation, or use a concrete wrapper type that guarantees comparability.
Practical Uses and Limits
Interface keys are rare in production code because they give up type safety and often signal that a design could be expressed more clearly. One domain where they appear is in generic caches or dispatch tables where the key type genuinely varies.
type Cache struct {
mu sync.RWMutex
items map[interface{}]interface{}
}
func (c *Cache) Set(key, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
// Hope key is comparable — no way to enforce at compile time
c.items[key] = value
}
This pattern is fragile. A caller passing []byte("query") as a key will crash the program. A safer approach is to use a concrete key type, or, if using Go 1.18+, a generic map with a comparable type parameter that the compiler enforces.
Comparing Struct Keys With Other Composite Key Strategies
When you face a lookup on multiple dimensions, you have three common options: nested maps, string concatenation, and struct keys. Each has trade-offs.
Nested maps (map[string]map[string]int) let you iterate over partial keys (e.g., all countries for a given path) efficiently, but they force you to initialize inner maps and write multi-step access logic.
String concatenation (map[string]int with keys like "/" + "|FR") is simple and avoids struct boilerplate, but it produces opaque keys, increases the chance of collisions if the delimiter appears in the data, and requires manual marshaling and unmarshaling of components.
Struct keys keep each dimension distinct, make code self-documenting, and collapse the lookup into a single map operation. They cost a small amount of extra typing for the struct definition and may be less convenient if you frequently need to iterate over partial groupings.
Choosing the right composite key approach:
Nested maps offer natural grouping but more ceremony. String concatenation is quick but fragile and opaque. Struct keys give you clean, type-safe composite lookups at the cost of a type definition. The decision should follow readability and safety; struct keys are usually the best default for fixed sets of dimensions.
Summary
Struct keys let you treat a combination of values as a single, comparable lookup target, eliminating the nesting and initialization logic of maps of maps. Their safety relies on all fields being comparable and the key never being mutated after insertion.
Interface keys allow dynamic types but sacrifice compile-time safety—a non-comparable concrete value causes a runtime panic. They are most defensible when a generic map can’t be used (pre-Go 1.18), and even then they require careful runtime guarding.