3 min read
On this page

Structs & Methods

Structs are Go's primary mechanism for grouping data. Combined with methods, they provide the functionality that classes offer in other languages -- but without inheritance, constructors, or access modifiers beyond the exported/unexported naming convention. This simplicity is the point.

Defining Structs

A struct is a named collection of fields, each with a name and type.

type User struct {
    ID        int
    Email     string
    Name      string
    CreatedAt time.Time
    Active    bool
}

The zero value of a struct has all fields set to their respective zero values: 0 for int, "" for string, false for bool, and so on. A zero-value User is valid Go -- you can use it immediately.

Creating Struct Instances

// Named fields (preferred -- resilient to field reordering)
u := User{
    ID:    1,
    Email: "alice@example.com",
    Name:  "Alice",
    Active: true,
}

// Positional (fragile -- breaks if fields change)
u := User{1, "alice@example.com", "Alice", time.Now(), true}

// Zero value
var u User  // All fields at zero values

// Pointer to struct
u := &User{
    ID:    1,
    Email: "alice@example.com",
}

Always use named fields in struct literals. Positional initialization compiles but breaks silently when someone adds or reorders fields.

Field Access

u := User{ID: 1, Name: "Alice"}
fmt.Println(u.Name)   // Alice
u.Active = true        // Set a field

Go automatically dereferences pointers to structs:

p := &User{ID: 1, Name: "Alice"}
fmt.Println(p.Name)   // Alice -- no need for (*p).Name
p.Active = true        // Works through the pointer

Methods

Methods are functions with a receiver argument. The receiver binds the method to a type.

