Struct Copy Semantics in Go

How Go structs behave when copied — value semantics, shallow copies, and handling pointer, slice, and map fields.

When you assign one struct variable to another, Go copies the entire struct value. Every field that is itself a value — numbers, strings, booleans, arrays — gets replicated into the new variable. The two structs then live independently in memory. Changing a field in one will not affect the other.

This is the fundamental rule: Go is a pass-by-value language, and assignment always copies. The behavior is consistent whether you are assigning to a new variable, passing a struct as a function argument, or returning a struct from a function.

What Gets Copied When You Assign a Struct

Take a struct with only primitive fields and an array. Arrays in Go are value types, so they are copied in their entirety, element by element.

type Point struct {
    X, Y int
    Labels [2]string
}
p1 := Point{X: 10, Y: 20, Labels: [2]string{"A", "B"}}
p2 := p1
p2.X = 99
p2.Labels[0] = "Z"
fmt.Println(p1) // {10 20 [A B]}
fmt.Println(p2) // {99 20 [Z B]}

The assignment p2 := p1 created a full, independent copy. Modifying p2.X and the array inside p2 leaves p1 untouched. This is the simplest case and aligns with what most programmers expect from a “copy.”

Value Semantics and Safety:

Go's decision to copy structs by default eliminates an entire class of bugs where two variables accidentally share mutable state. You can pass a struct value around without fearing that a function will silently mutate the original. This is one of the reasons Go code is easier to reason about in concurrent programs.

Shallow Copy Behaviour with Pointers, Slices, and Maps

The guarantee of a full independent copy only holds for fields that are themselves values. Three types in Go behave differently: slices, maps, and pointers. These are reference-like: they store a descriptor (pointer, length, capacity) that refers to an underlying data structure elsewhere in memory. When you copy a struct, you copy the descriptor, not the data it points to.

Slices Inside Structs

A slice field is a small header containing a pointer to a backing array, a length, and a capacity. Assignment copies the header, so both the original and the copy share the same backing array.

type Car struct {
    Model  string
    Owners []string
}
c1 := Car{Model: "BMW", Owners: []string{"John", "Martha"}}
c2 := c1
c2.Owners[0] = "Antony"
c2.Model = "Audi"
fmt.Println(c1) // {BMW [Antony Martha]}
fmt.Println(c2) // {Audi [Antony Martha]}

Modifying the string Model on c2 didn't affect c1 — the string was copied. But changing an element inside the Owners slice through c2 also changed the slice that c1 sees, because both slices point to the same underlying array.

Appending Can Make This Worse:

If you append to the copied slice and the underlying array has enough capacity, the appended element will appear in the original's slice as well. This is the same slice aliasing issue that occurs outside of structs. To completely isolate slices inside a struct you must create a new slice and copy the elements.

Pointer Fields

A pointer field contains a memory address. Copying the struct duplicates the address, so both copies point to the same location.

type Record struct {
    ID   int
    Note *string
}
note := "original"
r1 := Record{ID: 1, Note: &note}
r2 := r1
*r2.Note = "changed"
fmt.Println(*r1.Note) // changed

Here r1.Note and r2.Note hold the same address, so dereferencing either gives the same string. This is a shallow copy.

Map Fields

Maps are runtime-initialized references. A struct that contains a map field will share the same map data after assignment.

type Cache struct {
    Name  string
    Items map[string]int
}
c1 := Cache{Name: "scores", Items: map[string]int{"alice": 10}}
c2 := c1
c2.Items["alice"] = 99
fmt.Println(c1.Items["alice"]) // 99

The map itself is not duplicated — both structs reference the same hash table. To get a deep copy, you must iterate and copy key-value pairs into a new map.

Struct Copies in Function Arguments and Return Values

The same copy semantics apply when you pass a struct to a function or return one. A function that receives a struct by value works on a copy; it cannot alter the caller's original.

func reset(p Point) {
    p.X = 0
    p.Y = 0
}
pt := Point{X: 5, Y: 7}
reset(pt)
fmt.Println(pt) // {5 7}

Inside reset, p is a separate copy of pt. The zeroing had no effect outside the function. This is the foundation of value receivers in methods: the method receives a copy of the struct, so mutations are lost unless you use a pointer receiver.

When a function returns a struct, the caller gets a fresh copy. That copy is independent of whatever the function used to construct it.

func makePoint() Point {
    p := Point{X: 3, Y: 4}
    return p
}
p := makePoint()
// p is a separate copy; the original inside makePoint is gone.

If you need the caller and the function to share the same instance — for example, so that changes made inside the function persist — pass a pointer instead:

func resetByPointer(p *Point) {
    p.X = 0
    p.Y = 0
}
pt := Point{X: 5, Y: 7}
resetByPointer(&pt)
fmt.Println(pt) // {0 0}

The Range Loop Variable Is a Copy

A widespread bug stems from combining range loops with struct copy semantics. When you iterate over a slice of structs using for _, v := range slice, the variable v is not an alias for the slice element. It is a separate copy that gets overwritten on each iteration. Taking the address of v therefore gives you a pointer to a reused copy, not to the original element in the slice.

type Item struct {
    Name  string
    Price int
}
func findItem(items []Item, name string) *Item {
    for _, item := range items {
        if item.Name == name {
            return &item // WRONG: returns pointer to loop variable copy
        }
    }
    return nil
}
func main() {
    stock := []Item{{"pen", 2}, {"book", 10}}
    found := findItem(stock, "pen")
    found.Price = 5
    fmt.Println(stock) // [{pen 2} {book 10}] - the original was not changed
}

