Structs

A struct in Go is a composite type that groups together zero or more named fields, each with its own type, into a single value. Structs are how Go represents structured data — a point in space, a user record, a network request — without needing classes or inheritance. Once you understand structs and how methods attach to them, you have the foundation for almost everything else in Go, including interfaces and much of the standard library.

Overview: How Structs Work

Go does not have classes. Instead, it has struct types: plain data containers whose fields are laid out contiguously in memory, in the order you declare them (the compiler may insert padding bytes between fields so each field starts at an address suitable for its type — this is called alignment). A struct’s total size is roughly the sum of its field sizes plus any padding, which is why grouping fields of similar size together can sometimes shrink a struct’s memory footprint.

Every struct type has a well-defined zero value: a struct literal that omits a field, or a struct declared with var instead of a literal, gets each field set to that field’s own zero value (0 for numbers, “” for strings, nil for pointers/slices/maps, false for bools). This means a struct is always ready to use the moment it’s declared — there is no “uninitialized” state to guard against, unlike a nil pointer.

Structs have value semantics: assigning a struct to a new variable, or passing it to a function, copies every field. This is different from a slice or a map, which are small header values containing a pointer to shared underlying data — copying a slice header copies the pointer, not the data it points to. Copying a struct copies everything, including any embedded arrays. For large structs this copying has a real cost, which is one reason Go code often passes pointers to structs (*Person) instead of the structs themselves once they grow beyond a few fields.

Methods let you attach behavior to a type. A method is a regular function with an extra receiver parameter written before the function name: func (p Person) Greet() string. There is no class keyword and no implements keyword — a type simply has whatever methods you define for it, and it satisfies any interface whose method set it happens to match, entirely implicitly. The receiver can be a value (p Person) or a pointer (p *Person); this choice determines whether the method operates on a copy or on the original struct, and it’s one of the most important decisions you make about a type.

Finally, Go supports composition through embedding: placing one struct type inside another without giving it a field name. The outer struct automatically gets access to the embedded type’s fields and methods, “promoted” as if they were its own. This is resolved entirely at compile time by the compiler generating the necessary field and method lookups — it is not inheritance, there’s no dynamic dispatch, and the embedded type has no idea it’s being embedded.

Syntax

A struct type declaration and a method declaration follow these general forms:

type StructName struct {
	FieldName1 FieldType1
	FieldName2 FieldType2
	// as many fields as you need
}
func (receiverName ReceiverType) MethodName(paramName ParamType) ReturnType {
	// method body; use receiverName to access fields
}
Part Meaning
type StructName struct { ... } Declares a new named struct type with the given fields.
FieldName Type A field: an exported name (starts uppercase) is visible outside the package; unexported (lowercase) is package-private.
(receiverName ReceiverType) The receiver: binds the method to ReceiverType. Use T for a value receiver or *T for a pointer receiver.
StructName{Field: value, ...} A keyed struct literal — the safest way to construct a value, since it doesn’t depend on field order.

By convention, receiver names are short abbreviations of the type (p for Person, r for Rectangle), not this or self.

Examples

Example 1: Defining and using a struct

package main

import "fmt"

type Person struct {
	Name string
	Age  int
}

func main() {
	p := Person{Name: "Alice", Age: 30}
	fmt.Println(p.Name, "is", p.Age, "years old")

	p.Age = 31
	fmt.Println(p)
}

Output:

Alice is 30 years old
{Alice 31}

The keyed literal Person{Name: "Alice", Age: 30} creates a fully-initialized value in one step. Fields are accessed and reassigned with dot notation. When you print a struct value directly with fmt.Println, Go’s default formatter prints its fields in declaration order inside curly braces, with no field names — that’s what produces {Alice 31}.

Example 2: Methods with value and pointer receivers

package main

import "fmt"

type Rectangle struct {
	Width  float64
	Height float64
}

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

func (r *Rectangle) Scale(factor float64) {
	r.Width *= factor
	r.Height *= factor
}

func main() {
	rect := Rectangle{Width: 3, Height: 4}
	fmt.Println("Area:", rect.Area())

	rect.Scale(2)
	fmt.Println("After scaling:", rect)
	fmt.Println("New area:", rect.Area())
}

Output:

Area: 12
After scaling: {6 8}
New area: 48

Area only reads the receiver’s fields, so it takes a value receiver — calling it never affects the caller’s rect. Scale needs to mutate the struct, so it takes a pointer receiver; Go automatically takes the address of rect for the call rect.Scale(2) because rect is an addressable variable, so you don’t need to write (&rect).Scale(2) yourself.

Example 3: Composition with embedding

package main

import "fmt"

type Address struct {
	City    string
	Country string
}

type Employee struct {
	Name string
	Address
	Salary float64
}

func main() {
	e := Employee{
		Name: "Bob",
		Address: Address{
			City:    "Berlin",
			Country: "Germany",
		},
		Salary: 55000,
	}

	fmt.Println(e.Name, "lives in", e.City, e.Country)
	fmt.Printf("%+v\n", e)
}

Output:

Bob lives in Berlin Germany
{Name:Bob Address:{City:Berlin Country:Germany} Salary:55000}

