Comparing and Copying Structs

Learn how struct comparison works in Go, when structs are comparable, and how struct copy semantics behave including shallow vs deep copies and common pitfalls.

When you work with structs in Go, two operations that come up constantly are comparing two values to check if they are equal and copying a value so you can modify one without affecting the other. Both operations are governed by rules that are simple on the surface but have consequences that trip up developers regularly — especially when reference-typed fields like slices and maps enter the picture, or when range loops create copies you weren't expecting.

This section walks through exactly what Go lets you do with == and != on structs, what happens when you assign one struct variable to another, and where the line between a safe copy and a shared-data surprise lies.

Struct Comparability

Two struct values can be compared with the == and != operators only when every field in the struct is of a comparable type. Comparable types in Go include:

  • Basic types: integers, floats, booleans, strings
  • Pointers
  • Arrays (whose element type is comparable)
  • Other structs (when all their own fields are comparable)
  • Channels and interfaces (with some rules)

Types that are not comparable — and therefore make any struct containing them non-comparable — are slices, maps, and functions.

Comparing structs with comparable fields

When all fields are comparable, == checks that every field has the same value, field by field. This works exactly as you'd expect for simple structs.

package main
import "fmt"
type Point struct {
    X int
    Y int
}
func main() {
    p1 := Point{10, 20}
    p2 := Point{10, 20}
    p3 := Point{5, 20}
    fmt.Println(p1 == p2) // true
    fmt.Println(p1 == p3) // false
}

Here both X and Y are int, which is comparable, so p1 == p2 evaluates to true. The comparison goes field by field: 10 == 10 and 20 == 20.

Everything in sync:

If your struct contains only primitive types or arrays of primitive types and you compare two instances built with identical values, == will give you the result you expect — no hidden mechanics.

The type barrier: comparing structs of different types

Even if two struct types have identical field declarations, you cannot compare a value of one type with a value of the other. Go's type system is nominal: two different type definitions are always distinct, regardless of their structure.

type s1 struct {
    x int
}
type s2 struct {
    x int
}
func main() {
    a := s1{x: 5}
    b := s2{x: 5}
    // fmt.Println(a == b) // compile error: mismatched types s1 and s2
}

The attempt to compare a and b fails at compile time. The fix is always to compare values of the exact same type. If you need cross-type equality checks, write a method that extracts the values you care about.

When a struct contains a slice, map, or function

If any field is a non-comparable type, the struct itself becomes non-comparable. The compiler will reject any use of == or != on values of that type.

type Team struct {
    Name    string
    Members []string
}
func main() {
    t1 := Team{Name: "Alpha", Members: []string{"Alice", "Bob"}}
    t2 := Team{Name: "Alpha", Members: []string{"Alice", "Bob"}}
    // fmt.Println(t1 == t2) // compile error: invalid operation: t1 == t2 (struct containing []string cannot be compared)
}

The field Members is a slice, so Team is not comparable. Even though t1 and t2 hold identical data, you can't use ==. The language enforces this because comparing two slices with == is not allowed — the compiler doesn't know whether you mean "same elements" or "same backing array".

Compile-time error, not a runtime surprise:

Trying to compare structs with slices or maps will not compile. This is protective: it forces you to be explicit about what equality means for those fields rather than silently doing a shallow pointer comparison.

Deep comparison with reflect.DeepEqual

When you need to compare structs that contain non-comparable fields (or any complex nested data), the reflect package provides DeepEqual. It recursively walks through the values and checks whether they are deeply equal — for slices, it checks that all elements match; for maps, it checks keys and values.

import (
    "fmt"
    "reflect"
)
type Team struct {
    Name    string
    Members []string
}
func main() {
    t1 := Team{Name: "Alpha", Members: []string{"Alice", "Bob"}}
    t2 := Team{Name: "Alpha", Members: []string{"Alice", "Bob"}}
    fmt.Println(reflect.DeepEqual(t1, t2)) // true
}

reflect.DeepEqual treats nil slices and empty slices as equal, and will follow pointers to compare the values they point to.

DeepEqual is slow and has edge cases:

reflect.DeepEqual uses reflection under the hood, so it's considerably slower than a plain == on comparable structs. Use it only when you need deep comparison and the struct is small enough that the performance cost doesn't matter — or in tests, where clarity matters more than speed.

Common mistake: comparing pointers to structs instead of struct values

When you have *Team variables, comparing them with == compares the memory addresses, not the struct contents. To compare the actual struct data, dereference first.

t1 := &Team{Name: "A"}
t2 := &Team{Name: "A"}
fmt.Println(t1 == t2)           // false (different pointers)
fmt.Println(*t1 == *t2)         // false if Team not comparable, compile error
fmt.Println(reflect.DeepEqual(t1, t2)) // true

Always decide whether you intend to compare identities or contents.

Struct Copy Semantics

In Go, when you assign one struct variable to another, the entire struct value is copied. This is a shallow copy: each field is copied as if you'd assigned it individually. For fields that are themselves value types (integers, booleans, arrays, other structs), the copy is independent — modifying the copy does not affect the original. For fields that are reference types (pointers, slices, maps, channels, functions), the copy shares the underlying data with the original.