The function returns a pointer to the copy item, not to the element inside stock. All modifications to *found are discarded when the loop variable is reused.

Silently Incorrect Code:

The compiler does not warn you about this. The code compiles and runs, but the mutation never reaches the intended data structure. This is one of the most expensive bugs to spot in code review because it looks correct at a glance.

To fix it, work with the index directly:

func findItem(items []Item, name string) *Item {
    for i := range items {
        if items[i].Name == name {
            return &items[i]
        }
    }
    return nil
}

Now the returned pointer points to an actual element in the slice, and modifications will be reflected in the original stock slice.

Creating a Deep Copy

There is no built-in deep copy function in Go. If you need a struct copy that also duplicates all the data reachable through pointers, slices, and maps, you must write the copy logic yourself. The exact approach depends on the fields.

1

Step 1: Copy simple fields by assignment

Start by assigning the top-level value fields. Strings, ints, booleans, and arrays will be copied automatically.

copy := original
// All value-type fields are now independent.
2

Step 2: Duplicate slice fields

For each slice field, allocate a new slice and copy every element. If the elements are themselves structs that contain references, you'll need to deep-copy those as well (see Step 4).

copy.Owners = make([]string, len(original.Owners))
copy(copy.Owners, original.Owners)

An alternative that works for slices of any element type and resizes automatically:

copy.Items = append([]Item(nil), original.Items...)
3

Step 3: Duplicate pointer fields

For a non-nil pointer, allocate a new value of the same type and copy the pointed-to data. If that data is itself a struct with reference fields, recurse.

if original.Note != nil {
    noteCopy := *original.Note
    copy.Note = &noteCopy
}
4

Step 4: Duplicate map fields

Create a fresh map and copy every key-value pair. For values that are slices, pointers, or maps, apply the same deep-copy logic.

copy.Items = make(map[string]int, len(original.Items))
for k, v := range original.Items {
    copy.Items[k] = v // if the value is a reference type, copy it deeply as well
}
5

Step 5: (Optional) Use reflection or code generation

For large, deeply nested structs, writing manual deep-copy code becomes tedious and error-prone. Third-party libraries like jinzhu/copier or using gob.Encode/gob.Decode can produce a deep copy through reflection, but they come with a performance cost. Code generation via go generate can also automate field-by-field copy logic. Use these when the maintenance burden of handwritten code outweighs the runtime overhead.

Independent Copies Verified:

After applying the manual deep copy steps, modifying the copy's slices, pointers, or maps will have no effect on the original struct. Run the examples above and confirm with fmt.Printf that the original's data is unchanged — that is the sign of a correct deep copy.

How to Think About Value vs. Pointer Semantics for Structs

Beginners often treat the question as purely performance-related: "Copy big structs is slow, use pointers." Performance matters, but a more reliable starting point is the identity of the type.

Ask yourself: if two struct values have identical fields, are they the same thing?

For a time.Time, the answer is yes — two Time values that represent the same instant are interchangeable. time.Time is a value object, and the standard library uses value semantics everywhere: methods like Add return a new Time rather than modifying the receiver.

For a Customer or a Project, two instances with the same name and ID are not interchangeable; they are distinct entities that evolve independently over time. These types should be used with pointer semantics so that a single logical entity is represented by a single memory location, and all parts of the program refer to that same location.

Mixing Semantics Creates Bugs:

If you accidentally make copies of an entity struct (by passing it by value, or assigning it without a pointer), you now have two representations of what is supposed to be the same thing. Changes made through one copy are invisible to the other, leading to split-brain bugs where the program's state diverges without any explicit synchronization.

This mental model also guides method receiver choices. A type that is a value object should typically use value receivers. A type that represents an entity should use pointer receivers, even if the method does not mutate the receiver, to preserve identity and avoid accidental copies.

Common Mistakes and How to Avoid Them

Mistakes around struct copying almost always fall into two categories: assuming a full deep copy when you get a shallow one, and accidentally capturing the address of a copy.

  • Modifying a slice through a copied struct. The original struct's slice sees the changes. Solve by deep-copying slices when independence is required.
  • Taking the address of a range variable. As shown earlier, &v inside a range loop points to a reused copy. Always use the index to access the slice element.
  • Passing a large struct by value in a hot loop. This can degrade performance. For structs larger than a few words, prefer passing a pointer if the function is called frequently. The go test -bench tool will tell you where it matters.
  • Returning a pointer to a local variable inside a function that returns the struct by value. This is safe (Go's escape analysis will move the variable to the heap), but it breaks the copy-on-return semantics. If you intended to return an independent copy, returning a pointer defeats that.

Summary

Struct copy semantics in Go are simple at the surface — assignment copies the struct — but the real work happens in understanding what exactly is being copied. The value fields become independent; the reference-like fields (slices, maps, pointers) share their underlying data. This is not a flaw; it's a deliberate design that keeps the language predictable and performant, as long as you are aware of the sharing.

The one idea to take away: when you assign a struct, you duplicate the struct's memory, not the memory its fields might point to. That one rule explains every behavior described in this document.

Once you internalise that, deciding when to deep-copy and when to use pointers becomes a design choice about identity rather than a source of surprise.