1. Introduction
A very natural desire: you have two tasks (Task), and you want to understand “is this the same task” or “are these two different ones”. This especially comes up in practical things: avoid adding a duplicate, find an existing record, verify that after computations the data didn’t change, or do a “naive” check in tests (we’ll get to tests later, but the idea matters now).
In Go, comparison looks nice and honest: a == b. But the language does not promise that any type can be compared. Moreover: sometimes the compiler will forbid the comparison outright, and sometimes the comparison is syntactically allowed but can still crash the program at runtime (we’ll talk about crashes separately later, but you can already start being wary of the words “runtime error”).
By “comparable” in everyday Go usage, people usually mean a type whose values can be compared using == and !=. In this lecture we’ll sort out when a struct becomes comparable, when it does not, and what to do if == is unavailable.
2. Rule: a struct is comparable if all fields are comparable
Here is the main rule of this lecture, which would be good to write with a marker on your forehead (or at least in a code comment): you can compare a struct with == only if each of its fields can also be compared with ==. If at least one field is “non-comparable”, then the entire struct becomes non-comparable, and the compiler forbids ==.
This rule is recursive. If a field is also a struct, it must be comparable. If a field is an array [N]T, then the array is comparable only if its element type is comparable. If a field is a pointer *T, then the pointer is comparable (by address). If a field is a slice or a map, then that’s it: == is not defined for such fields (except comparison with nil, but that’s a separate story).
Here is a small table to help you quickly orient yourself (and not try to “convince the compiler with sheer willpower”):
| Field type | Can use ==? | What == means |
|---|---|---|
|
yes | value comparison |
|
yes, if is comparable |
element-by-element comparison |
|
yes, if all fields are comparable | comparison of all fields |
|
yes | address comparison |
slice () |
no | (except == nil) |
|
no | (except == nil) |
|
no | (except == nil) |
| interface (any, error, …) | syntactically yes | but it may crash at runtime if it contains a non-comparable value |
The last row about interfaces sounds a bit tricky, but it is honest: you can compare interfaces, however if the interface contains a value of a non-comparable type (for example, a slice), then the comparison can end in a runtime panic. This is rare for beginners, but it’s useful to know: sometimes “everything compiles”, and then the program crashes.
3. Task as an example of a comparable struct
To avoid drifting into abstractions, let’s continue our learning mini-app with a task list. Let a task have ID, Title, and a Done flag. All of these fields are comparable (int, string, bool), which means Task is comparable and we are allowed to use ==.
package main
import "fmt"
type Task struct {
ID int
Title string
Done bool
}
func main() {
a := Task{ID: 1, Title: "Read spec", Done: false}
b := Task{ID: 1, Title: "Read spec", Done: false}
fmt.Println(a == b) // true
}
What exactly happens when a == b? Go compares fields in the order they are declared (logically—as if you wrote a.ID == b.ID && a.Title == b.Title && a.Done == b.Done). This is convenient: for simple data models you get “data equality” almost for free.
The practical benefit shows up quickly: you can look for “exactly the same” task in a list and avoid creating duplicates.
package main
import "fmt"
type Task struct {
ID int
Title string
Done bool
}
func containsTask(tasks []Task, x Task) bool {
for _, t := range tasks {
if t == x {
return true
}
}
return false
}
func main() {
tasks := []Task{{ID: 1, Title: "Read spec"}}
fmt.Println(containsTask(tasks, Task{ID: 1, Title: "Read spec"})) // true
}
Yes, our struct has an ID, and usually “uniqueness” is based on it, but for the lecture it’s useful to see that == really compares the entire contents.
4. How a struct becomes non-comparable: a slice field
In real life, temptation appears quickly: “let’s add tags”, “let’s add a list of subtasks”, “let’s add a change history”. And most often that means slices and maps. And slices and maps are non-comparable types.
Let’s show this carefully without breaking our app: we’ll introduce a separate type just for demonstration.
package main
type TaskWithTags struct {
ID int
Title string
Tags []string
}
func main() {
a := TaskWithTags{ID: 1, Title: "Learn Go", Tags: []string{"go", "study"}}
b := TaskWithTags{ID: 1, Title: "Learn Go", Tags: []string{"go", "study"}}
_ = a
_ = b
// _ = (a == b) // Does not compile: the Tags field (slice) cannot be compared with ==
}
Why is Go so strict? Because a slice is not a “container of values”, but a small descriptor struct (pointer to an array + length + capacity). If == for slices existed, Go would have to decide what exactly we compare: the array address? the length? the elements? Those are different operations with different costs, and Go deliberately does not make a “magic” choice for you.
And this is not a “drawback”, but protection from situations where you accidentally trigger an expensive deep comparison while thinking it is “fast”. In Go, expensive operations are typically made explicit.
5. If == is not allowed: comparing by meaning
When a struct is non-comparable, you still need to be able to answer the question “are they the same?”. It’s just no longer the job of the == operator, but the job of your code and your domain model.
The most common scenario in applications: an entity has an identifier, and “equality” means “the same ID”. For tasks, this is almost always reasonable: two different tasks can have the same Title, but that does not make them the same task.
Let’s write a small comparison function by ID. Note: this is not a method (we’ll talk about methods separately), but a regular function.
package main
import "fmt"
type Task struct {
ID int
Title string
Done bool
}
func sameTaskByID(a, b Task) bool {
return a.ID == b.ID
}
func main() {
a := Task{ID: 1, Title: "Write"}
b := Task{ID: 1, Title: "Write (edited title)"}
fmt.Println(sameTaskByID(a, b)) // true
}
What if you need equality “by data”, but you have non-comparable fields (slices/maps) inside? Then you have to compare fields manually: numbers/strings via ==, slices element-by-element in a loop, maps by keys/values. It’s a bit more code, but the behavior becomes obvious: you know exactly what is considered “equal”.
Without going deep into universal “deep equal” approaches yet, here is the minimal idea using tags: “equal if ID is the same, and the tag list matches element-by-element in the same order”.
package main
import "fmt"
type TaskWithTags struct {
ID int
Tags []string
}
func equalTags(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := 0; i < len(a); i++ {
if a[i] != b[i] {
return false
}
}
return true
}
func sameTask(a, b TaskWithTags) bool {
return a.ID == b.ID && equalTags(a.Tags, b.Tags)
}
func main() {
a := TaskWithTags{ID: 1, Tags: []string{"go", "study"}}
b := TaskWithTags{ID: 1, Tags: []string{"go", "study"}}
fmt.Println(sameTask(a, b)) // true
}
Notice an important “hidden question”: are tags an ordered list or a set? If it’s a set, then comparing by order is wrong. And here it suddenly turns out that the problem is not Go and not ==, but that the data model must be clearly defined. Programming often looks like this: you came to “just compare two things”, and you left with the philosophical question “what does ‘the same’ even mean?”.
6. Pointers: addresses vs data
Even if the struct itself is comparable, you may work with pointers *Task (we already know what &x is). And here come the classic pitfalls: pointer comparison compares addresses. This is not “bad”, it’s just a different meaning.
Let’s show it with an example: two identical tasks in memory, but created separately.
package main
import "fmt"
type Task struct {
ID int
Title string
Done bool
}
func main() {
p1 := &Task{ID: 1, Title: "Same data"}
p2 := &Task{ID: 1, Title: "Same data"}
fmt.Println(p1 == p2) // false: different addresses
fmt.Println(*p1 == *p2) // true: same data
}
This double check (p1 == p2 and *p1 == *p2) explains reality well: “same address” and “same data” are different concepts.
Sometimes you need address comparison. For example, you store pointers in a slice and want to know whether two variables point to the same object. But in data modeling, you more often need a semantic check: is it the same entity (usually by ID) or does it have the same contents (by fields).
Comparability and map keys
In Go there is one more important connection: keys in a map must be comparable, because map internally uses key comparison/hashing. In practice we experience this as the rule “you can’t use any type as a key”.
If Task is comparable, we can use it as a key. From a practical standpoint, this is sometimes convenient for “sets” (set) or quick de-duplication.
package main
import "fmt"
type Task struct {
ID int
Title string
Done bool
}
func main() {
seen := make(map[Task]struct{})
t := Task{ID: 1, Title: "Read spec"}
seen[t] = struct{}{}
_, ok := seen[Task{ID: 1, Title: "Read spec"}]
fmt.Println(ok) // true
}
If you try to use as a key a struct that contains a slice/map inside, you’ll get a compilation error along the lines of invalid map key type .... This is the logical continuation of the same rule: if you can’t use ==, then you can’t use it as a map key either.
A separate nuance about interfaces: interface values can be compared, and formally they can be used as map keys, but if a non-comparable value gets stored inside the interface, operations may lead to a runtime panic. Early in your career, it’s better to follow the principle “keys are simple types or structs of simple types” to avoid adventures.
7. Common mistakes when comparing structs
Mistake #1: expecting == to “work for everything”, including slices and maps.
A beginner adds a field Tags []string or Meta map[string]string to a struct, and then wonders why the compiler complains about a == b. This is not a whim: the language does not know what you want to compare—addresses, lengths, or elements. The fix is to accept the rule “a struct is comparable only if all fields are comparable” and compare explicitly by the criterion you need.
Mistake #2: comparing pointers and thinking you compared the contents.
p1 == p2 for *Task means “the same address”, not “the same fields”. Because of this, it’s easy to get a false false when the data is the same but the objects are different. If you need to compare data, compare *p1 == *p2 (when the struct is comparable) or compare the needed fields explicitly.
Mistake #3: trying to do “universal equality” without defining what it means.
Sometimes people want: “consider the task equal if absolutely everything matches”. Then they add UpdatedAt, then Tags, then History, and suddenly “equality” becomes either impossible or meaningless. Usually it’s more useful to agree that “the same entity” is defined by ID, while “the same data” is a separate check that is needed rarely and for a specific scenario.
Mistake #4: forgetting about interface fields and unexpected runtime crashes.
Interfaces (any, error) are comparable at the syntax level, but comparison can crash if the interface contains a non-comparable dynamic type. Beginners are better off not using interface fields in data models without a clear need; and if you do use them, store only values of guaranteed comparable types inside.
Mistake #5: using a struct as a map key without realizing that “key equality” means equality of all fields.
If you made map[Task]struct{}, then the key is the entire Task, not just ID. This is sometimes useful, but more often surprising: you change Done—and the key is already “different”. If you want uniqueness by ID, then the key should be an int (or string), that is, map[int]Task, not map[Task]....
GO TO FULL VERSION