How to Copy a Struct in Go (Shallow vs Deep Copy)

Go copies structs by value (shallow), requiring manual recursion or Clone() methods for deep copies of nested pointers.

The copy that shares the house

You load a configuration struct at startup and pass it to three different services. One service decides to tweak a nested setting to handle a retry. Suddenly, the other two services see the change too. You didn't mean to share state. You wanted a copy. Go gives you a copy, but it's a copy that still points to the same house.

Shallow copy vs deep copy

When you assign a struct to a new variable, Go copies the struct. Every field gets copied. If a field is an integer, the integer value is duplicated. If a field is a pointer, the pointer address is duplicated. Both variables now hold the same address. They point to the same memory. Changing the data through one pointer changes it for everyone holding that pointer. This is a shallow copy.

A deep copy means you allocate new memory for everything and copy the contents recursively. No shared pointers. No shared slices. No shared maps. The copy is completely independent.

Go defaults to shallow copy because it is fast and predictable. The compiler copies the bits it sees. It does not follow pointers to guess what you want. Deep copying can be expensive for large structures, and it requires the compiler to understand the shape of your data. Go forces you to be explicit. You decide when isolation matters.

Go copies values. Pointers are values. The copy shares the pointee.

Minimal example

Here's the simplest case: a struct with a pointer field. Assignment copies the struct, but the pointer field points to the same nested object.

package main

import "fmt"

type Config struct {
	Timeout int
	// Pointer to nested data. Shallow copy shares this address.
	Details *Details
}

type Details struct {
	Retries int
}

func main() {
	// Original struct with a pointer field.
	c1 := Config{
		Timeout: 30,
		Details: &Details{Retries: 3},
	}

	// Assignment copies the struct fields.
	// The pointer value in Details is copied, not the struct it points to.
	c2 := c1

	// Modifying c2.Details.Retries changes the shared memory.
	c2.Details.Retries = 5

	// c1 sees the change because c1.Details and c2.Details point to the same address.
	fmt.Println(c1.Details.Retries) // prints: 5
}

The compiler generates code to copy the struct value. For c2 := c1, it reads the memory of c1 and writes it to c2. The Timeout field is an int, so the value 30 is written to c2.Timeout. The Details field is a *Details, which is a pointer. The pointer is just a number representing a memory address. The compiler copies that number. c2.Details now holds the exact same address as c1.Details. When you write c2.Details.Retries = 5, the runtime follows the address in c2.Details, finds the Details struct, and updates Retries. Since c1.Details holds the same address, reading c1.Details.Retries finds the updated value. The copy happened, but the copy shared the nested object.

Slices and maps: references in disguise

Slices and maps hide pointers behind a value-like syntax. A slice is a header containing a pointer to an underlying array, a length, and a capacity. When you copy a slice, you copy the header. The pointer is duplicated. Both slices point to the same backing array. If you modify an element through one slice, the other slice sees the change.

package main

import "fmt"

func main() {
	// Slice header contains a pointer to the underlying array.
	s1 := []int{1, 2, 3}

	// Copying the slice copies the header, not the array.
	// s2 points to the same backing array as s1.
	s2 := s1

	// Modifying an element affects both slices.
	s2[0] = 99

	fmt.Println(s1[0]) // prints: 99
}

Maps work similarly. A map variable is a reference to a hash table stored on the heap. Copying the map variable copies the reference. Both variables point to the same map. Inserting a key through one variable makes it visible through the other.

package main

import "fmt"

func main() {
	// Map variable is a reference to the hash table.
	m1 := map[string]int{"a": 1}

	// Copying the map copies the reference.
	// m2 points to the same map as m1.
	m2 := m1

	// Inserting via m2 affects m1.
	m2["b"] = 2

	fmt.Println(m1["b"]) // prints: 2
}

Slices and maps are references in disguise. Copying them shares the data.

Realistic example: standard library types

