In the previous tutorial, you learned about slices and maps. Now it is time to learn how to create your own types with structs.

Go does not have classes. Instead, it uses structs and methods. Structs hold data, and methods add behavior. This is simpler than class-based languages like Java or Python.

Defining a Struct

A struct groups related fields together:

package main

import "fmt"

type User struct {
    Name  string
    Email string
    Age   int
}

func main() {
    // Create a struct with field names
    user1 := User{
        Name:  "Alex",
        Email: "alex@example.com",
        Age:   25,
    }
    fmt.Println(user1)

    // Access fields with dot notation
    fmt.Println("Name:", user1.Name)
    fmt.Println("Email:", user1.Email)

    // Update a field
    user1.Age = 26
    fmt.Println("New age:", user1.Age)
}

Output:

{Alex alex@example.com 25}
Name: Alex
Email: alex@example.com
New age: 26

Zero Value Structs

If you don’t set a field, it gets its zero value:

package main

import "fmt"

type Config struct {
    Host    string
    Port    int
    Debug   bool
    Timeout float64
}

func main() {
    // All fields get zero values
    var cfg Config
    fmt.Printf("Host: %q\n", cfg.Host)       // ""
    fmt.Printf("Port: %d\n", cfg.Port)        // 0
    fmt.Printf("Debug: %t\n", cfg.Debug)      // false
    fmt.Printf("Timeout: %.1f\n", cfg.Timeout) // 0.0
}

This means every struct is always in a valid state. No null or undefined fields like in some other languages.

Anonymous Structs

You can create a struct without defining a type. This is useful for one-time use:

package main

import "fmt"

func main() {
    point := struct {
        X, Y int
    }{
        X: 10,
        Y: 20,
    }
    fmt.Println("Point:", point)
}

Methods

A method is a function with a receiver. The receiver connects the method to a type:

package main

import "fmt"

type Rectangle struct {
    Width  float64
    Height float64
}

// Method with a value receiver
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

// Method with a value receiver
func (r Rectangle) Perimeter() float64 {
    return 2 * (r.Width + r.Height)
}

func main() {
    rect := Rectangle{Width: 10, Height: 5}
    fmt.Printf("Width: %.1f, Height: %.1f\n", rect.Width, rect.Height)
    fmt.Printf("Area: %.1f\n", rect.Area())
    fmt.Printf("Perimeter: %.1f\n", rect.Perimeter())
}

Output:

Width: 10.0, Height: 5.0
Area: 50.0
Perimeter: 30.0

The (r Rectangle) before the method name is the receiver. It is like self in Python or this in Java, but you choose the name. Go convention is to use a short name (one or two letters) based on the type name.

Value Receivers vs Pointer Receivers

A value receiver gets a copy of the struct. A pointer receiver gets a reference to the original:

package main

import "fmt"

type Counter struct {
    Value int
}

// Value receiver — works on a COPY (cannot modify the original)
func (c Counter) Current() int {
    return c.Value
}

// Pointer receiver — works on the ORIGINAL (can modify it)
func (c *Counter) Increment() {
    c.Value++
}

// Pointer receiver — can modify the original
func (c *Counter) Reset() {
    c.Value = 0
}

func main() {
    counter := Counter{Value: 0}
    fmt.Println("Start:", counter.Current()) // 0

    counter.Increment()
    counter.Increment()
    counter.Increment()
    fmt.Println("After 3 increments:", counter.Current()) // 3

    counter.Reset()
    fmt.Println("After reset:", counter.Current()) // 0
}

Output:

Start: 0
After 3 increments: 3
After reset: 0

When to use which?

  • Use a pointer receiver when the method needs to modify the struct
  • Use a pointer receiver when the struct is large (avoids copying)
  • Use a value receiver when the method only reads data and the struct is small
  • Be consistent: if one method uses a pointer receiver, all methods on that type should use pointer receivers

Constructor Functions

