Compare structs

Go structs are compared by value using the `==` operator, but only if all their fields are comparable types.

When equality breaks

You are building a cache for a web service. You want to check if an incoming request matches a cached entry. You write a simple check: if req == cachedReq. The code compiles and runs. You add a Headers field to your request struct to store metadata. You run the build again. The compiler rejects the program with invalid operation: req == cachedReq (struct containing map[string]string cannot be compared).

Go stops you here. It refuses to guess what you mean by "equal" when your data contains complex structures. This behavior feels restrictive at first. You come from languages where objects compare by reference or deep-equality by default. Go takes a different path. It makes comparability an explicit property of the type. If a type cannot be compared safely, the language forbids the operation at compile time.

This rule prevents subtle bugs where code compares memory addresses instead of content, or where performance degrades silently due to deep recursion. Understanding comparability helps you design data structures that fit Go's model. It also teaches you how to write correct equality checks when the built-in operator falls short.

What makes a type comparable

In Go, comparability is a type-level contract. A type is comparable if the == and != operators are defined for it. The basic types are comparable: int, string, bool, float64, and pointers. Structs are comparable if and only if all their fields are comparable types.

This rule applies recursively. If a struct contains another struct, the inner struct must also be fully comparable. If any field in the chain is a slice, map, or function, the entire outer struct becomes uncomparable.

// Point is comparable because int is comparable.
type Point struct {
    X, Y int
}

// Rectangle is comparable because Point is comparable.
type Rectangle struct {
    TopLeft, BottomRight Point
}

// Shape is uncomparable because it contains a slice.
type Shape struct {
    Name   string
    Points []Point // Slice breaks comparability
}

func main() {
    r1 := Rectangle{TopLeft: Point{0, 0}, BottomRight: Point{10, 10}}
    r2 := Rectangle{TopLeft: Point{0, 0}, BottomRight: Point{10, 10}}

    // This works. Go compares field by field.
    if r1 == r2 {
        fmt.Println("Rectangles are equal")
    }

    s1 := Shape{Name: "Square", Points: []Point{{0, 0}}}
    s2 := Shape{Name: "Square", Points: []Point{{0, 0}}}

    // This fails to compile.
    // Error: invalid operation: s1 == s2 (struct containing []Point cannot be compared)
    // if s1 == s2 { ... }
}

The compiler checks comparability statically. It does not need to run the program to know that Shape cannot be compared. This allows Go to optimize comparable structs heavily. The == operator on a comparable struct compiles down to a series of efficient machine instructions that compare registers or memory blocks. There is no function call overhead. There is no dynamic dispatch. The comparison is as fast as comparing the primitive fields directly.

Go prefers explicit code over implicit magic. The compiler error forces you to decide what equality means for your data. Do you care about the order of elements in a slice? Do you ignore certain fields? Do you need deep equality or shallow equality? The language requires you to answer these questions in code.

The slice trap

Slices are the most common cause of uncomparable structs. A slice in Go is not just a list of values. It is a descriptor struct containing a pointer to an underlying array, a length, and a capacity. When you compare two slices with ==, you are comparing these descriptors, not the elements.

If Go allowed slice comparison, the result would be surprising. Two slices with identical content but backed by different arrays would be unequal. Two slices pointing to the same array would be equal, even if you only look at different portions of the array. This behavior confuses developers and leads to bugs. Go removes the ambiguity by banning slice comparison entirely.

Maps and functions share this restriction. Maps are reference types backed by hash tables. Functions are closures that capture environment state. Comparing them by value is impossible or meaningless. Go treats them as uncomparable.

This design choice has a consequence. If you add a slice field to a struct that was previously comparable, the struct becomes uncomparable. You cannot use the struct as a map key anymore. You cannot use it in a set. You must write custom logic to handle equality.

Comparing slices and maps in modern Go

