Golang Structs Tutorial with Examples
Golang 12 min read
Field naming and visibility, why positional initialisation is a liability, value semantics and what a copy actually copies, struct comparability, and embedding — which is composition, not inheritance.
A struct is a typed collection of fields laid out contiguously in memory. Go has no classes, so the struct plus methods on its type is the whole object model, and it is a value type, which is the difference that produces most of the surprises.
Written against Go 1.22.
Defining and initialising
type Note struct {
ID int64
Title string
Content string
Tags []string
CreatedAt time.Time
}
Four ways to make one, and they are not equally good:
var a Note // zero value, immediately usable
b := Note{} // same
c := Note{ID: 1, Title: "Shopping"} // named fields — do this
d := Note{1, "Shopping", "milk", nil, time.Now()} // positional — avoid
Positional initialisation is a liability. It requires every field in declaration order, so adding a field breaks every call site, which sounds like a feature until two adjacent fields share a type, at which point reordering them compiles cleanly and silently swaps your data. Named fields also document themselves and let you omit what should stay zero.
go vet flags positional composite literals for structs from other packages, for exactly this
reason.
The zero value is designed to be useful
var b strings.Builder // ready to use
var mu sync.Mutex // ready to lock
var wg sync.WaitGroup // ready
Every field is zeroed: numbers 0, strings "", pointers, slices and maps nil. Good struct design makes that state valid, so a caller needs no constructor. When it cannot be, a required dependency, an invariant — write one:
func NewNote(title, content string) (Note, error) {
if title == "" {
return Note{}, errors.New("title is required")
}
return Note{Title: title, Content: content, CreatedAt: time.Now()}, nil
}
Return the value, not a pointer, unless the struct is large or must be shared.
Visibility is per field
type Account struct {
ID int64 // exported
Balance int64 // exported
secret string // package-private
}
A capital first letter exports; lowercase does not. This is per field, independent of the type, so an exported struct can have unexported fields, and code in another package then cannot set them, including in a composite literal.
It also governs encoding: encoding/json and every reflection-based codec can only see exported
fields. A field that stubbornly refuses to serialise is almost always lowercase.
Tags
type Note struct {
ID int64 `json:"id"`
Title string `json:"title"`
Content string `json:"content,omitempty"`
internal string `json:"-"`
CreatedAt time.Time `json:"created_at"`
}
A tag is a string literal read by reflection at runtime. Nothing validates it at compile time, so a
typo (json:"titel", or a space after the colon) silently produces the wrong field name. go vet
catches malformed tags; it cannot catch a misspelled name.
Value semantics
Assignment and function arguments copy the struct:
a := Note{Title: "Shopping"}
b := a
b.Title = "Groceries"
fmt.Println(a.Title) // Shopping — unaffected
func rename(n Note) {
n.Title = "changed" // modifies the copy
}
func renamePtr(n *Note) {
n.Title = "changed" // modifies the original
}
The copy is shallow. A slice or map field is copied as a header, so both structs share the underlying data:
a := Note{Tags: []string{"home"}}
b := a
b.Tags[0] = "work"
fmt.Println(a.Tags[0]) // work — the same backing array
This is the same aliasing that makes slices interesting, arriving through a struct. If you need an independent copy, copy the slice too.
Pointers to structs
n := &Note{Title: "Shopping"}
fmt.Println(n.Title) // no (*n).Title needed — Go dereferences automatically
n.Title = "Groceries"
p := new(Note) // *Note with everything zeroed
Field access through a pointer needs no explicit dereference, which is why *Note and Note read
almost identically at the point of use, and why it is easy to lose track of which you have.
A nil pointer dereference panics:
var n *Note
fmt.Println(n.Title) // panic: invalid memory address or nil pointer dereference
Returning a pointer to a local is safe in Go, unlike C. Escape analysis moves the value to the heap:
func newNote() *Note {
n := Note{Title: "Shopping"}
return &n // fine
}
When to use a pointer: the callee must mutate the value; the struct is large enough that copying matters; or nil is a meaningful state. Otherwise pass the value. It avoids an indirection and gives the compiler more to work with. “Large” is worth measuring rather than guessing; copying a handful of words is cheaper than a pointer chase.
Comparability
Structs are comparable with == if all their fields are:
type Point struct { X, Y int }
fmt.Println(Point{1, 2} == Point{1, 2}) // true — field by field
One slice, map or function field and the type is not comparable at all:
type Note struct {
Title string
Tags []string
}
Note{} == Note{} // compile error: invalid operation, Note cannot be compared
That is a compile error, not a runtime one, which is helpful. Use reflect.DeepEqual for a general
deep comparison, or write an Equal method: DeepEqual is slow and treats a nil slice and an empty
slice as different, which is rarely what you want in a test.
Comparable structs can be map keys; the others cannot.
Embedding is composition
An anonymous field promotes its fields and methods to the outer type:
type Timestamps struct {
CreatedAt time.Time
UpdatedAt time.Time
}
type Note struct {
Timestamps // embedded
Title string
}
n := Note{Title: "Shopping"}
n.CreatedAt = time.Now() // promoted
n.Timestamps.CreatedAt = time.Now() // the same field, explicit
This is not inheritance. There is no subtype relationship: a Note is not a Timestamps, and a
function taking Timestamps will not accept a Note. What you get is field and method promotion,
and satisfying an interface through an embedded type:
type ReadCloser struct {
io.Reader // embedding an interface
io.Closer
}
Promotion is shallowest-wins. A field on the outer type shadows one with the same name from an embedded type, and two embedded types with the same field name make that name ambiguous, accessing it becomes a compile error unless qualified.
Initialising an embedded field in a literal uses its type name:
n := Note{
Timestamps: Timestamps{CreatedAt: time.Now()},
Title: "Shopping",
}
Methods, and receiver consistency
func (n Note) Summary() string { // value receiver: gets a copy
return n.Title + ": " + n.Content
}
func (n *Note) Publish() { // pointer receiver: can mutate
n.Published = true
n.UpdatedAt = time.Now()
}
Pick one for the whole type. A mix is legal and confusing, and it has a real consequence: the method
set of Note contains only value-receiver methods, while *Note contains both. So a type with any
pointer-receiver methods satisfies an interface only through its pointer:
var s fmt.Stringer = Note{} // fails if String() has a pointer receiver
var s fmt.Stringer = &Note{} // works
If any method needs a pointer receiver, use pointer receivers throughout.
The empty struct
set := map[string]struct{}{}
set["go"] = struct{}{}
done := make(chan struct{})
close(done)
struct{} occupies zero bytes. It is the idiomatic map value for a set and the idiomatic channel
element for a signal that carries no data, the type says “there is no value here”, which a bool
does not.
Frequently asked questions
Should I initialise structs positionally or by field name?
By name. Positional literals break when
fields are added and silently swap values when same-typed fields are reordered; go vet flags them
for other packages’ types.
Why is my field not appearing in JSON?
It is unexported. Reflection-based encoders only see capitalised fields.
Are structs copied when passed to a function?
Yes, and the copy is shallow: slice and map fields still share their underlying data with the original.
When should I use a pointer receiver?
When the method mutates the receiver, or the struct is large enough that copying costs. Keep receivers consistent across the type.
Why does my type not satisfy an interface?
Probably a pointer-receiver method. Those belong to
*T’s method set, not T’s, so pass &value.
Why can I not compare two structs?
One or more fields are slices, maps or functions, which are
not comparable. Use reflect.DeepEqual or write an Equal method.
Is embedding the same as inheritance?
No. It promotes fields and methods but creates no subtype relationship, a function taking the embedded type will not accept the outer one.
What happens if two embedded types have the same field name?
The name is ambiguous and using it unqualified is a compile error. Qualify it with the embedded type name.
Is returning a pointer to a local variable safe?
Yes. Escape analysis moves the value to the heap; there is no dangling pointer as there would be in C.
What is struct{} used for?
A zero-size value: the map value type for a set, and the element type for a channel used purely as a signal.