Notes · Learn Go › LESSON 6 OF 8

Structs & Pointers

Go's "classes" aren't classes - and you'll thank me for the difference

Contents
  1. Structs: typed data bundles
  2. Methods: behavior on structs
  3. Pointers: explicit addresses
  4. Value vs pointer receivers (the big one)
  5. Constructors? No. Just functions.
  6. Embedding: composition over inheritance
  7. Real-world examples
  8. Gotchas
  9. Glossary

Python bundles data and behavior into classes. Go splits the concept into two explicit pieces: structs (data) and methods (behavior). And instead of hiding object references, Go makes you declare exactly whether a variable holds a value or a pointer to one. That explicitness is why Go code is easy to reason about at scale.

Structs: typed data bundles

A struct is a named collection of fields, each with its own type. Define one with type + struct:

dog.go
type Dog struct {
    Name string
    Age  int
}

// Named initialization - preferred, order doesn't matter
d := Dog{Name: "Rex", Age: 3}

// Positional - matches field order in the struct, fragile if you add fields
d2 := Dog{"Rex", 3}

// Field access with dot notation
fmt.Println(d.Name) // Rex
d.Age = 4           // mutate a field directly
The 5-year-old version
A struct is a lunchbox with labeled compartments. Each compartment has a name and only fits things of one type - the "Name" compartment only fits strings, the "Age" compartment only fits whole numbers. The shape of the lunchbox IS the type. If you want to give someone a dog, you hand them a lunchbox with those exact two compartments filled in.

Python @dataclass vs Go struct

Python - @dataclass
from dataclasses import dataclass

@dataclass
class Dog:
    name: str
    age: int

d = Dog(name="Rex", age=3)
print(d.name)  # Rex
Go - struct
type Dog struct {
    Name string
    Age  int
}

d := Dog{Name: "Rex", Age: 3}
fmt.Println(d.Name) // Rex

Key difference: Python's @dataclass is a class - it inherits from object, has __dict__, and can be subclassed. A Go struct is just a fixed-size block of memory with named slots. No magic, no hidden machinery.

type Dog struct { ... } in memory Dog (one value, lives on the stack) Name type: string "Rex" 16 bytes (header+ptr) Age type: int 3 8 bytes d.Name dot notation, always d.Age read or write freely variable name: d
A struct is a fixed-size block of named, typed slots. No hidden class machinery - just contiguous memory.
Zero values: If you declare var d Dog without initializing, Go fills every field with its zero value: Name becomes "", Age becomes 0. No None surprises.

Methods: behavior on structs

A method is a function with a special first parameter called the receiver. That's it. Go's entire "OOP" story in one sentence.

dog.go
type Dog struct {
    Name string
    Age  int
}

// (d Dog) is the receiver - Go's version of "self"
func (d Dog) Bark() string {
    return "Woof from " + d.Name
}

func (d Dog) IsAdult() bool {
    return d.Age >= 2
}

// Call exactly like Python: d.Bark()
d := Dog{Name: "Rex", Age: 3}
fmt.Println(d.Bark())    // Woof from Rex
fmt.Println(d.IsAdult()) // true
The 5-year-old version
A method is just a function with a special first parameter called the receiver. It's the function saying "hey, I belong to this struct." In Python, that first parameter is always called self and Python passes it automatically. In Go, you choose the name (usually a 1-2 letter abbreviation like d for Dog) and write it explicitly in parentheses before the function name.

Python class method vs Go receiver syntax

Python
class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def bark(self) -> str:
        return f"Woof from {self.name}"

d = Dog("Rex", 3)
print(d.bark())
Go
type Dog struct {
    Name string
    Age  int
}

func (d Dog) Bark() string {
    return "Woof from " + d.Name
}

d := Dog{Name: "Rex", Age: 3}
fmt.Println(d.Bark())}

Methods on any named type - not just structs

You can attach methods to any type you define, including primitive aliases:

