Copying a Struct in Go and the Shallow Copy Problem
Assigning a struct copies its fields, but slices, maps and pointers inside still share data. See the bug, a Clone method and what each fix costs.

You copy a struct, change the copy, and the original changes too. Nobody finds this surprising the second time, and everybody finds it surprising the first. The cause is that Go copies exactly what is in the struct, and for a slice or a map, what is in the struct is a small header and not the data. This article reproduces the bug, shows the Clone method that fixes it for good, compares the alternatives with numbers, and covers the fields that cannot be copied at all. It follows the structs guide, which introduced the rule, and applies the slice copying idioms to a whole type. It sits in the Go basics path.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64, Apple M1 Max)
The bug
A profile with a few kinds of field, copied by assignment and then changed through the copy:
package main
import "fmt"
type Address struct{ City, Zip string }
type Profile struct {
Name string
Tags []string
Scores map[string]int
Home *Address
History [2]Address
}
func main() {
original := Profile{
Name: "ada",
Tags: []string{"admin", "beta"},
Scores: map[string]int{"a": 1},
Home: &Address{"London", "N1"},
History: [2]Address{{"Paris", "75"}, {"Rome", "00"}},
}
clone := original
clone.Name = "grace"
clone.Tags[0] = "changed"
clone.Scores["b"] = 2
clone.Home.City = "Boston"
clone.History[0].City = "Berlin"
fmt.Println(original.Name, original.Tags, original.Scores)
fmt.Println(original.Home.City, original.History[0].City)
}ada [changed beta] map[a:1 b:2]
Boston ParisWalk the fields. The Name change did not reach the original: a string is copied by value. The History array change did not either: an array is a value, so all of it was copied, and its nested structs with it. The other three crossed over. The slice element changed in both because Tags in each struct points at the same backing array. The map gained b in both because it is one table. And the home address changed to Boston in both because Home is a pointer to one Address.
This is a copy of every field, which is the definition of shallow. Nothing in the language is broken here. The consequence is that a struct is only safely copyable if everything inside it is a plain value, and the moment it contains a slice, map, pointer or channel, a := is a sharing decision.
Why it matters beyond the demo
The bug is quiet because the code looks right. A function that receives a Profile by value appears to have its own. It does not, for three of the five fields. In a web service that is the handler that mutates a "copy" of the cached user and corrupts the cache for the next request. In a test that is a helper that modifies a shared fixture so that later tests fail depending on order. And it is a data race when two goroutines each hold a "copy" and write through a shared map, which ends in fatal error: concurrent map writes, as the maps guide shows.
The Clone method
The fix is to write down how to copy the type once, next to the type, and call it wherever independence is needed. Each reference field gets its own treatment:
package main
import (
"fmt"
"maps"
"slices"
)
type Address struct{ City, Zip string }
type Profile struct {
Name string
Tags []string
Scores map[string]int
Home *Address
History []Address
}
func (p Profile) Clone() Profile {
c := p
c.Tags = slices.Clone(p.Tags)
c.Scores = maps.Clone(p.Scores)
if p.Home != nil {
h := *p.Home
c.Home = &h
}
c.History = slices.Clone(p.History)
return c
}
func main() {
original := Profile{
Name: "ada",
Tags: []string{"admin", "beta"},
Scores: map[string]int{"a": 1},
Home: &Address{"London", "N1"},
History: []Address{{"Paris", "75"}},
}
clone := original.Clone()
clone.Tags[0] = "changed"
clone.Scores["b"] = 2
clone.Home.City = "Boston"
clone.History[0].City = "Berlin"
fmt.Println(original.Tags, original.Scores, original.Home.City, original.History[0].City)
fmt.Println(clone.Tags, clone.Scores, clone.Home.City, clone.History[0].City)
}[admin beta] map[a:1] London Paris
[changed beta] map[a:1 b:2] Boston BerlinThe original is untouched by every change to the clone. The pattern starts with c := p, which copies all the plain fields, and then replaces each reference field with a fresh copy. That order has a property worth keeping: when someone adds a plain field to the struct, Clone copies it automatically, and only a new reference field needs a line. The Home field shows the one case that has no helper: copy the pointed-to value into a new variable and take its address. Be careful with nil, which Clone must preserve: slices.Clone and maps.Clone return nil for nil, and the pointer check does the same.
Slices of structs that themselves contain references need a loop, because slices.Clone of a []Address copies the addresses' fields but not what those fields point at. For Address as defined, with two strings, one slices.Clone is a full copy. If it also held a slice, the loop would call Clone on each element.
What each approach costs
Three ways to get a second Profile: plain assignment, the Clone method and a JSON round trip, which is the shortcut many people reach for. I measured each on a profile with three tags, three scores, a home address and two history entries:
package main
import (
"encoding/json"
"maps"
"slices"
"testing"
)
type Address struct{ City, Zip string }
type Profile struct {
Name string
Tags []string
Scores map[string]int
Home *Address
History []Address
}
func (p Profile) Clone() Profile {
c := p
c.Tags = slices.Clone(p.Tags)
c.Scores = maps.Clone(p.Scores)
if p.Home != nil {
h := *p.Home
c.Home = &h
}
c.History = slices.Clone(p.History)
return c
}
func viaJSON(p Profile) Profile {
b, _ := json.Marshal(p)
var c Profile
_ = json.Unmarshal(b, &c)
return c
}
var base = Profile{
Name: "ada",
Tags: []string{"admin", "beta", "ops"},
Scores: map[string]int{"a": 1, "b": 2, "c": 3},
Home: &Address{"London", "N1"},
History: []Address{{"Paris", "75"}, {"Rome", "00"}},
}
var sink Profile
func BenchmarkAssign(b *testing.B) {
for b.Loop() {
sink = base
}
}
func BenchmarkClone(b *testing.B) {
for b.Loop() {
sink = base.Clone()
}
}
func BenchmarkJSON(b *testing.B) {
for b.Loop() {
sink = viaJSON(base)
}
}goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkAssign-10 514206745 2.187 ns/op 0 B/op 0 allocs/op
BenchmarkAssign-10 560777035 2.149 ns/op 0 B/op 0 allocs/op
BenchmarkAssign-10 563334548 2.136 ns/op 0 B/op 0 allocs/op
BenchmarkClone-10 7426152 176.2 ns/op 400 B/op 5 allocs/op
BenchmarkClone-10 7606484 155.9 ns/op 400 B/op 5 allocs/op
BenchmarkClone-10 7927963 154.8 ns/op 400 B/op 5 allocs/op
BenchmarkJSON-10 332862 3646 ns/op 1424 B/op 39 allocs/op
BenchmarkJSON-10 337713 3640 ns/op 1424 B/op 39 allocs/op
BenchmarkJSON-10 290241 3738 ns/op 1424 B/op 39 allocs/op
PASS
ok example.test/lab 11.028sAssignment took about 2 ns and allocated nothing, because it copies a few words. The Clone method took about 155 ns, with 400 bytes in five allocations: the two slices, the map, the address and the history. The JSON round trip took about 3,600 ns, with 1,424 bytes in 39 allocations, which is roughly 23 times slower and about eight times the allocations. These are single-machine numbers on an Apple M1 Max, but the ratio is what to take away. Deep copying is cheap when it is written for the type, and expensive when a general-purpose encoder does it.
The JSON trick also changes the data. Unexported fields vanish, because the encoder cannot see them. A time.Time keeps its instant and loses its monotonic reading. Fields tagged json:"-" are dropped, and numbers decoded into any become float64. The nil and empty slice distinction survives only because JSON has both null and [], as slices explained describes. For test fixtures where none of that matters, it is a quick way to get a copy. For production code, it is the wrong tool, and encoding/gob has similar limits with its own costs.
Fields that must not be copied at all
Some fields cannot be given a meaningful second copy. A sync.Mutex copied while locked produces a second locked mutex. sync.WaitGroup, sync.Once and strings.Builder all misbehave when copied after use, and go vet reports the first of these as passing a lock by value, as shown in pointer or value receiver. A chan or a *os.File field copies the handle, and two owners of one file will close it twice. If your struct holds any of these, do not hand out copies. Return and pass a pointer, make the type's methods pointer receivers, and expose a constructor, so the question of copying never comes up. Where you need a copy of the data, give the type a method that builds a new value and initializes the fresh mutex, not one that duplicates the old.
Keeping Clone correct as the type grows
A Clone method has one failure mode: someone adds a slice field and forgets the line. A test finds it at the cost of ten lines. Build a value with every reference field populated, clone it, mutate every reference in the clone, and assert the original is unchanged:
func TestCloneIsDeep(t *testing.T) {
orig := newFullProfile()
c := orig.Clone()
c.Tags[0] = "x"
c.Scores["new"] = 9
c.Home.City = "elsewhere"
if !reflect.DeepEqual(orig, newFullProfile()) {
t.Fatalf("clone shares memory with the original: %+v", orig)
}
}The comparison against a freshly built profile is the useful part: it fails no matter which field leaks. Keep newFullProfile in the test file and update it whenever the type changes, and the test will fail until Clone is updated too.
When sharing is what you want
Copying is not always the goal. A large read-only structure shared by many readers is better as one copy behind a pointer, with the promise in the type's documentation that it is not modified. A cache entry that several callers should see change at once is a pointer, intentionally. And a struct that is only ever read after construction needs no copy, since nothing can change it. The question to ask at each assignment is who owns the data and who may change it. If the answer is "me alone", clone it. The specifics of how assignment treats each kind are in the specification on assignability, and the maps and slices packages document their Clone helpers, which are themselves shallow for the elements.


