The slice header trap
You define a struct to hold a configuration. It has a name and a list of enabled features. You load the config twice. You want to check if the second load matches the first so you can skip redundant initialization. You write if config1 == config2. The compiler rejects the program with invalid operation: config1 == config2 (struct containing []string cannot be compared).
You stare at the screen. The data looks identical. The strings match. The lists match. Why does Go refuse to compare them?
The answer lies in what a slice actually is. A slice is not the data. A slice is a header describing where the data lives. When you compare two slices, Go has to decide what "equal" means. Does it mean the headers point to the exact same memory address? Or does it mean the bytes inside the underlying arrays match? Go picks the safer option: it forbids the comparison entirely. This forces you to make the choice explicit. It prevents a whole class of bugs where developers assume content equality but get pointer equality by accident.
What a slice really is
A slice is a small struct under the hood. It holds three values: a pointer to the underlying array, the length of the slice, and the capacity of the underlying array.
When you create a slice, Go allocates an array on the heap and fills it with your data. The slice variable itself just points to that array. If you copy the slice variable, you get a new header pointing to the same array. The data is shared.
This sharing is powerful. It allows you to pass large lists to functions without copying the data. It also creates ambiguity. If you compare two slice headers with ==, you are comparing the pointers. Two slices can contain identical data but point to different arrays. If Go allowed == on slices and compared pointers, you would get false for identical data. That is rarely what you want.
If Go compared contents instead, it would have to walk the entire array. That is an O(N) operation. It hides performance costs. It breaks the expectation that == is a fast, simple check. Go designers chose to make the cost and intent visible. If you want content equality, you must ask for it explicitly.
The compiler's hard stop
The compiler checks struct definitions at compile time. It looks at every field. If it finds a field of a type that is not comparable, it marks the entire struct as incomparable. Slices, maps, and functions are not comparable. If your struct contains any of these, you cannot use == on the struct.
This happens at compile time, not runtime. You don't get a panic. You get a hard stop. The compiler error is explicit: invalid operation: struct1 == struct2 (struct containing []string cannot be compared). It tells you exactly which field is the problem. This design saves you from subtle runtime bugs. You cannot accidentally compare the wrong thing.
Here is a minimal example of the problem.
package main
import "fmt"
type Config struct {
Name string
Tags []string
}
func main() {
c1 := Config{Name: "prod", Tags: []string{"web", "api"}}
c2 := Config{Name: "prod", Tags: []string{"web", "api"}}
// This line causes a compile error.
// Go refuses to guess whether you want pointer equality or content equality.
// if c1 == c2 {
// fmt.Println("Equal")
// }
fmt.Println("Compilation stops before this runs")
}
The code above does not compile. You must remove the comparison or replace it with a valid alternative.
The easy way: reflect.DeepEqual
The standard library provides reflect.DeepEqual to compare values recursively. It handles slices, maps, nested structs, and pointers. It compares the actual content, not the headers.
package main
import (
"fmt"
"reflect"
)
type Config struct {
Name string
Tags []string
}
func main() {
c1 := Config{Name: "prod", Tags: []string{"web", "api"}}
c2 := Config{Name: "prod", Tags: []string{"web", "api"}}
// reflect.DeepEqual walks the entire structure recursively.
// It handles slices, maps, and nested structs automatically.
// Warning: this is slow. Do not use this in a tight loop or hot path.
if reflect.DeepEqual(c1, c2) {
fmt.Println("Configs match")
}
}
reflect.DeepEqual is convenient. It works out of the box. It is the go-to solution for tests and one-off scripts. It is not the right tool for production hot paths. Reflection is slow. It allocates memory. It walks type information at runtime. If you call it thousands of times per second, it will hurt your performance.
There is also a subtle trap. reflect.DeepEqual treats a nil slice and an empty slice []string{} as different. This is often a surprise. If your code initializes a slice with make([]string, 0), it won't match a nil slice, even though both have zero length. You might need to normalize your slices before comparing.
Reflection is a hammer. Don't use it to crack a nut.
The modern way: slices.Equal
Go 1.21 introduced the slices package in the standard library. It provides efficient comparison functions for slices. slices.Equal compares two slices element by element. It is faster than reflect.DeepEqual because it avoids reflection overhead. It works for any type that supports ==.
package main
import (
"fmt"
"slices"
)
type Config struct {
Name string
Tags []string
}
func main() {
c1 := Config{Name: "prod", Tags: []string{"web", "api"}}
c2 := Config{Name: "prod", Tags: []string{"web", "api"}}
// slices.Equal compares elements using ==.
// It is faster than reflect.DeepEqual and avoids reflection overhead.
// It returns false if lengths differ or any element differs.
tagsMatch := slices.Equal(c1.Tags, c2.Tags)
if c1.Name == c2.Name && tagsMatch {
fmt.Println("Configs match")
}
}
This approach requires you to compare each field manually. You lose the one-liner convenience of reflect.DeepEqual. You gain performance and clarity. You explicitly state which fields matter. This is the recommended approach for Go 1.21 and later.
If you are on an older version of Go, you can use cmp.Equal from golang.org/x/exp. It provides similar functionality with a clean API.
The fast way: manual loops
When you are on a critical path, every nanosecond counts. Manual loops are the fastest option. They avoid function calls, allocations, and generic overhead. You write the comparison logic yourself. It is more code, but it is blazing fast.
You should define a method on your struct to encapsulate the logic. This keeps your code organized and reusable. Follow the Go convention for receiver names: use a short name matching the type, like (c Config).
package main
import "fmt"
type Config struct {
Name string
Tags []string
}
// Equals checks if two configs are identical.
// It compares fields manually for maximum performance.
func (c Config) Equals(other Config) bool {
// Compare simple fields first.
// Short-circuit if they differ to save time.
if c.Name != other.Name {
return false
}
// Compare slice lengths.
// Different lengths mean different content.
if len(c.Tags) != len(other.Tags) {
return false
}
// Compare slice elements one by one.
// Break early if a mismatch is found.
for i := range c.Tags {
if c.Tags[i] != other.Tags[i] {
return false
}
}
// All fields match.
return true
}
func main() {
c1 := Config{Name: "prod", Tags: []string{"web", "api"}}
c2 := Config{Name: "prod", Tags: []string{"web", "api"}}
if c1.Equals(c2) {
fmt.Println("Configs match")
}
}
This code is verbose. It requires maintenance if you add fields. It is worth it when performance matters. You control exactly what happens. You can optimize the order of checks. You can skip expensive comparisons if cheap ones fail.
Manual loops are fast. Write them when it counts.
Pitfalls and edge cases
Comparing slices is not just about choosing a function. You need to understand the nuances of slice equality.
The nil vs empty slice distinction is the most common trap. A nil slice has no underlying array. An empty slice []string{} has an underlying array of length zero. They behave the same in most operations. They print the same. They have the same length. But they are not identical. reflect.DeepEqual returns false. slices.Equal returns false. Manual loops return false if you check lengths but not pointers, or true if you only check lengths and elements. You must decide which behavior you want. If you treat them as equal, normalize them before comparing. Set nil slices to []string{} or vice versa.
Floating point numbers add another layer of complexity. If your slice contains float64, you must handle NaN (Not a Number). NaN is not equal to itself. NaN == NaN is false. This breaks all comparison methods. reflect.DeepEqual returns false for slices containing NaN. slices.Equal returns false. Manual loops return false. If you need to treat NaN as equal, you must write a custom comparison function. Use math.IsNaN to check for NaN and handle it explicitly.
Maps and functions also make structs incomparable. If your struct contains a map[string]int or a func(), you cannot use ==. You must use reflect.DeepEqual or manual comparison. Maps are not comparable because they are reference types with complex internal structures. Functions are not comparable because they are closures with captured environments.
Slices are headers. Compare the data, not the ticket.
Decision matrix
Choosing the right comparison method depends on your context. Use the right tool for the job.
Use reflect.DeepEqual when you are writing tests or one-off scripts where performance doesn't matter. It handles complex nested structures automatically. It is the fastest way to get a working comparison.
Use slices.Equal when you are on Go 1.21 or later and need a balance of performance and convenience. It avoids reflection overhead. It is standard library and well-optimized.
Use cmp.Equal from golang.org/x/exp when you are on an older Go version and want a clean API without reflection. It provides a middle ground between reflect and manual loops.
Use a manual loop when you are on a hot path and need maximum speed. It avoids all overhead. It allows custom logic for edge cases like NaN or nil normalization.
Use pointer comparison (&a == &b) when you actually care about identity, not content. This is rare. It checks if two variables point to the same memory address. It is not useful for data comparison.