temperature.go
type Celsius float64
type Fahrenheit float64

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

func (c Celsius) String() string {
    return fmt.Sprintf("%.1f°C", float64(c))
}

boiling := Celsius(100)
fmt.Println(boiling.ToFahrenheit()) // 212
Methods live outside the struct body. In Python, all methods are indented inside the class. In Go, the struct definition just declares fields - you define methods anywhere in the same package. This makes it easy to spread a type's methods across multiple files.

Pointers: explicit addresses

In Python, every variable is secretly a reference to an object. In Go, you choose explicitly: this variable IS the value, or this variable HOLDS THE ADDRESS of a value. Two operators do all the work:

OperatorNameWhat it doesExample
&address-ofGive me the memory address of this variable&d - address of d
*dereferenceGive me the value at this address*p - value at p
pointers.go
d := Dog{Name: "Rex", Age: 3}

var p *Dog = &d  // p is a pointer to Dog; &d gives us d's address

fmt.Println(p.Name)  // Rex - Go auto-dereferences for field access
fmt.Println((*p).Name) // Rex - explicit dereference, same result

p.Age = 4  // modifies d.Age via the pointer - d.Age is now 4
fmt.Println(d.Age) // 4
The 5-year-old version
In Python, every variable is secretly a sticky note pointing to a real object somewhere. You never see the sticky note - Python hides it from you.

In Go, you say out loud: "this variable is the actual lunchbox" (d Dog) or "this variable is a sticky note that points to a lunchbox" (p *Dog). Go shows you the sticky note and makes you ask for it explicitly with &. That's the whole trick.
Memory layout: d Dog and p *Dog STACK d (type: Dog) Name "Rex" addr: 0xc000014070 Age 3 addr: 0xc000014080 p (type: *Dog) value: 0xc000014070 (the address of d) p points to d d := Dog{...} - value variable holds the actual struct data p := &d - pointer variable holds only an address (8 bytes)
d holds the Dog data directly. p holds only a memory address - 8 bytes regardless of how big Dog gets. Changes through p affect d.

Why pass pointers?

When you pass a struct to a function, Go copies the entire struct. For a 2-field Dog that's fine. For a struct with hundreds of fields or large buffers, copying is expensive. A pointer is always 8 bytes, regardless of struct size.

Python - everything is a reference
# Python: d is already a reference.
# The function gets the same object.
def rename(dog, name):
    dog.name = name  # mutates original

rename(d, "Max")
print(d.name)  # Max
Go - you choose
// Pass by value: function gets a copy
func rename(dog Dog, name string) {
    dog.Name = name // changes the COPY
}

// Pass by pointer: function gets the real thing
func renamePtr(dog *Dog, name string) {
    dog.Name = name // changes the ORIGINAL
}

Value vs pointer receivers (the big one)

This is the most common source of bugs for Go beginners. The receiver type of a method determines whether it gets the real struct or a copy:

VALUE receiver - gets a COPY func (d Dog) Rename(n string) original d Name: "Rex" Age: 3 copy inside method Name: "Max" (changed) Age: 3 copy original d.Name is still "Rex" - changes did NOT stick POINTER receiver - gets the REAL thing func (d *Dog) Rename(n string) original d Name: "Rex" Age: 3 method receiver (ptr) d *Dog = &original 8 bytes, points to d points to d.Name becomes "Max" - changes DO stick func (d Dog ) Rename (n string ) { d.Name = n // changes copy only } func (d * Dog ) Rename (n string ) { d.Name = n // changes original }
Left: value receiver gets a photocopy - changes vanish when the method returns. Right: pointer receiver gets the real struct - changes persist.

Here is a runnable example that shows both sides:

receivers.go
package main

import "fmt"

type Dog struct {
    Name string
}

// Value receiver: d is a copy. d.Name = n affects only the copy.
func (d Dog) RenameValue(n string) {
    d.Name = n
}

// Pointer receiver: d points to the original. d.Name = n sticks.
func (d *Dog) RenamePointer(n string) {
    d.Name = n
}

func main() {
    dog := Dog{Name: "Rex"}

    dog.RenameValue("Max")
    fmt.Println(dog.Name) // Rex - unchanged!

    dog.RenamePointer("Max")
    fmt.Println(dog.Name) // Max - changed!
}
The 5-year-old version
Value receiver = method gets a photocopy of the lunchbox. You can draw on the photocopy all day long - the original lunchbox stays the same.

Pointer receiver = method gets the actual lunchbox with your name on it. Anything you do to it sticks.

Rules of thumb

SituationUse
Method needs to modify the structPointer receiver (d *Dog)
Struct is large (many fields or big buffers)Pointer receiver - avoids copying
Read-only method on a small structValue receiver (d Dog) is fine
Any method on the same type uses a pointer receiverUse pointer receiver on ALL methods (consistency)
Consistency matters. If you write one pointer receiver method on a type, make all methods on that type use pointer receivers. Mixing value and pointer receivers confuses the interface satisfaction rules and makes code harder to follow.

Constructors? No. Just functions.

Go has no __init__. There is no special constructor syntax. The convention is a plain function named New<Type> that returns a pointer:

dog.go
func NewDog(name string, age int) *Dog {
    if age < 0 {
        age = 0 // validation logic lives here
    }
    return &Dog{Name: name, Age: age}
}

// Usage:
d := NewDog("Rex", 3)  // returns *Dog

// Or skip NewDog entirely when no validation is needed:
d2 := Dog{Name: "Rex", Age: 3}  // value
d3 := &Dog{Name: "Rex", Age: 3} // pointer, same as NewDog without validation
Python - __init__
class Dog:
    def __init__(self, name: str, age: int):
        if age < 0:
            age = 0
        self.name = name
        self.age = age

d = Dog("Rex", 3)
Go - NewDog function
func NewDog(name string, age int) *Dog {
    if age < 0 {
        age = 0
    }
    return &Dog{Name: name, Age: age}
}

d := NewDog("Rex", 3)

When initialization is trivial - no validation, no computed fields - just use the struct literal directly: Dog{Name: "Rex", Age: 3}. Add a NewDog function when you need to enforce invariants or hide implementation details.

Embedding: composition over inheritance

Go has no inheritance. Full stop. Instead, it has embedding: you include one struct as an anonymous field inside another, and its fields and methods are promoted to the outer struct.

embedding.go
type Animal struct {
    Name string
}

func (a Animal) Breathe() string {
    return a.Name + " breathes"
}

type Dog struct {
    Animal        // anonymous (embedded) field - no field name!
    Breed string
}

d := Dog{
    Animal: Animal{Name: "Rex"},
    Breed:  "Husky",
}

// Promoted field access - looks like inheritance, isn't
fmt.Println(d.Name)       // Rex   (promoted from Animal)
fmt.Println(d.Breathe())  // Rex breathes (promoted method)
fmt.Println(d.Breed)      // Husky

// Can also be explicit:
fmt.Println(d.Animal.Name) // Rex - same thing, explicit path
The 5-year-old version
Imagine you have a lunchbox (Animal) with a name label on it. Now you make a bigger lunchbox (Dog) and put the smaller Animal lunchbox inside it, without giving the inner lunchbox a label - just drop it in. Now if someone asks the big Dog lunchbox for its name, it reaches inside, finds the Animal lunchbox, and reads the name off that. That's embedding. The fields and methods "bubble up" to the outer struct automatically.
Embedding: Dog struct contains Animal struct Dog struct Animal (embedded - no field name) Name string "Rex" method: Breathe() string Breed string "Husky" Dog's own field d.Name - promoted from Animal shorthand for d.Animal.Name d.Breathe() - promoted method shorthand for d.Animal.Breathe() d.Breed - Dog's own field no promotion needed Dog is NOT an Animal in the type system - embedding is composition, not inheritance. You can't assign a Dog to an Animal variable.
Dog contains Animal as an anonymous field. Animal's fields and methods are promoted - accessible directly on Dog as if they were Dog's own. But Dog is not an Animal subtype.
Python - inheritance
class Animal:
    def __init__(self, name):
        self.name = name
    def breathe(self):
        return f"{self.name} breathes"

# Dog IS-A Animal (subtype)
class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)
        self.breed = breed
