Skip to content
CalliCoder

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.

Where should I go next?

Pointers covers the indirection these examples use, and slices explains the aliasing a shallow struct copy inherits.