Free Handbook · Every example compiled & verified

Structs & Methods

Group data into structs, attach methods, choose between value and pointer receivers, and compose types with embedding instead of inheritance.

0 / 134 lessons🔥 0 day streak
ShareXLinkedIn

Module 05 · what you'll be able to do

  • Declare struct types, build them with literals, and rely on useful zero values
  • Use pointers to share and modify a struct, and avoid nil pointer panics
  • Write methods and choose value or pointer receivers — and spot the copy that silently loses an update
  • Write NewX constructor functions that validate input, and compose types with embedding
  • Control JSON field names with struct tags, and know when two structs can be compared with ==
01

Struct types, literals and zero values

A struct groups named fields into one type: type User struct { Name string; Age int }. It is Go's answer to a class without the inheritance. You create values with a struct literal, preferably naming each field — User{Name: "Ana", Age: 31} — so the code still compiles and still reads correctly when someone adds a field later.

Fields you leave out get their zero value, and var u User gives a struct where every field is zero. Good Go types are designed so that the zero value is already useful. An anonymous struct — a struct type written inline with no name — is handy for one-off groupings such as table-driven test cases.

gomain.go
package main

import "fmt"

type User struct {
	Name   string
	Age    int
	Admin  bool
	Emails []string
}

func main() {
	ana := User{Name: "Ana", Age: 31} // named fields: order-free
	fmt.Println(ana.Name, ana.Age, ana.Admin, ana.Emails == nil)

	var nobody User // every field is its zero value
	fmt.Printf("%+v\n", nobody)

	ana.Age++ // fields are read and written with a dot
	ana.Emails = append(ana.Emails, "[email protected]")
	fmt.Printf("%+v\n", ana)

	point := struct{ X, Y int }{3, 4} // anonymous struct
	fmt.Println(point.X*point.X + point.Y*point.Y)

	tests := []struct {
		in   string
		want int
	}{
		{"go", 2},
		{"gopher", 6},
	}
	for _, tc := range tests {
		fmt.Println(tc.in, len(tc.in) == tc.want)
	}
}
Outputcompiled & run with real Go
Ana 31 false true
{Name: Age:0 Admin:false Emails:[]}
{Name:Ana Age:32 Admin:false Emails:[[email protected]]}
25
go true
gopher true
Your turn

Add a Country string field to User. Which lines of main need to change? (None — that is the benefit of named-field literals.)

Error you will hit

unknown field Nmae in struct literal of type User

go
package main

import "fmt"

type User struct {
	Name string
	Age  int
}

func main() {
	u := User{Nmae: "Ana", Age: 31}
	fmt.Println(u)
}
# command-line-arguments
./main.go:11:12: unknown field Nmae in struct literal of type User
Why the compiler said that

A struct's fields are fixed at compile time, so a typo in a field name is caught immediately — unlike a map key or a JavaScript object property, which would silently create a new entry.

The fix

Correct the field name. An editor running gopls offers field-name completion inside struct literals.

go
package main

import "fmt"

type User struct {
	Name string
	Age  int
}

func main() {
	u := User{Name: "Ana", Age: 31}
	fmt.Println(u)
}
Exported fields
A field whose name starts with a capital letter (Name) is exported — visible to other packages, and to packages like encoding/json. A lowercase field (name) is private to the package. The same rule applies to the type name itself.
02

Pointers basics

A pointer holds the address of a value. &x takes the address of x; the type of &x is *int when x is an int; and *p reads or writes the value the pointer points to. Go has no pointer arithmetic, so pointers are safe to use — their only jobs are sharing one value between several places and letting a function modify its caller's value.

Because every argument is copied, a function that takes a User gets its own copy. To modify the caller's struct, take a *User. With a pointer to a struct you still write u.Age — Go dereferences automatically, so you never need (*u).Age. &User{...} creates a struct and returns a pointer to it in one step.

gomain.go
package main

import "fmt"