type Rectangle struct {
    Width  float64
    Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

func (r Rectangle) Perimeter() float64 {
    return 2 * (r.Width + r.Height)
}

func main() {
    rect := Rectangle{Width: 10, Height: 5}
    fmt.Println(rect.Area())      // 50
    fmt.Println(rect.Perimeter()) // 30
}

Value Receivers vs Pointer Receivers

This distinction matters. A value receiver gets a copy of the struct. A pointer receiver gets the original.

type Account struct {
    Balance float64
}

// Value receiver: operates on a copy. Cannot modify the original.
func (a Account) Display() string {
    return fmt.Sprintf("$%.2f", a.Balance)
}

// Pointer receiver: operates on the original. Can modify it.
func (a *Account) Deposit(amount float64) {
    a.Balance += amount
}

func (a *Account) Withdraw(amount float64) error {
    if amount > a.Balance {
        return fmt.Errorf("insufficient funds: have %.2f, want %.2f", a.Balance, amount)
    }
    a.Balance -= amount
    return nil
}
func main() {
    acc := Account{Balance: 100.0}
    acc.Deposit(50.0)
    fmt.Println(acc.Display())  // $150.00

    if err := acc.Withdraw(200.0); err != nil {
        fmt.Println(err)  // insufficient funds: have 150.00, want 200.00
    }
}

When to Use Pointer Receivers

Use a pointer receiver when:

  • The method modifies the struct
  • The struct is large and copying is expensive
  • You want consistency (if one method needs a pointer receiver, use pointer receivers for all methods on that type)

Use a value receiver when:

  • The method only reads data
  • The struct is small (a few fields of basic types)
  • You want the type to be safe for concurrent use without locks (copies do not share state)

In practice, most methods use pointer receivers. The performance argument is less important than the mutation argument -- if any method modifies the struct, make them all pointer receivers for consistency.

Embedding for Composition

Go uses embedding instead of inheritance. You embed one struct inside another, and the inner struct's methods get promoted to the outer struct.

type Timestamps struct {
    CreatedAt time.Time
    UpdatedAt time.Time
}

type Post struct {
    Timestamps          // Embedded
    ID      int
    Title   string
    Content string
}

type Comment struct {
    Timestamps          // Embedded
    ID     int
    PostID int
    Body   string
}

Both Post and Comment now have CreatedAt and UpdatedAt fields:

p := Post{
    Timestamps: Timestamps{
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    },
    ID:      1,
    Title:   "Go Embedding",
    Content: "Embedding is composition, not inheritance.",
}

fmt.Println(p.CreatedAt)  // Accessed directly -- promoted from Timestamps

Methods are promoted too:

func (t *Timestamps) Touch() {
    t.UpdatedAt = time.Now()
}

// Post inherits Touch() through embedding
p.Touch()  // Updates p.UpdatedAt

This is not inheritance. There is no method dispatch table, no virtual methods, no "is-a" relationship. A Post is not a Timestamps -- it contains one. The fields and methods are promoted for convenience.

Struct Tags

Struct tags are string annotations that provide metadata for serialization, database mapping, and validation.

type User struct {
    ID        int       `json:"id" db:"id"`
    Email     string    `json:"email" db:"email"`
    Name      string    `json:"name" db:"full_name"`
    Password  string    `json:"-" db:"password_hash"`
    CreatedAt time.Time `json:"created_at" db:"created_at"`
}

JSON Tags

The encoding/json package uses struct tags to control serialization.

type APIResponse struct {
    Status  string      `json:"status"`
    Data    interface{} `json:"data,omitempty"`    // Omit if zero value
    Error   string      `json:"error,omitempty"`
    TraceID string      `json:"trace_id"`
}

resp := APIResponse{
    Status:  "ok",
    TraceID: "abc-123",
}
data, _ := json.Marshal(resp)
fmt.Println(string(data))
{"status":"ok","trace_id":"abc-123"}

The Data and Error fields are omitted because they are zero values and tagged with omitempty. The Password field in the earlier example uses "-" to exclude it from JSON entirely.

Tags are just strings. The reflect package parses them at runtime. Any library can define its own tag conventions -- yaml, db, env, validate, and more.

Anonymous Structs

For one-off use cases -- test data, inline configuration, API responses you will not reuse -- anonymous structs avoid polluting your package with types.

// Inline struct for test cases
tests := []struct {
    name     string
    input    int
    expected int
}{
    {"zero", 0, 0},
    {"positive", 5, 25},
    {"negative", -3, 9},
}

for _, tt := range tests {
    got := square(tt.input)
    if got != tt.expected {
        fmt.Printf("%s: got %d, want %d\n", tt.name, got, tt.expected)
    }
}

Anonymous structs also work for one-off API responses, inline configuration, and partial JSON decoding where you only need a few fields from a larger payload.

Constructor Functions

Go has no constructors, but the convention is a New function that returns a pointer.

type Server struct {
    host    string
    port    int
    handler http.Handler
    logger  *slog.Logger
}

func NewServer(host string, port int, handler http.Handler) *Server {
    return &Server{
        host:    host,
        port:    port,
        handler: handler,
        logger:  slog.Default(),
    }
}

For types with many optional parameters, the functional options pattern (func WithPort(p int) Option) is idiomatic -- each option is a function that modifies the struct. Search for "functional options Go" for the full pattern.

Common Pitfalls

  • Mixing value and pointer receivers. Pick one. If any method needs a pointer receiver, make all methods on that type use pointer receivers. Mixing causes confusion about whether the method modifies the original.
  • Forgetting that value receivers copy. If your method calls a.Balance += amount on a value receiver, the original is unchanged. This compiles without warning.
  • Struct tag typos. A tag like `json:"naem"` silently maps to the wrong JSON field. Run go vet to catch malformed tags, but it cannot catch spelling errors in field names.
  • Embedding for code reuse alone. Embedding promotes all methods, including ones you may not want exposed. Embed when the outer type genuinely "has a" relationship with the inner type, not just to save typing.
  • Exporting fields you should not. Once a field is uppercase, it is part of your public API. Changing it later is a breaking change. Default to unexported fields with accessor methods if stability matters.
  • Deep comparison with ==. Structs are comparable with == only if all their fields are comparable. Structs containing slices, maps, or functions cannot be compared with ==.

Key Takeaways

  • Structs group related data. The zero value is always valid.
  • Methods bind functions to types through receivers. Use pointer receivers for mutation, value receivers for read-only access.
  • Embedding promotes fields and methods for composition. It is not inheritance.
  • Struct tags control JSON serialization, database mapping, and other reflection-based behavior.
  • Anonymous structs are useful for test tables, one-off responses, and partial JSON decoding.
  • Use New functions as constructors. Use functional options for types with many optional parameters.