Deep Engineering
Intermediate·Published·55 MIN

Structs and methods in Go: a value that is copied whole

Interview questions on structs climb a ladder: does a struct need a constructor — what happens after b := a — can two structs be compared and can a struct be a map key — how much does it weigh — on which type can a method be declared. A run answers each step: after b := a an edit through a slice field shows up in a, while an edit to an array field does not; == on a struct with a slice does not compile, and behind an interface it panics; struct{ a bool; b int64; c bool } weighs 24 bytes on linux/amd64, and with int64 first, 16; and in a measurement on go1.24.7 a map[string]struct{} set of a million keys weighs as much as map[string]bool — 56 MB.

Full technical treatment

TL;DR

A struct is a composite value type: a sequence of named fields that lie in memory one after another. Go has no classes, constructors or inheritance: there are structs, functions, and methods declared on a type. A struct without initialization has all its fields zero, and a well-written type works with exactly that value: var mu sync.Mutex can already be locked, var buf bytes.Buffer can already be written to.

The main consequence: assignment and passing copy the struct whole — but only its own bytes. After b := a an edit to b is not visible in a for strings, numbers and arrays of such values, but for slices, maps and pointers it is: the copy is shallow, the headers are copied (for a slice: a pointer to the array, a length and a capacity), not the data behind them. slices.Clone and maps.Clone also copy one level only. range over a slice of structs also hands out a copy, and v.X = 0 in the loop does not change the element.

Beyond that comes what separates real understanding from having read about it. == is allowed for a struct only if all its fields are comparable: a struct with a slice cannot be compared and cannot be a map key, while comparing it behind an interface compiles and panics; struct{ X any } is comparable at compile time but panics if X holds a slice. The size is not the sum of the fields: on linux/amd64 struct{ a bool; b int64; c bool } weighs 24 bytes, the same struct with int64 first weighs 16, and the compiler does not reorder fields. An empty struct weighs 0 bytes, but at the end of another struct it adds a byte, which alignment inflates — to eight after an int64. That is why in our measurement (go1.24.7, linux/amd64) a map[string]struct{} set of a million keys weighs as much as map[string]bool: 56 MB. And a method is a function with the receiver as its first argument, and it can be declared only on a type of its own package.

Where to start
Before this lesson it is enough to understand
  • what a variable, a type and a function are in Go;
  • that a slice and a map keep their data outside the variable itself: a slice has a header (a pointer to the array, a length, a capacity), a map is a pointer to a table; in detail — in the lessons on slices and on maps;
  • what a pointer is: &x and *p; in detail — in the next lesson, on pointers;
  • for Mechanism 3 — that a value of type any holds a value of any type inside; in detail — in the lesson on interfaces.
You do not need to know in advance
  • the zero value of a struct and when it works;
  • the shallow copy, comparison with ==, a struct as a map key, size, alignment and the empty struct;
  • where a method can be declared and why a method is a function.

What is really being asked

The ladder is almost always this one:

  1. "What is a struct? Does Go have classes and constructors?" — the warm-up; it checks whether the candidate knows that instead of a constructor there is the zero value and an ordinary New… function.
  2. "What happens if you assign a struct to another variable and change a field?" — this checks whether the candidate understands that a struct is a value, and where the copy ends.
  3. "Can two structs be compared? Can a struct be a map key?" — about the comparability of fields.
  4. "How much does a struct weigh? Why does field order matter? Why struct{}?" — about alignment.
  5. "On which type can a method be declared? How is a method different from a function?" — about a method being bound to a type of its own package.

Choosing the receiver — value or pointer — is the next step of this ladder, and it has its own lesson; struct embedding has another one.

The ladder has one spine: a struct is a value, its fields lie one after another in memory, and copying, comparison and size work on those bytes as a whole — but not on what they point to.

Base: a struct is a value made of fields

The specification defines a struct without a single word about classes:

A struct is a sequence of named elements, called fields, each of which has a name and a type.

— The Go Specification — Struct types
language contractThe zero value, copying on assignment and passing, the comparison rules and where a method is declared are part of the language specification; the run of bench/gostruct/internals.go illustrates them rather than establishes them.

A declaration, a literal and field access — a fragment, the full file is below:

GO
type Point struct{ X, Y int }
 
p := Point{X: 1, Y: 2} // literal with field names
var z Point            // zero value: {0 0}
pp := &p               // pointer to the struct
pp.X = 7               // same as (*pp).X = 7

The last line is a convenience of the language: through a pointer to a struct a field is reached with the same dot, without explicit dereferencing.

Output of bench/gostruct/internals.go, section 2 (the function moveRight and the slice pts are declared in the same file); labels translated:

2) ASSIGNMENT AND PASSING COPY THE STRUCT
─────────────────────────────────────────
  q := p; q.X = 10                    -> p.X = 1, q.X = 10
  moveRight(p) added 100 to a copy    -> p.X = 1
  for _, v := range pts { v.X = 0 }   -> pts[0].X = 1
  for i := range pts { pts[i].X = 0 } -> pts[0].X = 0
  pp := &p; pp.X = 7                  -> p.X = 7 (through the pointer — the same p)