type User struct {
	Name string
	Age  int
}

func birthdayCopy(u User) { u.Age++ } // changes a copy
func birthday(u *User)    { u.Age++ } // changes the caller's struct

func main() {
	n := 10
	p := &n // p is a *int holding n's address
	*p = 20 // write through the pointer
	fmt.Println(n, *p == n)

	ana := User{Name: "Ana", Age: 31}
	birthdayCopy(ana)
	fmt.Println(ana.Age) // still 31
	birthday(&ana)
	fmt.Println(ana.Age) // 32

	bo := &User{Name: "Bo"} // pointer to a new struct
	bo.Age = 25             // automatic dereference
	fmt.Println(*bo)

	var missing *User
	fmt.Println(missing == nil)
}
Outputcompiled & run with real Go
20 true
31
32
{Bo 25}
true
Your turn

Write func rename(u *User, name string) and use it on bo. Why do you not need & in that call?

Returning a pointer to a local is safe
In C, returning the address of a local variable is a bug. In Go, func newUser() *User { u := User{}; return &u } is fine: the compiler's escape analysis sees the pointer outlive the function and puts u on the heap. The garbage collector frees it when nothing points to it any more.
Error you will hit

panic: runtime error: invalid memory address or nil pointer dereference

go
package main

import "fmt"

type Config struct {
	Port int
}

func main() {
	var cfg *Config // declared, never assigned
	fmt.Println("starting")
	fmt.Println(cfg.Port)
}
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x1041c7584]

goroutine 1 [running]:
main.main()
	./main.go:12 +0x54
exit status 2
Why the compiler said that

The zero value of a pointer is nil — it points at nothing. Reading cfg.Port means "go to the address in cfg", and there is no address. The trace's main.go:12 points at the exact line. In real code the nil usually comes from a lookup or constructor that returned nil alongside an error nobody checked.

The fix

Initialise the pointer (or use a plain value instead of a pointer), and always check the error before using a returned pointer.

go
package main

import "fmt"

type Config struct {
	Port int
}

func main() {
	cfg := &Config{Port: 8080}
	fmt.Println("starting")
	fmt.Println(cfg.Port)
}
03

Methods

A method is a function with a receiver — an extra parameter written before the name: func (r Rect) Area() float64. You call it with a dot, r.Area(). The receiver is named with a short abbreviation of the type (r, u, srv), never this or self.

Methods can be declared on any named type defined in the same package, not only structs. type Celsius float64 can have a String() method, and a type Stack []int can have Push and Pop. You cannot add methods to types from another package, such as int or time.Time — wrap them in your own type instead.

gomain.go
package main

import (
	"fmt"
	"math"
)

type Rect struct {
	W, H float64
}

func (r Rect) Area() float64      { return r.W * r.H }
func (r Rect) Perimeter() float64 { return 2 * (r.W + r.H) }

type Circle struct {
	R float64
}

func (c Circle) Area() float64 { return math.Pi * c.R * c.R }

type Celsius float64

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

func main() {
	r := Rect{W: 3, H: 4}
	fmt.Println(r.Area(), r.Perimeter())
	fmt.Printf("%.2f\n", Circle{R: 1}.Area()) // same method name, different type
	fmt.Println(Celsius(100).Fahrenheit())

	area := r.Area // a method value: bound to r
	fmt.Println(area())
}
Outputcompiled & run with real Go
12 14
3.14
212
12
Your turn

Add func (r Rect) Scale(k float64) Rect that returns a new, scaled rectangle. Print r.Scale(2).Area().

04

Value vs pointer receivers

A value receiver (func (c Counter) ...) gets a copy of the value, exactly like any other argument. Changes to c inside the method vanish when it returns. A pointer receiver (func (c *Counter) ...) gets the address, so changes stick. When you call a pointer method on an addressable variable, Go inserts the & for you: c.Inc() means (&c).Inc().

