Go Structs: Custom Types and Methods
Structs are the backbone of data modeling in Go. Unlike languages with full object-oriented features, Go takes a minimalist approach—structs provide a way to group related data without the baggage of...
Key Insights
- Structs are Go’s primary mechanism for creating custom types, grouping related data together without the complexity of classes found in object-oriented languages.
- Value receivers create copies while pointer receivers allow mutation—choose pointer receivers when modifying state or working with large structs to avoid expensive copies.
- Go favors composition over inheritance through struct embedding, allowing you to build complex types by combining simpler ones while maintaining clarity and avoiding deep inheritance hierarchies.
Introduction to Structs in Go
Structs are the backbone of data modeling in Go. Unlike languages with full object-oriented features, Go takes a minimalist approach—structs provide a way to group related data without the baggage of classes, inheritance hierarchies, or complex access modifiers. If you’re building anything beyond trivial programs, you’ll use structs constantly.
A struct is simply a typed collection of fields. Each field has a name and a type, and together they represent a cohesive unit of data. Whether you’re modeling a user, an HTTP request, a database record, or a configuration object, structs are your tool.
type Person struct {
Name string
Age int
}
This Person struct groups a name and age together. It’s straightforward, readable, and gives you a custom type to work with throughout your codebase.
Defining and Using Custom Types
Struct declaration follows a simple pattern: the type keyword, the struct name, and the struct keyword followed by field definitions in curly braces. Fields can be any valid Go type—primitives, slices, maps, other structs, or even function types.
type User struct {
ID int
Username string
Email string
IsActive bool
Tags []string
Metadata map[string]string
CreatedAt time.Time
}
When you instantiate a struct without providing values, Go initializes all fields to their zero values: 0 for numbers, "" for strings, false for booleans, and nil for slices, maps, and pointers. This predictable behavior eliminates an entire class of undefined behavior bugs.
var u1 User // All fields are zero values
fmt.Println(u1.Username) // Prints empty string
fmt.Println(u1.IsActive) // Prints false
You have several options for initialization. Struct literals are the most common:
// Positional (fragile, avoid in production code)
u2 := User{1, "john_doe", "john@example.com", true, nil, nil, time.Now()}
// Named fields (preferred—clear and maintainable)
u3 := User{
ID: 1,
Username: "john_doe",
Email: "john@example.com",
IsActive: true,
}
// Pointer initialization
u4 := &User{
ID: 2,
Username: "jane_doe",
}
Named field initialization is the idiomatic choice. It’s self-documenting, survives field reordering, and doesn’t break when you add new fields to the struct.
Structs can contain other structs, enabling hierarchical data modeling:
type Address struct {
Street string
City string
Country string
}
type Employee struct {
Person Person
Address Address
Salary float64
}
emp := Employee{
Person: Person{Name: "Alice", Age: 30},
Address: Address{
Street: "123 Main St",
City: "San Francisco",
Country: "USA",
},
Salary: 120000,
}
fmt.Println(emp.Person.Name) // Access nested fields
Methods on Structs
Methods give structs behavior. In Go, you attach methods to types using receiver syntax—a parameter that appears between the func keyword and the method name.
type Rectangle struct {
Width float64
Height float64
}
// Value receiver
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
// Pointer receiver
func (r *Rectangle) Scale(factor float64) {
r.Width *= factor
r.Height *= factor
}
The receiver type matters significantly. Value receivers operate on a copy of the struct, while pointer receivers work with the original. This distinction affects both mutation and performance.
rect := Rectangle{Width: 10, Height: 5}
area := rect.Area() // Value receiver, rect is copied
fmt.Println(area) // 50
rect.Scale(2) // Pointer receiver, rect is modified
fmt.Println(rect.Area()) // 200
Use pointer receivers when:
- The method needs to modify the receiver
- The struct is large and copying would be expensive
- You want consistency (if some methods use pointer receivers, use them for all methods on that type)
Use value receivers when:
- The method doesn’t modify the receiver
- The struct is small
- The type is inherently a value type (like
time.Time)
Here’s a practical example showing the difference:
type Counter struct {
count int
}
// This won't work as expected
func (c Counter) IncrementBroken() {
c.count++ // Modifies the copy, not the original
}
// This works correctly
func (c *Counter) Increment() {
c.count++ // Modifies the original
}
func (c Counter) Value() int {
return c.count // Value receiver is fine for read-only
}
counter := Counter{}
counter.IncrementBroken()
fmt.Println(counter.Value()) // 0 - didn't change
counter.Increment()
fmt.Println(counter.Value()) // 1 - changed correctly
Struct Embedding and Composition
Go doesn’t have inheritance, but it has something better: composition through embedding. When you embed a struct, its fields and methods are “promoted” to the outer struct, giving you code reuse without the complexity of inheritance chains.
type Person struct {
Name string
Age int
}
func (p Person) Introduce() string {
return fmt.Sprintf("Hi, I'm %s and I'm %d years old", p.Name, p.Age)
}
type Employee struct {
Person // Embedded struct
Company string
Salary float64
}
func (e Employee) Work() string {
return fmt.Sprintf("%s is working at %s", e.Name, e.Company)
}
The embedded Person struct promotes its fields and methods to Employee:
emp := Employee{
Person: Person{Name: "Bob", Age: 35},
Company: "TechCorp",
Salary: 90000,
}
// Direct access to embedded fields
fmt.Println(emp.Name) // "Bob"
// Access to embedded methods
fmt.Println(emp.Introduce()) // "Hi, I'm Bob and I'm 35 years old"
// Employee's own methods
fmt.Println(emp.Work()) // "Bob is working at TechCorp"
You can override promoted methods by defining a method with the same name on the outer struct:
func (e Employee) Introduce() string {
return fmt.Sprintf("Hi, I'm %s, I work at %s", e.Name, e.Company)
}
This composition model is clearer than inheritance. You can see exactly what’s being combined, and there’s no hidden behavior from deep class hierarchies.
Struct Tags and Reflection
Struct tags are string literals that attach metadata to fields. They’re primarily used by packages that need to know how to process struct fields—JSON encoding, database mapping, validation, and more.
type User struct {
ID int `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"-"` // Excluded from JSON
CreatedAt time.Time `json:"created_at,omitempty"`
}
user := User{
ID: 1,
Username: "alice",
Email: "alice@example.com",
Password: "secret",
}
data, _ := json.Marshal(user)
fmt.Println(string(data))
// {"id":1,"username":"alice","email":"alice@example.com"}
Tags follow a key:"value" format. Multiple tags are space-separated. The json package uses these tags to control marshaling behavior—field names, omission, and special handling.
You can define custom tags for your own packages:
type Config struct {
Host string `env:"DB_HOST" default:"localhost"`
Port int `env:"DB_PORT" default:"5432"`
Username string `env:"DB_USER" required:"true"`
}
Reading tags requires the reflect package:
import "reflect"
func printTags(v interface{}) {
t := reflect.TypeOf(v)
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
fmt.Printf("%s: %s\n", field.Name, field.Tag.Get("json"))
}
}
Best Practices and Common Patterns
Constructor functions are idiomatic in Go. Since Go doesn’t have constructors, you create functions that return initialized structs:
func NewUser(username, email string) *User {
return &User{
ID: generateID(),
Username: username,
Email: email,
IsActive: true,
CreatedAt: time.Now(),
Tags: make([]string, 0),
Metadata: make(map[string]string),
}
}
user := NewUser("alice", "alice@example.com")
Constructor functions let you enforce invariants, set defaults, and initialize complex fields properly. Name them New or New<Type>.
Encapsulation in Go uses package-level visibility. Unexported fields (lowercase first letter) are private to the package:
type Account struct {
id int // Unexported
balance float64 // Unexported
}
func NewAccount(initialBalance float64) *Account {
return &Account{
id: generateID(),
balance: initialBalance,
}
}
func (a *Account) Balance() float64 {
return a.balance
}
func (a *Account) Deposit(amount float64) error {
if amount <= 0 {
return errors.New("amount must be positive")
}
a.balance += amount
return nil
}
This pattern protects internal state while exposing a controlled API.
Structs satisfy interfaces implicitly. If a struct has the methods an interface requires, it implements that interface—no explicit declaration needed:
type Writer interface {
Write(data []byte) error
}
type FileLogger struct {
filename string
}
func (f *FileLogger) Write(data []byte) error {
// Implementation
return nil
}
// FileLogger implements Writer automatically
var w Writer = &FileLogger{filename: "app.log"}
This implicit satisfaction makes interfaces lightweight and flexible. You can define interfaces where you need them, and types implement them naturally through their methods.
Structs are fundamental to Go programming. Master them, understand the receiver type implications, embrace composition over inheritance, and use constructor functions to maintain clean, predictable code. The simplicity of Go’s struct model is a feature, not a limitation—it keeps your code clear and maintainable as your systems grow.