Copying a struct with only value-type fields

type Account struct {
    ID     int
    Active bool
}
func main() {
    a1 := Account{ID: 1, Active: true}
    a2 := a1
    a2.ID = 99
    fmt.Println(a1.ID) // 1
}

a2 is a full, independent copy. The field ID in a1 stays 1 because the copy duplicated the integer value. This is the straightforward case — if your struct contains only comparable, non-reference fields, assignment behaves exactly as you'd hope.

Copy works as expected:

If the output shows the original struct unchanged after modifying the copy, you've confirmed that the fields are all value types and the copy is fully independent.

The shallow-copy reality: slices and maps are shared

Now consider a struct with a slice field:

type Project struct {
    Name  string
    Tasks []string
}
func main() {
    p1 := Project{Name: "Apollo", Tasks: []string{"Design", "Build"}}
    p2 := p1
    p2.Tasks[0] = "Research"
    fmt.Println(p1.Tasks[0]) // "Research"
}

Both p1 and p2 point to the same slice backing array. The struct assignment copied the slice header (pointer, length, capacity) but not the elements themselves. So changing an element through p2.Tasks also changes what you see through p1.Tasks. The same principle applies to maps and channels.

Silent data sharing:

This is a source of subtle bugs. You assign a struct expecting a clean copy, but later mutations through one variable leak into another. Always check whether your struct contains reference-typed fields before relying on assignment for isolation.

The range loop trap: copies, not references

A common knock-on effect of struct copy semantics shows up in for ... range loops. When you iterate over a slice of structs, the loop variable is a copy of each element, not a reference to the element in the slice.

type Record struct {
    Value int
}
func main() {
    records := []Record{{Value: 10}, {Value: 20}}
    for _, r := range records {
        r.Value += 5
    }
    fmt.Println(records) // [{10} {20}]
}

Modifying r.Value changes the local copy, not the slice element. The same pitfall applies if you take the address of r inside the loop: &r points to the loop variable's memory, which is reused across iterations, not to the slice element.

To mutate the original slice elements, use the index:

for i := range records {
    records[i].Value += 5
}

The copy in range is the most reported struct-copy mistake:

If you've been writing Go for a while and never hit this, you will. The bug is particularly insidious when you return a pointer to &r inside a loop and all entries end up pointing to the last element's value.

Deep copying: how to get a truly independent duplicate

Go has no built-in "deep copy" operator. If your struct contains slices, maps, or pointers and you need a fully independent copy, you must build one explicitly.

Manual field-by-field copy — the clearest, safest approach for known types:

func copyProject(p Project) Project {
    tasksCopy := make([]string, len(p.Tasks))
    copy(tasksCopy, p.Tasks)
    return Project{
        Name:  p.Name,
        Tasks: tasksCopy,
    }
}

This duplicates the backing array of the slice, so the new Project owns its data.

Gob-based deep copy — works for any serializable struct, but slow:

import (
    "bytes"
    "encoding/gob"
)
func deepCopy[T any](src T) (T, error) {
    var buf bytes.Buffer
    enc := gob.NewEncoder(&buf)
    dec := gob.NewDecoder(&buf)
    if err := enc.Encode(src); err != nil {
        return src, err
    }
    var dst T
    if err := dec.Decode(&dst); err != nil {
        return src, err
    }
    return dst, nil
}

Third-party libraries like github.com/jinzhu/copier can automate deep copying via reflection, but they introduce performance costs and potential edge cases. For most code, the explicit manual copy is the right default.

Prefer explicit deep copies over reflection-based helpers:

When you write the copy logic yourself, the code documents exactly which fields are duplicated and how. Reflection-based tools are convenient but can hide complexity, especially when nested structs contain unexported fields or circular references.

Assignment copies structs — even when returned from a function

Because function arguments and return values are passed by value, returning a struct from a function also copies it (unless you return a pointer). That means a function like this produces independent copies:

func newRecord(v int) Record {
    return Record{Value: v}
}

But if you return a pointer, no copy occurs — the caller gets a reference to the same memory. The same logic applies to passing structs into functions: by value copies the struct; by pointer avoids copying.


Summary

Struct comparison in Go is type-safe and compile-time-checked: you can use == only when every field is comparable, and you can never compare values of different named types. When a struct contains a slice, map, or function field, the language forces you to confront what "equality" means for that data — reflect.DeepEqual is the escape hatch, but it comes with a performance cost.

Struct copying is always a shallow copy by assignment, which is safe and efficient for structs composed entirely of value types. The moment a slice, map, or pointer enters the struct, that same assignment creates a shared data relationship that will surprise you unless you're consciously tracking which parts of the struct are references.

The single most important lesson: when you see a range loop or a struct with a slice field, assume copying is happening and decide explicitly whether you need a shallow copy, a deep copy, or direct index-based access. This one habit prevents the majority of struct-copy bugs in real Go codebases.