Go's "classes" aren't classes - and you'll thank me for the difference
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.
A struct is a named collection of fields, each with its own type. Define one with type + struct:
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
from dataclasses import dataclass
@dataclass
class Dog:
name: str
age: int
d = Dog(name="Rex", age=3)
print(d.name) # Rex
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.
var d Dog without initializing, Go fills every field with its zero value: Name becomes "", Age becomes 0. No None surprises.
A method is a function with a special first parameter called the receiver. That's it. Go's entire "OOP" story in one sentence.
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
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.
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())
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())}
You can attach methods to any type you define, including primitive aliases:
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
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:
| Operator | Name | What it does | Example |
|---|---|---|---|
& | address-of | Give me the memory address of this variable | &d - address of d |
* | dereference | Give me the value at this address | *p - value at p |
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
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.
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.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: 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
// 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
}
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:
Here is a runnable example that shows both sides:
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!
}
| Situation | Use |
|---|---|
| Method needs to modify the struct | Pointer receiver (d *Dog) |
| Struct is large (many fields or big buffers) | Pointer receiver - avoids copying |
| Read-only method on a small struct | Value receiver (d Dog) is fine |
| Any method on the same type uses a pointer receiver | Use pointer receiver on ALL methods (consistency) |
Go has no __init__. There is no special constructor syntax. The convention is a plain function named New<Type> that returns a pointer:
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
class Dog:
def __init__(self, name: str, age: int):
if age < 0:
age = 0
self.name = name
self.age = age
d = Dog("Rex", 3)
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.
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.
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
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
// Dog HAS-A Animal (composition)
type Dog struct {
Animal // embedded
Breed string
}
// Dog cannot be used where
// Animal is expected.
// Use interfaces for that.
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.
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.
Parse flags into a struct, then pass a pointer to functions that need config. Avoids a mess of global variables.
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.
Kubernetes API types use *int32 instead of int32 for fields like Replicas. This looks weird until you understand why.
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.
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.
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.
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.
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.
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.
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!
| Term | Meaning |
|---|---|
struct | A named type that groups fields together. Declared with type T struct { ... }. The Go equivalent of a class (data only, no behavior built in). |
| field | A named, typed slot inside a struct. Accessed with dot notation: d.Name. |
| receiver | The first parameter of a method, written before the function name in parentheses: (d Dog). Equivalent to Python's self. |
| value receiver | A receiver of type T (not a pointer). The method gets a copy of the struct. Changes do not affect the original. |
| pointer receiver | A receiver of type *T. The method gets the address of the struct. Changes affect the original. |
| method | A 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. |
| pointer | A 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. |
| embedding | Including one struct as an anonymous field in another: type Dog struct { Animal; Breed string }. The inner struct's fields and methods are promoted. |
| promoted field | A field or method from an embedded struct that can be accessed directly on the outer struct, as if it were defined there. |
| composition | Building complex types by combining simpler ones (embedding). The Go alternative to inheritance. Dog has-a Animal rather than is-a Animal. |
Backstory and pitfalls that make this chapter stick.
&x is address-of, *p is dereference. Go has no pointer arithmetic.func (u User) Foo(). Pointer receiver: func (u *User) Foo().*T. Rarely used; struct literals &MyStruct{...} are more idiomatic.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.
Output is {10 20} or {1 2}?
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)
}{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:
// 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}
}A microservice had this method:
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.
Don't peek. Think first, then click to reveal each answer.
1. func (u *User) UpdateName(name string) is a method with...
*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...
3. The zero value of a *Person pointer is:
nilPerson{}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 &pp := &Person{name: "A"}p := new(Person) then assign fieldsp 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 ==:
reflect.DeepEqual==. Use reflect.DeepEqual or compare field-by-field in that case.