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.
- 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:
&xand*p; in detail — in the next lesson, on pointers; - for Mechanism 3 — that a value of type
anyholds a value of any type inside; in detail — in the lesson on interfaces.
- 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:
- "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. - "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.
- "Can two structs be compared? Can a struct be a map key?" — about the comparability of fields.
- "How much does a struct weigh? Why does field order matter? Why
struct{}?" — about alignment. - "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.
A declaration, a literal and field access — a fragment, the full file is below:
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 = 7The 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 .:
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:
| field | after b := a and an edit to b | == for the struct |
|---|---|---|
number, string, bool | a keeps its own value | allowed |
array of values, e.g. [3]int | a keeps its own array | allowed if the element type is comparable |
array of pointers [N]*T | own array, but the same pointers in it — the objects are shared | allowed — addresses are compared |
| pointer | the value behind it is shared | allowed — addresses are compared |
interface, e.g. any | the interface value is copied; a slice or pointer inside stays shared | compiles, but panics if the value inside is not comparable |
| slice, map | the data is shared | not 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 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:
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:
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:
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.
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
.
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 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:
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
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.
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
What is an anonymous struct and where is it needed?
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.
Why write field names in a literal?
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.
How do you compare two structs if there is a slice inside?
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:
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
A struct in Go is a class without inheritance
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.
Without a New… function a struct cannot be used
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.
b := a creates an independent copy of the struct
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.
slices.Clone and maps.Clone make an independent copy
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.
An element can be changed right in a for _, v := range loop
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.
If a struct is not comparable, the compiler will always catch it
Only for a direct comparison. Behind an interface any(Team{}) == any(Team{}) compiles and panics: comparing uncomparable type main.Team.
If a struct compiles as a map key, inserting the key will not fail
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.
The compiler will lay out the fields optimally by itself
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.
A set on the empty struct is lighter than one on bool
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.
| Version | Change | What it means for code |
|---|---|---|
| go1.24.7, GOEXPERIMENT=noswissmap | The 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.7 | The 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.1 | The 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.27 | Released 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
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
Knowledge check
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?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- 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.Mutexcan already be locked,var buf bytes.Buffercan already be written to. - The main consequence: assignment and passing copy the struct whole — but only its own bytes. After
b := aan edit tobis not visible inafor 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.Cloneandmaps.Clonealso copy one level only.rangeover a slice of structs also hands out a copy, andv.X = 0in 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 ifXholds a slice. The size is not the sum of the fields: on linux/amd64struct{ a bool; b int64; c bool }weighs 24 bytes, the same struct withint64first 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 anint64. That is why in our measurement (go1.24.7, linux/amd64) amap[string]struct{}set of a million keys weighs as much asmap[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.
In fact
- 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 float64as well. var mu sync.Mutexandvar buf bytes.Bufferwork 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 mapforRegistry.- 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.Ptrbecame visible ina. - Only one level deep: the documentation calls both a shallow clone. In the run, after
slices.Cloneof a[][]intan edit toc[0][0]changed the originals[0][0], and aftermaps.Cloneof amap[string][]int— the original slice. For[]intandmap[string]intfields that is enough; for nested ones — a recursiveClonemethod of the type itself. vis a copy of the element: afterv.X = 0in the runpts[0].Xstayed 1. The element is changed through the index:pts[i].X = 0.- Only for a direct comparison. Behind an interface
{any(Team{}) == any(Team{})}compiles and panics:comparing uncomparable type main.Team. - 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. - 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, booland 16 inint64, bool, bool. Thefieldalignmentanalyzer points this out. - 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 inmap[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.
By version
- go1.24.7, GOEXPERIMENT=noswissmap
- The previous map implementation: a bucket's keys and values lie apart. A million string keys — 40 MB for
{map[string]struct{}}, 42 MB formap[string]bool, 58 MB formap[string]int64(runs/sets-noswissmap.txt).< - go1.24.7
- The default map implementation is a Swiss table (
SwissMap: trueininternal/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.< - go1.25.1
- The output of
internals.go,compile.shandexample.shmatched 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).< - Go 1.26, Go 1.27
- Released 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.<
What is covered
- What is really being asked
- Base: a struct is a value made of fields
- Mechanism 1: the zero value works — if the type is written for it
- Mechanism 2: a whole copy — but a shallow one
- Mechanism 3: `==` exists while all fields are comparable
- Mechanism 4: size is the fields plus alignment
- Mechanism 5: a method is a function bound to a type of its own package
- Deeper: the empty struct weighs zero — a set built on it does not
- How to answer in an interview
- Next they ask
- Common misconceptions
- Version history
- Practice
- Knowledge check
Sources & further reading
7 SOURCES
- 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
- 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
- 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
- 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
- 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
- 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
- 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