That is enough for a junior-level answer to "what is a struct": a struct is a composite type of named fields. It is a value, not a reference: q := p creates an independent copy, and a function receives a copy. The language has no constructors — a struct's zero value consists of the zero values of its fields, and NewPoint functions are written by convention when the zero value is not enough.

All of this fits in a short program. The full file cases/example/main.go from the bench/gostruct directory can be copied into an empty directory next to a go.mod (module example, go 1.24) and run with go run .:

GO
package main
 
import "fmt"
 
type Point struct{ X, Y int }
 
func (p Point) Add(q Point) Point { return Point{X: p.X + q.X, Y: p.Y + q.Y} }
 
type Team struct {
	Name    string
	Members []string
}
 
func main() {
	var z Point
	fmt.Println(z)
 
	p := Point{X: 1, Y: 2}
	q := p
	q.X = 10
	fmt.Println(p, q, p == Point{X: 1, Y: 2})
	fmt.Println(p.Add(q))
 
	seen := map[Point]bool{p: true}
	fmt.Println(seen[Point{X: 1, Y: 2}])
 
	a := Team{Name: "core", Members: []string{"ann"}}
	b := a
	b.Members[0] = "bob"
	fmt.Println(a.Members[0])
}

Output of bench/gostruct/example.sh; the header label is translated:

go1.24.7 linux/amd64 | full example: go run .

{0 0}
{1 2} {10 2} true
{11 4}
true
bob

The zero Point is {0 0}; the copy q does not touch p; structs are compared with == and work as map keys; the method Add is called through the dot. The last line is the lesson's first surprise: b is a copy of a, yet the slice element changed for both. Why is covered in Mechanism 2.

One table is worth memorising right away — it answers two questions from the ladder; where each cell comes from is covered in Mechanisms 2 and 3:

fieldafter b := a and an edit to b== for the struct
number, string, boola keeps its own valueallowed
array of values, e.g. [3]inta keeps its own arrayallowed if the element type is comparable
array of pointers [N]*Town array, but the same pointers in it — the objects are sharedallowed — addresses are compared
pointerthe value behind it is sharedallowed — addresses are compared
interface, e.g. anythe interface value is copied; a slice or pointer inside stays sharedcompiles, but panics if the value inside is not comparable
slice, mapthe data is sharednot allowed

The "slice, map" row is the source of almost every surprise in this lesson: there is a copy, but no independence, and no comparison at all. The "interface" row is the source of the second: the compiler lets == through, and the panic comes at run time.

Mechanism 1: the zero value works — if the type is written for it

Go has no constructors. Everything declared without initialization gets the zero value, and for a struct the specification does this field by field:

This initialization is done recursively, so for instance each element of an array of structs will have its fields zeroed if no value is specified.

— The Go Specification — The zero value

The question is whether you can work with such a value. Four types, none of them has had a constructor called. Output of bench/gostruct/internals.go, section 1; labels translated:

1) ZERO VALUE WITHOUT A CONSTRUCTOR
───────────────────────────────────
  var mu sync.Mutex; mu.Lock()      -> mu.TryLock(): false (the lock is already held)
  var buf bytes.Buffer; WriteString -> "ok", Len(): 2
  var s Stats; s.Add("go")          -> s.Count("go"): 1
  var r Registry; r.Add("go")       -> panic: assignment to entry in nil map
  zero Bag: {Name: Arr:[0 0 0] Slice:[] Map:map[] Ptr:<nil>}
  None of the four types has a constructor call. Mutex, Buffer
  and Stats are written so that the zero value works; Registry is not.

sync.Mutex and bytes.Buffer work because their documentation promises it: The zero value for a Mutex is an unlocked mutex. and The zero value for Buffer is an empty buffer ready to use. Our own two types differ by one check — a fragment of the same file:

GO
type Registry struct {
    names map[string]int
}
 
func (r *Registry) Add(name string) { r.names[name]++ }
 
type Stats struct {
    counts map[string]int
}
 
func (s *Stats) Add(name string) {
    if s.counts == nil {
        s.counts = make(map[string]int)
    }
    s.counts[name]++
}

The zero value of a map field is nil, and per the specification such a map can be read but not added to: A nil map is equivalent to an empty map except that no elements may be added. Registry silently counts on a constructor to create the map and panics on the first write. Stats checks the field and creates the map itself — it needs no constructor.

Hence the answer to "but what about NewBuffer?". New… functions are a convention, not part of the language: they are ordinary functions returning a value or a pointer. The bytes package has NewBuffer, yet var buf bytes.Buffer works too; the sync package has no NewMutex function at all. A constructor is needed where a type has mandatory configuration — a server address, a dependency — without which the zero value makes no sense. In every other case a good type is written so that var x T already works.

Mechanism 2: a whole copy — but a shallow one

The block in the Base showed the first half: q := p gives an independent copy, the function moveRight added 100 to its own copy, and range over a slice of structs handed out a copy of the element. That is one rule, not three. Passing an argument is the same assignment: the arguments of the call are passed to the function, which means that they are assigned to their corresponding function parameters. And range assigns iteration values to corresponding iteration variables. So v.X = 0 in the loop changes a copy, and the element is changed through the index: pts[i].X = 0.

