Copying structs in Go — why a shallow copy changes the map too
Contents5 sections
Interfaces, slices, defer, embedding, context. Looking back after five posts, most of the pitfalls came from two axes. One is that everything is copied; the other is doing object orientation without classes. This post folds the five posts along those two axes and works through, with hands-on examples, how to decide whether to copy or share.
One rule: everything is copied
Let’s pin this down first. Go has no separate pass-by-reference mechanism. Whether you pass something to a function, assign it to a variable, or receive it as a method receiver, the value is copied. Pass a pointer and the pointer, a value, is what gets copied.
But what that copied value is differs by type. That splits values into three kinds, and half the pitfalls in this series come from here.
Byte counts assume a 64-bit platform and may vary by architecture and implementation. Treat the numbers as reference; the three kinds are what to remember. The middle kind shares its contents even when copied, and the last kind has two slots, which is why nil checks defy intuition.
🧪 Try it
Copying a struct still changes its tags and map
A struct is a value holding its fields, so assigning it copies each field’s value. An integer field copies the integer; a slice field copies the slice header. I used to assume that assigning a struct detached everything inside it. Until I ran the code below, I didn’t expect the map to change along with it.
package main
import "fmt"
type User struct { Name string Tags []string Meta map[string]string}
func main() { original := User{ Name: "Jaden", Tags: []string{"reader"}, Meta: map[string]string{"team": "backend"}, }
shallow := original // struct assignment: each field's value is copied shallow.Name = "Copy" // replace the string field shallow.Tags[0] = "editor" // write into the array the slice points to shallow.Meta["team"] = "platform" // write into the hash table the map points to
fmt.Println("original:", original.Name, original.Tags, original.Meta) fmt.Println("copy :", shallow.Name, shallow.Tags, shallow.Meta)
isolated := original isolated.Tags = append([]string(nil), original.Tags...) // separate the array too isolated.Tags[0] = "admin" fmt.Println("original tags:", original.Tags) fmt.Println("isolated copy:", isolated.Tags) fmt.Println("map unchanged:", original.Meta, isolated.Meta)}$ go run shallow/main.gooriginal: Jaden [editor] map[team:platform]copy : Copy [editor] map[team:platform]original tags: [editor]isolated copy: [admin]map unchanged: map[team:platform] map[team:platform]All three fields are “copy, then modify,” yet the results diverge. shallow.Name = "Copy" just swaps a different string into the copy’s field and leaves the original string’s contents alone. Tags[0] and Meta["team"], on the other hand, write into the data the headers point to, which both structs share, so the original sees it too.
The last two lines matter. Tags was copied into a new array, so it can now change independently, but Meta is still shared. Separating Meta as well takes a new map from maps.Clone. There’s no need to copy every field recursively; detach just the fields you’ll change independently, like this.
🧪 Try it
What Sizeof measures, and what it doesn't
Having seen how far a copy reaches, let’s look at the size of the value itself. unsafe.Sizeof returns the storage size of the value alone; it doesn’t add the array a value refers to or the concrete value stored in an interface.
package main
import ( "fmt" "runtime" "unsafe")
func main() { var arr [1000]int sl := make([]int, 1000) var m map[string]int var s string var boxed any = arr
fmt.Println(runtime.GOOS, runtime.GOARCH) fmt.Println("array :", unsafe.Sizeof(arr)) fmt.Println("slice :", unsafe.Sizeof(sl)) fmt.Println("map :", unsafe.Sizeof(m)) fmt.Println("string :", unsafe.Sizeof(s)) fmt.Println("interface :", unsafe.Sizeof(boxed))
sl = make([]int, 100_000) // 100x the elements fmt.Println("bigger slice :", unsafe.Sizeof(sl))}$ go run size/main.golinux amd64array : 8000slice : 24map : 8string : 16interface : 16bigger slice : 24Growing the slice from 1,000 to 100,000 elements still gives 24. Sizeof measures only the slice header, so the 800,000-byte array behind it never shows up in this number. The interface holding an 8,000-byte array measuring 16 is the same story.
The bigger limitation is that this number isn’t the bytes actually moved during a call, nor an execution time. The compiler can inline functions or eliminate unneeded copies, so cost has to be measured separately, in the next experiment.
| Metric | What it tells you | What it doesn’t |
|---|---|---|
unsafe.Sizeof |
storage size of the value itself | total memory including referenced data, actual runtime |
ns/op |
time per operation on the measured code path | whether it holds on another CPU, Go version, or optimization setting |
B/op · allocs/op |
heap bytes and allocations per operation | whether anything was copied, whether CPU cost was zero |
🧪 Try it
Passing cost depends on how you measure
Let’s measure passing the same 1,000 integers as an array versus as a slice or a pointer. All three functions read only the first element; only the passing style differs. //go:noinline is a compiler directive that keeps the compared functions from being merged into their call sites. You’ll rarely put it in production code; here it keeps the comparison fair.
package main
import "testing"
var sink int
//go:noinlinefunc byArray(a [1000]int) int { return a[0] }
//go:noinlinefunc bySlice(a []int) int { return a[0] }
//go:noinlinefunc byPointer(a *[1000]int) int { return a[0] }
func BenchmarkArray(b *testing.B) { var a [1000]int b.ResetTimer() for i := 0; i < b.N; i++ { a[0] = i sink = byArray(a) // keep the result so the work isn't optimized away }}
func BenchmarkSlice(b *testing.B) { var a [1000]int s := a[:] b.ResetTimer() for i := 0; i < b.N; i++ { a[0] = i sink = bySlice(s) }}
func BenchmarkPointer(b *testing.B) { var a [1000]int b.ResetTimer() for i := 0; i < b.N; i++ { a[0] = i sink = byPointer(&a) }}$ go test main.go copy_test.go -run '^$' -bench . -benchmem -count=3BenchmarkArray-2 7447418 159.6 ns/op 0 B/op 0 allocs/opBenchmarkSlice-2 755760110 1.576 ns/op 0 B/op 0 allocs/opBenchmarkPointer-2 566290033 2.171 ns/op 0 B/op 0 allocs/opAll three show 0 allocs/op, yet the times differ a lot. This table shows right away that copying a value and allocating on the heap are separate costs. On this machine the pointer was slightly slower than the slice, but you can’t conclude from this that pointers are always faster or always slower.
Now delete the three //go:noinline lines and run it again. One changed condition, and every number moves.
BenchmarkArray-2 19423303 62.87 ns/op 0 B/op 0 allocs/opBenchmarkSlice-2 1000000000 0.6354 ns/op 0 B/op 0 allocs/opBenchmarkPointer-2 1000000000 0.3176 ns/op 0 B/op 0 allocs/opWith inlining allowed, all three got faster and the pointer and slice swapped places. Passing the array is still noticeably slower, probably because merging the call still leaves 8,000 bytes to move.
Running the same code on my Mac (darwin/arm64, Go 1.23) gave different results again. The pointer beat the slice with or without inlining, and the array stayed in the 120ns range even with inlining allowed. Order and ratios depend on the environment. The one thing that held was that passing the array was more than an order of magnitude slower.
The two runs show different things. Blocking inlining shows the conditions where cost becomes visible; default optimization looks closer to a real call path.
Note too that this benchmark can’t measure memory copying on its own. It also measures updating the first element in the loop and the call and return. So I don’t fix any particular ratio as the takeaway. What held across all three runs is that passing an array by value costs in proportion to its size, and that cost never shows up in allocs/op.
Three steps to object orientation without classes
On to the second axis. Go has no classes; it does object orientation in three steps instead. Those three steps were the skeleton of part 1 and part 4.
Here’s the question each step raises, with a one-line answer.
- Why put interfaces on the consuming side? When the package that calls storage declares only the methods it needs, the contract is small and swapping implementations or testing gets easier. Consumer-side interfaces are possible in other languages too, though. What Go’s implicit satisfaction removes is the need for the implementation to declare or
importthat interface. (part 1) - Why isn’t embedding inheritance? An embedded type doesn’t know about the type wrapping it. So there’s no overriding, and the template method pattern, where a parent calls a child’s implementation, doesn’t work. When you need polymorphism, inject an interface and flip the direction. (part 4)
- When to use generics? If you care about what a value can do, use an interface; if only the type differs and the logic is the same, use generics. Introducing them once you’ve actually copy-pasted the same function two or three times is the safe bet. (part 4)
While one function runs: where the four mechanisms sit
What’s left outside the two axes are part 3 (defer, errors, closures) and part 5 (context). These four act while a function runs. Lay them over one function’s lifetime and each one’s place becomes clear.
defer: arguments are evaluated at registration.defer f(i)usesias it was then; if you need the value at run time, wrap it in a closure.error: you return it as a value instead of throwing it. Wrapping with%wlets callers identify the cause witherrors.Is/As, but it also makes the inner error part of your public contract. Whether to cut the chain at a boundary is a design decision.- Closures: capturing the outer variable itself makes them stateful functions, at the cost that captured variables may move to the heap. The shared loop variable bug was fixed at the language level in Go 1.22 (when the
goversion ingo.modis 1.22 or later). context: after you cancel, the receiver still has to checkDone()before it stops. Detach cleanup from the cancellation signal withWithoutCancel.
🧪 Try it
A pure function preserves its input, not just its result
Apply the two axes at a function boundary and you’re straight into design. A function that returns the same result for the same input and doesn’t touch outside state is called pure, and keeping that promise in Go means watching how far copies reach, as we just saw. If the input is a slice, merely intending not to modify it isn’t enough.
package main
import ( "fmt" "strings")
// Builds a new result without changing the input.func Normalize(names []string) []string { out := make([]string, 0, len(names)) for _, s := range names { if t := strings.TrimSpace(s); t != "" { out = append(out, t) } } return out}
func main() { fmt.Println(Normalize([]string{" Go ", "", " Rust "}))}package main
import ( "reflect" "testing")
func TestNormalizePreservesInput(t *testing.T) { cases := []struct { name string input []string want []string }{ {"trim and drop", []string{" Go ", "", " Rust "}, []string{"Go", "Rust"}}, {"blanks only", []string{" ", "\t"}, []string{}}, {"nil input", nil, []string{}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { before := append([]string(nil), tc.input...) got := Normalize(tc.input) if !reflect.DeepEqual(got, tc.want) { t.Fatalf("got=%v, want=%v", got, tc.want) } if len(got) > 0 { got[0] = "changed result only" // modify the return value } if !reflect.DeepEqual(tc.input, before) { t.Fatalf("input was modified: %v", tc.input) // the input must stay as it was } }) }}$ go test main.go main_test.go -v=== RUN TestNormalizePreservesInput--- PASS: TestNormalizePreservesInput (0.00s) --- PASS: TestNormalizePreservesInput/trim_and_drop (0.00s) --- PASS: TestNormalizePreservesInput/blanks_only (0.00s) --- PASS: TestNormalizePreservesInput/nil_input (0.00s)PASSThis test doesn’t just check the result. It also checks that modifying the returned list leaves the input untouched. The point is to catch a function that looks pure but accidentally edits its input slice in place. Let’s confirm it really catches that with a counterexample: switch Normalize to an implementation that edits its input, and this check should fail.
for i := range names { names[i] = strings.TrimSpace(names[i]) // counterexample: edits the input in place}out := make([]string, 0, len(names))for _, s := range names { if s != "" { out = append(out, s) }}return out$ go test main.go main_test.go--- FAIL: TestNormalizePreservesInput (0.00s) --- FAIL: TestNormalizePreservesInput/trim_and_drop (0.00s) main_test.go:29: input was modified: [Go Rust] --- FAIL: TestNormalizePreservesInput/blanks_only (0.00s) main_test.go:29: input was modified: [ ]FAILHad the test compared only the result, the counterexample above would have passed. The second if, on line 29, is what caught it.
One caveat. The elements here are strings, so building a new slice is enough to isolate the effect of modifying elements. If the elements were structs holding slices or pointers, copying only the outer list might not be enough. It’s the same story as the Meta map that stayed shared in the first exercise. If the elements hold slices or maps, those need new copies too for the input to stay intact.
Decide copy versus share by ownership and lifetime
So what’s the criterion? Ownership and lifetime, not size. If a function only reads during the call, sharing may be enough. If it keeps the data after returning or needs to modify it independently, it may need a copy. Write that promise into the function’s name, comments, and input and output types. Normalize above says in its comment that it doesn’t change its input.
That doesn’t mean picking functional over object-oriented programming. What separates the two is where state lives. Object orientation bundles state and behavior inside objects and solves problems through objects cooperating; functional programming composes immutable data and pure functions. Go adopts a bit of both: it attaches methods to types, treats functions as first-class values, and doesn’t enforce immutability. So in practice, domain state is managed with types and methods, and only the computations that can be explained on their own get pulled out into functions.
Seen this way, it’s also clear why design patterns take the shapes they do in Go. Without inheritance or classes, heavy patterns turn into light compositions.
| Pattern | Shape in Go | Tools |
|---|---|---|
| Strategy | pull the varying part into an injected interface | interfaces |
| Decorator | middleware that wraps a handler and returns it | closures + first-class functions |
| Singleton | sync.OnceValue |
standard library |
| Builder | functional options (WithTimeout) |
closures + variadic args |
Half of the right column is closures and first-class functions. That’s where functional style actually shows up in Go: middleware chains, functional options, and, since generics, Map and Filter.
Finally, the difference between a library and a framework sits on this axis too. The test is who’s in control: you call a library; a framework calls your code. net/http is a nice example of both. Client is a library you call; Server defines a contract called Handler and calls your code, framework-style. Here too, the interface is defined on the calling side.
Summary: five posts were saying the same thing
- Part 1, interfaces: the type and value slots get copied together. A filled type slot means not nil, and method sets differ between values and pointers.
- Part 2, slices, maps, strings: only the header gets copied, so passing is cheap, but the data inside is shared and a write on one side shows on the other.
- Part 3, defer, errors, closures: defer arguments are evaluated at registration, and closures hold the variable itself. Wrapping an error with
%wmakes it part of your public contract. - Part 4, embedding: promotion only shortens names, so the embedded type’s methods can’t call the outer type’s methods.
- Part 5, context: if the receiver never checks
Done(), cancelling doesn’t stop it. - This post: decide how far to copy by ownership and lifetime, then measure size with
Sizeofand cost with benchmarks.
Wrapping up the series
While writing the five posts, I thought of them as separate topics. Folded onto one page, the conclusion came down to one sentence: Go doesn’t hide the underlying reality for convenience. A slice being a window, an interface having two slots, embedding being an anonymous field: these are structures the language puts in plain view. Most of the pitfalls came from reading over those structures with intuitions picked up in other languages.
So this series had one method: go down to the level of structure to check what something really is, and see whether that single fact explains the remaining pitfalls. Writing this post added one more: when you look at a number, also write down the questions it doesn’t answer.
The next series moves from what my code means to how that code actually runs on the machine: the scheduler, the GC, and where the two fall out of step inside a container.