Use a pointer receiver when…A value receiver is fine when…
the method changes the receiverthe method only reads it
the struct is large (copying costs)the type is small: a few numbers, a time, a string
the struct contains a sync.Mutex or other thing that must not be copiedthe type is a map, func or channel (already a reference)
any other method on the type already uses a pointer receiver — be consistentyou want the value to behave like an immutable number
gomain.go
package main

import "fmt"

type Counter struct {
	Name string
	N    int
}

func (c Counter) IncBroken() { c.N++ } // increments a copy
func (c *Counter) Inc()      { c.N++ } // increments the original

func main() {
	c := Counter{Name: "hits"}
	c.IncBroken()
	c.IncBroken()
	fmt.Println("after IncBroken:", c.N)

	c.Inc() // Go rewrites this to (&c).Inc()
	c.Inc()
	fmt.Println("after Inc:", c.N)

	counters := []Counter{{Name: "a"}, {Name: "b"}}
	for _, ctr := range counters {
		ctr.Inc() // ctr is a COPY of the element
	}
	fmt.Println(counters)

	for i := range counters {
		counters[i].Inc() // index into the slice: the real element
	}
	fmt.Println(counters)
}
Outputcompiled & run with real Go
after IncBroken: 0
after Inc: 2
[{a 0} {b 0}]
[{a 1} {b 1}]
Your turn

Change counters to []*Counter{{Name: "a"}, {Name: "b"}} and print *counters[0] after the first loop. Why does the copy now not matter?

VisualizeWhy range over a slice of structs loses the updateStep 1 / 6
counters := []Counter{{Name: "a"}, {Name: "b"}}
for _, ctr := range counters {
ctr.Inc()
}
fmt.Println(counters)
Line 1

The slice holds two Counter structs.

Variables now
counters[0].N0
counters[1].N0
All 6 steps as a table
StepLineWhat happenedVariables now
11The slice holds two Counter structs.counters[0].N = 0 counters[1].N = 0
22First iteration: ctr is a copy of counters[0].ctr.N = 0 counters[0].N = 0
33ctr.Inc() becomes (&ctr).Inc() — it increments the copy.ctr.N = 1 counters[0].N = 0
42Second iteration: ctr is a fresh copy of counters[1].ctr.N = 0 counters[1].N = 0
53Again only the copy changes.ctr.N = 1 counters[1].N = 0
65The slice elements were never touched.counters[0].N = 0 counters[1].N = 0
Error you will hit

cannot assign to struct field stock["apple"].Qty in map

go
package main

import "fmt"

type Item struct {
	Qty int
}

func main() {
	stock := map[string]Item{"apple": {Qty: 3}}
	stock["apple"].Qty = 5
	fmt.Println(stock)
}
# command-line-arguments
./main.go:11:2: cannot assign to struct field stock["apple"].Qty in map
Why the compiler said that

Map values are not addressable: the map may move entries around in memory as it grows, so Go will not let you hold a reference to (or assign into) a value stored inside it. stock["apple"] is a copy of the struct.

The fix

Read the struct, change it, write it back. Or store pointers — map[string]*Item — so the map value is an address you can follow.

go
package main

import "fmt"

type Item struct {
	Qty int
}

func main() {
	stock := map[string]Item{"apple": {Qty: 3}}
	item := stock["apple"]
	item.Qty = 5
	stock["apple"] = item
	fmt.Println(stock)
}
05

Constructors by convention: NewX

Go has no constructor keyword. When a type needs setup — validation, a map that must be made, a default value — the convention is a plain function named New plus the type name: NewAccount(owner string) (*Account, error). Return a pointer when the type has pointer methods or is large, and return an error when the input can be invalid.

Keep fields lowercase when you want the constructor to be the only way in: code in other packages then cannot build an Account with a negative balance or a nil map. When the zero value is already valid (var mu sync.Mutex, var buf bytes.Buffer), you do not need a constructor at all — that is the ideal.

gomain.go
package main

