How Go models data and behavior without classes, and how to choose between value and pointer receivers with confidence.
Introduction
If you come from PHP, Java, or C#, Go's lack of classes can feel strange at first. There is no class keyword, no inheritance, and no constructors. Yet Go powers some of the most demanding backend systems in the world.
The secret is simple: Go separates data from behavior. Structs hold the data. Receiver functions attach behavior to that data. Together they give you most of what you need from object-oriented design, with far less ceremony.
Think of a struct as the blueprint of a house (rooms, doors, windows), and receiver functions as the things the house can do (open the door, turn on the lights). The blueprint and the actions are defined separately, but they belong together.
In this post you will learn:
- How to define and initialize structs
- How receiver functions work
- When to use value receivers versus pointer receivers
- How methods interact with interfaces and embedding
- Common pitfalls and best practices
1. Structs: Grouping Related Data
A struct is a typed collection of fields. It is the primary way to model domain entities in Go.
package main import "fmt" type User struct { ID int Name string Email string Age int } func main() { // Named fields (recommended) u1 := User{ID: 1, Name: "Rahim", Email: "rahim@example.com", Age: 30} // Positional (fragile, avoid in real code) u2 := User{2, "Karim", "karim@example.com", 28} fmt.Println(u1.Name, u2.Name) } Always prefer named fields. Positional initialization breaks silently when someone adds or reorders a field.
Zero values
Every Go type has a usable zero value. A struct declared without initialization has each field set to its own zero value: 0 for numbers, "" for strings, nil for pointers, slices, and maps.
var u User fmt.Printf("%+v\n", u) // {ID:0 Name: Email: Age:0} A good Go design makes the zero value useful. For example, sync.Mutex and bytes.Buffer work immediately without setup.
Constructor functions
Go has no constructors, but the convention is a function named NewXxx that returns an initialized value, usually a pointer.
func NewUser(name, email string, age int) (*User, error) { if name == "" { return nil, fmt.Errorf("name is required") } return &User{Name: name, Email: email, Age: age}, nil } Constructors are the right place for validation and for setting up internal state such as maps or channels.
Struct tags
Tags attach metadata to fields. They are widely used for JSON, database mapping, and validation.
type Product struct { ID int `json:"id" db:"id"` Title string `json:"title" db:"title"` Price float64 `json:"price" db:"price"` Notes string `json:"notes,omitempty" db:"notes"` } Exported and unexported fields
Go controls visibility by capitalization. Uppercase names are exported (public); lowercase names are visible only inside the package.
type Account struct { Owner string // exported balance float64 // unexported: only this package can touch it } This is how Go achieves encapsulation: hide the fields, expose methods.
2. Receiver Functions: Attaching Behavior
A receiver function (commonly called a method) is a function with a special parameter placed between func and the function name.
func (receiver Type) MethodName(params) returnType { // body } Example:
func (u User) DisplayName() string { return fmt.Sprintf("%s <%s>", u.Name, u.Email) } func main() { u := User{Name: "Rahim", Email: "rahim@example.com"} fmt.Println(u.DisplayName()) // Rahim <rahim@example.com> } Under the hood, u.DisplayName() is just syntactic sugar for User.DisplayName(u). The receiver is simply the first argument.
Methods can be defined on any named type declared in the same package, not only structs. For example:
type Celsius float64 func (c Celsius) ToFahrenheit() float64 { return float64(c)*9/5 + 32 } 3. Value Receivers vs Pointer Receivers
This is the most important decision you will make when writing methods.
Value receiver
The method receives a copy of the struct. Changes made inside the method do not affect the original.
func (u User) Birthday() { u.Age++ // modifies the copy only } Pointer receiver
The method receives the address of the struct. Changes affect the original.
func (u *User) Birthday() { u.Age++ // modifies the original } See the difference:
u := User{Name: "Rahim", Age: 30} u.Birthday() fmt.Println(u.Age) // 31 with pointer receiver, 30 with value receiver An analogy: a value receiver is like handing someone a photocopy of a document. They can scribble on it all they want and your original stays clean. A pointer receiver is like handing over the original with a pen.
Note that Go automatically takes the address for you when calling a pointer method on an addressable variable, so u.Birthday() works even though u is not a pointer.
Quick comparison
| Aspect | Value receiver | Pointer receiver |
|---|---|---|
| Mutates original | No | Yes |
| Copying cost | Copies whole struct each call | Copies only a pointer |
| Works on nil | No (would panic on copy of nil pointer) | Can handle nil explicitly |
| Typical use | Small, immutable values | Entities, large structs, anything with state |
4. How to Choose
Use a pointer receiver when:
- The method needs to modify the receiver.
- The struct is large and copying would be wasteful.
- The struct contains something that must not be copied, such as a
sync.Mutex. - Other methods on the type already use pointer receivers (consistency).
Use a value receiver when:
- The type is small and behaves like a primitive (for example
time.Time, aPoint, or aMoneyvalue). - The method is read-only and you want to guarantee immutability.
- The type is a map, func, or channel (already reference-like).
The golden rule: do not mix receiver types on the same type. If any method needs a pointer receiver, make all of them pointer receivers. Mixing causes confusing method sets, which brings us to the next topic.
5. Method Sets and Interfaces
The set of methods attached to a type determines which interfaces it satisfies.
- The method set of
Tincludes only methods with value receivers. - The method set of
*Tincludes methods with both value and pointer receivers.
This surprises many developers:
type Notifier interface { Notify() error } type EmailSender struct{ Address string } func (e *EmailSender) Notify() error { fmt.Println("sending to", e.Address) return nil } func main() { var n Notifier n = &EmailSender{Address: "a@b.com"} // OK // n = EmailSender{Address: "a@b.com"} // compile error: // EmailSender does not implement Notifier (Notify has pointer receiver) _ = n.Notify() } Because Notify has a pointer receiver, only *EmailSender satisfies Notifier. Remember this whenever you pass structs into interface parameters.
6. Embedding: Composition Over Inheritance
Go favors composition. You can embed one struct inside another, and its fields and methods are promoted to the outer type.
type Timestamps struct { CreatedAt time.Time UpdatedAt time.Time } func (t *Timestamps) Touch() { t.UpdatedAt = time.Now() } type Article struct { Timestamps // embedded ID int Title string } func main() { a := &Article{ID: 1, Title: "Go Structs"} a.Touch() // promoted method fmt.Println(a.UpdatedAt) // promoted field } Embedding is excellent for reusable pieces like timestamps, audit fields, or soft-delete behavior. The outer type can also override a promoted method simply by defining its own method with the same name.
7. A Realistic Example: Order Domain
Let's combine everything in a small, practical example you might see in an e-commerce backend.
package order import ( "errors" "fmt" "time" ) type Status string const ( StatusPending Status = "pending" StatusPaid Status = "paid" StatusShipped Status = "shipped" ) type Item struct { SKU string Quantity int Price float64 } // Value receiver: Item is small and the method is read-only. func (i Item) Subtotal() float64 { return float64(i.Quantity) * i.Price } type Order struct { ID int Items []Item status Status CreatedAt time.Time } // Constructor ensures a valid initial state. func New(id int) *Order { return &Order{ ID: id, status: StatusPending, CreatedAt: time.Now(), } } // Pointer receivers: Order has identity and mutable state. func (o *Order) AddItem(it Item) error { if o.status != StatusPending { return errors.New("cannot modify a non-pending order") } if it.Quantity <= 0 { return errors.New("quantity must be positive") } o.Items = append(o.Items, it) return nil } func (o *Order) Total() float64 { var total float64 for _, it := range o.Items { total += it.Subtotal() } return total } func (o *Order) Pay() error { if o.status != StatusPending { return fmt.Errorf("order %d cannot be paid from status %q", o.ID, o.status) } o.status = StatusPaid return nil } func (o *Order) Status() Status { return o.status } Notice the design choices:
-
statusis unexported, so outside packages cannot put the order into an invalid state. They must go throughPay(). -
Itemuses a value receiver because it is a small, immutable value. -
Orderuses pointer receivers throughout, which keeps the method set consistent. - The constructor guarantees every
Orderstarts in a valid state.
Usage:
o := order.New(1001) _ = o.AddItem(order.Item{SKU: "BK-01", Quantity: 2, Price: 12.5}) _ = o.Pay() fmt.Println(o.Total(), o.Status()) // 25 paid 8. Common Pitfalls
1. Forgetting that value receivers copy. A setter with a value receiver silently does nothing useful. If a method "doesn't work", check the receiver first.
2. Copying structs that contain a mutex. Copying a sync.Mutex breaks its guarantees. Use pointer receivers and pass the struct by pointer. go vet will warn you about this.
type Counter struct { mu sync.Mutex n int } func (c *Counter) Inc() { c.mu.Lock() defer c.mu.Unlock() c.n++ } 3. Mixing receiver types. It makes the method set depend on whether you hold a value or a pointer, and it causes interface satisfaction errors.
4. Calling a pointer method on a map element. Map elements are not addressable, so this fails:
m := map[string]User{"a": {Name: "A"}} // m["a"].Birthday() // compile error: cannot take address of map element Store pointers in the map (map[string]*User) or copy the value out, modify it, and write it back.
5. Loop variable surprises with pointers. When you take &item of a loop variable in older Go versions, every pointer could end up referring to the same variable. Go 1.22 and later give each iteration its own variable, but be aware of it when reading older code.
6. Nil pointer receivers. Calling a method on a nil pointer is legal if the method does not dereference it. This can be useful but is usually a bug. Guard against it when the type may be nil.
9. Best Practices Checklist
- Use named fields when creating struct literals.
- Provide a
NewXxxconstructor when validation or internal setup is required. - Make the zero value useful whenever possible.
- Choose pointer receivers for entities and stateful types; value receivers for small immutable values.
- Keep receiver types consistent across a type.
- Name receivers with one or two letters derived from the type (
oforOrder), neverthisorself. - Hide invariants with unexported fields and expose behavior through methods.
- Prefer composition via embedding over deep hierarchies.
- Define interfaces where they are consumed, and keep them small.
- Run
go vetand a linter regularly to catch copy-lock and receiver issues.
Conclusion
Structs and receiver functions are the backbone of Go programs. Structs describe the shape of your data; receiver functions describe what that data can do. The one decision that matters most is value versus pointer receiver, and a simple rule covers most cases: if the type has identity or mutable state, use a pointer receiver; if it is a small value, use a value receiver; and never mix them.
Master these fundamentals and the rest of Go, from interfaces to dependency injection to clean service layers, becomes far easier to reason about.
Happy coding!