Go does not have constructors like Java or Python. Instead, you write a function that returns a new instance. By convention, the function name starts with New:

package main

import "fmt"

type User struct {
    Name  string
    Email string
    Age   int
}

// Constructor function — returns a pointer to a new User
func NewUser(name, email string, age int) *User {
    return &User{
        Name:  name,
        Email: email,
        Age:   age,
    }
}

func (u *User) String() string {
    return fmt.Sprintf("%s (%s, age %d)", u.Name, u.Email, u.Age)
}

func main() {
    user := NewUser("Alex", "alex@example.com", 25)
    fmt.Println(user.String())
}

Output:

Alex (alex@example.com, age 25)

Returning a pointer (*User) is common in Go. It avoids copying the struct when the function returns.

Composition with Embedding

Go does not have inheritance. Instead, it uses composition. You embed one struct inside another:

package main

import "fmt"

type Address struct {
    Street string
    City   string
    Zip    string
}

func (a Address) FullAddress() string {
    return fmt.Sprintf("%s, %s %s", a.Street, a.City, a.Zip)
}

type Employee struct {
    Name    string
    Salary  float64
    Address // Embedded struct — no field name
}

func main() {
    emp := Employee{
        Name:   "Alex",
        Salary: 75000,
        Address: Address{
            Street: "123 Main St",
            City:   "Berlin",
            Zip:    "10115",
        },
    }

    // Access Address fields directly — they are "promoted"
    fmt.Println("Name:", emp.Name)
    fmt.Println("City:", emp.City)       // Promoted field
    fmt.Println("Street:", emp.Street)   // Promoted field

    // Access the embedded struct's method directly
    fmt.Println("Address:", emp.FullAddress()) // Promoted method
}

Output:

Name: Alex
City: Berlin
Street: 123 Main St
Address: 123 Main St, Berlin 10115

When you embed a struct, all its fields and methods are “promoted.” You can access them as if they belong to the outer struct. This is composition, not inheritance. The Employee is not an Address. It has an Address.

Multiple Embeddings

You can embed multiple structs:

package main

import (
    "fmt"
    "time"
)

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

type SoftDelete struct {
    Deleted   bool
    DeletedAt time.Time
}

type Article struct {
    Title   string
    Content string
    Timestamps
    SoftDelete
}

func NewArticle(title, content string) *Article {
    now := time.Now()
    return &Article{
        Title:   title,
        Content: content,
        Timestamps: Timestamps{
            CreatedAt: now,
            UpdatedAt: now,
        },
    }
}

func main() {
    article := NewArticle("Go Structs", "Learn about structs in Go")
    fmt.Println("Title:", article.Title)
    fmt.Println("Created:", article.CreatedAt.Format("2006-01-02"))
    fmt.Println("Deleted:", article.Deleted) // false (zero value)
}

This pattern is common in Go. You create small, reusable structs (Timestamps, SoftDelete) and embed them where needed.

Struct Tags

Struct tags are metadata you attach to fields. They don’t change the struct’s behavior, but other packages can read them:

package main

import (
    "encoding/json"
    "fmt"
)

type Product struct {
    Name     string  `json:"name"`
    Price    float64 `json:"price"`
    InStock  bool    `json:"in_stock"`
    Internal string  `json:"-"` // "-" means skip this field
}

func main() {
    product := Product{
        Name:     "Keyboard",
        Price:    49.99,
        InStock:  true,
        Internal: "secret data",
    }

    // Convert struct to JSON
    data, err := json.Marshal(product)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println(string(data))
}

Output:

{"name":"Keyboard","price":49.99,"in_stock":true}

The json:"name" tag tells the JSON encoder to use name instead of Name. The json:"-" tag tells it to skip the field entirely. You will use struct tags a lot when building web APIs.

A Complete Example

Here is a program that uses structs, methods, and composition together:

package main

import "fmt"

