DEV Community

Cover image for Go Structs and Receiver Functions: A Practical Guide
Md Abu Musa
Md Abu Musa

Posted on

Go Structs and Receiver Functions: A Practical Guide

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)
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"`
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Pointer receiver

The method receives the address of the struct. Changes affect the original.

func (u *User) Birthday() {
    u.Age++ // modifies the original
}
Enter fullscreen mode Exit fullscreen mode

See the difference:

u := User{Name: "Rahim", Age: 30}
u.Birthday()
fmt.Println(u.Age) // 31 with pointer receiver, 30 with value receiver
Enter fullscreen mode Exit fullscreen mode

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:

  1. The method needs to modify the receiver.
  2. The struct is large and copying would be wasteful.
  3. The struct contains something that must not be copied, such as a sync.Mutex.
  4. Other methods on the type already use pointer receivers (consistency).

Use a value receiver when:

  1. The type is small and behaves like a primitive (for example time.Time, a Point, or a Money value).
  2. The method is read-only and you want to guarantee immutability.
  3. 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 T includes only methods with value receivers.
  • The method set of *T includes 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()
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

Notice the design choices:

  • status is unexported, so outside packages cannot put the order into an invalid state. They must go through Pay().
  • Item uses a value receiver because it is a small, immutable value.
  • Order uses pointer receivers throughout, which keeps the method set consistent.
  • The constructor guarantees every Order starts 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
Enter fullscreen mode Exit fullscreen mode

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++
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 NewXxx constructor 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 (o for Order), never this or self.
  • 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 vet and 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!

Top comments (0)