import (
	"errors"
	"fmt"
)

type Account struct {
	owner   string
	balance int
	history map[string]int
}

func NewAccount(owner string, opening int) (*Account, error) {
	if owner == "" {
		return nil, errors.New("owner is required")
	}
	if opening < 0 {
		return nil, fmt.Errorf("opening balance %d is negative", opening)
	}
	return &Account{
		owner:   owner,
		balance: opening,
		history: map[string]int{"open": opening}, // ready to use
	}, nil
}

func (a *Account) Deposit(amount int) {
	a.balance += amount
	a.history["deposit"] += amount
}

func (a *Account) Balance() int { return a.balance }

func main() {
	acct, err := NewAccount("ana", 100)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	acct.Deposit(50)
	acct.Deposit(25)
	fmt.Println(acct.owner, acct.Balance(), acct.history)

	if _, err := NewAccount("bo", -5); err != nil {
		fmt.Println("error:", err)
	}
}
Outputcompiled & run with real Go
ana 175 map[deposit:75 open:100]
error: opening balance -5 is negative
Your turn

Add func (a *Account) Withdraw(amount int) error that refuses to go below zero.

Too many parameters?
When a constructor grows past three or four parameters, pass a config struct — NewServer(ServerConfig{Addr: ":8080", Timeout: 5 * time.Second}) — so call sites name every value and zero fields mean "use the default".
06

Embedding: composition instead of inheritance

Go has no inheritance. Instead, a struct can embed another type by listing it without a field name. The embedded type's fields and methods are promoted: you can call admin.Greet() as if Greet were declared on Admin. Underneath it is still a normal field, named after the type (admin.User).

If the outer type declares a field or method with the same name, the outer one wins — it shadows the promoted one, and the inner one is still reachable through the field name. This is not overriding: when the inner method calls another method, it calls its own type's version, never the outer one.

gomain.go
package main

import "fmt"

type User struct {
	Name  string
	Email string
}

func (u User) Greet() string   { return "Hi, " + u.Name }
func (u User) Contact() string { return u.Name + " <" + u.Email + ">" }

type Admin struct {
	User  // embedded: no field name
	Level int
}

func (a Admin) Greet() string { return "Welcome back, admin " + a.Name } // shadows User.Greet

func main() {
	a := Admin{User: User{Name: "Ana", Email: "[email protected]"}, Level: 2}

	fmt.Println(a.Name, a.Level) // promoted field
	fmt.Println(a.Contact())     // promoted method
	fmt.Println(a.Greet())       // Admin's own Greet
	fmt.Println(a.User.Greet())  // the embedded one is still there
}
Outputcompiled & run with real Go
Ana 2
Ana <[email protected]>
Welcome back, admin Ana
Hi, Ana
Your turn

Embed a second type, Audit struct{ CreatedBy string }, in Admin, and print a.CreatedBy.

Embedding you will see in real code
A struct embedding sync.Mutex gets Lock/Unlock methods directly. HTTP middleware often embeds http.ResponseWriter to override just one method (WriteHeader) and inherit the rest. Embedding an interface works the same way — Module 06 covers it.
07

Struct tags and comparing structs

A struct tag is a string literal after a field's type, in backquotes: `json:"name"`. The compiler ignores it, but packages read it through reflection. encoding/json uses it to rename fields, omitempty skips zero values, and "-" leaves a field out entirely. Database libraries (db:"...") and validators (validate:"...") use the same mechanism.

gomain.go
package main

import (
	"encoding/json"
	"fmt"
)

type User struct {
	ID       int      `json:"id"`
	Name     string   `json:"name"`
	Email    string   `json:"email,omitempty"`
	Password string   `json:"-"`
	Tags     []string `json:"tags"`
	internal string   // unexported: json cannot see it
}