// Base types
type Dimensions struct {
    Width  float64
    Height float64
}

func (d Dimensions) Area() float64 {
    return d.Width * d.Height
}

// Shape types with embedding
type Box struct {
    Label string
    Dimensions
    Depth float64
}

func (b Box) Volume() float64 {
    return b.Area() * b.Depth // Area() is promoted from Dimensions
}

type Shelf struct {
    Name  string
    Boxes []Box
}

func NewShelf(name string) *Shelf {
    return &Shelf{
        Name:  name,
        Boxes: make([]Box, 0),
    }
}

func (s *Shelf) AddBox(box Box) {
    s.Boxes = append(s.Boxes, box)
}

func (s *Shelf) TotalVolume() float64 {
    total := 0.0
    for _, box := range s.Boxes {
        total += box.Volume()
    }
    return total
}

func (s *Shelf) Summary() {
    fmt.Printf("Shelf: %s (%d boxes)\n", s.Name, len(s.Boxes))
    for _, box := range s.Boxes {
        fmt.Printf("  - %s: %.1f x %.1f x %.1f = %.1f cubic units\n",
            box.Label, box.Width, box.Height, box.Depth, box.Volume())
    }
    fmt.Printf("  Total volume: %.1f cubic units\n", s.TotalVolume())
}

func main() {
    fmt.Println("=== GO-7: Structs, Methods, and Composition ===")
    fmt.Println()

    shelf := NewShelf("Storage A")

    shelf.AddBox(Box{
        Label:      "Books",
        Dimensions: Dimensions{Width: 30, Height: 25},
        Depth:      20,
    })

    shelf.AddBox(Box{
        Label:      "Electronics",
        Dimensions: Dimensions{Width: 40, Height: 30},
        Depth:      25,
    })

    shelf.AddBox(Box{
        Label:      "Tools",
        Dimensions: Dimensions{Width: 20, Height: 15},
        Depth:      10,
    })

    shelf.Summary()
}

Output:

=== GO-7: Structs, Methods, and Composition ===

Shelf: Storage A (3 boxes)
  - Books: 30.0 x 25.0 x 20.0 = 15000.0 cubic units
  - Electronics: 40.0 x 30.0 x 25.0 = 30000.0 cubic units
  - Tools: 20.0 x 15.0 x 10.0 = 3000.0 cubic units
  Total volume: 48000.0 cubic units

Common Mistakes

1. Forgetting to use a pointer receiver when modifying a struct.

// Wrong — this modifies a copy, not the original
func (c Counter) Increment() {
    c.Value++ // This change is lost
}

// Correct — use a pointer receiver
func (c *Counter) Increment() {
    c.Value++ // This modifies the original
}

If your method needs to change the struct, you must use a pointer receiver (*Counter).

2. Comparing structs with incomparable fields.

type Data struct {
    Values []int // Slices are NOT comparable
}

a := Data{Values: []int{1, 2}}
b := Data{Values: []int{1, 2}}
// a == b  // Compile error: struct containing []int cannot be compared

Structs are comparable only if all their fields are comparable. Slices, maps, and functions are not comparable.

3. Field name conflicts with embedding.

type A struct {
    Name string
}

type B struct {
    Name string
}

type C struct {
    A
    B
}

c := C{}
// c.Name  // Compile error: ambiguous selector
c.A.Name = "from A" // This works — be explicit

If two embedded structs have fields with the same name, you must access them explicitly.

Source Code

You can find the complete source code for this tutorial on GitHub:

GO-7 Source Code on GitHub

What’s Next?

In the next tutorial, Go Tutorial #8: Interfaces and Polymorphism, you will learn:

  • How interfaces work in Go — implicit implementation
  • The empty interface and any
  • Type assertions and type switches
  • Common standard library interfaces like io.Reader and fmt.Stringer
  • The “accept interfaces, return structs” principle

This is part 7 of the Go Tutorial series. Follow along to learn Go from scratch.