The second half is what exactly gets copied. A Bag struct with fields of five kinds; b := a, then everything in b is changed. A fragment of bench/gostruct/internals.go:

GO
type Bag struct {
    Name  string
    Arr   [3]int
    Slice []int
    Map   map[string]int
    Ptr   *int
}

Output of the same file, section 3; labels translated:

3) THE COPY IS SHALLOW
──────────────────────
  b := a, then we change everything in b:
  string  a.Name     = "a"  — unchanged
  [3]int  a.Arr[0]   = 1    — unchanged
  []int   a.Slice[0] = 99   — changed
  map     a.Map["k"] = 99   — changed
  *int    *a.Ptr     = 99   — changed
  unsafe.Sizeof(a)   = 80 bytes: headers are copied, not the data behind them

Exactly the 80 bytes of the struct itself are copied. The array lies inside it — and gets copied. A slice is a header of a pointer, a length and a capacity; a map and an *int are pointers; the copy gets the same addresses, and an edit through them is visible to both. A string is a header too, but strings are immutable: b.Name = "b" does not edit shared bytes, it puts a different string into b's field.

The practical consequence: the language has no deep copy, and the standard slices.Clone and maps.Clone do not make one either. The documentation says it plainly: The elements are copied using assignment, so this is a shallow clone — and the same for maps.Clone. Clone gives a new container, and its elements are copies of the elements, so for a nested slice, map or pointer — the same addresses. Output of the same file, section 9; labels translated:

9) CLONE IS SHALLOW TOO
───────────────────────
  []int:            slices.Clone, c[0] = 99        -> s[0] = 1
  [][]int:          slices.Clone, c[0][0] = 99     -> s[0][0] = 99
  map[string]int:   maps.Clone, c["k"] = 99        -> m["k"] = 1
  map[string][]int: maps.Clone, c["k"][0] = 99     -> m["k"][0] = 99
  [2]*int:          b := a, *b[0] = 99             -> *a[0] = 99
  Doc:              doc.Clone(), c.Grid[0][0] = 99 -> doc.Grid[0][0] = 1
  Clone copies one level: the container is new, the elements are the same
  values, and if an element is a slice, a map or a pointer, the data
  behind it is shared. The depth of the copy is decided by the type's own Clone method.

For a []int or map[string]int field, cloning the container is enough: the elements are numbers. For [][]int, map[string][]int and pointer fields the copy must be recursive — as many levels as the type's contract requires. An array of pointers [2]*int is copied whole, but what gets copied is the pointers, not the objects behind them. That is why an independent copy is written as the type's own Clone method, whose comment says what it breaks and what it leaves shared. A fragment of bench/gostruct/internals.go, comments translated:

GO
type Doc struct {
	Title string
	Grid  [][]int
}
 
// Clone returns a copy that shares no array with d: the outer slice
// is cloned, and every inner one too.
func (d Doc) Clone() Doc {
	c := d
	c.Grid = make([][]int, len(d.Grid))
	for i, row := range d.Grid {
		c.Grid[i] = slices.Clone(row)
	}
	return c
}

What to share on purpose is the type author's decision: for example, a shared *log.Logger or immutable data can stay shared, while mutable state cannot.

One type must not be copied at all, and the compiler will not tell you. The sync.Mutex documentation says it plainly: A Mutex must not be copied after first use. A copy of a struct with a mutex is a copy of the lock along with its state. Output of bench/gostruct/compile.sh, section 5 (the program cases/copylock compiles, and go vet objects; the message texts are go1.24.7 output, not wording of the language); labels translated:

5) COPYING A STRUCT WITH sync.Mutex
───────────────────────────────────
  go build copylock:
  builds without errors
  go vet copylock:
  ./main.go:11:15: report passes lock by value: copylock.Counter contains sync.Mutex
  ./main.go:16:14: assignment copies lock value to snapshot: copylock.Counter contains sync.Mutex
  ./main.go:18:13: call of report copies lock value: copylock.Counter contains sync.Mutex

All three places are an ordinary struct copy from this mechanism: a value parameter, an assignment and a call. Only go vet catches them — and not the subset that go test runs: copylocks is not in the list of checks given in go help test. That is why go vet ./... is run separately. More on the mutex in the lesson on sync and atomic.

The cost of a copy grows with unsafe.Sizeof, so large structs are often passed by pointer. A pointer has its own cost — the heap — and both sides are measured in the lessons on pointers and on method receivers.

Mechanism 3: == exists while all fields are comparable

Structs can be compared with == — but not every struct:

Struct types are comparable if all their field types are comparable. Two struct values are equal if their corresponding non-blank field values are equal.

— The Go Specification — Comparison operators

Output of bench/gostruct/internals.go, section 4 (Team is a struct with a Members []string field, t1 and t2 are two Team values in variables of type any); labels translated:

4) COMPARISON == AND A STRUCT AS A MAP KEY
──────────────────────────────────────────
  Point{1, 2} == Point{1, 2}        -> true
  grid[Point{1, 2}]                 -> "treasure"
  Node{&x} == Node{&y}, x == y == 5 -> false (pointers are compared by address)
  any(Team{}) == any(Team{})        -> panic: runtime error: comparing uncomparable type main.Team
  m := map[any]int{}; m[t1] = 1     -> panic: runtime error: hash of unhashable type main.Team
  reflect.DeepEqual(t1, t2)         -> true
  A direct a == b for Team does not compile — see compile.sh, section 1.

The first two lines are the main practical consequence: a struct of comparable fields works as a map key. A composite key — coordinates, a "user, day" pair — is written as a struct rather than glued into a string.

The third line is a trap: Node holds an *int, and == compares the pointers, that is, addresses. Two nodes with pointers to equal numbers are not equal.

The Team struct with a Members []string field is not comparable. The compiler catches it in two places — output of bench/gostruct/compile.sh, sections 1 and 2 (the programs in cases/cmpslice and cases/mapkey deliberately do not compile; the error text is go1.24.7 output, not wording of the specification); labels translated:

1) == ON A STRUCT WITH A SLICE
──────────────────────────────
  go build cmpslice:
  ./main.go:14:6: invalid operation: a == b (struct containing []string cannot be compared)
2) A STRUCT WITH A SLICE AS A MAP KEY
─────────────────────────────────────
  go build mapkey:
  ./main.go:13:10: invalid map key type Team

But behind an interface the check moves to run time — and that is the fourth line of the run, a panic. The specification calls it out separately: A comparison of two interface values with identical dynamic types causes a run-time panic if that type is not comparable. This happens in code that puts values into a map[any]… or compares any values: it compiles, and panics the first time a struct with a slice arrives. For a map key the message differs — the fifth line of the run, hash of unhashable type: the specification requires comparison to be defined for the dynamic key values, or else a panic. Such structs can be compared with reflect.DeepEqual — in the run it gave true — but that is reflection, and in production code an Equal method with slices.Equal for the slice fields is usually written instead.

The same trap hides inside a struct. A field of interface type is comparable, so for the compiler the whole struct type Key struct{ X any } is comparable too: == and a map key both compile. The check moves to where the value lies. Output of the same file, section 10; labels translated:

10) INTERFACE FIELD: COMPILES, BUT MAY PANIC
────────────────────────────────────────────
  Key{1} == Key{1}                   -> true
  Key{"1"} == Key{1}                 -> false
  Key{[]int{1}} == Key{[]int{1}}     -> panic: runtime error: comparing uncomparable type []int
  m := map[Key]int{}; m[Key{[]int{1}}] = 1 -> panic: runtime error: hash of unhashable type []int
  type Key struct{ X any } is comparable: the compiler lets through both ==
  and a map key. The comparability of the value in X is checked at run time.

Advice for map keys: a struct with an any field or another interface is a key that always compiles and fails on the first slice. If the key is built from outside data, check the allowed dynamic types before inserting (or make the field a concrete comparable type). The panic texts above are go1.24.7 output (the same on go1.25.1), not wording of the language; the specification guarantees the panic itself.

Mechanism 4: size is the fields plus alignment

A struct does not take the sum of its fields. Each field sits at an offset that is a multiple of its alignment, and the struct itself is aligned to its strictest field: For a variable x of struct type: unsafe.Alignof(x) is the largest of all the values unsafe.Alignof(x.f) for each field f of x, but at least 1. The numbers themselves depend on the platform: the sizes and alignments below are linux/amd64. Three fields, two orders; output of bench/gostruct/internals.go, section 5; labels translated:

5) SIZE AND ALIGNMENT: FIELD ORDER
──────────────────────────────────
  Alignof: bool 1, int64 8
  struct{ a bool; b int64; c bool }  Sizeof 24  offsets a=0 b=8 c=16
  struct{ b int64; a bool; c bool }  Sizeof 16  offsets b=0 a=8 c=9
  useful bytes in both: 10; struct alignment: 8

In the first order b must start at an address that is a multiple of 8, and seven bytes are lost after a. c takes byte 16, and seven more bytes are added at the end: a struct's size is a multiple of its alignment, otherwise the second element of an array of such structs would start with a misaligned b. In the second order both bool fields sit right after the int64, and 24 bytes become 16. The unsafe.Sizeof documentation says so directly: For a struct, the size includes any padding introduced by field alignment.

implementation detail · Go 1.24The specification guarantees alignment, but not the order of fields in memory. That the compiler lays fields out in declaration order is visible in cmd/compile/internal/types/size.go, calcStructOffset, lines 164–196: each field's offset is rounded up to its alignment, and the struct's size to the struct's alignment (line 542).

The compiler does not reorder fields — the order is set by the type's author. The fieldalignment analyzer helps find the wasted bytes. It is not part of plain go vet; it is enabled in golangci-lint (govet: enable: [fieldalignment]). Output of bench/gostruct/compile.sh, section 6; labels translated:

6) FIELD ORDER: THE fieldalignment ANALYZER
───────────────────────────────────────────
  golangci-lint has version 2.5.0 built with go1.25.1
  golangci-lint run fieldalign:
  main.go:4:12: fieldalignment: struct of size 24 could be 16 (govet)

When does it matter? When there are millions of such structs — in a slice, a cache, a map — and 24 bytes instead of 16 mean one and a half times the memory for the same data. For a struct that exists once per request, field order is better chosen by meaning: readability is worth more there than eight bytes.

Mechanism 5: a method is a function bound to a type of its own package

A method in Go is declared separately from the type, and it cannot be bound to just any type:

A receiver base type cannot be a pointer or interface type and it must be defined in the same package as the method.

— The Go Specification — Method declarations

The type does not have to be a struct — any defined type of your own package will do, except a pointer or an interface type. A fragment of bench/gostruct/internals.go:

GO
type Celsius float64
 
func (c Celsius) Fahrenheit() float64 { return float64(c)*9/5 + 32 }

But on a foreign type — even on the built-in int — a method cannot be declared. Output of bench/gostruct/compile.sh, section 3 (cases/foreignmethod deliberately does not compile); labels translated:

3) A METHOD ON A FOREIGN TYPE
─────────────────────────────
  go build foreignmethod:
  ./main.go:10:9: cannot define new methods on non-local type time.Duration
  ./main.go:12:9: cannot define new methods on non-local type int

The method expression Celsius.Fahrenheit shows that a method is a function. Output of bench/gostruct/internals.go, section 7; labels translated:

7) A METHOD IS A FUNCTION WITH A RECEIVER
─────────────────────────────────────────
  Celsius(100).Fahrenheit()          -> 212
  Celsius.Fahrenheit(100)            -> 212
  type of Celsius.Fahrenheit         -> func(main.Celsius) float64
  time.Duration(1.5 s)               -> 1.5s
  MyDuration(1.5 s)                  -> 1500000000 (no String method)
  MyDuration implements fmt.Stringer -> false

The expression's type is func(main.Celsius) float64: the receiver has become the first argument. The call c.Fahrenheit() is the same function with the receiver passed before the dot. Hence the copying rule: a value receiver gets a copy, exactly like a parameter in Mechanism 2. A method that must change fields is declared on a pointer — that is how (*Stats).Add from Mechanism 1 is declared. How to choose between value and pointer is covered in the lesson on method receivers.

The last three lines answer "how do I add a method to a foreign type". Declare your own type based on it: type MyDuration time.Duration. But the new type gets only the underlying type, not the methods — the specification says of a defined type: It does not inherit any methods bound to the given type. MyDuration has no String, and a second and a half prints as 1500000000 nanoseconds. If you need both the old methods and new ones, the foreign type is put into your struct as a field or embedded — that is the lesson on embedding.

Deeper: the empty struct weighs zero — a set built on it does not

measured observationbench/gostruct/sets.go, go1.24.7 linux/amd64, 2 cores. HeapAlloc growth after runtime.GC(), the smallest of 3 builds, rounded to a megabyte. go1.25.1 gives the same 56 MB (runs/sets-go1.25.txt).

An empty struct has no fields, and it weighs zero. Output of bench/gostruct/internals.go, section 6 (linux/amd64); labels translated:

6) THE EMPTY STRUCT
───────────────────
  unsafe.Sizeof(struct{}{})           0
  unsafe.Sizeof([1000]struct{}{})     0
  struct{ z struct{}; n int64 }       8  (z at offset 0)
  struct{ n int64; z struct{} }      16  (z at offset 8)
  A final empty field gets a byte, and alignment to 8 turns it
  into eight: the address &s.z must not point past the end of s.

The first two lines are a guarantee of the specification: A struct or array type has size zero if it contains no fields (or elements, respectively) that have a size greater than zero. Two idioms rest on this: map[string]struct{} as a set and chan struct{} as a signal without data.

The last line is an implementation detail that only a run shows. The field z sits at offset 8, right past the end of n. If the struct's size were 8, the address &s.z would point at the next object on the heap. The compiler does not allow that (cmd/compile/internal/types/size.go, lines 517–523):

For non-zero-sized structs which end in a zero-sized field, we add an extra byte of padding to the type. This padding ensures that taking the address of a zero-sized field can't manufacture a pointer to the next object in the heap.

— cmd/compile/internal/types/size.go, go1.24.7

Here, after an int64, alignment to 8 turns the byte into eight. And here is where it shows outside. On go1.24 a map stores an entry in a slot — a struct of key and value built by the compiler (the file map_swiss.go in cmd/compile/internal/reflectdata, lines 30–53). For a map[string]struct{} set, the value in the slot is an empty final field. Output of bench/gostruct/sets.go, section 2; labels translated:

2) A MAP SLOT: KEY AND VALUE AS ONE STRUCT
──────────────────────────────────────────
  struct{ key string; elem struct{} }  24 bytes
  struct{ key string; elem bool }      24 bytes
  struct{ key string; elem int64 }     24 bytes
  An empty value at the end of the pair gets a byte, and alignment to 8
  makes the pair the same 24 bytes as with bool and int64.