Go - embedding
// Dog HAS-A Animal (composition)
type Dog struct {
    Animal        // embedded
    Breed string
}

// Dog cannot be used where
// Animal is expected.
// Use interfaces for that.
Want polymorphism? Use interfaces (Lesson 7). An interface defines a set of methods. Any type that has those methods satisfies the interface - no explicit declaration needed. That's Go's answer to Python duck typing, but checked at compile time.

Real-world examples

BackendHTTP server with state

A struct holds server dependencies; methods on that struct are your HTTP handlers. Pointer receivers because the server struct is large and we want all handlers sharing the same db reference.

server.go
type Server struct {
    db     *Database
    logger *Logger
}

func NewServer(db *Database, logger *Logger) *Server {
    return &Server{db: db, logger: logger}
}

// Pointer receiver: s is shared across all requests, never copied
func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) {
    id := r.URL.Query().Get("id")
    user, err := s.db.FindUser(id)
    if err != nil {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    json.NewEncoder(w).Encode(user)
}

func main() {
    db := connectDB()
    srv := NewServer(db, log.Default())
    http.HandleFunc("/user", srv.handleGetUser)
    http.ListenAndServe(":8080", nil)
}

This pattern - struct holding deps, pointer receiver methods as handlers - is idiomatic Go HTTP server code. Every real Go web service looks like this.

CLIConfig struct for flags

Parse flags into a struct, then pass a pointer to functions that need config. Avoids a mess of global variables.

main.go
type Config struct {
    Verbose  bool
    Workers  int
    Output   string
}

func parseConfig() *Config {
    cfg := &Config{}
    flag.BoolVar(&cfg.Verbose, "verbose", false, "enable verbose logging")
    flag.IntVar(&cfg.Workers, "workers", 4, "number of worker goroutines")
    flag.StringVar(&cfg.Output, "output", "stdout", "output destination")
    flag.Parse()
    return cfg
}

func runWorkers(cfg *Config) {
    if cfg.Verbose {
        fmt.Printf("Starting %d workers\n", cfg.Workers)
    }
    // ...
}

func main() {
    cfg := parseConfig()
    runWorkers(cfg)
}

Passing *Config means every function gets the same config without copying. Changing a field in one place is visible everywhere - useful for things like a shared verbosity flag.

k8sWhy Kubernetes uses pointer fields

Kubernetes API types use *int32 instead of int32 for fields like Replicas. This looks weird until you understand why.

deployment_spec.go (simplified from k8s API)
type DeploymentSpec struct {
    // *int32, not int32 - intentional!
    Replicas *int32 `json:"replicas,omitempty"`

    Selector *LabelSelector `json:"selector"`
    Template PodTemplateSpec `json:"template"`
}

// Setting replicas to 3:
three := int32(3)
spec := DeploymentSpec{
    Replicas: &three,
}

// Checking if replicas was set at all:
if spec.Replicas != nil {
    fmt.Printf("replicas: %d\n", *spec.Replicas)
} else {
    fmt.Println("replicas: not set (use default)")
}

A plain int32 field defaults to 0. But 0 replicas is a valid setting (scale to zero). How do you tell "user set 0" from "user didn't set this at all"? You can't - unless you use a pointer. A nil pointer means "not set." A pointer to 0 means "explicitly zero." This is the standard Go pattern for optional fields, and Kubernetes uses it everywhere.

Gotchas

1. Forgetting pointer receiver: your changes don't stick

This is the number one Go beginner bug. You call d.SetName("Max"), then check d.Name and it's still the old value. Almost always means the method has a value receiver. Add a * to the receiver type.

2. Mixing value and pointer receivers on the same type

Go allows it syntactically, but it causes subtle issues with interface satisfaction. If *Dog has some methods and Dog has others, a Dog value won't satisfy interfaces that expect pointer-receiver methods. Pick one for the whole type and stick with it.

3. Slice and map types: copying is more subtle

A slice header (pointer + len + cap) is small - copying it doesn't copy the underlying array. So a value receiver can append to the same backing array - until it triggers a reallocation. For modifications that might grow a slice (append), use a pointer receiver or return the new slice. Maps are inherently reference-like, but the nil map still panics on write.

4. nil pointer dereference panics at runtime

If a function returns a *Dog from an external library and you use it without checking for nil, you'll get a runtime panic. Always check: if d == nil { ... } before calling methods on pointers from external sources.

5. Range copies values - use index to modify

In for _, d := range dogs { d.Name = "Max" }, d is a copy. The original slice is unchanged. To modify in place, use the index: for i := range dogs { dogs[i].Name = "Max" }. Or make it a slice of pointers: []* Dog.

range_gotcha.go
dogs := []Dog{{Name: "Rex"}, {Name: "Buddy"}}

// WRONG - modifies copies, original slice unchanged
for _, d := range dogs {
    d.Name = "Max"
}
fmt.Println(dogs[0].Name) // Rex - unchanged!

// CORRECT - use index to modify in place
for i := range dogs {
    dogs[i].Name = "Max"
}
fmt.Println(dogs[0].Name) // Max - changed!

Glossary

TermMeaning
structA named type that groups fields together. Declared with type T struct { ... }. The Go equivalent of a class (data only, no behavior built in).
fieldA named, typed slot inside a struct. Accessed with dot notation: d.Name.
receiverThe first parameter of a method, written before the function name in parentheses: (d Dog). Equivalent to Python's self.
value receiverA receiver of type T (not a pointer). The method gets a copy of the struct. Changes do not affect the original.
pointer receiverA receiver of type *T. The method gets the address of the struct. Changes affect the original.
methodA function with a receiver. Called with dot notation: d.Bark(). Can be defined anywhere in the same package as the type.
constructor (convention)A plain function named NewT(...) *T that allocates and initializes a struct. Not a language feature - just a convention.
pointerA variable that holds a memory address rather than a value directly. Declared as *T for a pointer to type T.
address-of (&)The & operator takes the address of a variable: &d gives a *Dog pointing to d.
dereference (*)The * operator (as unary prefix) gives the value at a pointer's address: *p gives the Dog that p points to.
embeddingIncluding one struct as an anonymous field in another: type Dog struct { Animal; Breed string }. The inner struct's fields and methods are promoted.
promoted fieldA field or method from an embedded struct that can be accessed directly on the outer struct, as if it were defined there.
compositionBuilding complex types by combining simpler ones (embedding). The Go alternative to inheritance. Dog has-a Animal rather than is-a Animal.

Origins, gotchas, and a quick check

Backstory and pitfalls that make this chapter stick.

What's in a name?
struct
Composite value type with named fields. Same word as C structs but more predictable layout.
pointer
Holds a memory address. &x is address-of, *p is dereference. Go has no pointer arithmetic.
receiver
The "this" of Go methods. Value receiver: func (u User) Foo(). Pointer receiver: func (u *User) Foo().
embedding
Composition by including another type as an anonymous field. Promotes its methods/fields. Go's main alternative to inheritance.
composition
"Has-a" relationship. Go's design preference over inheritance ("is-a"). Stronger coupling avoided.
nil
Zero value for pointer, interface, channel, map, slice, function. Different from C's NULL - it's typed.
new()
Allocates zeroed memory of T and returns *T. Rarely used; struct literals &MyStruct{...} are more idiomatic.
Fun fact Go has no class keyword and no inheritance. Rob Pike's stance: "Inheritance is a stronger coupling than composition. Avoid it." So Go uses embedding (composition) instead. A type Manager struct { Employee } "is" an Employee in terms of method promotion, but they're not in an inheritance relationship - it's just composition with sugar. This single design choice eliminates 90% of the OOP complexity (multiple inheritance, diamond problem, fragile base class) at the cost of slightly more typing for some patterns.
Bug hunt

Output is {10 20} or {1 2}?

point.go
package main

import "fmt"

type Point struct{ X, Y int }

func (p Point) Scale(factor int) {
    p.X *= factor
    p.Y *= factor
}

func main() {
    p := Point{1, 2}
    p.Scale(10)
    fmt.Println(p)
}
Click to reveal the bug

{1 2}. Scale has a VALUE receiver, so it gets a copy of p. Mutations to p.X and p.Y inside Scale affect the copy, not the original.

Two fixes:

fix-pointer.go
// 1. Pointer receiver (mutates in place)
func (p *Point) Scale(factor int) {
    p.X *= factor
    p.Y *= factor
}

// 2. Value receiver returning new struct
func (p Point) Scale(factor int) Point {
    return Point{p.X * factor, p.Y * factor}
}
Real-world incident Value receiver swallowed every metric increment 1 day of debugging + lost telemetry

A microservice had this method:

server.go
func (s Server) HandleRequest(r *Request) {
    s.requestCount++
    log.Printf("count=%d", s.requestCount)
}

The bug: s is a value receiver. Each call gets a copy of the Server struct. s.requestCount++ increments the copy, which is discarded when the method returns. The log line always printed count=1. The team spent a day debugging "why isn't our metric incrementing?" before realizing the receiver should be *Server (pointer).

Lesson: If a method mutates fields, the receiver must be a pointer (*Server). Mixing value and pointer receivers on the same type is also a code smell - pick one and use it consistently for all methods of that type.

Quick check

Test yourself

Don't peek. Think first, then click to reveal each answer.

1. func (u *User) UpdateName(name string) is a method with...

  • A value receiver
  • A pointer receiver
  • Both
  • Invalid syntax
Show answer
Answer: b. The *User makes it a pointer receiver. Required if the method needs to mutate fields. Idiomatic Go uses pointer receivers consistently for methods on types that have any mutating methods.

2. Struct embedding type Manager struct { Employee } makes Manager...

  • Inherit from Employee
  • Have a hidden Employee field, with Employee's methods promoted
  • Implement the Employee interface
  • None of the above
Show answer
Answer: b. Embedding adds an anonymous Employee field. Methods on Employee can be called directly on Manager. No actual inheritance - just composition with method promotion. You can override by defining a method on Manager that shadows Employee's.

3. The zero value of a *Person pointer is:

  • An empty Person struct
  • nil
  • Undefined - reading panics
  • Person{}
Show answer
Answer: b. Pointer zero value is nil. Reading a field via a nil pointer panics: runtime error: invalid memory address or nil pointer dereference.

4. To create a struct on the heap and get a pointer:

  • p := Person{name: "A"} then &p
  • p := &Person{name: "A"}
  • p := new(Person) then assign fields
  • All of the above
Show answer
Answer: d. All work. Escape analysis moves p in option a to the heap when you take its address. Option b is the idiomatic one-liner. Option c is explicit allocation. Use b unless you have a reason otherwise.

5. Two values of Point can be compared with ==:

  • Always
  • Only if all fields are comparable
  • Never - must compare field-by-field
  • Only via reflect.DeepEqual
Show answer
Answer: b. Structs are comparable if all fields are comparable. Maps, slices, and funcs aren't comparable, so a struct containing those isn't comparable with ==. Use reflect.DeepEqual or compare field-by-field in that case.