Standard library types often contain pointers. net/url.URL has internal pointers for query parameters and fragments. Assigning a url.URL creates a shallow copy. Modifying the copy can mutate the original. The standard library provides a Clone method for this type. Clone allocates a new url.URL and copies all fields recursively. It handles the internal pointers safely.

package main

import (
	"fmt"
	"net/url"
)

func main() {
	// Parse a URL with query parameters.
	// Discard error with _ because the input is a hardcoded string.
	u1, _ := url.Parse("https://example.com/path?foo=bar")

	// Assignment copies the struct, but url.URL contains internal pointers.
	// u2 shares mutable state with u1.
	u2 := *u1

	// Modifying u2 can mutate shared internal buffers.
	// This is unsafe if u1 is still in use.
	u2.RawQuery = "foo=baz"

	// u1 might be affected depending on internal implementation details.
	// Never rely on assignment for isolation with complex types.
	fmt.Println(u1.RawQuery)
}

Use the Clone method to get a deep copy.

// Clone creates a fully independent copy.
// Use this when you need to modify a URL without touching the original.
u3 := u1.Clone()

u3.RawQuery = "foo=qux"

// u1 remains unchanged. u3 is safe to mutate.
fmt.Println(u1.RawQuery) // prints: foo=bar
fmt.Println(u3.RawQuery) // prints: foo=qux

Trust the Clone method. Don't reinvent deep copy for complex types.

Writing a deep copy method

When you define your own structs, you write the deep copy logic. Define a method on the struct that returns a new instance. Use a value receiver so the method doesn't mutate the original. Name the receiver with a short variable matching the type. The method allocates new memory for pointer fields and copies the values. Check for nil pointers to avoid panics. If a field is nil, the copy should also be nil.

// Copy returns a deep copy of Config.
// Allocates new memory for Details to ensure isolation.
func (c Config) Copy() Config {
	// Copy the value fields directly.
	result := Config{
		Timeout: c.Timeout,
	}

	// Check for nil before dereferencing.
	// If Details is nil, the copy should also have nil.
	if c.Details != nil {
		// Allocate a new Details struct.
		// Copy the value from the original.
		result.Details = &Details{
			Retries: c.Details.Retries,
		}
	}

	return result
}

The receiver name c matches the type Config. This follows Go convention. Receiver names should be one or two letters derived from the type name. (this Config) or (self Config) are not Go style. Also, if the struct has unexported fields, the copy method must live in the same package to access them. You cannot deep copy a struct with private fields from outside its package. The compiler rejects access to unexported fields with an error like c.privateField undefined (cannot refer to unexported field or method privateField).

Write the copy method in the same package. Private fields stay private.

Pitfalls and silent bugs

Structs containing channels are dangerous to copy. A channel is a reference type. Copying a struct with a channel field copies the reference. Both copies hold the same channel. If one copy closes the channel, the other copy panics on the next send. The runtime panics with panic: send on closed channel. Never copy structs that hold channels unless you understand the lifecycle.

The compiler won't stop you from making a shallow copy. There is no error message for "you forgot to deep copy." The bug manifests as data corruption later. The compiler rejects type mismatches with cannot use x (type A) as type B in assignment, but it accepts struct assignment silently. You must reason about the fields. If a struct has pointers, slices, or maps, assignment shares state.

The compiler won't save you from shared state. You own the isolation.

When to use what

Use a shallow copy when the struct contains only value types like integers, booleans, or strings, and you need a fast duplicate. Use a shallow copy when the struct contains pointers but you intentionally want both variables to share the nested state. Use a manual deep copy when you control the struct definition and can write a method that allocates new memory for all pointer fields recursively. Use a Clone method when the type comes from the standard library or a dependency that provides one, such as url.URL or http.Request. Use an immutable design when you want to avoid copy bugs entirely by never modifying structs after creation and always returning new structs with changes.

Copy what you need. Share what you mean. Isolate what you mutate.

Where to go next