Address is embedded in Employee without a field name, so its fields City and Country are promoted: you can write e.City directly instead of e.Address.City (though the longer form still works). The %+v verb in Printf prints field names alongside values, which is invaluable for debugging nested structs.

How It Works Step by Step

Walking through Example 2’s rect.Scale(2) call shows the mechanics that matter most when learning receivers:

  • The compiler sees that Scale is declared on *Rectangle, but rect is a plain Rectangle value.
  • Because rect is an addressable variable (a local variable, not a temporary), the compiler automatically rewrites the call as (&rect).Scale(2).
  • Inside Scale, the receiver r is a pointer that points at the same memory as main‘s rect — there is no copy of the struct.
  • r.Width *= factor dereferences that pointer and writes through it, so the change is visible back in main as soon as Scale returns.
  • By contrast, calling rect.Area() (a value receiver) copies the whole Rectangle into r; anything Area did to r itself (it doesn’t, here) would never reach rect.

The same automatic-addressing rule is why mixing value and pointer receivers on the same type usually works fine when you’re using a named variable, but breaks if you ever try to call a pointer-receiver method on a value that isn’t addressable (a map element, or a literal returned directly from a function) — the compiler will reject it because it can’t take that value’s address.

Common Mistakes

Mistake 1: Using a value receiver when you meant to mutate

This compiles cleanly but silently does nothing, because Increment only modifies its own copy of the struct:

package main

import "fmt"

type Counter struct {
	Count int
}

func (c Counter) Increment() {
	c.Count++
}

func main() {
	c := Counter{Count: 0}
	c.Increment()
	c.Increment()
	fmt.Println(c.Count)
}

Output:

0

The fix is a pointer receiver, so Increment operates on the original Counter:

package main

import "fmt"

type Counter struct {
	Count int
}

func (c *Counter) Increment() {
	c.Count++
}

func main() {
	c := Counter{Count: 0}
	c.Increment()
	c.Increment()
	fmt.Println(c.Count)
}

Output:

2

As a rule of thumb: if a type has even one method that needs a pointer receiver, make all of its methods pointer receivers, even the ones that only read fields. This keeps the method set consistent and avoids exactly this kind of half-fixed bug.

Mistake 2: Comparing structs that contain slices or maps

The == operator works on structs only if every field is itself comparable. Slices, maps, and functions are not comparable, so this doesn’t even compile:

package main

import "fmt"

type Data struct {
	Values []int
}

func main() {
	d1 := Data{Values: []int{1, 2, 3}}
	d2 := Data{Values: []int{1, 2, 3}}

	// invalid operation: d1 == d2
	// (struct containing []int cannot be compared)
	if d1 == d2 {
		fmt.Println("equal")
	}
}

Use reflect.DeepEqual (or write a manual field-by-field comparison for performance-sensitive code) instead:

package main

import (
	"fmt"
	"reflect"
)

type Data struct {
	Values []int
}

func main() {
	d1 := Data{Values: []int{1, 2, 3}}
	d2 := Data{Values: []int{1, 2, 3}}

	if reflect.DeepEqual(d1, d2) {
		fmt.Println("equal")
	}
}

Output:

equal

Best Practices

  • Prefer keyed struct literals (Person{Name: "Alice", Age: 30}) over positional ones (Person{"Alice", 30}) — positional literals break silently if a field is added or reordered.
  • Pick a receiver type (value or pointer) per type, not per method, and stick with it once any method needs a pointer.
  • Pass small structs (a few machine words) by value; pass larger structs, or ones that must be mutated, by pointer.
  • Use embedding for genuine composition relationships where promoted methods make sense, not just to save typing — embedding can leak internal details of the embedded type.
  • Add struct tags (for example, json:"name") when a struct is serialized with encoding/json or similar packages, so the wire format doesn’t depend on Go field names.
  • Never compare structs containing slices, maps, or funcs with ==; use reflect.DeepEqual or a hand-written comparison method instead.

Practice Exercises

  • Define a Book struct with Title, Author, and Pages fields. Write a value-receiver method Summary() that returns a string like "War and Peace by Leo Tolstoy (1225 pages)".
  • Define a BankAccount struct with a Balance float64 field. Write pointer-receiver methods Deposit(amount float64) and Withdraw(amount float64) error, where Withdraw returns an error if the amount exceeds the balance. Call both from main and print the final balance.
  • Create a Vehicle struct with Make and Model, then a Car struct that embeds Vehicle and adds a Doors int field. Construct a Car and print a promoted field alongside Doors to confirm embedding works as expected.

Summary

  • A struct groups named fields of different types into one value; fields are laid out in memory in declaration order.
  • Structs have value semantics — assignment and function calls copy every field, unlike slices and maps.
  • Methods attach behavior to a type via a receiver, written as func (r T) Method(...) or func (r *T) Method(...).
  • Value receivers operate on a copy; pointer receivers operate on the original and are required for mutation.
  • Embedding promotes an inner struct’s fields and methods to the outer struct at compile time — it’s composition, not inheritance.
  • Structs with slice, map, or func fields cannot be compared with ==; use reflect.DeepEqual instead.