When you need to compare structs with slices or maps, you have options. The best option depends on your Go version and performance requirements. Go 1.21 introduced the slices and maps packages in the standard library. These packages provide efficient, generic functions for comparing collections.

Use slices.Equal to compare two slices of comparable elements. The function checks length first, then iterates over elements. It short-circuits on the first mismatch.

import (
    "cmp"
    "slices"
)

// Config holds application settings.
type Config struct {
    Name    string
    Plugins []string
}

// ConfigsEqual checks if two configs are logically equal.
// WHY: Config contains a slice, so we cannot use == directly.
func ConfigsEqual(a, b Config) bool {
    if a.Name != b.Name {
        return false
    }
    // WHY: slices.Equal handles length check and element comparison efficiently.
    return slices.Equal(a.Plugins, b.Plugins)
}

For maps, use maps.Equal. It compares keys and values. If the map values are comparable, the function works out of the box. If the values are uncomparable, you can use maps.EqualFunc with a custom comparator.

import "maps"

// User holds profile data.
type User struct {
    ID       int
    Settings map[string]string
}

// UsersEqual compares two users.
func UsersEqual(a, b User) bool {
    if a.ID != b.ID {
        return false
    }
    // WHY: maps.Equal compares keys and string values directly.
    return maps.Equal(a.Settings, b.Settings)
}

These functions are fast. They avoid reflection. They compile to efficient loops. They are the standard way to compare collections in modern Go code.

Custom equality methods

Sometimes you need more control. You might want to ignore a field. You might want to compare elements case-insensitively. You might have a struct with multiple uncomparable fields. In these cases, write a custom Equal method.

Define a method on the struct type. The receiver should be a value receiver if the struct is small, or a pointer receiver if the struct is large. The convention is to name the receiver with a short variable name matching the type, like c for Config or u for User. Do not use this or self.

// Config holds application settings.
type Config struct {
    Name    string
    Plugins []string
    Debug   bool
}

// Equal checks if another Config is logically equal.
// WHY: We ignore the Debug flag because it does not affect cache keys.
func (c Config) Equal(other Config) bool {
    if c.Name != other.Name {
        return false
    }
    // WHY: Compare plugins using slices.Equal.
    if !slices.Equal(c.Plugins, other.Plugins) {
        return false
    }
    // WHY: Debug is intentionally ignored.
    return true
}

func main() {
    c1 := Config{Name: "app", Plugins: []string{"auth"}, Debug: true}
    c2 := Config{Name: "app", Plugins: []string{"auth"}, Debug: false}

    // c1 == c2 is a compile error.
    // c1.Equal(c2) returns true.
    if c1.Equal(c2) {
        fmt.Println("Configs match for caching")
    }
}

Custom methods give you full control. You can implement complex logic. You can handle floating-point tolerance. You can normalize strings before comparison.

There is a trade-off. Custom methods require maintenance. If you add a field to the struct, you must update the Equal method. The compiler will not warn you. If you forget to update the method, your equality check becomes silently incorrect. This is a common source of bugs. Document the method clearly. List the fields it considers. Consider adding a comment that warns future maintainers to update the method when the struct changes.

Pointers versus values

Pointers add another layer of complexity. A pointer is comparable. You can compare two pointers with ==. The result is true if and only if they point to the same memory address. This is identity comparison, not equality comparison.

If you have pointers to structs, == checks if the pointers are identical. It does not check if the structs they point to have the same values.

type Point struct {
    X, Y int
}

func main() {
    p1 := &Point{1, 2}
    p2 := &Point{1, 2}
    p3 := p1

    // p1 == p2 is false. They point to different allocations.
    // p1 == p3 is true. They point to the same allocation.
    if p1 == p3 {
        fmt.Println("p1 and p3 are the same object")
    }

    // To compare values, dereference the pointers.
    if *p1 == *p2 {
        fmt.Println("p1 and p2 have equal values")
    }
}

