Structs in Go and the Design Choices Behind Them All
Declare structs, build them with literals, copy and compare them, and size them. Covers field padding that wastes memory and when not to copy a struct.

If you came from a language with classes, the struct is where Go feels thin at first: a list of fields, no constructor syntax, no inheritance, no access modifiers. Then you notice that nearly everything else in the language hangs off it. Methods attach to named types, usually structs. Interfaces are satisfied by their method sets. JSON, databases and configuration all decode into them. The design choices are small and consistent, and they are worth learning as a set. This guide goes through declaring and building structs, what copying them really does, comparison, memory layout, and the decisions that separate a struct that is pleasant to use from one that is not. It is part of the Go basics path, and the articles on struct tags, embedding and pointer or value receivers go deeper on three pieces mentioned here.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
Declaring a struct and building one
A struct type lists its fields with their types. A field starting with a capital letter is exported, visible from other packages, and a lowercase one is private to its package, which is the same rule as for every other identifier in Go.
package main
import "fmt"
type User struct {
Name string
Age int
Email string
admin bool
friends []string
}
func main() {
a := User{Name: "Ada", Age: 36, Email: "[email protected]"}
b := User{"Grace", 45, "[email protected]", true, nil}
var c User
fmt.Printf("%+v\n", a)
fmt.Printf("%v\n", b)
fmt.Printf("%#v\n", c)
pa := &a
pa.Age++
fmt.Println(a.Age, (*pa).Age)
}{Name:Ada Age:36 Email:[email protected] admin:false friends:[]}
{Grace 45 [email protected] true []}
main.User{Name:"", Age:0, Email:"", admin:false, friends:[]string(nil)}
37 37The first literal is keyed: each field is named, unmentioned fields take their zero value, and the order does not matter. The second is positional, which must list every field in declaration order. Positional literals are fine inside a package for small private types, and they are fragile for anything else. Adding a field to User breaks b at compile time, which is merely annoying in your own package and a breaking change in a library. go vet has a composites check that warns about positional literals of imported struct types for that reason. The third form, var c User, gives every field its zero value, exactly the behavior from zero values in Go.
The %+v verb prints field names, and %#v prints something close to Go source. Both are handy when you debug, and you will use them more than any other struct printing. The last two lines show that a pointer to a struct reaches fields with the same dot, pa.Age, and the compiler dereferences for you.
A struct is a value, and copying it is shallow
Assigning a struct copies every field. Passing it to a function copies it again. For a struct of plain values that gives you independence, and for a struct holding a slice, map or pointer it gives you a copy that shares those with the original:
package main
import "fmt"
type Team struct {
Name string
Members []string
Scores map[string]int
}
func rename(t Team) {
t.Name = "renamed"
t.Members[0] = "changed"
}
func main() {
original := Team{
Name: "core",
Members: []string{"ada", "grace"},
Scores: map[string]int{"ada": 1},
}
clone := original
clone.Name = "clone"
clone.Members[1] = "linus"
clone.Scores["linus"] = 2
fmt.Println(original.Name, original.Members, original.Scores)
rename(original)
fmt.Println(original.Name, original.Members)
}core [ada linus] map[ada:1 linus:2]
core [changed linus]The name stayed core in both cases, because a string field was copied. The members and scores changed under the original, because a slice header and a map reference were copied and both point at the same data. rename shows the same thing through a function call: the name change is lost, and the change to Members[0] is visible. This is the shallow-copy rule, and it is the most frequent source of "I copied it, why did it change" questions. When a struct holds shared state and needs a true copy, write a Clone method that copies the slices and maps, and use the slice copying idioms inside it. Copying a struct and the shallow copy problem is a deeper look at that failure.
Comparison and what blocks it
Two struct values are equal with == if all their fields are equal. That works until a field has a type that cannot be compared:
package main
import "fmt"
type Config struct {
Name string
Tags []string
}
func main() {
a := Config{Name: "x"}
b := Config{Name: "x"}
fmt.Println(a == b)
}# example.test/lab
./main.go:13:14: invalid operation: a == b (struct containing []string cannot be compared)The compiler says the struct is not comparable and names the offending field, because slices, maps and functions have no == in Go. For a struct with only comparable fields, the same equality makes it usable as a map key, which is a quiet convenience: map[Point]bool with Point{X, Y int} is a visited set with no string-building. When you need to compare a struct that holds a slice, write an Equal method and use slices.Equal on that field. reflect.DeepEqual works on anything and is slow and surprising around nil versus empty. The full rules are in comparing structs, slices and maps.
Memory layout and padding
A struct is stored as one block with its fields in the order you declare them. CPUs want each value at an address that is a multiple of its size, so the compiler inserts padding between fields when needed. The padding is invisible in the source and the field order controls how much there is. Here are two structs with the same three fields:
package main
import (
"fmt"
"unsafe"
)
type Wasteful struct {
A bool
B int64
C bool
}
type Packed struct {
B int64
A bool
C bool
}
func main() {
fmt.Println(unsafe.Sizeof(Wasteful{}), unsafe.Sizeof(Packed{}))
fmt.Println(unsafe.Offsetof(Wasteful{}.B), unsafe.Offsetof(Wasteful{}.C))
fmt.Println(unsafe.Offsetof(Packed{}.A), unsafe.Offsetof(Packed{}.C))
fmt.Println(unsafe.Alignof(int64(0)), unsafe.Sizeof(struct{}{}))
}24 16
8 16
8 9
8 0Wasteful takes 24 bytes and Packed takes 16, for the same data. In the first, the int64 must start at a multiple of 8, so seven bytes of padding follow A, and then C sits after it with seven more bytes of padding at the end so that arrays of the struct stay aligned. Putting the largest fields first leaves a small tail pad instead. The saving is one third here. For a million of them it is 8 MB, which is real, and for a struct you allocate a few of, it is nothing. Reorder fields when the struct appears in big slices or maps, and keep the logical order when it does not, because a struct read by a person is better grouped by meaning. The fieldalignment analyzer from golang.org/x/tools reports struct types that could be smaller, and it is the right way to find them, rather than rearranging by hand. The last line shows that an empty struct, struct{}, has size zero, which is why sets use it as the value type.
Anonymous structs, nested structs and tables
You do not need to name every struct. An anonymous struct type defined in place is common in tests, where a table of cases is a slice of them:
package main
import "fmt"
func main() {
cases := []struct {
name string
in int
want int
}{
{"zero", 0, 0},
{"double", 4, 8},
{"negative", -3, -6},
}
for _, c := range cases {
got := c.in * 2
fmt.Println(c.name, got == c.want)
}
point := struct{ X, Y int }{3, 4}
fmt.Println(point)
}zero true
double true
negative true
{3 4}The same shape is how you return a few related values without declaring a type, and how you decode a one-off JSON document. Beyond a couple of uses, name it. Nested structs, a field whose type is another struct, are plain composition: order.Customer.Address.City. When the inner struct is just a bundle of fields to be promoted into the outer one, embedding does it without the extra name, which embedding in Go explains and where I would warn against thinking of it as inheritance.
Structs inside slices: range gives you a copy
A slice of structs is the most common container you will use, and it has a trap that follows from the copy rule. In for _, item := range items, the variable item is a copy of the element. Changing it changes nothing in the slice:
package main
import "fmt"
type Item struct {
Name string
Stock int
}
func main() {
items := []Item{{"bolt", 10}, {"nut", 4}, {"washer", 0}}
for _, item := range items {
item.Stock += 100
}
fmt.Println(items)
for i := range items {
items[i].Stock += 100
}
fmt.Println(items)
for i := range items {
it := &items[i]
it.Name = "[" + it.Name + "]"
}
fmt.Println(items)
}[{bolt 10} {nut 4} {washer 0}]
[{bolt 110} {nut 104} {washer 100}]
[{[bolt] 110} {[nut] 104} {[washer] 100}]The first loop did nothing visible, since it edited copies. The second assigned through the index, which reaches the real element. The third took a pointer to the element so it could change several fields without repeating items[i]. A pointer into a slice is only valid until the slice is appended to and reallocated, a hazard explained in pointers in Go. When the elements are large, ranging by value also copies each one on every iteration, which is a cost to remember when profiling. A slice of pointers, []*Item, avoids both the copy and the confusion, at the price of scattering the data in memory and giving the garbage collector more to scan.
Optional fields and pointers
A field whose zero value is a legal value cannot also mean "not provided". An Age int of zero is ambiguous: a newborn or a missing value. The Go answer is a pointer, where nil is the missing state:
package main
import (
"encoding/json"
"fmt"
)
type Patch struct {
Name *string `json:"name"`
Age *int `json:"age"`
}
func ptr[T any](v T) *T { return &v }
func main() {
var p Patch
err := json.Unmarshal([]byte(`{"age":0}`), &p)
fmt.Println(p.Name == nil, p.Age != nil && *p.Age == 0, err)
out, _ := json.Marshal(Patch{Name: ptr("ada")})
fmt.Println(string(out))
}true true <nil>
{"name":"ada","age":null}The decoded patch has no name (a nil pointer) and an age of zero that was provided (a non-nil pointer to zero). The two cases are distinguishable, which a plain int cannot do. The ptr helper is a generic function that takes a value and returns its address, since Go does not let you write &"ada" for a literal. It is a very common utility in API code. The cost of the pointer style is a nil check at every use. Reserve it for fields where "absent" is a real state, such as partial updates, and keep plain values elsewhere.
A worked example: an invoice
To see the pieces working together, take a small domain type. An invoice has line items, each with a price in cents and a quantity, and the total is derived from them. The decisions are the ones from above: money in integers, keyed literals, a value type for the line and a pointer type for the invoice that changes:
package main
import (
"errors"
"fmt"
)
type Line struct {
SKU string
Cents int64
Quantity int
}
func (l Line) Total() int64 { return l.Cents * int64(l.Quantity) }
type Invoice struct {
Number string
Lines []Line
}
func NewInvoice(number string) (*Invoice, error) {
if number == "" {
return nil, errors.New("invoice number is required")
}
return &Invoice{Number: number}, nil
}
func (inv *Invoice) Add(sku string, cents int64, qty int) {
inv.Lines = append(inv.Lines, Line{SKU: sku, Cents: cents, Quantity: qty})
}
func (inv Invoice) Total() (sum int64) {
for _, l := range inv.Lines {
sum += l.Total()
}
return sum
}
func main() {
inv, err := NewInvoice("2024-001")
if err != nil {
fmt.Println(err)
return
}
inv.Add("bolt", 125, 40)
inv.Add("nut", 80, 40)
fmt.Printf("%s %d.%02d\n", inv.Number, inv.Total()/100, inv.Total()%100)
_, err = NewInvoice("")
fmt.Println(err)
}2024-001 82.00
invoice number is requiredLine is small and never mutated after creation, so its method takes a value receiver. Invoice grows through Add, so that method takes a pointer, and the constructor returns a pointer so callers cannot accidentally copy a half-built invoice. Total on Invoice uses a value receiver, and mixing receivers on one type is something I would not do in a real code base, since the method set then differs between Invoice and *Invoice; I left it here to show the compiler accepts it and to ask you to notice it. The rule in pointer or value receiver is to pick one kind for a type and stick with it. Note that money is stored as int64 cents, for the reasons in floating point precision.
Methods, mutation and when to use a pointer
A method is a function with a receiver, and a struct usually owns its methods. The choice of receiver, value or pointer, decides whether the method sees a copy or the original, and it also decides which interfaces the type satisfies. The short rule: if a method changes the struct, or the struct is large, or it contains something that must not be copied such as a sync.Mutex, use a pointer receiver, and be consistent across the type. A small immutable struct, a Point or a Money, can use value receivers. I measured the cost difference and put the numbers in pointer or value receiver.
One related trap is a method with a value receiver that tries to modify the struct. It compiles and the change is lost, because the method edited its own copy. If a setter appears to do nothing, check the receiver.
Constructors, options and keeping zero values useful
Go has no constructors. By convention a function named NewThing returns a ready value, and the interesting design question is whether you need one at all. A struct whose zero value works needs none: var buf bytes.Buffer is ready, and so is var wg sync.WaitGroup. When a struct needs a map to be made, a default to be set or a resource to be opened, provide a New function, and say in the doc comment whether the zero value is usable. The patterns for a constructor with many optional settings are in constructor functions and functional options, and the fields that trip people up on the zero value side are covered in the nil map panic.
Two conventions help downstream users. Keep fields unexported when the type has invariants that a caller could break, and offer methods to change them. And document whether the struct is safe for concurrent use. The default answer is no, and a type that holds a mutex should say so and not be copied.
Exported fields and why data goes missing
Visibility on a struct follows the capital-letter rule, and it has a consequence that surprises everyone once. Packages that work through reflection, encoding/json first among them, can only see exported fields. A lowercase field exists in memory and is invisible to them:
package main
import (
"encoding/json"
"fmt"
)
type Account struct {
ID int
Owner string
balance int64
Notes []string
}
func main() {
a := Account{ID: 1, Owner: "ada", balance: 5000}
out, _ := json.Marshal(a)
fmt.Println(string(out))
var back Account
_ = json.Unmarshal([]byte(`{"ID":2,"Owner":"grace","balance":9999}`), &back)
fmt.Printf("%+v\n", back)
}{"ID":1,"Owner":"ada","Notes":null}
{ID:2 Owner:grace balance:0 Notes:[]}The balance is absent from the encoded form, and the decoder silently ignored the balance key in the input, so the field stayed zero. Nothing reported an error. This is the usual explanation when "the field is there in my struct and not in the output". If a value needs to be both private to the package and persisted, give the type MarshalJSON and UnmarshalJSON methods, or keep a separate exported data transfer struct for the wire. Note too that Notes, a nil slice, encodes as null, which the slices guide warned about.
The same rule is why a struct with only unexported fields can look empty in logs that use reflection, and why a type from another package that you cannot build with a literal is a signal to look for its constructor.
The empty struct
A struct with no fields takes no memory, and the language and the community use that. map[string]struct{} is a set. chan struct{} is a signal channel, where only the fact of the send matters, and closing it broadcasts to every receiver. A method-only type, type server struct{}, gives you a namespace for methods without state. Each of those is a case of "I need a type, and the type has nothing to say", and the zero size is a bonus rather than the point. Do not go looking for places to use it. The readability of map[string]bool is worth a byte per entry in most programs.
Printing and debugging structs
fmt.Println(user) prints values in field order. %+v adds names, and %#v prints the type and nil-ness of nested values. If a struct defines a String() string method, fmt uses it for %v and %s, which is useful and also hides fields in logs, so keep String for what a human wants to read and use %+v when debugging. Be careful with a String method on a value receiver that calls Sprintf("%v", u) on itself: it recurses forever. The usual fix is to convert to a plain type with the same layout first.
The decisions at a glance
Most of what a struct needs reduces to a few choices, and each has a default that I use unless something specific argues against it.
| Question | Default | Choose otherwise when |
|---|---|---|
| Literal style | Keyed fields | A tiny private type with two obvious fields |
| Receiver | Pointer, for the whole type | The type is a small immutable value such as a point or a currency amount |
| Optional field | Plain value | "Absent" and "zero" must differ, as in partial updates |
| Slice of values or pointers | Values | Elements are large, shared or mutated by several holders |
| Comparison | == on comparable fields | A slice or map field exists, so write an Equal method |
| Field order | Grouped by meaning | The type appears in millions of instances, so order by size |
Where structs are the wrong fit
A struct is fixed at compile time. If you receive data whose keys are not known in advance, such as arbitrary JSON headers or user-defined attributes, a map is the right container, and the struct fields you do know can sit beside it. If a value is only ever a number with some behavior, a named basic type, type Celsius float64, is lighter than a struct with one field. If you find yourself writing a struct for every function's return values, consider whether the function is doing too much. And a struct with fifteen fields is a sign that two types are fighting inside one, which splits more cleanly than it grows. The language specification on struct types covers the rules above, and Effective Go on composite literals gives the conventions around keyed literals.


