How to Compare Structs in Go
You are writing a cache layer. You fetch a user object from the database and compare it to the one in your cache to decide whether to update the stored copy. You write if userFromDB == userFromCache. The compiler rejects the code with invalid operation: userFromDB == userFromCache (struct containing []string cannot be compared). You stare at the error. The structs look identical. The values match. Why won't Go let you compare them?
Struct comparison in Go follows a strict rule. The == operator works only when every field in the struct is comparable. Integers, strings, booleans, arrays, and other comparable structs pass the test. Slices, maps, and functions do not. Go refuses to guess what you mean by "equal" for dynamic data structures. This design prevents subtle bugs and forces you to make comparison logic explicit.
Comparable versus non-comparable fields
A struct is a collection of fields packed together in memory. When you compare two structs with ==, Go checks each field in order. If all fields match, the structs are equal. This works for simple types. It also works recursively. A struct containing another struct is comparable as long as the inner struct is comparable.
The boundary appears with fields that manage dynamic memory. A slice is not just a list of values. It is a header containing a pointer to an underlying array, a length, and a capacity. Two slices can point to the same array, different arrays, or overlapping regions. Comparing slices requires a decision. Do you compare the headers? Do you compare the underlying data? Do you care about capacity? Go does not decide for you. If a struct contains a slice, the struct is non-comparable.
Maps and functions follow the same rule. A map is a pointer to a hash table. Functions are closures that may capture environment state. Neither has a simple value representation. The compiler blocks comparison for structs containing these types.
Arrays behave differently. An array has a fixed size known at compile time. [3]int is a value type. The elements are part of the array itself. Arrays are comparable. Two arrays are equal if all their elements are equal. This distinction often surprises developers coming from languages where arrays and slices are the same thing. In Go, arrays are values. Slices are references to values. Treat them accordingly.
Minimal example with comparable fields
Here is the simplest case. Two structs with only comparable fields. The compiler generates efficient comparison code. No reflection is involved.
package main
import "fmt"
// Point holds coordinates.
type Point struct {
X int
Y int
}
// Rectangle uses a Point and an array.
type Rectangle struct {
Origin Point
Sides [4]float64
}
func main() {
// Both points have identical values.
p1 := Point{X: 1, Y: 2}
p2 := Point{X: 1, Y: 2}
// == checks X against X and Y against Y.
fmt.Println(p1 == p2) // prints: true
// Arrays are comparable. Elements are checked one by one.
r1 := Rectangle{Origin: p1, Sides: [4]float64{1.0, 2.0, 1.0, 2.0}}
r2 := Rectangle{Origin: p1, Sides: [4]float64{1.0, 2.0, 1.0, 2.0}}
// Rectangle is comparable because Point and [4]float64 are comparable.
fmt.Println(r1 == r2) // prints: true
// Changing one field breaks equality.
r3 := Rectangle{Origin: p1, Sides: [4]float64{1.0, 2.0, 1.0, 3.0}}
fmt.Println(r1 == r3) // prints: false
}
The compiler knows the layout of Point and Rectangle. It emits machine instructions to compare the memory bytes directly. This is fast. The comparison happens in constant time relative to the struct size. There is no allocation. There is no overhead.
Pointers compare addresses, not values
Comparing pointers to structs is different from comparing the structs themselves. A pointer holds a memory address. == on pointers checks if they point to the same location. It does not dereference the pointer to compare the underlying values.
package main
import "fmt"
type Config struct {
Port int
}
func main() {
// Two distinct variables with the same value.
c1 := Config{Port: 8080}
c2 := Config{Port: 8080}
// Pointers point to different memory addresses.
p1 := &c1
p2 := &c2
// Pointer comparison checks addresses.
fmt.Println(p1 == p2) // prints: false
// Dereference to compare values.
fmt.Println(*p1 == *p2) // prints: true
}
This behavior is consistent with how pointers work everywhere in Go. If you need value equality for pointers, dereference them first. Be careful with nil pointers. Dereferencing a nil pointer causes a runtime panic. Check for nil before dereferencing, or use a helper function that handles nil safely.
Pointers compare addresses. Values compare contents. Know which one you need.
Realistic example with slices and reflection
Real code often involves structs with slices. A configuration struct might hold a list of allowed hosts. A database record might hold a list of tags. These structs are non-comparable. You cannot use ==.
The standard library provides reflect.DeepEqual for content comparison. It walks the entire structure recursively. It compares slice elements, map keys and values, and nested structs. It handles the dynamic cases that == blocks.
package main
import (
"fmt"
"reflect"
)
// AppConfig holds settings including a slice.
type AppConfig struct {
Port int
Hosts []string
Debug bool
}
func main() {
// Two configs with identical settings.
cfg1 := AppConfig{
Port: 8080,
Hosts: []string{"localhost", "127.0.0.1"},
Debug: false,
}
cfg2 := AppConfig{
Port: 8080,
Hosts: []string{"localhost", "127.0.0.1"},
Debug: false,
}
// cfg1 == cfg2 fails to compile.
// Error: invalid operation: cfg1 == cfg2 (struct containing []string cannot be compared)
// reflect.DeepEqual checks contents recursively.
fmt.Println(reflect.DeepEqual(cfg1, cfg2)) // prints: true
// Different order in slice breaks equality.
cfg3 := AppConfig{
Port: 8080,
Hosts: []string{"127.0.0.1", "localhost"},
Debug: false,
}
fmt.Println(reflect.DeepEqual(cfg1, cfg3)) // prints: false
}
reflect.DeepEqual is a general-purpose tool. It works for any type. It is also slower than ==. Reflection adds overhead. The function must inspect type information at runtime. It allocates memory for internal bookkeeping. For small structs, the cost is negligible. For large structs or hot loops, the cost adds up.
Pitfalls and edge cases
Using reflect.DeepEqual introduces behaviors that differ from ==. Understanding these differences prevents bugs.
Nil slices are not equal to empty slices. []int(nil) is not deep equal to []int{}. The nil slice has no underlying array. The empty slice has an underlying array of length zero. reflect.DeepEqual treats them as different. This catches many developers off guard. If your code initializes a slice with make versus leaving it nil, comparison results change. Normalize slices before comparison if you want nil and empty to be equivalent.
Floating-point comparison uses bitwise equality. NaN is not equal to NaN. If your struct contains floats, a value of NaN will never match another NaN. This follows IEEE 754 standards. If you need tolerance-based comparison for floats, reflect.DeepEqual is the wrong tool. Write a custom comparison function.
Maps are compared by keys and values. Two maps are equal if they have the same keys and the same values for each key. Order does not matter. This is usually what you want. Be aware that map values must be comparable. A map with slice values cannot be deep equal compared. The error propagates.
Performance matters in tight loops. If you compare structs inside a request handler that runs thousands of times per second, reflect.DeepEqual can become a bottleneck. Profile your code. Look for allocation spikes. Consider alternatives if reflection dominates CPU time.
The compiler blocks slice comparison for a reason. Trust the error. Use reflection or a custom method when you need content equality.
Custom comparison methods
When performance matters or you need custom logic, define an Equal method on the struct. This gives you full control. You can compare fields selectively. You can handle nil slices gracefully. You can implement floating-point tolerance. You can short-circuit early if a cheap field differs.
package main
import "fmt"
// Config holds application settings.
type Config struct {
Port int
Hosts []string
}
// Equal checks if another Config has the same settings.
// The receiver is a value to avoid pointer aliasing issues.
func (c Config) Equal(other Config) bool {
// Compare simple fields first. Fast path.
if c.Port != other.Port {
return false
}
// Compare slice lengths.
if len(c.Hosts) != len(other.Hosts) {
return false
}
// Compare slice elements.
for i := range c.Hosts {
if c.Hosts[i] != other.Hosts[i] {
return false
}
}
return true
}
func main() {
c1 := Config{Port: 8080, Hosts: []string{"localhost"}}
c2 := Config{Port: 8080, Hosts: []string{"localhost"}}
fmt.Println(c1.Equal(c2)) // prints: true
}
The receiver name is c, matching the type Config. This follows Go convention. Receiver names are usually one or two letters. The method takes a value parameter, not a pointer. This avoids issues with nil pointers and makes the method callable on both values and pointers. The implementation is explicit. It compares fields in an order that maximizes early returns. It handles the slice manually. This code is faster than reflect.DeepEqual and clearer in intent.
Custom methods also let you document comparison rules. If your struct has a field that should be ignored for equality, the method can skip it. If order does not matter for a list, the method can sort before comparing. The method encapsulates the logic. Callers use a single function. The rules stay in one place.
Write Equal methods when you need performance or custom rules. Keep the logic close to the type.
Decision matrix
Choose the right comparison tool based on your struct layout and requirements.
Use == when your struct contains only comparable fields like integers, strings, booleans, arrays, and other comparable structs. This is the fastest and safest option. The compiler verifies the comparison. No runtime overhead exists.
Use reflect.DeepEqual when you need a quick content comparison for structs with slices or maps, and performance is not critical. This works for any type. It handles nested structures automatically. Accept the reflection cost for infrequent checks.
Use a custom Equal method when you have specific comparison rules, such as ignoring certain fields, handling floating-point tolerance, or normalizing nil slices. This gives you control over logic and performance. Document the rules in the method.
Use cmp.Equal from the golang.org/x/exp/cmp package when you want a type-safe alternative to reflect.DeepEqual that works at compile time for comparable types. This package provides generic comparison functions that avoid reflection for comparable types while handling non-comparable types gracefully.