A measurement on a million string keys confirms the model — in this configuration (go1.24.7, linux/amd64, 2 cores); on your own machine and version it has to be measured again. Output of bench/gostruct/sets.go, section 1; labels translated:

1) A MAP WITH 1 000 000 STRING KEYS
───────────────────────────────────
  map[string]struct{}    56 MB
  map[string]bool        56 MB
  map[string]int64       56 MB
  bool versus struct{}: 1.00
  Smallest HeapAlloc growth over 3 builds; the keys are created
  in advance and are not part of the measurement.

All three maps weigh the same. The advice "a set on struct{} saves memory" holds for the previous map implementation, where a bucket's keys and values lie apart. In go1.24 it can still be switched on, GOEXPERIMENT=noswissmap, and the difference comes back. Output of GOEXPERIMENT=noswissmap go run bench/gostruct/sets.go, section 1; labels translated:

1) A MAP WITH 1 000 000 STRING KEYS
───────────────────────────────────
  map[string]struct{}    40 MB
  map[string]bool        42 MB
  map[string]int64       58 MB
  bool versus struct{}: 1.06

The authors of the previous implementation wrote exactly this in runtime/map_noswiss.go: storing them apart allows us to eliminate padding which would be needed for, e.g., map[int64]int8. The new implementation is on by default (internal/buildcfg/exp.go, line 81: SwissMap: true) and stores the pair together — with alignment.

The takeaway for an interview: map[string]struct{} is still the right way to write a set — for its meaning: the value carries no meaning, and there is no m[k] = false entry to confuse with a missing key. But promising that it saves memory is not possible: on go1.24.7 and go1.25.1 the measurement did not show it.

How to answer in an interview

Short answer: a struct is a composite value type of named fields that lie in memory one after another. Assignment and passing copy it whole, but the copy is shallow: slices, maps and pointers inside stay shared, and slices.Clone with maps.Clone do not change that — they copy one level. There are no constructors — a struct's zero value consists of the zero values of its fields, and a good type works with it directly. == is allowed if all fields are comparable; an interface field gets past the compiler, and the panic comes at run time. Methods are declared on a defined type of your own package and are essentially functions with the receiver as the first argument.

That is enough for a correct answer. What follows is what you add if the interviewer digs.

If the interviewer digs deeper

On "does it need a constructor" — the zero value. "var mu sync.Mutex and var buf bytes.Buffer work without initialization; that is written in their documentation. Your own type is made the same way: the map is created on the first write. New… is a convention for types with mandatory configuration, not part of the language."

On "what happens after b := a" — where the copy ends. "The struct's own bytes are copied. Strings, numbers and arrays of values in a stay its own, while through a slice, a map and a pointer a and b see the same data. The language has no deep copy, and slices.Clone with maps.Clone do not make one: they copy one level, and for [][]int the inner slices stay shared. An independent copy is written as the type's Clone method, which knows which references to break. A struct with a sync.Mutex must not be copied, and go vet catches that, not the compiler."

On "can they be compared" — the field rule and the panic behind an interface. "A struct is comparable if all its fields are — and then it is a map key too. With a slice it is a compile error, but behind any the comparison compiles and panics: comparing uncomparable type. The same with an any field: struct{ X any } is comparable at compile time and panics if X holds a slice — so for a map key you check which types will get there. Pointers are compared by address."

On "how much does it weigh" — alignment. "On linux/amd64 {bool, int64, bool} is 24 bytes, with int64 first it is 16: every field is aligned, the size is a multiple of the struct's alignment, and the compiler does not reorder fields. fieldalignment finds this. An empty struct weighs zero, but at the end of another struct it adds a byte that alignment can inflate — to eight after an int64; that is why in my measurement on go1.24.7 map[string]struct{} turned out no lighter than map[string]bool."

On "where can a method be declared" — your own package. "On any defined type of your own package other than a pointer or an interface, not necessarily a struct; on time.Duration or int it is a compile error. A new type based on a foreign one does not inherit its methods. A method is a function: Celsius.Fahrenheit has the type func(Celsius) float64."

Next they ask

Next they ask

What is an anonymous struct and where is it needed?

Short answer

A struct without a type name — the declaration and the literal at once. Output of bench/gostruct/internals.go, section 8; labels translated:

8) AN ANONYMOUS STRUCT
──────────────────────
  cfg := struct{ Host string; Port int }{...} -> {Host:localhost Port:8080}
  var pt Point = struct{ X, Y int }{1, 2}     -> {X:1 Y:2}
  A type without a name is assignable to a named one if the fields match:
  name, type and tag of every field, in the same order.

It is used where a type is needed in one place: a table of cases in a test ([]struct{ name string; in, want int }), decoding a single JSON response, grouping values inside a function. The second line shows that such a struct is assignable to a named one if the sequence of fields matches. An anonymous struct never has methods of its own: a method's receiver must be a defined type, and it has no name.

Next they ask

Why write field names in a literal?

Short answer

A positional literal geo.Point{1, 2} depends on the order and number of fields, and three tool answers show it — output of bench/gostruct/compile.sh, section 4 (cases/mixed and cases/unkeyed deliberately do not compile); labels translated:

4) LITERAL: FIELD NAMES OR POSITIONS
────────────────────────────────────
  go build mixed:
  ./main.go:7:18: mixture of field:value and value elements in struct literal
  go build vetunkeyed (geo.Point has fields X, Y):
  builds without errors
  go vet vetunkeyed:
  ./main.go:14:7: vetunkeyed/geo.Point struct literal uses unkeyed fields
  go build unkeyed (field Z was added to geo.Point):
  ./main.go:12:21: too few values in struct literal of type geo.Point

The forms cannot be mixed. A positional literal of a type from another package compiles, but go vet warns — because once a field is added in the next version of the package, it stops compiling. A literal with names, geo.Point{X: 1, Y: 2}, survives that: the new field gets its zero value. For types of your own package go vet lets a positional literal through.

Next they ask

How do you compare two structs if there is a slice inside?

Short answer

The == operator will not compile. There are two ways. reflect.DeepEqual(a, b) compares the fields recursively — in the run it gave true for two Team values with the same contents — but it is reflection, and per its documentation a nil slice and an empty slice are not equal. In production code a method is usually written — an illustrative fragment, not part of the lesson's runs:

GO
func (t Team) Equal(o Team) bool {
    return t.Name == o.Name && slices.Equal(t.Members, o.Members)
}

It states explicitly what counts as equality and does not depend on reflection.

Common misconceptions

Claim

A struct in Go is a class without inheritance

Actually

A struct is a value: assignment creates a copy, a function receives a copy. It has no constructor — it has a zero value — and methods are declared separately and bound to a type of the package, not to the struct: a method can be declared on type Celsius float64 as well.

Claim

Without a New… function a struct cannot be used

Actually

var mu sync.Mutex and var buf bytes.Buffer work right away — that is written in their documentation. New… is a convention for types with mandatory configuration. The zero value fails only where the type counts on a constructor: assignment to entry in nil map for Registry.

Claim

b := a creates an independent copy of the struct

Actually

Only fields that hold their value entirely are independent: numbers, strings, arrays. A slice, a map and a pointer are copied as a header, and in the run edits to b.Slice[0], b.Map["k"] and *b.Ptr became visible in a.

Claim

slices.Clone and maps.Clone make an independent copy

Actually

Only one level deep: the documentation calls both a shallow clone. In the run, after slices.Clone of a [][]int an edit to c[0][0] changed the original s[0][0], and after maps.Clone of a map[string][]int — the original slice. For []int and map[string]int fields that is enough; for nested ones — a recursive Clone method of the type itself.

Claim

An element can be changed right in a for _, v := range loop

Actually

v is a copy of the element: after v.X = 0 in the run pts[0].X stayed 1. The element is changed through the index: pts[i].X = 0.

Claim

If a struct is not comparable, the compiler will always catch it

Actually

Only for a direct comparison. Behind an interface any(Team{}) == any(Team{}) compiles and panics: comparing uncomparable type main.Team.

Claim

If a struct compiles as a map key, inserting the key will not fail

Actually

Not if it has an interface field. type Key struct{ X any } is comparable at compile time, and m[Key{[]int{1}}] = 1 panicked in the run: hash of unhashable type []int.

Claim

The compiler will lay out the fields optimally by itself

Actually

It does not reorder them: fields lie in declaration order. The same three fields on linux/amd64 weigh 24 bytes in the order bool, int64, bool and 16 in int64, bool, bool. The fieldalignment analyzer points this out.

Claim

A set on the empty struct is lighter than one on bool

Actually

In our measurement on go1.24.7 and go1.25.1 (linux/amd64) it is not: a million string keys took 56 MB both in map[string]struct{} and in map[string]bool. A map slot is a "key, value" pair, and an empty value at the end of the pair is aligned up to eight bytes. A difference — 40 against 42 MB — exists only with the previous map implementation.

Version history

The lesson's runs are go1.24.7 and go1.25.1, linux/amd64, 2 cores; the language rules were checked against their specifications and go doc on 14.10.2026. Sizes and offsets are for linux/amd64. By that date Go 1.26 and Go 1.27 had been released — the lesson did not measure them: they are not in the measurement environment, and no size, error text or map weight has been checked on them.

VersionChangeWhat it means for code
go1.24.7, GOEXPERIMENT=noswissmapThe previous map implementation: a bucket's keys and values lie apart. A million string keys — 40 MB for map[string]struct{}, 42 MB for map[string]bool, 58 MB for map[string]int64 (runs/sets-noswissmap.txt).a set on the empty struct is slightly lighter than on bool
go1.24.7The default map implementation is a Swiss table (SwissMap: true in internal/buildcfg/exp.go, line 81): a slot stores key and value as one struct. All three maps — 56 MB (runs/sets.txt). Struct sizes and the copying and comparison rules are as in the lesson.no memory saving from struct{} in a map
go1.25.1The output of internals.go, compile.sh and example.sh matched go1.24.7 except for the version line; the maps are the same 56 MB (runs/sets-go1.25.txt, runs/internals-go1.25.txt).as on go1.24.7
Go 1.26, Go 1.27Released by 14.10.2026; the lesson did not measure sizes, map weights or error texts on them. The rules for copying, comparison and method declarations are part of the specification; checked against the go1.24.7 and go1.25.1 specifications.on your version — measure again