This distinction matters when you store pointers in maps or slices. If you use a pointer as a map key, you are keying by address. If the object moves or is recreated, the key changes. Use values as map keys when you want to key by content. Use pointers as keys only when identity is what you care about.

Reflection and testing

Before Go 1.21, developers often used reflect.DeepEqual to compare structs with uncomparable fields. This function uses reflection to walk the data structure recursively. It compares every field, including unexported ones.

reflect.DeepEqual is slow. Reflection involves dynamic type checks, interface boxing, and runtime metadata lookups. It is orders of magnitude slower than direct comparison. Avoid it in hot paths. Do not use it in production code where performance matters.

reflect.DeepEqual also has quirks. It treats NaN (Not a Number) as equal to NaN. The == operator treats NaN != NaN. This inconsistency can cause tests to pass while production code fails. It also struggles with cyclic references and can panic if the structure contains channels or functions.

For testing, the cmp package from google/go-cmp is a better choice. It is designed for tests. It provides a safe, configurable comparison API. It generates readable diff output when values differ. It handles floating-point tolerance. It panics on cyclic references instead of looping forever.

import "github.com/google/go-cmp/cmp"

func TestConfig(t *testing.T) {
    got := Config{Name: "app", Plugins: []string{"auth"}}
    want := Config{Name: "app", Plugins: []string{"auth"}}

    // WHY: cmp.Equal is safer and provides better error messages than reflect.DeepEqual.
    if diff := cmp.Diff(got, want); diff != "" {
        t.Errorf("Config mismatch (-got +want):\n%s", diff)
    }
}

Use cmp in tests. Use slices.Equal and custom methods in production. Keep reflection out of your runtime code.

Pitfalls and edge cases

Floating-point comparison is tricky. float64 values can have precision errors. Two calculations that should yield the same result might differ by a tiny epsilon. Direct comparison with == fails in these cases. Use a tolerance check.

import "math"

// FloatEqual checks if two floats are within epsilon.
func FloatEqual(a, b, epsilon float64) bool {
    return math.Abs(a-b) < epsilon
}

Nested uncomparable fields break comparability transitively. If a struct contains a struct that contains a slice, the outer struct is uncomparable. You cannot compare the outer struct even if the slice is deep inside. You must write a custom method that drills down to the slice.

Interface values are comparable if the underlying types are comparable. Comparing two interface values checks both the dynamic type and the dynamic value. If the types differ, the values are unequal. If the types match, the values are compared. If the underlying type is uncomparable, the interface comparison panics at runtime.

var i1 interface{} = []int{1, 2}
var i2 interface{} = []int{1, 2}

// This panics at runtime.
// panic: runtime error: comparing uncomparable type []int
// if i1 == i2 { ... }

The panic happens because the interface values have the same type ([]int), and Go tries to compare the underlying values. Since slices are uncomparable, the runtime panics. This is a runtime error, not a compile error, because the compiler only sees interface types, which are comparable. Be careful when comparing interface values that might hold slices or maps.

Decision matrix

Use the == operator when your struct contains only comparable fields like integers, strings, booleans, and nested comparable structs. This is the fastest and simplest approach.

Use the slices.Equal function when you need to compare a struct that contains a single slice of comparable elements. This avoids reflection and provides clear code.

Use the maps.Equal function when you need to compare a struct that contains a single map with comparable keys and values.

Use a custom Equal method when your struct contains multiple uncomparable fields, or when you need to ignore specific fields, or when you need custom comparison logic like case-insensitivity.

Use the cmp package in tests when you need a generic comparison tool that provides readable diff output and handles edge cases safely.

Avoid reflect.DeepEqual in production code due to performance costs and unpredictable behavior with floating-point NaN values.

Use a tolerance check when comparing floating-point numbers to account for precision errors.

Use dereferenced pointers when you need to compare the values behind pointers rather than their memory addresses.

The compiler error is a feature, not a bug. It forces you to define what equality means for your data. Write explicit comparison logic. Your future self will thank you when you debug a cache miss.

Where to go next