func main() {
	u := User{ID: 7, Name: "Ana", Password: "secret", internal: "x"}
	out, _ := json.Marshal(u)
	fmt.Println(string(out))

	var back User
	err := json.Unmarshal([]byte(`{"id": 9, "name": "Bo", "email": "[email protected]"}`), &back)
	fmt.Println(err, back.ID, back.Name, back.Email)
}
Outputcompiled & run with real Go
{"id":7,"name":"Ana","tags":null}
<nil> 9 Bo [email protected]

Password is gone because of "-", Email because it is empty, and Tags is a nil slice, so it encodes as null (Module 04).

Your turn

Remove omitempty from Email. What appears in the JSON now?

Two struct values can be compared with == when every field is comparable; they are equal when all fields are equal. That also makes such structs usable as map keys — a Point{X, Y} key is a common trick for grids. A struct with a slice, map or function field is not comparable at all.

gomain.go
package main

import "fmt"

type Point struct {
	X, Y int
}

func main() {
	a := Point{1, 2}
	b := Point{X: 1, Y: 2}
	fmt.Println(a == b, a == Point{2, 1})

	visited := map[Point]bool{}
	visited[a] = true
	fmt.Println(visited[Point{1, 2}], visited[Point{0, 0}])
}
Outputcompiled & run with real Go
true false
true false
Error you will hit

invalid operation: a == b (struct containing []string cannot be compared)

go
package main

import "fmt"

type User struct {
	Name string
	Tags []string
}

func main() {
	a := User{Name: "Ana"}
	b := User{Name: "Ana"}
	fmt.Println(a == b)
}
# command-line-arguments
./main.go:13:14: invalid operation: a == b (struct containing []string cannot be compared)
Why the compiler said that

Slices cannot be compared with == (Module 04), and a struct is only comparable if all its fields are. Adding one slice field to a struct can therefore break code elsewhere that used == or used the struct as a map key.

The fix

Compare the fields you care about yourself, typically in an Equal method that uses slices.Equal for the slice fields.

go
package main

import (
	"fmt"
	"slices"
)

type User struct {
	Name string
	Tags []string
}

func (u User) Equal(o User) bool {
	return u.Name == o.Name && slices.Equal(u.Tags, o.Tags)
}

func main() {
	a := User{Name: "Ana"}
	b := User{Name: "Ana"}
	fmt.Println(a.Equal(b))
}
Struct
A type made of named fields, e.g. type User struct { Name string }.
Struct literal
An expression that builds a struct value: User{Name: "Ana"}. Omitted fields get zero values.
Pointer
A value holding the address of another value. &x makes one, *p follows it; its zero value is nil.
Method
A function with a receiver, declared as func (r T) Name() and called as v.Name().
Receiver
The value a method is called on. A value receiver gets a copy; a pointer receiver gets the address and can modify the original.
Embedding
Listing a type inside a struct without a field name, which promotes its fields and methods to the outer struct.
Struct tag
A backquoted string after a field's type, read by packages such as encoding/json via reflection.
Escape analysis
The compiler pass that decides whether a value can live on the stack or must move to the heap because a pointer to it outlives the function.
Quick check

func (c Counter) Inc() { c.N++ } is called three times on c := Counter{}. What is c.N afterwards?

Quick check

An Admin struct embeds User, and both declare Greet(). What does admin.Greet() call?

Frequently asked questions

Does Go have classes?
No. A Go struct holds data and methods are declared separately with a receiver. Instead of inheritance, Go uses embedding to reuse fields and methods, and interfaces to write code that works with many types.
Should a Go method use a value or a pointer receiver?
Use a pointer receiver if the method modifies the receiver, if the struct is large, or if it contains a mutex. Otherwise a value receiver is fine. Keep all methods of a type consistent: if one needs a pointer receiver, give them all pointer receivers.
How do you write a constructor in Go?
By convention, a function named New plus the type, such as NewAccount, that validates its arguments, initialises maps and defaults, and returns the value (usually a pointer) plus an error when the input can be invalid.

Finish the Go handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.