Practice

Two problems. Answer first, then check against the real output: in both, the correct answer is taken from a run of the script rather than assigned.

Practice · predict the output

Point is two ints. Team is a name and a slice of members. Event is bool, int64, bool in that order. What will this code print on linux/amd64?
type Point struct{ X, Y int }

type Team struct {
Name    string
Members []string
}

type Event struct {
Active bool
ID     int64
Urgent bool
}

p := Point{1, 2}
q := p
q.X = 10
fmt.Println(p.X, q.X)
a := Team{Name: "core", Members: []string{"ann"}}
b := a
b.Name = "infra"
b.Members[0] = "bob"
fmt.Println(a.Name, a.Members[0])
fmt.Println(p == Point{1, 2})
fmt.Println(unsafe.Sizeof(Event{}))

Practice · estimate

A million string keys are put into a map[string]bool and into a map[string]struct{}. How many times heavier is the first map than the second on go1.24.7?
times

Knowledge check

Question 1 of 5

A struct has a Members field, a slice of strings. After b := a we ran b.Members[0] = `bob`. What will a.Members[0] show?

Sources & further reading

7 SOURCES

  1. The Go Specification — Struct types, The zero valueOfficial documentation. What a struct is: «A struct is a sequence of named elements, called fields, each of which has a name and a type». What a value without initialization looks like: «This initialization is done recursively, so for instance each element of an array of structs will have its fields zeroed if no value is specified». And about a map in the zero value: «A nil map is equivalent to an empty map except that no elements may be added». Checked against doc/go_spec.html of the go1.24.7 and go1.25.1 distributions, 14.10.2026.https://go.dev/ref/spec#Struct_types
  2. The Go Specification — Comparison operators, Map typesOfficial documentation. When structs are comparable: «Struct types are comparable if all their field types are comparable. Two struct values are equal if their corresponding non-blank field values are equal». What happens behind an interface: «A comparison of two interface values with identical dynamic types causes a run-time panic if that type is not comparable». And the requirement on a map key: «thus the key type must not be a function, map, or slice», and for an interface key — «If the key type is an interface type, these comparison operators must be defined for the dynamic key values; failure will cause a run-time panic». Checked 14.10.2026.https://go.dev/ref/spec#Comparison_operators
  3. The Go Specification — Calls, Method declarations, Type definitionsOfficial documentation. Why passing to a function is a copy too: «the arguments of the call are passed to the function, which means that they are assigned to their corresponding function parameters». Where a method can be declared: «A receiver base type cannot be a pointer or interface type and it must be defined in the same package as the method». And what a new type gets: «It does not inherit any methods bound to the given type».https://go.dev/ref/spec#Method_declarations
  4. The Go Specification — Size and alignment guarantees; the unsafe packageOfficial documentation. A struct's alignment: «For a variable x of struct type: unsafe.Alignof(x) is the largest of all the values unsafe.Alignof(x.f) for each field f of x, but at least 1». The empty struct: «A struct or array type has size zero if it contains no fields (or elements, respectively) that have a size greater than zero». From the documentation of unsafe.Sizeof: «For a struct, the size includes any padding introduced by field alignment».https://go.dev/ref/spec#Size_and_alignment_guarantees
  5. The sync package — Mutex; the bytes package — BufferOfficial documentation. A zero value ready to use is written into the types' documentation. sync.Mutex: «The zero value for a Mutex is an unlocked mutex», and right there «A Mutex must not be copied after first use». bytes.Buffer (https://pkg.go.dev/bytes#Buffer): «The zero value for Buffer is an empty buffer ready to use».https://pkg.go.dev/sync#Mutex
  6. The slices and maps packages — CloneOfficial documentation. Both functions copy one level. slices.Clone: «The elements are copied using assignment, so this is a shallow clone». maps.Clone (https://pkg.go.dev/maps#Clone): «This is a shallow clone: the new keys and values are set using ordinary assignment». Checked with go doc, go1.24.7, 14.10.2026.https://pkg.go.dev/slices#Clone
  7. cmd/compile/internal/types/size.go and maps in go1.24.7Go source code. A struct's layout is computed by calcStructOffset (lines 164–196): fields go in declaration order, and each one's offset is rounded up to its alignment; the final size is rounded up to the struct's alignment (line 542). Lines 517–523 are about a final empty field: «For non-zero-sized structs which end in a zero-sized field, we add an extra byte of padding to the type». A map slot is a struct of key and value: cmd/compile/internal/reflectdata/map_swiss.go, lines 30–53. The previous map implementation, runtime/map_noswiss.go, lines 154–157, keeps keys and values apart: «it allows us to eliminate padding which would be needed for, e.g., map[int64]int8». That the new one is on by default: SwissMap: true in internal/buildcfg/exp.go, line 81.https://github.com/golang/go/blob/go1.24.7/src/cmd/compile/internal/types/size.go